feat: wave 2 migration (P3, P5, P7 openspec changes)
Implements, verifies, and archives the three remaining Wave 2 changes from openspec/MIGRATION-PLAN.md. - add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and one-time admin review, paginated own fund entries, idempotent per-shop weekly/monthly settlement statements with commission rate and one-time payout confirmation. - add-merchant-onboarding (P5): personal/enterprise applications with one live application per user, guarded review with mandatory rejection reason, and transactional shop + owner provisioning returning one-time credentials; mall onboarding/status pages and an admin review console. - add-membership-messaging (P7): platform member levels, append-only growth accrual on order completion with guarded one-way leveling, order/shipment/ refund system messages with unread/read state and soft deletion, plus the mall header unread badge. Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding, membership and messaging modules, event hooks in order/fulfillment/aftersale, and integration suites for each. Shared contract extended and all three frontends updated; code indexes, domain docs, backend guidelines and the migration tracker synced. Verification: cargo test -p vmall-api green twice consecutively; mall, admin and shop-admin builds pass; browser smoke on every new surface; openspec validate --all --strict green (33 passed). The three changes share the @vmall/shared contract, the mall mock adapter and per-app locale/nav files, so they are committed together to keep every commit buildable.
This commit is contained in:
@@ -78,6 +78,18 @@ watchEffect(() => {
|
||||
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
|
||||
>{{ $t("nav.invoices") }}</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
to="/settlements"
|
||||
active-class="bg-primary-soft text-primary"
|
||||
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
|
||||
>{{ $t("nav.settlements") }}</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
to="/shop-account"
|
||||
active-class="bg-primary-soft text-primary"
|
||||
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
|
||||
>{{ $t("nav.shopAccount") }}</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
to="/coupons"
|
||||
active-class="bg-primary-soft text-primary"
|
||||
|
||||
@@ -12,6 +12,8 @@ export const enExtra = {
|
||||
aftersales: "After-sales",
|
||||
freightTemplates: "Freight templates",
|
||||
reviews: "Reviews",
|
||||
settlements: "Settlements",
|
||||
shopAccount: "Shop account",
|
||||
},
|
||||
shop: {
|
||||
profileSaved: "Shop profile saved.",
|
||||
@@ -170,6 +172,87 @@ export const enExtra = {
|
||||
groupMembersInvalid: "At least two paid members are required.",
|
||||
groupLifetimeInvalid: "Group lifetime must be a positive number of hours.",
|
||||
groupWindowInvalid: "End must not precede start.",
|
||||
settlement: {
|
||||
generate: "Generate statement",
|
||||
generateHint:
|
||||
"Pick any date inside the target week or month. An existing statement for the same period is returned unchanged.",
|
||||
periodKind: "Period",
|
||||
periodWeek: "Week",
|
||||
periodMonth: "Month",
|
||||
periodDate: "Date inside the period",
|
||||
period: "Period",
|
||||
orderCount: "Orders",
|
||||
gross: "Gross",
|
||||
refund: "Refunds",
|
||||
commissionRate: "Commission rate",
|
||||
commission: "Commission",
|
||||
payable: "Payable",
|
||||
confirmedAt: "Confirmed at",
|
||||
generatedNew: "Statement generated.",
|
||||
generatedExisting:
|
||||
"A statement for this period already exists; the existing statement is shown.",
|
||||
newStatement: "New",
|
||||
existingStatement: "Existing",
|
||||
notClosed:
|
||||
"That period has not closed yet, so it cannot be settled. Only completed periods can be settled.",
|
||||
dateRequired: "Pick a date inside the period.",
|
||||
detail: "Statement detail",
|
||||
orders: "Contributing orders",
|
||||
noOrders: "No orders in this period.",
|
||||
orderNo: "Order no.",
|
||||
orderCurrency: "Order currency",
|
||||
hideDetail: "Hide detail",
|
||||
status: {
|
||||
pending: "Pending",
|
||||
confirmed: "Confirmed",
|
||||
},
|
||||
},
|
||||
account: {
|
||||
available: "Available balance",
|
||||
frozen: "Frozen balance",
|
||||
frozenHint: "Frozen while a withdrawal request awaits platform review.",
|
||||
withdraw: "Request a withdrawal",
|
||||
amount: "Amount",
|
||||
method: "Payout method",
|
||||
methodBank: "Bank transfer",
|
||||
methodDemo: "Demo channel",
|
||||
account: "Payout account",
|
||||
holder: "Account holder (optional)",
|
||||
withdrawHint:
|
||||
"The amount is frozen immediately and stays pending until the platform reviews the request.",
|
||||
submitWithdrawal: "Submit request",
|
||||
withdrawalRequested: "Withdrawal request submitted; the amount is now frozen pending review.",
|
||||
withdrawalHistory: "Withdrawal history",
|
||||
noWithdrawals: "No withdrawal requests yet.",
|
||||
reviewNote: "Review note",
|
||||
reviewedAt: "Reviewed at",
|
||||
entries: "Recent fund entries",
|
||||
noEntries: "No fund entries yet.",
|
||||
accountKind: "Account",
|
||||
entryReason: "Reason",
|
||||
delta: "Change",
|
||||
balanceAfter: "Balance after",
|
||||
amountInvalid: "Enter a positive amount valid for this currency.",
|
||||
methodRequired: "Choose a payout method.",
|
||||
accountRequired: "Enter the payout account.",
|
||||
insufficient: "The amount exceeds your available balance.",
|
||||
kind: {
|
||||
available: "Available",
|
||||
frozen: "Frozen",
|
||||
},
|
||||
status: {
|
||||
pending: "Pending",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
},
|
||||
reason: {
|
||||
wallet_recharge: "Demo recharge",
|
||||
wallet_withdrawal_freeze: "Withdrawal frozen",
|
||||
wallet_withdrawal_approved: "Withdrawal paid out",
|
||||
wallet_withdrawal_rejected: "Withdrawal unfrozen",
|
||||
settlement_payout: "Settlement payout",
|
||||
},
|
||||
},
|
||||
},
|
||||
freight: {
|
||||
title: "Freight templates",
|
||||
@@ -291,6 +374,8 @@ export const zhExtra = {
|
||||
shopProfile: "店铺资料",
|
||||
aftersales: "售后",
|
||||
freightTemplates: "运费模板",
|
||||
settlements: "结算对账",
|
||||
shopAccount: "店铺账户",
|
||||
},
|
||||
shop: {
|
||||
profileSaved: "店铺资料已保存。",
|
||||
@@ -349,6 +434,7 @@ export const zhExtra = {
|
||||
status: "状态",
|
||||
invoiceNo: "发票号",
|
||||
noInvoice: "—",
|
||||
orderNo: "订单号",
|
||||
items: "商品数",
|
||||
total: "合计",
|
||||
details: "详情",
|
||||
@@ -448,6 +534,83 @@ export const zhExtra = {
|
||||
groupMembersInvalid: "成团人数至少为 2。",
|
||||
groupLifetimeInvalid: "团有效期必须为正数小时。",
|
||||
groupWindowInvalid: "结束时间不能早于开始时间。",
|
||||
settlement: {
|
||||
generate: "生成结算单",
|
||||
generateHint: "可选择目标周/月内的任意一天;同一周期已存在的结算单会原样返回。",
|
||||
periodKind: "周期类型",
|
||||
periodWeek: "周",
|
||||
periodMonth: "月",
|
||||
periodDate: "周期内日期",
|
||||
period: "周期",
|
||||
orderCount: "订单数",
|
||||
gross: "销售总额",
|
||||
refund: "退款额",
|
||||
commissionRate: "佣金比例",
|
||||
commission: "佣金",
|
||||
payable: "应结金额",
|
||||
confirmedAt: "确认时间",
|
||||
generatedNew: "结算单已生成。",
|
||||
generatedExisting: "该周期已存在结算单,下方展示的是已有结算单。",
|
||||
newStatement: "新建",
|
||||
existingStatement: "已存在",
|
||||
notClosed: "该周期尚未结束,无法结算,只有已结束的周期可以结算。",
|
||||
dateRequired: "请选择周期内的日期。",
|
||||
detail: "结算单详情",
|
||||
orders: "关联订单",
|
||||
noOrders: "该周期内没有订单。",
|
||||
orderNo: "订单号",
|
||||
orderCurrency: "订单币种",
|
||||
hideDetail: "收起详情",
|
||||
status: {
|
||||
pending: "待确认",
|
||||
confirmed: "已确认",
|
||||
},
|
||||
},
|
||||
account: {
|
||||
available: "可用余额",
|
||||
frozen: "冻结余额",
|
||||
frozenHint: "提现申请待平台审核期间该金额会被冻结。",
|
||||
withdraw: "申请提现",
|
||||
amount: "金额",
|
||||
method: "提现方式",
|
||||
methodBank: "银行转账",
|
||||
methodDemo: "演示渠道",
|
||||
account: "收款账户",
|
||||
holder: "开户人(可选)",
|
||||
withdrawHint: "提交后金额立即冻结,在平台审核前保持待审核状态。",
|
||||
submitWithdrawal: "提交申请",
|
||||
withdrawalRequested: "提现申请已提交,金额已冻结待审核。",
|
||||
withdrawalHistory: "提现记录",
|
||||
noWithdrawals: "暂无提现申请。",
|
||||
reviewNote: "审核备注",
|
||||
reviewedAt: "审核时间",
|
||||
entries: "最近资金流水",
|
||||
noEntries: "暂无资金流水。",
|
||||
accountKind: "账户",
|
||||
entryReason: "原因",
|
||||
delta: "变动",
|
||||
balanceAfter: "变动后余额",
|
||||
amountInvalid: "请输入该币种下的有效正数金额。",
|
||||
methodRequired: "请选择提现方式。",
|
||||
accountRequired: "请填写收款账户。",
|
||||
insufficient: "金额超过可用余额。",
|
||||
kind: {
|
||||
available: "可用",
|
||||
frozen: "冻结",
|
||||
},
|
||||
status: {
|
||||
pending: "待审核",
|
||||
approved: "已通过",
|
||||
rejected: "已拒绝",
|
||||
},
|
||||
reason: {
|
||||
wallet_recharge: "演示充值",
|
||||
wallet_withdrawal_freeze: "提现冻结",
|
||||
wallet_withdrawal_approved: "提现已打款",
|
||||
wallet_withdrawal_rejected: "提现解冻",
|
||||
settlement_payout: "结算打款",
|
||||
},
|
||||
},
|
||||
},
|
||||
freight: {
|
||||
title: "运费模板",
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError } from "@vmall/shared";
|
||||
import type {
|
||||
Paged,
|
||||
SettlementPeriodKind,
|
||||
SettlementStatement,
|
||||
SettlementStatementDetail,
|
||||
SettlementStatus,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt } = useMoney();
|
||||
|
||||
const statements = ref<Paged<SettlementStatement> | null>(null);
|
||||
const page = ref(1);
|
||||
const statusFilter = ref<"" | SettlementStatus>("");
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const serverError = ref("");
|
||||
const notice = ref("");
|
||||
|
||||
const detailId = ref("");
|
||||
const detail = ref<SettlementStatementDetail | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const detailError = ref("");
|
||||
|
||||
const periodKind = ref<SettlementPeriodKind>("month");
|
||||
const periodDate = ref(defaultPeriodDate());
|
||||
const generating = ref(false);
|
||||
const generated = ref<SettlementStatement | null>(null);
|
||||
const generatedExisting = ref(false);
|
||||
|
||||
/** Mid-point of the previous month: safely inside a closed period. */
|
||||
function defaultPeriodDate(): string {
|
||||
const now = new Date();
|
||||
const previous = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 15));
|
||||
return previous.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
"border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2";
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function statusTone(value: SettlementStatus): "green" | "orange" {
|
||||
return value === "confirmed" ? "green" : "orange";
|
||||
}
|
||||
|
||||
/** Integer basis points to a fixed 2-decimal percentage (no float money math). */
|
||||
function formatBps(bps: number): string {
|
||||
const whole = Math.trunc(bps / 100);
|
||||
const fraction = Math.abs(bps % 100)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${whole}.${fraction}%`;
|
||||
}
|
||||
|
||||
function periodKindLabel(kind: SettlementPeriodKind): string {
|
||||
return kind === "week"
|
||||
? translate("shop.settlement.periodWeek")
|
||||
: translate("shop.settlement.periodMonth");
|
||||
}
|
||||
|
||||
async function loadStatements(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
serverError.value = "";
|
||||
try {
|
||||
statements.value = await $api.shop.listShopSettlementStatements({
|
||||
page: page.value,
|
||||
status: statusFilter.value || undefined,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail(): void {
|
||||
detailId.value = "";
|
||||
detail.value = null;
|
||||
detailError.value = "";
|
||||
}
|
||||
|
||||
async function changePage(next: number): Promise<void> {
|
||||
if (!statements.value || next < 1) return;
|
||||
if (next > Math.ceil(statements.value.total / statements.value.per_page)) return;
|
||||
page.value = next;
|
||||
closeDetail();
|
||||
await loadStatements();
|
||||
}
|
||||
|
||||
async function changeStatus(): Promise<void> {
|
||||
page.value = 1;
|
||||
closeDetail();
|
||||
await loadStatements();
|
||||
}
|
||||
|
||||
async function toggleDetail(id: string): Promise<void> {
|
||||
if (detailId.value === id) {
|
||||
closeDetail();
|
||||
return;
|
||||
}
|
||||
detailId.value = id;
|
||||
detail.value = null;
|
||||
detailError.value = "";
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await $api.shop.getShopSettlementStatement(id);
|
||||
} catch (err: unknown) {
|
||||
detailError.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
const date = periodDate.value.trim();
|
||||
if (!date) {
|
||||
error.value = translate("shop.settlement.dateRequired");
|
||||
return;
|
||||
}
|
||||
generating.value = true;
|
||||
error.value = "";
|
||||
serverError.value = "";
|
||||
notice.value = "";
|
||||
generated.value = null;
|
||||
generatedExisting.value = false;
|
||||
try {
|
||||
// Snapshot the existing statement ids first so an idempotent repeat can be
|
||||
// reported as "existing" rather than "new". 100 rows covers years of
|
||||
// weekly statements for a single shop.
|
||||
const known = await $api.shop.listShopSettlementStatements({ page: 1, per_page: 100 });
|
||||
const knownIds = new Set(known.items.map((item) => item.id));
|
||||
const statement = await $api.shop.generateShopSettlementStatement({
|
||||
period_kind: periodKind.value,
|
||||
period_start: date,
|
||||
});
|
||||
generated.value = statement;
|
||||
generatedExisting.value = knownIds.has(statement.id);
|
||||
notice.value = generatedExisting.value
|
||||
? translate("shop.settlement.generatedExisting")
|
||||
: translate("shop.settlement.generatedNew");
|
||||
page.value = 1;
|
||||
await loadStatements();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
// The period is not closed yet; keep the server message visible too.
|
||||
error.value = translate("shop.settlement.notClosed");
|
||||
serverError.value = err.message;
|
||||
} else {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
}
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMoney();
|
||||
loadStatements();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('nav.settlements')">
|
||||
<div v-if="error" class="text-danger my-2 text-sm" role="alert">
|
||||
{{ error }}<span v-if="serverError" class="text-muted"> — {{ serverError }}</span>
|
||||
</div>
|
||||
<div v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</div>
|
||||
|
||||
<VCard>
|
||||
<h2 class="mb-4 text-lg font-semibold">{{ $t("shop.settlement.generate") }}</h2>
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<VField :label="$t('shop.settlement.periodKind')">
|
||||
<select id="settlement-period-kind" v-model="periodKind" :class="selectClass">
|
||||
<option value="week">{{ $t("shop.settlement.periodWeek") }}</option>
|
||||
<option value="month">{{ $t("shop.settlement.periodMonth") }}</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="$t('shop.settlement.periodDate')">
|
||||
<VInput id="settlement-period-date" v-model="periodDate" type="date" />
|
||||
</VField>
|
||||
<div class="mb-3.5 flex items-end">
|
||||
<VBtn variant="primary" :disabled="generating" @click="generate">
|
||||
{{ generating ? $t("common.loading") : $t("shop.settlement.generate") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted text-xs">{{ $t("shop.settlement.generateHint") }}</p>
|
||||
|
||||
<div v-if="generated" class="border-border bg-bg mt-4 rounded-md border p-4">
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<VBadge :tone="generatedExisting ? 'gray' : 'blue'">{{
|
||||
generatedExisting
|
||||
? $t("shop.settlement.existingStatement")
|
||||
: $t("shop.settlement.newStatement")
|
||||
}}</VBadge>
|
||||
<VBadge :tone="statusTone(generated.status)">{{
|
||||
$t(`shop.settlement.status.${generated.status}`)
|
||||
}}</VBadge>
|
||||
<strong>{{ periodKindLabel(generated.period_kind) }}</strong>
|
||||
<span class="text-muted text-sm"
|
||||
>{{ generated.period_start }} → {{ generated.period_end }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="grid [grid-template-columns:repeat(auto-fit,minmax(160px,1fr))] gap-4">
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.orderCount") }}</span>
|
||||
<strong>{{ generated.order_count }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.gross") }}</span>
|
||||
<strong>{{ fmt(generated.gross_minor, generated.currency) }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.refund") }}</span>
|
||||
<strong>{{ fmt(generated.refund_minor, generated.currency) }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.commissionRate") }}</span>
|
||||
<strong>{{ formatBps(generated.commission_rate_bps) }}</strong>
|
||||
<span class="text-muted text-xs">{{ generated.commission_rate_bps }} bps</span>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.commission") }}</span>
|
||||
<strong>{{ fmt(generated.commission_minor, generated.currency) }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.payable") }}</span>
|
||||
<strong>{{ fmt(generated.payable_minor, generated.currency) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<div class="mt-6 mb-3 flex items-center gap-3">
|
||||
<label for="settlement-status" class="text-sm font-medium">{{
|
||||
$t("shop.filterStatus")
|
||||
}}</label>
|
||||
<select
|
||||
id="settlement-status"
|
||||
v-model="statusFilter"
|
||||
:class="selectClass"
|
||||
class="max-w-48"
|
||||
@change="changeStatus"
|
||||
>
|
||||
<option value="">{{ $t("common.all") }}</option>
|
||||
<option value="pending">{{ $t("shop.settlement.status.pending") }}</option>
|
||||
<option value="confirmed">{{ $t("shop.settlement.status.confirmed") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<VCard v-else-if="!statements?.items.length" class="text-muted">{{
|
||||
$t("common.empty")
|
||||
}}</VCard>
|
||||
<template v-else-if="statements">
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("shop.settlement.period") }}</th>
|
||||
<th>{{ $t("shop.settlement.orderCount") }}</th>
|
||||
<th>{{ $t("shop.settlement.gross") }}</th>
|
||||
<th>{{ $t("shop.settlement.refund") }}</th>
|
||||
<th>{{ $t("shop.settlement.commissionRate") }}</th>
|
||||
<th>{{ $t("shop.settlement.payable") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("shop.settlement.confirmedAt") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="statement in statements.items" :key="statement.id">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-medium">{{ periodKindLabel(statement.period_kind) }}</div>
|
||||
<div class="text-muted text-xs">
|
||||
{{ statement.period_start }} → {{ statement.period_end }}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ statement.order_count }}</td>
|
||||
<td>{{ fmt(statement.gross_minor, statement.currency) }}</td>
|
||||
<td>{{ fmt(statement.refund_minor, statement.currency) }}</td>
|
||||
<td>
|
||||
<div>{{ formatBps(statement.commission_rate_bps) }}</div>
|
||||
<div class="text-muted text-xs">
|
||||
{{ fmt(statement.commission_minor, statement.currency) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ fmt(statement.payable_minor, statement.currency) }}</td>
|
||||
<td>
|
||||
<VBadge :tone="statusTone(statement.status)">{{
|
||||
$t(`shop.settlement.status.${statement.status}`)
|
||||
}}</VBadge>
|
||||
</td>
|
||||
<td>{{ statement.confirmed_at ? formatDate(statement.confirmed_at) : "—" }}</td>
|
||||
<td>
|
||||
<VBtn size="sm" @click="toggleDetail(statement.id)">{{
|
||||
detailId === statement.id
|
||||
? $t("shop.settlement.hideDetail")
|
||||
: $t("shop.details")
|
||||
}}</VBtn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="detailId === statement.id">
|
||||
<td colspan="9">
|
||||
<p v-if="detailLoading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="detailError" class="text-danger text-sm" role="alert">
|
||||
{{ detailError }}
|
||||
</p>
|
||||
<div v-else-if="detail">
|
||||
<h3 class="mb-3 text-base font-semibold">
|
||||
{{ $t("shop.settlement.detail") }}
|
||||
</h3>
|
||||
<div class="mb-4 grid [grid-template-columns:repeat(auto-fit,minmax(160px,1fr))] gap-4">
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.orderCount") }}</span>
|
||||
<strong>{{ detail.order_count }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.gross") }}</span>
|
||||
<strong>{{ fmt(detail.gross_minor, detail.currency) }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.refund") }}</span>
|
||||
<strong>{{ fmt(detail.refund_minor, detail.currency) }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{
|
||||
$t("shop.settlement.commissionRate")
|
||||
}}</span>
|
||||
<strong>{{ formatBps(detail.commission_rate_bps) }}</strong>
|
||||
<span class="text-muted text-xs">{{ detail.commission_rate_bps }} bps</span>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.commission") }}</span>
|
||||
<strong>{{ fmt(detail.commission_minor, detail.currency) }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.payable") }}</span>
|
||||
<strong>{{ fmt(detail.payable_minor, detail.currency) }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("shop.settlement.confirmedAt") }}</span>
|
||||
<strong>{{ detail.confirmed_at ? formatDate(detail.confirmed_at) : "—" }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3 text-base font-semibold">
|
||||
{{ $t("shop.settlement.orders") }}
|
||||
</h3>
|
||||
<p v-if="!detail.orders.length" class="text-muted">
|
||||
{{ $t("shop.settlement.noOrders") }}
|
||||
</p>
|
||||
<VTable v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("shop.settlement.orderNo") }}</th>
|
||||
<th>{{ $t("shop.settlement.orderCurrency") }}</th>
|
||||
<th>{{ $t("shop.settlement.gross") }}</th>
|
||||
<th>{{ $t("shop.settlement.refund") }}</th>
|
||||
<th>{{ $t("shop.created") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="line in detail.orders" :key="line.order_id">
|
||||
<td>
|
||||
<NuxtLink
|
||||
:to="`/orders/${line.order_id}`"
|
||||
class="text-primary hover:underline"
|
||||
>{{ line.order_no }}</NuxtLink
|
||||
>
|
||||
</td>
|
||||
<td>{{ line.order_currency }}</td>
|
||||
<td>{{ fmt(line.gross_minor, detail.currency) }}</td>
|
||||
<td>{{ fmt(line.refund_minor, detail.currency) }}</td>
|
||||
<td>{{ formatDate(line.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</VTable>
|
||||
<div v-if="statements.total > statements.per_page" class="mt-4 flex items-center justify-between">
|
||||
<VBtn size="sm" :disabled="page <= 1 || loading" @click="changePage(page - 1)">{{
|
||||
$t("common.prev")
|
||||
}}</VBtn>
|
||||
<span class="text-muted text-sm"
|
||||
>{{ $t("common.page") }} {{ page }} /
|
||||
{{ Math.ceil(statements.total / statements.per_page) }}</span
|
||||
>
|
||||
<VBtn
|
||||
size="sm"
|
||||
:disabled="page >= Math.ceil(statements.total / statements.per_page) || loading"
|
||||
@click="changePage(page + 1)"
|
||||
>{{ $t("common.next") }}</VBtn
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</VPage>
|
||||
</template>
|
||||
@@ -0,0 +1,329 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError } from "@vmall/shared";
|
||||
import type {
|
||||
Paged,
|
||||
WalletEntry,
|
||||
WalletSummary,
|
||||
WalletWithdrawal,
|
||||
WithdrawalAccountDetails,
|
||||
WithdrawalStatus,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt, majorToMinor } = useMoney();
|
||||
|
||||
const wallet = ref<WalletSummary | null>(null);
|
||||
const entries = ref<Paged<WalletEntry> | null>(null);
|
||||
const entryPage = ref(1);
|
||||
const withdrawals = ref<WalletWithdrawal[]>([]);
|
||||
const loading = ref(true);
|
||||
const refreshing = ref(false);
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
|
||||
const form = reactive({ amountMajor: "", method: "bank", account: "", holder: "" });
|
||||
const formError = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
const selectClass =
|
||||
"border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2";
|
||||
|
||||
/** Backend ledger reasons that have app-local labels; unknown ones render raw. */
|
||||
const ENTRY_REASONS: Record<string, string> = {
|
||||
wallet_recharge: "shop.account.reason.wallet_recharge",
|
||||
wallet_withdrawal_freeze: "shop.account.reason.wallet_withdrawal_freeze",
|
||||
wallet_withdrawal_approved: "shop.account.reason.wallet_withdrawal_approved",
|
||||
wallet_withdrawal_rejected: "shop.account.reason.wallet_withdrawal_rejected",
|
||||
settlement_payout: "shop.account.reason.settlement_payout",
|
||||
};
|
||||
|
||||
/** Known payout methods have labels; anything else renders verbatim. */
|
||||
const METHOD_KEYS: Record<string, string> = {
|
||||
bank: "shop.account.methodBank",
|
||||
demo: "shop.account.methodDemo",
|
||||
};
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function withdrawalTone(value: WithdrawalStatus): "green" | "red" | "orange" {
|
||||
if (value === "approved") return "green";
|
||||
if (value === "rejected") return "red";
|
||||
return "orange";
|
||||
}
|
||||
|
||||
function accountKindLabel(kind: WalletEntry["account_kind"]): string {
|
||||
return kind === "frozen"
|
||||
? translate("shop.account.kind.frozen")
|
||||
: translate("shop.account.kind.available");
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string): string {
|
||||
const key = ENTRY_REASONS[reason];
|
||||
return key ? translate(key) : reason;
|
||||
}
|
||||
|
||||
function methodLabel(method: string): string {
|
||||
const key = METHOD_KEYS[method];
|
||||
return key ? translate(key) : method;
|
||||
}
|
||||
|
||||
function deltaClass(deltaMinor: number): string {
|
||||
if (deltaMinor > 0) return "text-success";
|
||||
if (deltaMinor < 0) return "text-danger";
|
||||
return "text-muted";
|
||||
}
|
||||
|
||||
async function loadWallet(): Promise<void> {
|
||||
wallet.value = await $api.getWallet();
|
||||
}
|
||||
|
||||
async function loadEntries(): Promise<void> {
|
||||
entries.value = await $api.listWalletEntries(entryPage.value);
|
||||
}
|
||||
|
||||
async function loadWithdrawals(): Promise<void> {
|
||||
withdrawals.value = await $api.listMyWithdrawals();
|
||||
}
|
||||
|
||||
async function loadAll(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await Promise.all([loadWallet(), loadWithdrawals(), loadEntries()]);
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read the summary (available/frozen) plus history after a mutation. */
|
||||
async function refreshAfterMutation(): Promise<void> {
|
||||
refreshing.value = true;
|
||||
try {
|
||||
await Promise.all([loadWallet(), loadWithdrawals(), loadEntries()]);
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeEntryPage(next: number): Promise<void> {
|
||||
if (!entries.value || next < 1) return;
|
||||
if (next > Math.ceil(entries.value.total / entries.value.per_page)) return;
|
||||
entryPage.value = next;
|
||||
error.value = "";
|
||||
try {
|
||||
await loadEntries();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitWithdrawal(): Promise<void> {
|
||||
formError.value = "";
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
const current = wallet.value;
|
||||
if (!current) {
|
||||
formError.value = translate("common.error");
|
||||
return;
|
||||
}
|
||||
const amountMinor = majorToMinor(form.amountMajor, current.currency);
|
||||
if (amountMinor === null || amountMinor <= 0) {
|
||||
formError.value = translate("shop.account.amountInvalid");
|
||||
return;
|
||||
}
|
||||
const method = form.method.trim();
|
||||
const account = form.account.trim();
|
||||
const holder = form.holder.trim();
|
||||
if (!method) {
|
||||
formError.value = translate("shop.account.methodRequired");
|
||||
return;
|
||||
}
|
||||
if (!account) {
|
||||
formError.value = translate("shop.account.accountRequired");
|
||||
return;
|
||||
}
|
||||
if (amountMinor > current.available_minor) {
|
||||
formError.value = translate("shop.account.insufficient");
|
||||
return;
|
||||
}
|
||||
const details: WithdrawalAccountDetails = { method, account };
|
||||
if (holder) details.holder = holder;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await $api.applyWithdrawal(amountMinor, details);
|
||||
form.amountMajor = "";
|
||||
form.account = "";
|
||||
form.holder = "";
|
||||
notice.value = translate("shop.account.withdrawalRequested");
|
||||
// Refresh so the frozen balance is reflected immediately.
|
||||
await refreshAfterMutation();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
// The balance changed under us; keep the server message and re-sync.
|
||||
formError.value = translate("shop.account.insufficient");
|
||||
error.value = err.message;
|
||||
await refreshAfterMutation();
|
||||
} else {
|
||||
formError.value = err instanceof Error ? err.message : translate("common.error");
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMoney();
|
||||
loadAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('nav.shopAccount')">
|
||||
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
|
||||
<div v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</div>
|
||||
|
||||
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<template v-else>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<VCard>
|
||||
<div class="text-muted text-sm">{{ $t("shop.account.available") }}</div>
|
||||
<div class="mt-1 text-2xl font-bold">
|
||||
{{ wallet ? fmt(wallet.available_minor, wallet.currency) : "—" }}
|
||||
</div>
|
||||
<div class="text-muted mt-1 text-xs">{{ wallet?.currency }}</div>
|
||||
</VCard>
|
||||
<VCard>
|
||||
<div class="text-muted text-sm">{{ $t("shop.account.frozen") }}</div>
|
||||
<div class="mt-1 text-2xl font-bold">
|
||||
{{ wallet ? fmt(wallet.frozen_minor, wallet.currency) : "—" }}
|
||||
</div>
|
||||
<div class="text-muted mt-1 text-xs">{{ $t("shop.account.frozenHint") }}</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<VCard class="mt-4">
|
||||
<h2 class="mb-4 text-lg font-semibold">{{ $t("shop.account.withdraw") }}</h2>
|
||||
<form class="grid gap-3" @submit.prevent="submitWithdrawal">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<VField
|
||||
:label="`${$t('shop.account.amount')} · ${wallet?.currency ?? ''}`"
|
||||
:error="formError"
|
||||
>
|
||||
<VInput id="withdraw-amount" v-model="form.amountMajor" inputmode="decimal" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.account.method')">
|
||||
<select id="withdraw-method" v-model="form.method" :class="selectClass">
|
||||
<option value="bank">{{ $t("shop.account.methodBank") }}</option>
|
||||
<option value="demo">{{ $t("shop.account.methodDemo") }}</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="$t('shop.account.account')">
|
||||
<VInput id="withdraw-account" v-model="form.account" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.account.holder')">
|
||||
<VInput id="withdraw-holder" v-model="form.holder" />
|
||||
</VField>
|
||||
</div>
|
||||
<p class="text-muted text-xs">{{ $t("shop.account.withdrawHint") }}</p>
|
||||
<div>
|
||||
<VBtn variant="primary" type="submit" :disabled="submitting || refreshing">
|
||||
{{ submitting ? $t("common.loading") : $t("shop.account.submitWithdrawal") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</form>
|
||||
</VCard>
|
||||
|
||||
<h2 class="mt-6 mb-3 text-base font-semibold">
|
||||
{{ $t("shop.account.withdrawalHistory") }}
|
||||
</h2>
|
||||
<p v-if="!withdrawals.length" class="text-muted">
|
||||
{{ $t("shop.account.noWithdrawals") }}
|
||||
</p>
|
||||
<VTable v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("shop.created") }}</th>
|
||||
<th>{{ $t("shop.account.amount") }}</th>
|
||||
<th>{{ $t("shop.account.method") }}</th>
|
||||
<th>{{ $t("shop.account.account") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("shop.account.reviewNote") }}</th>
|
||||
<th>{{ $t("shop.account.reviewedAt") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in withdrawals" :key="item.id">
|
||||
<td>{{ formatDate(item.created_at) }}</td>
|
||||
<td>{{ fmt(item.amount_minor, item.currency) }}</td>
|
||||
<td>{{ methodLabel(item.account_details.method) }}</td>
|
||||
<td class="max-w-xs truncate" :title="item.account_details.account">
|
||||
{{ item.account_details.account }}
|
||||
</td>
|
||||
<td>
|
||||
<VBadge :tone="withdrawalTone(item.status)">{{
|
||||
$t(`shop.account.status.${item.status}`)
|
||||
}}</VBadge>
|
||||
</td>
|
||||
<td class="max-w-xs whitespace-pre-wrap">{{ item.review_note ?? "—" }}</td>
|
||||
<td>{{ item.reviewed_at ? formatDate(item.reviewed_at) : "—" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
|
||||
<h2 class="mt-6 mb-3 text-base font-semibold">
|
||||
{{ $t("shop.account.entries") }}
|
||||
</h2>
|
||||
<p v-if="!entries?.items.length" class="text-muted">
|
||||
{{ $t("shop.account.noEntries") }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("shop.created") }}</th>
|
||||
<th>{{ $t("shop.account.accountKind") }}</th>
|
||||
<th>{{ $t("shop.account.entryReason") }}</th>
|
||||
<th>{{ $t("shop.account.delta") }}</th>
|
||||
<th>{{ $t("shop.account.balanceAfter") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in entries.items" :key="entry.id">
|
||||
<td>{{ formatDate(entry.created_at) }}</td>
|
||||
<td>{{ accountKindLabel(entry.account_kind) }}</td>
|
||||
<td>{{ reasonLabel(entry.reason) }}</td>
|
||||
<td :class="deltaClass(entry.delta_minor)">
|
||||
{{ wallet ? fmt(entry.delta_minor, wallet.currency) : "—" }}
|
||||
</td>
|
||||
<td>{{ wallet ? fmt(entry.balance_minor, wallet.currency) : "—" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
<div v-if="entries.total > entries.per_page" class="mt-4 flex items-center justify-between">
|
||||
<VBtn size="sm" :disabled="entryPage <= 1" @click="changeEntryPage(entryPage - 1)">{{
|
||||
$t("common.prev")
|
||||
}}</VBtn>
|
||||
<span class="text-muted text-sm"
|
||||
>{{ $t("common.page") }} {{ entryPage }} /
|
||||
{{ Math.ceil(entries.total / entries.per_page) }}</span
|
||||
>
|
||||
<VBtn
|
||||
size="sm"
|
||||
:disabled="entryPage >= Math.ceil(entries.total / entries.per_page)"
|
||||
@click="changeEntryPage(entryPage + 1)"
|
||||
>{{ $t("common.next") }}</VBtn
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</VPage>
|
||||
</template>
|
||||
Reference in New Issue
Block a user