feat(aftersale): per-line refund/return flow with ledger-backed completion (add-aftersale-refunds)

This commit is contained in:
Chengdong Zhang
2026-09-23 16:56:30 +08:00
parent 93e5a05d48
commit 2c2b54c21c
43 changed files with 4889 additions and 32 deletions
+90
View File
@@ -19,6 +19,51 @@ export default {
pendingReceipt: "To receive",
completed: "Completed",
afterSale: "After-sale",
aftersalesTitle: "My after-sales",
aftersaleDetail: "After-sale details",
aftersaleApply: "Apply for after-sale",
aftersaleType: "Request type",
refundOnly: "Refund only",
returnRefund: "Return and refund",
aftersaleReason: "Reason",
aftersaleAmount: "Refund amount",
aftersaleRemaining: "Remaining refundable",
aftersaleEvidence: "Evidence URLs",
aftersaleEvidenceHint: "One URL per line",
submitAftersale: "Submit application",
aftersaleStatus: "Status",
aftersaleCreatedAt: "Created",
aftersaleOrder: "Order",
aftersaleItem: "Item",
aftersaleMessages: "Messages",
aftersaleMessagePlaceholder: "Write a message",
sendMessage: "Send message",
cancelAftersale: "Cancel application",
reopenAftersale: "Reopen application",
returnShipping: "Return shipping",
submitReturnTracking: "Submit tracking",
aftersaleRefunded: "Refund completed",
aftersaleRefundResult: "Refunded amount",
aftersaleCarrier: "Carrier",
aftersaleTrackingNo: "Tracking number",
aftersaleReasonHint: "Enter the same reason in both languages",
aftersaleAmountHint: "Enter a major-unit amount; it cannot exceed the remaining balance.",
aftersaleLoadFailed: "Unable to load after-sale data.",
aftersaleSubmitFailed: "Unable to submit the after-sale request.",
aftersaleActionFailed: "Unable to update this after-sale request.",
noAftersales: "No after-sale requests yet.",
applyAftersaleForItem: "Apply for after-sale",
aftersaleViewEvidence: "View evidence",
buyer: "Buyer",
merchant: "Merchant",
platform: "Platform",
aftersaleStatus_pending: "Pending",
aftersaleStatus_approved: "Approved",
aftersaleStatus_rejected: "Rejected",
aftersaleStatus_buyer_shipping: "Buyer shipping",
aftersaleStatus_merchant_confirmed: "Merchant confirmed",
aftersaleStatus_refunded: "Refunded",
aftersaleStatus_cancelled: "Cancelled",
recentOrders: "Recent orders",
favoriteProducts: "Favorite products",
viewAll: "View all",
@@ -118,6 +163,51 @@ export default {
pendingReceipt: "待收货",
completed: "已完成",
afterSale: "售后中",
aftersalesTitle: "我的售后",
aftersaleDetail: "售后详情",
aftersaleApply: "申请售后",
aftersaleType: "售后类型",
refundOnly: "仅退款",
returnRefund: "退货退款",
aftersaleReason: "申请原因",
aftersaleAmount: "退款金额",
aftersaleRemaining: "剩余可退",
aftersaleEvidence: "凭证链接",
aftersaleEvidenceHint: "每行填写一个 URL",
submitAftersale: "提交申请",
aftersaleStatus: "状态",
aftersaleCreatedAt: "申请时间",
aftersaleOrder: "订单",
aftersaleItem: "商品",
aftersaleMessages: "留言",
aftersaleMessagePlaceholder: "请输入留言",
sendMessage: "发送留言",
cancelAftersale: "取消申请",
reopenAftersale: "重新申诉",
returnShipping: "退货物流",
submitReturnTracking: "提交物流",
aftersaleRefunded: "退款已完成",
aftersaleRefundResult: "退款金额",
aftersaleCarrier: "承运商",
aftersaleTrackingNo: "物流单号",
aftersaleReasonHint: "中英文输入相同内容即可",
aftersaleAmountHint: "请输入主币种金额,不得超过剩余可退金额。",
aftersaleLoadFailed: "售后数据加载失败。",
aftersaleSubmitFailed: "售后申请提交失败。",
aftersaleActionFailed: "售后操作失败。",
noAftersales: "暂无售后申请。",
applyAftersaleForItem: "申请售后",
aftersaleViewEvidence: "查看凭证",
buyer: "买家",
merchant: "商家",
platform: "平台",
aftersaleStatus_pending: "待审核",
aftersaleStatus_approved: "已同意",
aftersaleStatus_rejected: "已拒绝",
aftersaleStatus_buyer_shipping: "买家寄回中",
aftersaleStatus_merchant_confirmed: "商家已收货",
aftersaleStatus_refunded: "已退款",
aftersaleStatus_cancelled: "已取消",
recentOrders: "最近订单",
favoriteProducts: "收藏的宝贝",
viewAll: "查看全部",
+262 -4
View File
@@ -8,6 +8,13 @@ import type {
Address,
AddressBookEntry,
AddressInput,
Aftersale,
AftersaleApplyBody,
AftersaleDetail,
AftersaleMessage,
AftersaleMessageBody,
AftersaleReturnTrackingBody,
AftersaleStatus,
ApiClient,
AuthTokens,
Cart,
@@ -77,6 +84,9 @@ interface MockState {
coupons: Coupon[];
/** Persisted customer favorites for fixed-adapter reload parity. */
favorites: Favorite[];
/** Persisted customer aftersales for fixed-adapter reload parity. */
aftersales: Aftersale[];
aftersaleMessages: AftersaleMessage[];
/** In-memory points catalog and redemptions for the fixed-data path. */
pointsProducts: IntegralProduct[];
redemptions: IntegralOrder[];
@@ -85,15 +95,17 @@ interface MockState {
orderSeq: number;
invoiceSeq: number;
redemptionSeq: number;
aftersaleSeq: number;
aftersaleMessageSeq: number;
}
// v4: customer favorites joined the persisted rollback state.
const STORAGE_KEY = "vmall.mock.state.v4";
// v5: customer aftersales joined the persisted rollback state.
const STORAGE_KEY = "vmall.mock.state.v5";
type PersistedState = Pick<
MockState,
"cart" | "orders" | "shipments" | "invoices" | "addresses" | "favorites" |
"orderSeq" | "invoiceSeq" | "addressSeq" | "favoriteSeq"
"aftersales" | "aftersaleMessages" | "orderSeq" | "invoiceSeq" | "addressSeq" |
"favoriteSeq" | "aftersaleSeq" | "aftersaleMessageSeq"
>;
// Load cart/order session state persisted by a previous page load (client only).
@@ -110,6 +122,8 @@ function loadPersisted(): PersistedState | null {
if (typeof p.orderSeq !== "number" || typeof p.invoiceSeq !== "number") return null;
if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null;
if (!Array.isArray(p.favorites) || typeof p.favoriteSeq !== "number") return null;
if (!Array.isArray(p.aftersales) || !Array.isArray(p.aftersaleMessages)) return null;
if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number") return null;
return p as PersistedState;
} catch {
return null;
@@ -312,6 +326,61 @@ function mockGroupBuying(): GroupBuyingActivityView[] {
});
}
function seedAftersales(orders: Order[]): Aftersale[] {
const item = (orderId: string, itemId: string) => {
const order = orders.find((entry) => entry.id === orderId);
const line = order?.items.find((entry) => entry.id === itemId);
return order && line ? { order, line } : null;
};
const rows: Aftersale[] = [];
const pending = item("o1", "o1-it1");
const approved = item("o1", "o1-it2");
const refunded = item("o2", "o2-it1");
const rejected = item("o2", "o2-it2");
if (pending) rows.push({
id: "as-demo-pending", order_id: pending.order.id, order_item_id: pending.line.id,
shop_id: pending.order.shop_id, user_id: MOCK_USER.id, kind: "refund_only", status: "pending",
reason: { en: "The item arrived with a visible issue.", zh: "商品到货后发现明显问题。" },
amount_minor: Math.min(500, pending.line.unit_price_minor * pending.line.qty), currency: pending.order.currency,
evidence: ["https://example.com/aftersale/demo-evidence.jpg"], reopened: false,
return_carrier: null, return_tracking_no: null, created_at: "2026-09-10T12:00:00.000Z", updated_at: "2026-09-10T12:00:00.000Z",
});
if (approved) rows.push({
id: "as-demo-approved", order_id: approved.order.id, order_item_id: approved.line.id,
shop_id: approved.order.shop_id, user_id: MOCK_USER.id, kind: "return_refund", status: "approved",
reason: { en: "The item needs to be returned.", zh: "商品需要退回。" },
amount_minor: approved.line.unit_price_minor * approved.line.qty, currency: approved.order.currency,
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
created_at: "2026-09-10T13:00:00.000Z", updated_at: "2026-09-11T09:00:00.000Z",
});
if (refunded) rows.push({
id: "as-demo-refunded", order_id: refunded.order.id, order_item_id: refunded.line.id,
shop_id: refunded.order.shop_id, user_id: MOCK_USER.id, kind: "refund_only", status: "refunded",
reason: { en: "The product did not match the listing.", zh: "商品与描述不符。" },
amount_minor: refunded.line.unit_price_minor * refunded.line.qty, currency: refunded.order.currency,
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
created_at: "2026-09-12T17:00:00.000Z", updated_at: "2026-09-14T09:00:00.000Z",
});
if (rejected) rows.push({
id: "as-demo-rejected", order_id: rejected.order.id, order_item_id: rejected.line.id,
shop_id: rejected.order.shop_id, user_id: MOCK_USER.id, kind: "return_refund", status: "rejected",
reason: { en: "Please review this return request.", zh: "请重新审核本次退货申请。" },
amount_minor: rejected.line.unit_price_minor * rejected.line.qty, currency: rejected.order.currency,
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
created_at: "2026-09-12T18:00:00.000Z", updated_at: "2026-09-13T09:00:00.000Z",
});
return rows;
}
function seedAftersaleMessages(aftersales: Aftersale[]): AftersaleMessage[] {
const refunded = aftersales.find((row) => row.id === "as-demo-refunded");
return refunded ? [{
id: "asm-demo-1", aftersale_id: refunded.id, author_role: "buyer", author_id: MOCK_USER.id,
content: { en: "Please help review this refund.", zh: "请帮忙审核退款。" }, evidence: [],
created_at: "2026-09-12T17:30:00.000Z",
}] : [];
}
function initialState(): MockState {
const persisted = loadPersisted();
// Coupons and points are session-only, so a restored snapshot re-seeds them.
@@ -326,6 +395,12 @@ function initialState(): MockState {
};
}
const seed = seedOrders(MOCK_USER.id);
const aftersales = seedAftersales(seed.orders);
for (const row of aftersales) {
if (row.status !== "refunded") continue;
const order = seed.orders.find((entry) => entry.id === row.order_id);
if (order) order.refund_total_minor += row.amount_minor;
}
const seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({
id: a.id,
user_id: MOCK_USER.id,
@@ -348,6 +423,8 @@ function initialState(): MockState {
addresses: seededAddresses,
coupons: seedCoupons(),
favorites: seedFavorites(),
aftersales,
aftersaleMessages: seedAftersaleMessages(aftersales),
pointsProducts: seedPointsProducts(),
redemptions: [],
addressSeq: 100,
@@ -355,6 +432,8 @@ function initialState(): MockState {
orderSeq: 100,
invoiceSeq: 100,
redemptionSeq: 0,
aftersaleSeq: 100,
aftersaleMessageSeq: 100,
};
}
@@ -409,6 +488,10 @@ export function createMockApi(): ApiClient {
addressSeq: state.addressSeq,
favorites: state.favorites,
favoriteSeq: state.favoriteSeq,
aftersales: state.aftersales,
aftersaleMessages: state.aftersaleMessages,
aftersaleSeq: state.aftersaleSeq,
aftersaleMessageSeq: state.aftersaleMessageSeq,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
@@ -429,6 +512,57 @@ export function createMockApi(): ApiClient {
state.orderSeq += 1;
return `VM20260917${String(state.orderSeq).padStart(3, "0")}`;
}
function findAftersale(id: string): Aftersale {
const row = state.aftersales.find((entry) => entry.id === id && entry.user_id === MOCK_USER.id);
if (!row) throw new ApiError(404, "NOT_FOUND", "Aftersale not found");
return row;
}
function findOrderItem(orderItemId: string): { order: Order; item: Order["items"][number] } | null {
for (const order of state.orders) {
const item = order.items.find((entry) => entry.id === orderItemId);
if (item) return { order, item };
}
return null;
}
function eligibleOrder(order: Order): boolean {
const statusEligible = ["paid", "fulfilling", "shipped", "completed"].includes(order.status);
const windowEnd = Date.parse(order.created_at) + 15 * 24 * 60 * 60 * 1000;
return statusEligible && Date.now() <= windowEnd;
}
function remainingFor(orderItemId: string, lineAmount: number): number {
const refunded = state.aftersales
.filter((row) => row.order_item_id === orderItemId && row.status === "refunded")
.reduce((sum, row) => sum + row.amount_minor, 0);
return Math.max(0, lineAmount - refunded);
}
function copyAftersale(row: Aftersale): Aftersale {
return { ...row, reason: { ...row.reason }, evidence: [...row.evidence] };
}
function aftersaleDetail(id: string): AftersaleDetail {
const row = findAftersale(id);
const found = findOrderItem(row.order_item_id);
if (!found || found.order.id !== row.order_id) throw new ApiError(404, "NOT_FOUND", "Aftersale item not found");
return {
...copyAftersale(row),
item: {
product_name: { ...found.item.product_name },
sku_code: found.item.sku_code,
image: found.item.image,
unit_price_minor: found.item.unit_price_minor,
qty: found.item.qty,
},
messages: state.aftersaleMessages
.filter((message) => message.aftersale_id === row.id)
.sort((a, b) => a.created_at.localeCompare(b.created_at))
.map((message) => ({ ...message, content: { ...message.content }, evidence: [...message.evidence] })),
remaining_refundable_minor: remainingFor(row.order_item_id, found.item.unit_price_minor * found.item.qty),
};
}
return {
register: () => Promise.resolve(tokens()),
@@ -573,6 +707,7 @@ export function createMockApi(): ApiClient {
currency,
total_minor: subtotal - discount,
discount_minor: discount,
refund_total_minor: 0,
coupon_id: couponId ?? null,
group_activity_id: groupHere ? activity.id : null,
group_id: groupHere ? `g-${Date.now()}` : null,
@@ -687,6 +822,119 @@ export function createMockApi(): ApiClient {
},
listMyInvoices: () => Promise.resolve(state.invoices.map((i) => ({ ...i }))),
applyForAftersale: async (body: AftersaleApplyBody): Promise<AftersaleDetail> => {
if (body.kind !== "refund_only" && body.kind !== "return_refund") {
throw new ApiError(400, "BAD_REQUEST", "Invalid aftersale kind");
}
if (!body.reason.en?.trim() || !body.reason.zh?.trim()) {
throw new ApiError(400, "BAD_REQUEST", "reason needs non-empty en and zh");
}
if (!Number.isInteger(body.amount_minor) || body.amount_minor <= 0) {
throw new ApiError(400, "BAD_REQUEST", "amount_minor must be positive");
}
const evidence = (body.evidence ?? []).map((url) => url.trim());
if (evidence.some((url) => !url)) throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
const found = findOrderItem(body.order_item_id);
if (!found || found.order.user_id !== MOCK_USER.id) throw new ApiError(404, "NOT_FOUND", "order item");
if (!eligibleOrder(found.order)) throw new ApiError(409, "CONFLICT", "order item is not eligible for after-sale");
const active = state.aftersales.some((row) => row.order_item_id === body.order_item_id &&
["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status));
if (active) throw new ApiError(409, "CONFLICT", "an active aftersale already exists for this item");
const remaining = remainingFor(body.order_item_id, found.item.unit_price_minor * found.item.qty);
if (body.amount_minor > remaining) throw new ApiError(409, "CONFLICT", "amount exceeds the remaining refundable balance");
state.aftersaleSeq += 1;
const now = new Date().toISOString();
const row: Aftersale = {
id: `as-${state.aftersaleSeq}`,
order_id: found.order.id,
order_item_id: body.order_item_id,
shop_id: found.order.shop_id,
user_id: MOCK_USER.id,
kind: body.kind,
status: "pending",
reason: { en: body.reason.en.trim(), zh: body.reason.zh.trim() },
amount_minor: body.amount_minor,
currency: found.order.currency,
evidence,
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: now,
updated_at: now,
};
state.aftersales = [row, ...state.aftersales];
persist();
return aftersaleDetail(row.id);
},
listMyAftersales: async (): Promise<Aftersale[]> => state.aftersales
.filter((row) => row.user_id === MOCK_USER.id)
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map(copyAftersale),
getAftersale: async (id: string): Promise<AftersaleDetail> => aftersaleDetail(id),
cancelAftersale: async (id: string): Promise<Aftersale> => {
const row = findAftersale(id);
if (!["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status)) {
throw new ApiError(409, "CONFLICT", "aftersale cannot be cancelled");
}
row.status = "cancelled";
row.updated_at = new Date().toISOString();
persist();
return copyAftersale(row);
},
reopenAftersale: async (id: string): Promise<Aftersale> => {
const row = findAftersale(id);
if (row.status !== "rejected" || row.reopened) {
throw new ApiError(409, "CONFLICT", "aftersale cannot be reopened");
}
row.status = "pending";
row.reopened = true;
row.updated_at = new Date().toISOString();
persist();
return copyAftersale(row);
},
submitAftersaleReturnTracking: async (id: string, body: AftersaleReturnTrackingBody): Promise<Aftersale> => {
const carrier = body.carrier.trim();
const trackingNo = body.tracking_no.trim();
if (!carrier || !trackingNo) throw new ApiError(400, "BAD_REQUEST", "carrier and tracking_no are required");
const row = findAftersale(id);
if (row.kind !== "return_refund") throw new ApiError(409, "CONFLICT", "not a return-refund aftersale");
if (row.status !== "approved") throw new ApiError(409, "CONFLICT", "aftersale is not awaiting return shipping");
row.status = "buyer_shipping";
row.return_carrier = carrier;
row.return_tracking_no = trackingNo;
row.updated_at = new Date().toISOString();
persist();
return copyAftersale(row);
},
addAftersaleMessage: async (id: string, body: AftersaleMessageBody): Promise<AftersaleMessage> => {
const row = findAftersale(id);
const en = body.content.en?.trim() ?? "";
const zh = body.content.zh?.trim() ?? "";
if (!en && !zh) throw new ApiError(400, "BAD_REQUEST", "content needs text");
const evidence = (body.evidence ?? []).map((url) => url.trim());
if (evidence.some((url) => !url)) throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
state.aftersaleMessageSeq += 1;
const now = new Date().toISOString();
const message: AftersaleMessage = {
id: `asm-${state.aftersaleMessageSeq}`,
aftersale_id: row.id,
author_role: "buyer",
author_id: MOCK_USER.id,
content: { en: en || zh, zh: zh || en },
evidence,
created_at: now,
};
state.aftersaleMessages.push(message);
row.updated_at = now;
persist();
return { ...message, content: { ...message.content }, evidence: [...message.evidence] };
},
// Mirror of the seeded storefront-content rows, so the home page renders
// identically when every domain is configured to fixed data.
@@ -974,6 +1222,13 @@ export function createMockApi(): ApiClient {
createGroupBuyingActivity: (_body: GroupBuyingActivityInput) => unsupported(),
updateGroupBuyingActivity: (_id: string, _body: GroupBuyingActivityInput) => unsupported(),
deleteGroupBuyingActivity: (_id: string) => unsupported(),
listAftersales: (_status?: AftersaleStatus) => unsupported(),
getAftersale: (_id: string) => unsupported(),
approveAftersale: (_id: string) => unsupported(),
rejectAftersale: (_id: string) => unsupported(),
confirmAftersaleReceipt: (_id: string) => unsupported(),
refundAftersale: (_id: string) => unsupported(),
addAftersaleMessage: (_id: string, _body: AftersaleMessageBody) => unsupported(),
},
admin: {
listUsers: () => unsupported(),
@@ -997,6 +1252,9 @@ export function createMockApi(): ApiClient {
listPointsRedemptions: (_page?: number) => unsupported(),
fulfillRedemption: (_id: string) => unsupported(),
cancelRedemption: (_id: string) => unsupported(),
listAftersales: (_status?: AftersaleStatus) => unsupported(),
getAftersale: (_id: string) => unsupported(),
arbitrateAftersale: (_id: string, _outcome: "refund" | "reject") => unsupported(),
},
};
}
+2 -1
View File
@@ -705,6 +705,7 @@ export function seedOrders(userId: string): MockOrderSeed {
currency: BASE_CURRENCY,
total_minor: total,
discount_minor: 0,
refund_total_minor: 0,
coupon_id: null,
group_activity_id: null,
group_id: null,
@@ -714,7 +715,7 @@ export function seedOrders(userId: string): MockOrderSeed {
};
};
const orders: Order[] = [
mk("o1", "VM20260910001", [0], "shipped", "2026-09-10T10:00:00.000Z"),
mk("o1", "VM20260910001", [0, 1], "shipped", "2026-09-10T10:00:00.000Z"),
mk("o2", "VM20260912002", [2, 6], "completed", "2026-09-12T15:30:00.000Z"),
mk("o3", "VM20260915003", [20], "pending_payment", "2026-09-15T09:12:00.000Z"),
];
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineNuxtConfig({
// Domains served by the live backend; every other domain stays on the
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
// See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3.
liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points", "flashSales", "groupBuying", "favorites"],
liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points", "flashSales", "groupBuying", "favorites", "aftersales"],
appName: "mall",
},
},
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import type { AftersaleDetail, AftersaleStatus } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const route = useRoute();
const detail = ref<AftersaleDetail | null>(null);
const loading = ref(true);
const working = ref(false);
const failed = ref(false);
const errorKey = ref("");
const carrier = ref("");
const trackingNo = ref("");
const message = ref("");
const evidence = ref("");
const progress: AftersaleStatus[] = ["pending", "approved", "buyer_shipping", "merchant_confirmed", "refunded"];
const id = computed(() => String(route.params.id ?? ""));
function tone(status: AftersaleStatus): "green" | "blue" | "red" | "orange" {
if (status === "refunded") return "green";
if (["approved", "buyer_shipping", "merchant_confirmed"].includes(status)) return "blue";
if (["rejected", "cancelled"].includes(status)) return "red";
return "orange";
}
function statusLabel(status: AftersaleStatus): string {
return t(`user.aftersaleStatus_${status}`);
}
function progressTone(status: AftersaleStatus): string {
if (!detail.value) return "border-border bg-surface text-muted";
const current = progress.indexOf(detail.value.status);
const step = progress.indexOf(status);
return step >= 0 && current >= step ? "border-primary bg-primary text-white" : "border-border bg-surface text-muted";
}
async function load(): Promise<void> {
loading.value = true;
failed.value = false;
try {
detail.value = await $api.getAftersale(id.value);
carrier.value = detail.value.return_carrier ?? "";
trackingNo.value = detail.value.return_tracking_no ?? "";
} catch {
failed.value = true;
} finally {
loading.value = false;
}
}
async function cancel(): Promise<void> {
if (!detail.value) return;
working.value = true;
errorKey.value = "";
try {
await $api.cancelAftersale(detail.value.id);
await load();
} catch {
errorKey.value = "user.aftersaleActionFailed";
} finally {
working.value = false;
}
}
async function reopen(): Promise<void> {
if (!detail.value) return;
working.value = true;
errorKey.value = "";
try {
await $api.reopenAftersale(detail.value.id);
await load();
} catch {
errorKey.value = "user.aftersaleActionFailed";
} finally {
working.value = false;
}
}
async function submitTracking(): Promise<void> {
if (!detail.value || !carrier.value.trim() || !trackingNo.value.trim()) {
errorKey.value = "user.validationRequired";
return;
}
working.value = true;
errorKey.value = "";
try {
await $api.submitAftersaleReturnTracking(detail.value.id, {
carrier: carrier.value.trim(),
tracking_no: trackingNo.value.trim(),
});
await load();
} catch {
errorKey.value = "user.aftersaleActionFailed";
} finally {
working.value = false;
}
}
async function sendMessage(): Promise<void> {
const content = message.value.trim();
if (!detail.value || !content) {
errorKey.value = "user.validationRequired";
return;
}
working.value = true;
errorKey.value = "";
try {
await $api.addAftersaleMessage(detail.value.id, {
content: { en: content, zh: content },
evidence: evidence.value.split("\n").map((url) => url.trim()).filter(Boolean),
});
message.value = "";
evidence.value = "";
await load();
} catch {
errorKey.value = "user.aftersaleActionFailed";
} finally {
working.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<VCard class="min-h-[560px]">
<div class="mb-4 flex items-center justify-between gap-3">
<h1 class="m-0 text-xl font-bold text-text">{{ t("user.aftersaleDetail") }}</h1>
<NuxtLink class="text-sm text-primary no-underline" to="/user/aftersales">{{ t("user.aftersalesTitle") }}</NuxtLink>
</div>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<p v-else-if="failed || !detail" class="py-5 text-sm text-danger">{{ t("user.aftersaleLoadFailed") }}</p>
<div v-else class="space-y-4">
<header class="flex items-center justify-between gap-3 border border-border bg-bg p-4 max-sm:flex-col max-sm:items-start">
<div>
<p class="m-0 text-sm text-muted">{{ t("user.aftersaleOrder") }} {{ detail.order_id }}</p>
<p class="mt-1 text-xs text-muted">{{ t("user.aftersaleCreatedAt") }} {{ detail.created_at.slice(0, 10) }}</p>
</div>
<VBadge :tone="tone(detail.status)">{{ statusLabel(detail.status) }}</VBadge>
</header>
<VCard :padded="false" class="p-5">
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.aftersaleItem") }}</h2>
<div class="flex items-center gap-3">
<img class="h-14 w-14 border border-border object-contain" :src="detail.item.image || '/mock/product-1.svg'" :alt="pick(detail.item.product_name, locale)" />
<div class="min-w-0 flex-1">
<p class="m-0 truncate text-sm text-text">{{ pick(detail.item.product_name, locale) }}</p>
<p class="mt-1 text-xs text-muted">{{ detail.item.sku_code }} × {{ detail.item.qty }}</p>
</div>
<PriceText :amount-minor="detail.item.unit_price_minor * detail.item.qty" :currency="detail.currency" />
</div>
<div class="mt-4 grid gap-2 text-sm text-muted sm:grid-cols-2">
<span>{{ t("user.aftersaleType") }}: {{ t(detail.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</span>
<span>{{ t("user.aftersaleAmount") }}: <PriceText :amount-minor="detail.amount_minor" :currency="detail.currency" /></span>
<span>{{ t("user.aftersaleRemaining") }}: <PriceText :amount-minor="detail.remaining_refundable_minor" :currency="detail.currency" /></span>
<span>{{ t("user.aftersaleReason") }}: {{ pick(detail.reason, locale) }}</span>
</div>
<div v-if="detail.evidence.length" class="mt-4 flex flex-wrap gap-2 text-sm">
<a v-for="url in detail.evidence" :key="url" class="text-primary" :href="url" target="_blank" rel="noreferrer">{{ t("user.aftersaleViewEvidence") }}</a>
</div>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-4 text-[15px] font-semibold text-text">{{ t("user.aftersaleStatus") }}</h2>
<div class="flex flex-wrap items-center gap-2">
<template v-for="(step, index) in progress" :key="step">
<span class="rounded-full border px-3 py-1 text-xs" :class="progressTone(step)">{{ statusLabel(step) }}</span>
<span v-if="index < progress.length - 1" class="text-muted">→</span>
</template>
</div>
<p v-if="detail.status === 'rejected' || detail.status === 'cancelled'" class="mt-3 text-sm text-danger">{{ statusLabel(detail.status) }}</p>
<div v-if="detail.return_carrier || detail.return_tracking_no" class="mt-3 text-sm text-muted">
{{ t("user.returnShipping") }}: {{ detail.return_carrier }} {{ detail.return_tracking_no }}
</div>
<div v-if="detail.status === 'refunded'" class="mt-3 text-sm text-success">
{{ t("user.aftersaleRefunded") }} · {{ t("user.aftersaleRefundResult") }}:
<PriceText :amount-minor="detail.amount_minor" :currency="detail.currency" />
</div>
<div class="mt-4 flex flex-wrap gap-2">
<VBtn v-if="['pending', 'approved', 'buyer_shipping', 'merchant_confirmed'].includes(detail.status)" variant="danger" size="sm" type="button" :disabled="working" @click="cancel">{{ t("user.cancelAftersale") }}</VBtn>
<VBtn v-if="detail.status === 'rejected'" variant="primary" size="sm" type="button" :disabled="working" @click="reopen">{{ t("user.reopenAftersale") }}</VBtn>
</div>
</VCard>
<VCard v-if="detail.status === 'approved' && detail.kind === 'return_refund'" :padded="false" class="p-5">
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.returnShipping") }}</h2>
<form class="grid max-w-[520px] gap-3" @submit.prevent="submitTracking">
<VField :label="t('user.aftersaleCarrier')"><VInput v-model="carrier" /></VField>
<VField :label="t('user.aftersaleTrackingNo')"><VInput v-model="trackingNo" /></VField>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.submitReturnTracking") }}</VBtn>
</form>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.aftersaleMessages") }}</h2>
<div v-if="detail.messages.length === 0" class="mb-4 text-sm text-muted">{{ t("user.noAftersales") }}</div>
<div v-else class="mb-4 space-y-2">
<div v-for="entry in detail.messages" :key="entry.id" class="rounded border border-border p-3" :class="entry.author_role === 'buyer' ? 'bg-bg' : 'bg-surface'">
<div class="flex items-center justify-between gap-2 text-xs text-muted"><span>{{ t(`user.${entry.author_role}`) }}</span><time>{{ entry.created_at.slice(0, 16).replace('T', ' ') }}</time></div>
<p class="m-0 mt-2 whitespace-pre-wrap text-sm text-text">{{ pick(entry.content, locale) }}</p>
<div v-if="entry.evidence.length" class="mt-2 flex flex-wrap gap-2 text-xs"><a v-for="url in entry.evidence" :key="url" class="text-primary" :href="url" target="_blank" rel="noreferrer">{{ t("user.aftersaleViewEvidence") }}</a></div>
</div>
</div>
<form class="grid max-w-[640px] gap-3" @submit.prevent="sendMessage">
<VField :label="t('user.aftersaleMessagePlaceholder')"><textarea v-model="message" class="min-h-24 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleMessagePlaceholder')" /></VField>
<VField :label="t('user.aftersaleEvidence')"><textarea v-model="evidence" class="min-h-16 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleEvidenceHint')" /></VField>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.sendMessage") }}</VBtn>
</form>
</VCard>
<p v-if="errorKey" class="m-0 text-xs text-danger">{{ t(errorKey) }}</p>
</div>
</VCard>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { Aftersale, AftersaleKind, Order } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { t } = useI18n();
const { $api } = useNuxtApp();
const route = useRoute();
const { ensureCurrencies, exponentFor } = usePrice();
const order = ref<Order | null>(null);
const aftersales = ref<Aftersale[]>([]);
const loading = ref(true);
const working = ref(false);
const errorKey = ref("");
const kind = ref<AftersaleKind>("refund_only");
const reason = ref("");
const amountMajor = ref("");
const evidence = ref("");
const orderId = computed(() => String(route.query.order_id ?? ""));
const itemId = computed(() => String(route.query.item_id ?? ""));
const item = computed(() => order.value?.items.find((entry) => entry.id === itemId.value) ?? null);
const remainingMinor = computed(() => {
const line = item.value;
if (!line || !order.value) return 0;
const refunded = aftersales.value
.filter((row) => row.order_item_id === line.id && row.status === "refunded")
.reduce((sum, row) => sum + row.amount_minor, 0);
return Math.max(0, line.unit_price_minor * line.qty - refunded);
});
async function load(): Promise<void> {
loading.value = true;
try {
await ensureCurrencies();
const [loadedOrder, loadedAftersales] = await Promise.all([$api.getOrder(orderId.value), $api.listMyAftersales()]);
order.value = loadedOrder;
aftersales.value = loadedAftersales;
} catch {
errorKey.value = "user.aftersaleLoadFailed";
} finally {
loading.value = false;
}
}
function amountMinor(): number | null {
const raw = Number(amountMajor.value);
if (!Number.isFinite(raw) || raw <= 0 || !order.value) return null;
const exponent = exponentFor(order.value.currency);
const minor = Math.round(raw * 10 ** exponent);
return Number.isSafeInteger(minor) && minor > 0 ? minor : null;
}
async function submit(): Promise<void> {
errorKey.value = "";
const content = reason.value.trim();
const amount = amountMinor();
if (!item.value || !order.value || !content || amount === null) {
errorKey.value = "user.validationRequired";
return;
}
if (amount > remainingMinor.value) {
errorKey.value = "user.aftersaleAmountHint";
return;
}
working.value = true;
try {
const created = await $api.applyForAftersale({
order_item_id: item.value.id,
kind: kind.value,
reason: { en: content, zh: content },
amount_minor: amount,
evidence: evidence.value.split("\n").map((url) => url.trim()).filter(Boolean),
});
await navigateTo(`/user/aftersales/${created.id}`);
} catch {
errorKey.value = "user.aftersaleSubmitFailed";
} finally {
working.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<VCard class="min-h-[560px]">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.aftersaleApply") }}</h1>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<form v-else class="grid max-w-[640px] gap-4" @submit.prevent="submit">
<VCard :padded="false" class="flex items-center gap-3 border border-border p-4">
<img class="h-14 w-14 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="item.product_name.en" />
<div class="min-w-0 flex-1"><p class="m-0 truncate text-sm text-text">{{ item.product_name.en }}</p><p class="mt-1 text-xs text-muted">{{ item.sku_code }} × {{ item.qty }}</p></div>
</VCard>
<fieldset class="grid gap-2 border-0 p-0">
<legend class="mb-1 block text-[13px] text-muted">{{ t("user.aftersaleType") }}</legend>
<label class="text-sm text-text"><input v-model="kind" class="mr-1 accent-primary" type="radio" value="refund_only" /> {{ t("user.refundOnly") }}</label>
<label class="text-sm text-text"><input v-model="kind" class="mr-1 accent-primary" type="radio" value="return_refund" /> {{ t("user.returnRefund") }}</label>
</fieldset>
<VField :label="t('user.aftersaleReason')"><textarea v-model="reason" class="min-h-24 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleReasonHint')" /></VField>
<VField :label="t('user.aftersaleAmount')">
<VInput v-model="amountMajor" />
<p class="m-0 mt-1 text-xs text-muted">{{ t("user.aftersaleAmountHint") }} {{ t("user.aftersaleRemaining") }}: <PriceText :amount-minor="remainingMinor" :currency="order.currency" /></p>
</VField>
<VField :label="t('user.aftersaleEvidence')"><textarea v-model="evidence" class="min-h-20 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleEvidenceHint')" /></VField>
<p v-if="errorKey" class="m-0 text-xs text-danger">{{ t(errorKey) }}</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.submitAftersale") }}</VBtn>
</form>
</VCard>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { Aftersale } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const rows = ref<Aftersale[]>([]);
const loading = ref(true);
const failed = ref(false);
function tone(status: Aftersale["status"]): "green" | "blue" | "red" | "orange" {
if (status === "refunded") return "green";
if (["approved", "buyer_shipping", "merchant_confirmed"].includes(status)) return "blue";
if (["rejected", "cancelled"].includes(status)) return "red";
return "orange";
}
function statusLabel(status: Aftersale["status"]): string {
return t(`user.aftersaleStatus_${status}`);
}
async function load(): Promise<void> {
loading.value = true;
failed.value = false;
try {
rows.value = await $api.listMyAftersales();
} catch {
failed.value = true;
} finally {
loading.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<VCard class="min-h-[560px]">
<div class="mb-4 flex items-center justify-between gap-3">
<h1 class="m-0 text-xl font-bold text-text">{{ t("user.aftersalesTitle") }}</h1>
<NuxtLink class="text-sm text-primary no-underline" to="/user/orders">{{ t("user.myOrders") }}</NuxtLink>
</div>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<p v-else-if="failed" class="py-5 text-sm text-danger">{{ t("user.aftersaleLoadFailed") }}</p>
<UiEmptyState v-else-if="rows.length === 0" :text="t('user.noAftersales')" />
<VTable v-else>
<thead>
<tr>
<th>{{ t("user.aftersaleOrder") }}</th>
<th>{{ t("user.aftersaleType") }}</th>
<th>{{ t("user.aftersaleAmount") }}</th>
<th>{{ t("user.aftersaleStatus") }}</th>
<th>{{ t("user.aftersaleCreatedAt") }}</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>
<NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{ row.order_id }}</NuxtLink>
</td>
<td>{{ t(row.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</td>
<td><PriceText :amount-minor="row.amount_minor" :currency="row.currency" /></td>
<td><VBadge :tone="tone(row.status)">{{ statusLabel(row.status) }}</VBadge></td>
<td class="text-muted">{{ row.created_at.slice(0, 10) }}</td>
<td><NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{ t("user.viewDetails") }}</NuxtLink></td>
</tr>
</tbody>
</VTable>
</VCard>
</template>
+15 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { InvoiceKind, Order, Shipment } from "@vmall/shared";
import type { Aftersale, InvoiceKind, Order, Shipment } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
@@ -10,6 +10,7 @@ const route = useRoute();
const order = ref<Order | null>(null);
const shipments = ref<Shipment[]>([]);
const aftersales = ref<Aftersale[]>([]);
const loading = ref(true);
const confirmingShipmentId = ref("");
const invoiceWorking = ref(false);
@@ -23,13 +24,23 @@ const invoiceKind = ref<InvoiceKind>("personal");
const orderId = computed(() => String(route.params.id ?? ""));
const orderShipments = computed(() => shipments.value.filter((shipment) => shipment.order_id === order.value?.id));
const activeAftersaleItemIds = computed(() => new Set(
aftersales.value
.filter((row) => ["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status))
.map((row) => row.order_item_id),
));
function canApply(itemId: string): boolean {
return Boolean(order.value && ["paid", "fulfilling", "shipped", "completed"].includes(order.value.status) && !activeAftersaleItemIds.value.has(itemId));
}
async function load(): Promise<void> {
loading.value = true;
try {
const [o, allShipments] = await Promise.all([$api.getOrder(orderId.value), $api.listMyShipments()]);
const [o, allShipments, mine] = await Promise.all([$api.getOrder(orderId.value), $api.listMyShipments(), $api.listMyAftersales()]);
order.value = o;
shipments.value = allShipments;
aftersales.value = mine;
} finally {
loading.value = false;
}
@@ -92,13 +103,14 @@ onMounted(() => {
<VCard :padded="false" class="p-5">
<h2 class="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.orderItems") }}</h2>
<VTable>
<thead><tr><th>{{ t("user.product") }}</th><th>{{ t("common.price") }}</th><th>{{ t("common.qty") }}</th><th>{{ t("common.total") }}</th></tr></thead>
<thead><tr><th>{{ t("user.product") }}</th><th>{{ t("common.price") }}</th><th>{{ t("common.qty") }}</th><th>{{ t("common.total") }}</th><th /></tr></thead>
<tbody>
<tr v-for="item in order.items" :key="item.id">
<td><div class="flex items-center gap-2.5"><img class="h-11 w-11 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" /><span>{{ pick(item.product_name, locale) }}</span><VBadge v-if="item.flash_sale_item_id" tone="red">{{ t("marketing.flashTag") }}</VBadge></div></td>
<td><PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" /></td>
<td>{{ item.qty }}</td>
<td><PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="order.currency" /></td>
<td><NuxtLink v-if="canApply(item.id)" class="text-primary no-underline" :to="{ path: '/user/aftersales/apply', query: { order_id: order.id, item_id: item.id } }">{{ t("user.applyAftersaleForItem") }}</NuxtLink></td>
</tr>
</tbody>
</VTable>
+12 -1
View File
@@ -24,7 +24,8 @@ type LiveDomain =
| "points"
| "flashSales"
| "groupBuying"
| "favorites";
| "favorites"
| "aftersales";
/**
* Explicit per-domain method picks rather than a string allowlist: indexing
@@ -92,6 +93,15 @@ const LIVE_PICKS = {
addShopFavorite: a.addShopFavorite,
removeShopFavorite: a.removeShopFavorite,
}),
aftersales: (a: ApiClient) => ({
applyForAftersale: a.applyForAftersale,
listMyAftersales: a.listMyAftersales,
getAftersale: a.getAftersale,
cancelAftersale: a.cancelAftersale,
reopenAftersale: a.reopenAftersale,
submitAftersaleReturnTracking: a.submitAftersaleReturnTracking,
addAftersaleMessage: a.addAftersaleMessage,
}),
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
@@ -115,6 +125,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
"flashSales",
"groupBuying",
"favorites",
"aftersales",
];
export default defineNuxtPlugin(() => {