feat(pricing): introduce real-time budget estimator

This commit introduces a real-time budget estimator for event services. A new summary sidebar now displays a live,
itemized breakdown of costs as the user selects options in the order form.

Key changes include:
- A new `OrderSummary` component to display the price breakdown and total.
- Comprehensive pricing logic implemented in the `useEventOrder` composable.
- A responsive two-column layout on the main page to accommodate the summary.
- UI/UX improvements across the form, including clearer labels and subtle transition animations for conditional fields.
This commit is contained in:
xiaomai
2025-10-16 21:14:44 +08:00
parent eb69f6c48e
commit a5ac00baa1
12 changed files with 449 additions and 136 deletions

View File

@@ -1,5 +1,5 @@
import * as z from "zod";
import { createSharedComposable } from "@vueuse/core";
import * as z from "zod";
export const _useEventOrder = () => {
const sectionIndex = ref(0);
@@ -68,7 +68,9 @@ export const _useEventOrder = () => {
isSameContact: true,
contactNumber: "",
eventName: "",
eventDate: formatLocalDate(new Date()),
eventDate: formatLocalDate(
new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
), // 默认一周后
eventLocation: EVENT_LOCATIONS[0],
biddingSystem: false,
biddingSystemProvideImage: false,
@@ -83,11 +85,182 @@ export const _useEventOrder = () => {
sponsorListDesign: false,
});
// --- Pricing logic (editable) ---
const PRICE = {
bidding: 550, // base for bidding system
biddingProvideImageDiscount: 50, // discount if client provides all images
biddingLocationTierAdjustment: {
[EVENT_LOCATIONS[0]]: { adj: -100, reason: "同乡折扣" },
[EVENT_LOCATIONS[1]]: { adj: 0, reason: "" },
[EVENT_LOCATIONS[2]]: { adj: 200, reason: "含差旅费" },
[EVENT_LOCATIONS[3]]: { adj: 400, reason: "含差旅费" },
},
// biddingTierAdditions: [
// { range: [10, 20], add: 0 },
// { range: [20, 30], add: 200 },
// { range: [30, 40], add: 400 },
// { range: [40, 50], add: 600 },
// ],
background: {
static: 100,
dynamic: 250,
// highResSurcharge: 200, // if dimensions exceed 1920x1080
},
// 背景设计加急
// 静态背景:倒数 2 天 + 20倒数 1 天 + 50
// 动态背景:倒数 5 天 + 50倒数 1 天 + 100中间 Lerp 计算
backgroundRush: (daysLeft: number, isDynamic: boolean) => {
if (isDynamic) {
if (daysLeft >= 5) return 0;
if (daysLeft <= 1) return 100;
return Math.round(((5 - daysLeft) / 4) * 50 + 50);
} else {
if (daysLeft >= 2) return 0;
if (daysLeft <= 1) return 50;
return Math.round(((2 - daysLeft) / 1) * 30 + 20);
}
},
flowPptDesign: {
base: 90,
perSlide: 10,
slideAfterTen: 5,
providedSourceDiscount: 0.5, // 20% off if background sources provided
},
sponsorList: 100,
} as const;
// function parseRange(range?: string | null): [number, number] | null {
// if (!range) return null;
// const m = String(range).match(/(\d+)\s*[-~]\s*(\d+)/);
// if (!m) return null;
// return [Number(m[1]), Number(m[2])];
// }
const priceBreakdown = computed(() => {
const items: { label: string; amount: number; note?: string }[] = [];
// Bidding system
if (orderState.biddingSystem) {
let amount = PRICE.bidding;
amount -= orderState.biddingSystemProvideImage
? PRICE.biddingProvideImageDiscount
: 0;
// const r = parseRange(orderState.estimatedBidItemCount);
// if (r) {
// const tier = PRICE.biddingTierAdditions.find(
// (t) => r[0] >= t.range[0] && r[1] <= t.range[1]
// );
// if (tier) amount += tier.add;
// }
items.push({
label: "竞价系统",
amount,
note:
`${orderState.estimatedBidItemCount} 件区间` +
(orderState.biddingSystemProvideImage
? ",素材由客户提供"
: ",包含素材拍摄") +
",图片处理,现场技术支持",
});
const locationAdj =
PRICE.biddingLocationTierAdjustment[orderState.eventLocation];
if (locationAdj.adj) {
items.push({
label: locationAdj.reason,
amount: locationAdj.adj,
note: `活动地点:${orderState.eventLocation}`,
});
}
}
// Stage background design
if (orderState.backgroundDesign) {
let amount =
orderState.backgroundType === "dynamic"
? PRICE.background.dynamic
: PRICE.background.static;
const w = Number(orderState.backgroundWidthOverride || 1920);
const h = Number(orderState.backgroundHeightOverride || 1080);
// if (w > 1920 || h > 1080) amount += PRICE.background.highResSurcharge;
items.push({
label: `舞台背景设计(${
orderState.backgroundType === "dynamic" ? "动效" : "静态"
}`,
amount,
note: `${w}×${h}`,
});
}
// Background design rush fee
if (orderState.backgroundDesign) {
const eventDate = new Date(orderState.eventDate);
const today = new Date();
// 计算剩余天数(向上取整)
const diffTime = eventDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const rushFee = PRICE.backgroundRush(
diffDays,
orderState.backgroundType === "dynamic"
);
if (rushFee > 0) {
items.push({
label: "背景设计加急费",
amount: rushFee,
note: `距离活动仅剩 ${diffDays}`,
});
}
}
// Flow/PPT design
if (orderState.flowBackgroundDesign) {
const qty = Number(orderState.pptDesignQty || 0);
// 如果客户有要求背景设计Base Price 不算
let amount = orderState.backgroundDesign ? 0 : PRICE.flowPptDesign.base;
if (qty > 0) {
amount += Math.min(qty, 10) * PRICE.flowPptDesign.perSlide;
if (qty > 10) {
amount += (qty - 10) * PRICE.flowPptDesign.slideAfterTen;
}
}
if (orderState.backgroundSourceProvided) {
amount = Math.round(
amount * (1 - PRICE.flowPptDesign.providedSourceDiscount)
);
}
items.push({
label: "流程/PPT设计",
amount,
note:
`${qty} 张幻灯片设计` +
(orderState.backgroundSourceProvided ? ",客户提供背景素材" : ""),
});
}
// Sponsor list
if (orderState.sponsorListDesign) {
items.push({ label: "赞助商位设计", amount: PRICE.sponsorList });
}
return items;
});
const estimatedTotal = computed(() =>
priceBreakdown.value.reduce((sum, i) => sum + (i.amount || 0), 0)
);
const money = new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "MYR",
maximumFractionDigits: 0,
});
return {
sectionIndex,
eventLocationItems,
orderSchema,
orderState,
priceBreakdown,
estimatedTotal,
money,
};
};