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.
330 lines
12 KiB
Vue
330 lines
12 KiB
Vue
<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>
|