feat(freight): shop freight templates, server-side checkout fees, company dictionary (add-freight-templates)

This commit is contained in:
Chengdong Zhang
2026-09-24 15:03:51 +08:00
parent 5b426486ac
commit 473d19d089
44 changed files with 2409 additions and 67 deletions
+12
View File
@@ -26,6 +26,12 @@ export default {
coupon: "Coupon",
noCoupon: "No coupon",
couponDiscount: "Coupon discount",
subtotal: "Goods subtotal",
shippingFee: "Shipping fee",
shippingTotal: "Shipping total",
freeShipping: "Free shipping",
selectAddressForShipping: "Select a shipping address to calculate the shipping fee.",
shippingQuoteError: "Unable to calculate the shipping fee. Please try again.",
grandTotal: "Total payment",
confirmPay: "Confirm payment",
noPendingOrders: "There are no orders waiting for payment.",
@@ -64,6 +70,12 @@ export default {
coupon: "优惠券",
noCoupon: "不使用优惠券",
couponDiscount: "优惠券抵扣",
subtotal: "商品小计",
shippingFee: "运费",
shippingTotal: "运费合计",
freeShipping: "免运费",
selectAddressForShipping: "请先选择收货地址以计算运费。",
shippingQuoteError: "运费计算失败,请稍后重试。",
grandTotal: "应付总额",
confirmPay: "确认支付",
noPendingOrders: "暂无待支付订单。",
+32 -1
View File
@@ -777,6 +777,8 @@ export function createMockApi(): ApiClient {
}
discount = Math.min(coupon.amount_minor, subtotal);
}
// Same deterministic rule as quoteShipping: flat fee under the free threshold.
const shippingFee = subtotal - discount >= 20000 ? 0 : 599;
const order: Order = {
id: `o-${Date.now()}-${shopId}`,
order_no: nextOrderNo(),
@@ -784,8 +786,9 @@ export function createMockApi(): ApiClient {
user_id: MOCK_USER.id,
status: "pending_payment",
currency,
total_minor: subtotal - discount,
total_minor: subtotal - discount + shippingFee,
discount_minor: discount,
shipping_fee_minor: shippingFee,
refund_total_minor: 0,
coupon_id: couponId ?? null,
group_activity_id: groupHere ? activity.id : null,
@@ -879,6 +882,30 @@ export function createMockApi(): ApiClient {
listMyShipments: () => Promise.resolve(state.shipments.map((s) => ({ ...s }))),
// Deterministic fixture rule: a flat per-shop fee, free at/over 200 major.
quoteShipping: (_address, currency) => {
const byShop = new Map<string, number>();
for (const item of state.cart) {
byShop.set(item.shop_id, (byShop.get(item.shop_id) ?? 0) + item.unit_price_minor * item.qty);
}
const shops = [...byShop.entries()].map(([shop_id, subtotal]) => ({
shop_id,
fee_minor: subtotal >= 20000 ? 0 : 599,
}));
return Promise.resolve({
currency,
shops,
total_minor: shops.reduce((sum, s) => sum + s.fee_minor, 0),
});
},
listShippingCompanies: () =>
Promise.resolve([
{ code: "sf-express", name: { en: "SF Express", zh: "顺丰速运" }, active: true },
{ code: "zto", name: { en: "ZTO Express", zh: "中通快递" }, active: true },
{ code: "ups", name: { en: "UPS", zh: "联合包裹" }, active: true },
]),
requestInvoice: (orderId: string, title: string, taxNo: string | null, kind: InvoiceKind) => {
const order = state.orders.find((o) => o.id === orderId);
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
@@ -1338,6 +1365,10 @@ export function createMockApi(): ApiClient {
confirmAftersaleReceipt: (_id: string) => unsupported(),
refundAftersale: (_id: string) => unsupported(),
addAftersaleMessage: (_id: string, _body: AftersaleMessageBody) => unsupported(),
listFreightTemplates: () => unsupported(),
createFreightTemplate: () => unsupported(),
updateFreightTemplate: () => unsupported(),
deleteFreightTemplate: () => unsupported(),
},
admin: {
listUsers: () => unsupported(),
+2 -1
View File
@@ -1146,9 +1146,10 @@ export function seedOrders(userId: string): MockOrderSeed {
user_id: userId,
status,
currency: BASE_CURRENCY,
total_minor: total,
total_minor: total + 599,
discount_minor: 0,
refund_total_minor: 0,
shipping_fee_minor: 599,
coupon_id: null,
group_activity_id: null,
group_id: null,
+104 -4
View File
@@ -7,6 +7,7 @@ import type {
Coupon,
GroupBuyIntent,
LocalizedText,
ShippingQuote,
} from "@vmall/shared";
type CheckoutGroup = {
@@ -36,6 +37,11 @@ const remark = ref("");
const loading = ref(true);
const submitting = ref(false);
const error = ref("");
/** Server-computed shipping fees for the current cart and destination. */
const shippingQuote = ref<ShippingQuote | null>(null);
const quoteFailed = ref(false);
let quoteTimer: ReturnType<typeof setTimeout> | undefined;
let quoteSeq = 0;
// Inline manual form, used only when the customer has no saved address.
const manual = reactive<Address>({
@@ -75,6 +81,26 @@ const totalMinor = computed(() =>
items.value.reduce((total, item) => total + item.unit_price_minor * item.qty, 0),
);
/** Per-shop shipping fee from the latest server quote, keyed by shop id. */
const shopFeeMinor = computed<Record<string, number>>(() => {
const fees: Record<string, number> = {};
for (const shop of shippingQuote.value?.shops ?? []) fees[shop.shop_id] = shop.fee_minor;
return fees;
});
const shippingTotalMinor = computed(() => shippingQuote.value?.total_minor ?? 0);
/** Coupon preview only; the server decides the discount actually granted. */
const couponDiscountMinor = computed(() =>
groups.value.reduce((sum, group) => sum + (selectedCouponFor(group.shopId)?.amount_minor ?? 0), 0),
);
const grandTotalMinor = computed(
() => totalMinor.value + shippingTotalMinor.value - couponDiscountMinor.value,
);
const shippingHint = computed(() => {
if (quoteFailed.value) return t("checkout.shippingQuoteError");
if (!selectedAddress()) return t("checkout.selectAddressForShipping");
return t("common.loading");
});
function imageFor(item: CartItem): string {
return item.image ?? "/mock/product-1.svg";
}
@@ -111,6 +137,37 @@ function selectedAddress(): Address | null {
return { ...manual };
}
/** Requote when the destination changes; without an address no fee is quoted. */
async function refreshQuote(): Promise<void> {
const address = selectedAddress();
if (!address) {
shippingQuote.value = null;
quoteFailed.value = false;
return;
}
const seq = ++quoteSeq;
try {
const quote = await $api.quoteShipping(address, currency.value);
if (seq === quoteSeq) {
shippingQuote.value = quote;
quoteFailed.value = false;
}
} catch {
if (seq === quoteSeq) {
shippingQuote.value = null;
quoteFailed.value = true;
}
}
}
// Saved-address switches and manual-form edits both reprice; the debounce
// keeps typing from firing a quote per keystroke.
const addressKey = computed(() => JSON.stringify(selectedAddress()));
watch(addressKey, () => {
if (quoteTimer) clearTimeout(quoteTimer);
quoteTimer = setTimeout(() => void refreshQuote(), 300);
});
async function loadCart(): Promise<void> {
loading.value = true;
error.value = "";
@@ -178,6 +235,7 @@ onMounted(() => void loadCart());
// consumed by a successful submit, which clears it before navigating to pay.
onBeforeUnmount(() => {
groupIntent.value = null;
if (quoteTimer) clearTimeout(quoteTimer);
});
</script>
@@ -289,6 +347,20 @@ onBeforeUnmount(() => {
:currency="item.currency"
/></strong>
</div>
<div class="flex flex-wrap items-center gap-3 pt-3 text-sm">
<span class="text-muted">{{ t("checkout.shippingFee") }}</span>
<template v-if="group.shopId in shopFeeMinor">
<span v-if="shopFeeMinor[group.shopId] === 0" class="text-success">{{
t("checkout.freeShipping")
}}</span>
<PriceText
v-else
:amount-minor="shopFeeMinor[group.shopId]"
:currency="shippingQuote?.currency ?? sourceCurrency"
/>
</template>
<span v-else class="text-muted">{{ shippingHint }}</span>
</div>
<div
v-if="!groupIntent && couponsForShop(group.shopId).length > 0"
class="flex flex-wrap items-center gap-3 pt-3 text-sm"
@@ -333,10 +405,38 @@ onBeforeUnmount(() => {
<footer
class="border-border bg-surface mb-4 flex flex-wrap items-center justify-end gap-4 rounded-lg border p-4 shadow-sm"
>
<span class="text-sm">{{ t("checkout.total") }}:</span>
<strong class="text-primary text-lg"
><PriceText :amount-minor="totalMinor" :currency="sourceCurrency"
/></strong>
<div class="ml-auto flex flex-col items-end gap-1.5 text-sm">
<div class="flex items-center gap-3">
<span class="text-muted">{{ t("checkout.subtotal") }}</span>
<PriceText :amount-minor="totalMinor" :currency="sourceCurrency" />
</div>
<div class="flex items-center gap-3">
<span class="text-muted">{{ t("checkout.shippingTotal") }}</span>
<template v-if="shippingQuote">
<span v-if="shippingTotalMinor === 0" class="text-success">{{
t("checkout.freeShipping")
}}</span>
<PriceText
v-else
:amount-minor="shippingTotalMinor"
:currency="shippingQuote?.currency ?? sourceCurrency"
/>
</template>
<span v-else class="text-muted">{{ shippingHint }}</span>
</div>
<div v-if="couponDiscountMinor > 0" class="flex items-center gap-3">
<span class="text-muted">{{ t("checkout.couponDiscount") }}</span>
<span class="text-danger"
>−<PriceText :amount-minor="couponDiscountMinor" :currency="sourceCurrency"
/></span>
</div>
<div class="flex items-center gap-3">
<span>{{ t("checkout.total") }}</span>
<strong class="text-primary text-lg"
><PriceText :amount-minor="grandTotalMinor" :currency="sourceCurrency"
/></strong>
</div>
</div>
<VBtn variant="primary" type="button" :disabled="submitting" @click="submitOrder">{{
t("checkout.submit")
}}</VBtn>
+9
View File
@@ -9,6 +9,8 @@ const { locale, t } = useI18n();
const { currency } = usePrefs();
const router = useRouter();
const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
/** Carried to the success page so it can show each paid order's money. */
const paidOrders = useState<Order[]>("checkout-paid-orders", () => []);
// Shared with the store directory; orders carry only a shop id.
const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] });
@@ -68,6 +70,7 @@ async function confirmPayment(): Promise<void> {
error.value = "";
try {
for (const order of orders.value) await $api.payOrder(order.id);
paidOrders.value = orders.value;
pendingOrderIds.value = [];
await router.push("/checkout/success");
} catch {
@@ -125,6 +128,12 @@ onMounted(() => void loadOrders());
/></strong>
</div>
<footer class="flex flex-wrap items-center justify-end gap-3 px-4 py-3 text-sm">
<span class="text-muted">{{ t("checkout.shippingFee") }}</span>
<span v-if="order.shipping_fee_minor > 0"
><PriceText
:amount-minor="order.shipping_fee_minor"
:currency="order.currency" /></span
><span v-else>{{ t("checkout.freeShipping") }}</span>
<template v-if="order.discount_minor > 0"
><span>{{ t("checkout.couponDiscount") }}</span
><span class="text-danger"
+34
View File
@@ -1,7 +1,16 @@
<script setup lang="ts">
import type { Order } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { t } = useI18n();
/** Paid orders handed over by the pay page; empty on a fresh load. */
const paidOrders = useState<Order[]>("checkout-paid-orders", () => []);
// One-shot display: revisiting the page must not show stale orders.
onBeforeUnmount(() => {
paidOrders.value = [];
});
const stepLabels = computed(() => [
t("checkout.steps.cart"),
@@ -24,6 +33,31 @@ const stepLabels = computed(() => [
</div>
<h1 class="mt-5 mb-2 text-2xl font-medium">{{ t("checkout.successTitle") }}</h1>
<p class="text-muted m-0">{{ t("checkout.successMessage") }}</p>
<div v-if="paidOrders.length > 0" class="mx-auto mt-6 max-w-[560px] space-y-2 text-left">
<div
v-for="order in paidOrders"
:key="order.id"
class="border-border flex flex-wrap items-center justify-between gap-3 border p-3 text-sm"
>
<span class="text-muted">{{ t("checkout.orderNo") }} {{ order.order_no }}</span>
<span class="flex items-center gap-4">
<span class="flex items-center gap-2">
<span class="text-muted">{{ t("checkout.shippingFee") }}</span>
<span v-if="order.shipping_fee_minor > 0"
><PriceText
:amount-minor="order.shipping_fee_minor"
:currency="order.currency" /></span
><span v-else>{{ t("checkout.freeShipping") }}</span>
</span>
<span class="flex items-center gap-2">
<span class="text-muted">{{ t("checkout.total") }}</span>
<strong class="text-primary"
><PriceText :amount-minor="order.total_minor" :currency="order.currency"
/></strong>
</span>
</span>
</div>
</div>
<div class="mt-7 flex justify-center gap-3">
<NuxtLink
class="border-primary bg-primary hover:bg-primary-hover inline-flex items-center justify-center rounded-md border px-4 py-2 text-sm font-medium text-white"
+7
View File
@@ -180,6 +180,13 @@ onMounted(() => {
</tr>
</tbody>
</VTable>
<p class="text-muted mt-4 text-right text-[13px]">
{{ t("checkout.shippingFee") }}:
<span v-if="order.shipping_fee_minor > 0">
<PriceText :amount-minor="order.shipping_fee_minor" :currency="order.currency" />
</span>
<span v-else class="text-success">{{ t("checkout.freeShipping") }}</span>
</p>
<p v-if="order.discount_minor > 0" class="text-primary mt-4 text-right text-[13px]">
{{ t("checkout.couponDiscount") }}:
<PriceText :amount-minor="order.discount_minor" :currency="order.currency" />
+2
View File
@@ -56,6 +56,8 @@ const LIVE_PICKS = {
getOrder: a.getOrder,
cancelOrder: a.cancelOrder,
payOrder: a.payOrder,
quoteShipping: a.quoteShipping,
listShippingCompanies: a.listShippingCompanies,
}),
shipments: (a: ApiClient) => ({
confirmDelivered: a.confirmDelivered,