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:
@@ -134,6 +134,23 @@ const statusLinks = computed(() => [
|
||||
><b class="text-primary text-lg font-semibold">{{ item.count }}</b></NuxtLink
|
||||
>
|
||||
</div>
|
||||
<nav class="border-border flex flex-wrap items-center gap-x-6 gap-y-2 border-t py-3">
|
||||
<NuxtLink
|
||||
class="text-primary hover:text-primary-hover text-sm no-underline"
|
||||
to="/user/wallet"
|
||||
>{{ t("wallet.title") }} ›</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
class="text-primary hover:text-primary-hover text-sm no-underline"
|
||||
to="/user/membership"
|
||||
>{{ t("membership.title") }} ›</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
class="text-primary hover:text-primary-hover text-sm no-underline"
|
||||
to="/user/messages"
|
||||
>{{ t("messaging.title") }} ›</NuxtLink
|
||||
>
|
||||
</nav>
|
||||
</VCard>
|
||||
|
||||
<VCard>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import type { GrowthLogEntry, MembershipStatus } from "@vmall/shared";
|
||||
import { t as localizedText } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
|
||||
const status = ref<MembershipStatus | null>(null);
|
||||
const logs = ref<GrowthLogEntry[]>([]);
|
||||
const logPage = ref(1);
|
||||
const logTotal = ref(0);
|
||||
const logPerPage = ref(20);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const logError = ref("");
|
||||
|
||||
const REASON_KEYS: Record<string, string> = {
|
||||
order_complete: "membership.reason_order_complete",
|
||||
};
|
||||
|
||||
function levelName(name: Record<string, string>): string {
|
||||
return localizedText(name, locale.value);
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string): string {
|
||||
const key = REASON_KEYS[reason];
|
||||
return key ? t(key) : reason;
|
||||
}
|
||||
|
||||
async function loadStatus(): Promise<void> {
|
||||
status.value = await $api.getMembership();
|
||||
}
|
||||
|
||||
async function loadLogs(page: number): Promise<void> {
|
||||
const result = await $api.listGrowthLogs(page);
|
||||
logs.value = result.items;
|
||||
logPage.value = result.page;
|
||||
logTotal.value = result.total;
|
||||
logPerPage.value = result.per_page;
|
||||
}
|
||||
|
||||
async function changeLogPage(page: number): Promise<void> {
|
||||
logError.value = "";
|
||||
try {
|
||||
await loadLogs(page);
|
||||
} catch {
|
||||
logError.value = t("membership.historyFailed");
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await Promise.all([loadStatus(), loadLogs(1)]);
|
||||
} catch {
|
||||
error.value = t("membership.loadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress inside the current band: from the level's own threshold to the next
|
||||
* one. Below the lowest threshold the band starts at zero. Growth is a plain
|
||||
* integer, so the percentage is integer arithmetic with one rounding.
|
||||
*/
|
||||
const progressPercent = computed<number | null>(() => {
|
||||
const current = status.value;
|
||||
if (!current || !current.next_level) return null;
|
||||
const base = current.level?.growth_threshold ?? 0;
|
||||
const span = current.next_level.growth_threshold - base;
|
||||
if (span <= 0) return null;
|
||||
const within = Math.min(Math.max(current.growth_total - base, 0), span);
|
||||
return Math.round((within / span) * 100);
|
||||
});
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<h1 class="text-text mb-4 text-xl font-bold">{{ t("membership.title") }}</h1>
|
||||
<p
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
|
||||
<p v-else-if="!status" class="text-muted py-10">{{ t("membership.loadFailed") }}</p>
|
||||
<template v-else>
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-2">
|
||||
<div class="border-border bg-primary-soft border p-[18px]">
|
||||
<span class="text-muted block text-xs">{{ t("membership.currentLevel") }}</span>
|
||||
<div v-if="status.level" class="mt-2.5 flex items-center gap-2.5">
|
||||
<span
|
||||
class="border-primary/40 text-primary flex h-9 w-9 items-center justify-center rounded-full border text-sm font-bold"
|
||||
>{{ status.level.icon }}</span
|
||||
>
|
||||
<strong class="text-primary text-xl">{{ levelName(status.level.name) }}</strong>
|
||||
</div>
|
||||
<strong v-else class="text-muted mt-2.5 block text-xl">{{
|
||||
t("membership.noLevel")
|
||||
}}</strong>
|
||||
<p v-if="status.level" class="text-muted mt-2 mb-0 text-xs">
|
||||
{{ t("membership.benefits") }}: {{ levelName(status.level.benefits) }}
|
||||
</p>
|
||||
<p v-else-if="status.next_level" class="text-muted mt-2 mb-0 text-xs">
|
||||
{{
|
||||
t("membership.noLevelHint", {
|
||||
threshold: status.next_level.growth_threshold,
|
||||
unit: t("membership.growthUnit"),
|
||||
level: levelName(status.next_level.name),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-border bg-bg border p-[18px]">
|
||||
<span class="text-muted block text-xs">{{ t("membership.growthTotal") }}</span>
|
||||
<strong class="text-primary mt-2.5 block text-xl">{{ status.growth_total }}</strong>
|
||||
<span class="text-muted mt-1 block text-xs">{{ t("membership.growthUnit") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="border-border mb-6 rounded-md border p-4">
|
||||
<template v-if="status.next_level">
|
||||
<h2 class="text-text m-0 text-lg font-semibold">
|
||||
{{ t("membership.progressTitle", { level: levelName(status.next_level.name) }) }}
|
||||
</h2>
|
||||
<p class="text-muted mt-1 mb-3 text-xs">
|
||||
{{
|
||||
t("membership.progressThreshold", {
|
||||
current: status.growth_total,
|
||||
threshold: status.next_level.growth_threshold,
|
||||
unit: t("membership.growthUnit"),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="bg-border h-2.5 w-full overflow-hidden rounded-full"
|
||||
role="progressbar"
|
||||
:aria-valuenow="progressPercent ?? 0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-label="t('membership.progressTitle', { level: levelName(status.next_level.name) })"
|
||||
>
|
||||
<div
|
||||
class="bg-primary h-full rounded-full transition-all"
|
||||
:style="{ width: `${progressPercent ?? 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-primary mt-2 mb-0 text-xs font-medium">
|
||||
{{
|
||||
t("membership.remaining", {
|
||||
remaining: status.next_level.remaining,
|
||||
unit: t("membership.growthUnit"),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="status.level">
|
||||
<h2 class="text-text m-0 text-lg font-semibold">{{ t("membership.topLevel") }}</h2>
|
||||
<p class="text-muted mt-1 mb-0 text-xs">{{ t("membership.topLevelHint") }}</p>
|
||||
</template>
|
||||
<p v-else class="text-muted m-0 text-sm">{{ t("membership.noLevel") }}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("membership.historyTitle") }}</h2>
|
||||
<p v-if="logError" role="alert" class="text-danger mb-3 text-sm">{{ logError }}</p>
|
||||
<UiEmptyState v-if="logs.length === 0" :text="t('membership.historyEmpty')" />
|
||||
<template v-else>
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("membership.historyDate") }}</th>
|
||||
<th>{{ t("membership.historyReason") }}</th>
|
||||
<th class="text-right!">{{ t("membership.historyDelta") }}</th>
|
||||
<th class="text-right!">{{ t("membership.historyTotal") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in logs" :key="entry.id">
|
||||
<td class="text-muted text-xs whitespace-nowrap">
|
||||
{{ entry.created_at.slice(0, 10) }}
|
||||
</td>
|
||||
<td>{{ reasonLabel(entry.reason) }}</td>
|
||||
<td
|
||||
class="text-right font-semibold"
|
||||
:class="entry.delta < 0 ? 'text-danger' : 'text-success'"
|
||||
>
|
||||
{{ entry.delta < 0 ? "−" : "+" }}{{ Math.abs(entry.delta) }}
|
||||
</td>
|
||||
<td class="text-right">{{ entry.growth_total }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
<UiPagination
|
||||
:page="logPage"
|
||||
:total="logTotal"
|
||||
:per-page="logPerPage"
|
||||
@change="changeLogPage"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message, MessageKind } from "@vmall/shared";
|
||||
import { t as localizedText } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const { unread, refresh: refreshUnread } = useUnreadMessages();
|
||||
|
||||
const messages = ref<Message[]>([]);
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const perPage = ref(20);
|
||||
const unreadOnly = ref(false);
|
||||
const expandedId = ref<string | null>(null);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
const actionError = ref("");
|
||||
const actionNotice = ref("");
|
||||
|
||||
const KIND_KEYS: Record<MessageKind, string> = {
|
||||
order_paid: "messaging.kind_order_paid",
|
||||
order_shipped: "messaging.kind_order_shipped",
|
||||
refund_completed: "messaging.kind_refund_completed",
|
||||
};
|
||||
|
||||
const KIND_TONES: Record<MessageKind, "blue" | "green" | "orange"> = {
|
||||
order_paid: "blue",
|
||||
order_shipped: "green",
|
||||
refund_completed: "orange",
|
||||
};
|
||||
|
||||
function kindLabel(kind: MessageKind): string {
|
||||
return t(KIND_KEYS[kind]);
|
||||
}
|
||||
|
||||
function title(message: Message): string {
|
||||
return localizedText(message.title, locale.value);
|
||||
}
|
||||
|
||||
function body(message: Message): string {
|
||||
return localizedText(message.body, locale.value);
|
||||
}
|
||||
|
||||
function formatTime(createdAt: string): string {
|
||||
return createdAt.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
async function load(targetPage: number): Promise<void> {
|
||||
const result = await $api.listMessages({
|
||||
page: targetPage,
|
||||
unread_only: unreadOnly.value,
|
||||
});
|
||||
messages.value = result.items;
|
||||
page.value = result.page;
|
||||
total.value = result.total;
|
||||
perPage.value = result.per_page;
|
||||
}
|
||||
|
||||
/** Re-read both the list and the badge count after any mutation. */
|
||||
async function refreshAll(targetPage = page.value): Promise<void> {
|
||||
await Promise.all([load(targetPage), refreshUnread()]);
|
||||
}
|
||||
|
||||
async function initialLoad(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await refreshAll(1);
|
||||
} catch {
|
||||
error.value = t("messaging.loadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(targetPage: number): Promise<void> {
|
||||
actionError.value = "";
|
||||
try {
|
||||
await load(targetPage);
|
||||
} catch {
|
||||
actionError.value = t("messaging.listFailed");
|
||||
}
|
||||
}
|
||||
|
||||
async function setUnreadOnly(value: boolean): Promise<void> {
|
||||
unreadOnly.value = value;
|
||||
expandedId.value = null;
|
||||
actionError.value = "";
|
||||
try {
|
||||
await load(1);
|
||||
} catch {
|
||||
actionError.value = t("messaging.listFailed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Opening an unread message is what marks it read. */
|
||||
async function toggle(message: Message): Promise<void> {
|
||||
if (expandedId.value === message.id) {
|
||||
expandedId.value = null;
|
||||
return;
|
||||
}
|
||||
expandedId.value = message.id;
|
||||
if (message.status === "unread") await markRead(message);
|
||||
}
|
||||
|
||||
async function markRead(message: Message): Promise<void> {
|
||||
if (message.status === "read") return;
|
||||
actionError.value = "";
|
||||
actionNotice.value = "";
|
||||
try {
|
||||
await $api.markMessageRead(message.id);
|
||||
await refreshAll();
|
||||
} catch {
|
||||
actionError.value = t("messaging.markReadFailed");
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead(): Promise<void> {
|
||||
actionError.value = "";
|
||||
actionNotice.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
const result = await $api.markAllMessagesRead();
|
||||
expandedId.value = null;
|
||||
await refreshAll(1);
|
||||
actionNotice.value = t("messaging.markAllSuccess", { count: result.updated });
|
||||
} catch {
|
||||
actionError.value = t("messaging.markAllFailed");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(message: Message): Promise<void> {
|
||||
if (!confirm(t("messaging.deleteConfirm"))) return;
|
||||
actionError.value = "";
|
||||
actionNotice.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
await $api.deleteMessage(message.id);
|
||||
if (expandedId.value === message.id) expandedId.value = null;
|
||||
const target = messages.value.length === 1 && page.value > 1 ? page.value - 1 : page.value;
|
||||
await refreshAll(target);
|
||||
} catch {
|
||||
actionError.value = t("messaging.deleteFailed");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void initialLoad());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-text m-0 text-xl font-bold">{{ t("messaging.title") }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="text-muted flex cursor-pointer items-center gap-1.5 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary"
|
||||
:checked="unreadOnly"
|
||||
@change="setUnreadOnly(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ t("messaging.unreadOnly") }}
|
||||
</label>
|
||||
<VBtn
|
||||
variant="primary"
|
||||
size="sm"
|
||||
:disabled="busy || unread === 0"
|
||||
@click="markAllRead"
|
||||
>
|
||||
{{ t("messaging.markAllRead") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
|
||||
<template v-else>
|
||||
<p v-if="actionError" role="alert" class="text-danger mb-3 text-sm">{{ actionError }}</p>
|
||||
<p v-if="actionNotice" role="status" class="text-success mb-3 text-sm">
|
||||
{{ actionNotice }}
|
||||
</p>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="messages.length === 0"
|
||||
:text="unreadOnly ? t('messaging.emptyUnread') : t('messaging.empty')"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<article
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="border-border border"
|
||||
:class="message.status === 'unread' ? 'bg-primary-soft' : 'bg-surface'"
|
||||
>
|
||||
<header class="flex flex-wrap items-center gap-2.5 px-3 py-2.5">
|
||||
<VBadge :tone="KIND_TONES[message.kind]">{{ kindLabel(message.kind) }}</VBadge>
|
||||
<VBadge :tone="message.status === 'unread' ? 'orange' : 'gray'">
|
||||
{{
|
||||
message.status === "unread"
|
||||
? t("messaging.statusUnread")
|
||||
: t("messaging.statusRead")
|
||||
}}
|
||||
</VBadge>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary min-w-0 flex-1 cursor-pointer truncate text-left text-sm font-medium"
|
||||
@click="toggle(message)"
|
||||
>
|
||||
{{ title(message) }}
|
||||
</button>
|
||||
<span class="text-muted text-xs whitespace-nowrap">{{
|
||||
formatTime(message.created_at)
|
||||
}}</span>
|
||||
<VBtn size="sm" @click="toggle(message)">
|
||||
{{ expandedId === message.id ? t("messaging.close") : t("messaging.open") }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="message.status === 'unread'"
|
||||
size="sm"
|
||||
:disabled="busy"
|
||||
@click="markRead(message)"
|
||||
>
|
||||
{{ t("messaging.markRead") }}
|
||||
</VBtn>
|
||||
<VBtn variant="danger" size="sm" :disabled="busy" @click="remove(message)">
|
||||
{{ t("messaging.delete") }}
|
||||
</VBtn>
|
||||
</header>
|
||||
<p
|
||||
v-if="expandedId === message.id"
|
||||
class="border-border text-text m-0 border-t px-3 py-3 text-sm"
|
||||
>
|
||||
{{ body(message) }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<UiPagination :page="page" :total="total" :per-page="perPage" @change="changePage" />
|
||||
</template>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError, formatMoney } from "@vmall/shared";
|
||||
import type { WalletEntry, WalletSummary, WalletWithdrawal, WithdrawalStatus } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const { ensureCurrencies, exponentFor } = usePrice();
|
||||
|
||||
const wallet = ref<WalletSummary | null>(null);
|
||||
const entries = ref<WalletEntry[]>([]);
|
||||
const entryPage = ref(1);
|
||||
const entryTotal = ref(0);
|
||||
const entryPerPage = ref(20);
|
||||
const withdrawals = ref<WalletWithdrawal[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const entryError = ref("");
|
||||
|
||||
// Demo recharge form state.
|
||||
const rechargeMajor = ref("");
|
||||
const recharging = ref(false);
|
||||
const rechargeError = ref("");
|
||||
const rechargeNotice = ref("");
|
||||
|
||||
// Withdrawal request form state.
|
||||
const withdrawMajor = ref("");
|
||||
const withdrawMethod = ref("bank");
|
||||
const withdrawAccount = ref("");
|
||||
const withdrawHolder = ref("");
|
||||
const withdrawing = ref(false);
|
||||
const withdrawError = ref("");
|
||||
const withdrawNotice = ref("");
|
||||
|
||||
const walletCurrency = computed(() => wallet.value?.currency ?? "");
|
||||
|
||||
const REASON_KEYS: Record<string, string> = {
|
||||
wallet_recharge: "wallet.reason_wallet_recharge",
|
||||
wallet_withdrawal_freeze: "wallet.reason_wallet_withdrawal_freeze",
|
||||
wallet_withdrawal_approved: "wallet.reason_wallet_withdrawal_approved",
|
||||
wallet_withdrawal_rejected: "wallet.reason_wallet_withdrawal_rejected",
|
||||
order_payment: "wallet.reason_order_payment",
|
||||
aftersale_refund: "wallet.reason_aftersale_refund",
|
||||
settlement_payout: "wallet.reason_settlement_payout",
|
||||
opening_balance: "wallet.reason_opening_balance",
|
||||
};
|
||||
|
||||
const METHOD_KEYS: Record<string, string> = {
|
||||
bank: "wallet.withdrawMethodBank",
|
||||
alipay: "wallet.withdrawMethodAlipay",
|
||||
wechat: "wallet.withdrawMethodWechat",
|
||||
};
|
||||
|
||||
const STATUS_KEYS: Record<WithdrawalStatus, string> = {
|
||||
pending: "wallet.status_pending",
|
||||
approved: "wallet.status_approved",
|
||||
rejected: "wallet.status_rejected",
|
||||
};
|
||||
|
||||
/** Currency-aware formatting; the exponent comes from the currency table. */
|
||||
function money(amountMinor: number, code: string): string {
|
||||
if (!code) return String(amountMinor);
|
||||
return formatMoney(amountMinor, code, exponentFor(code), locale.value);
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string): string {
|
||||
const key = REASON_KEYS[reason];
|
||||
return key ? t(key) : reason;
|
||||
}
|
||||
|
||||
function methodLabel(method: string): string {
|
||||
const key = METHOD_KEYS[method];
|
||||
return key ? t(key) : method;
|
||||
}
|
||||
|
||||
function statusTone(status: WithdrawalStatus): "green" | "orange" | "red" {
|
||||
if (status === "approved") return "green";
|
||||
if (status === "rejected") return "red";
|
||||
return "orange";
|
||||
}
|
||||
|
||||
async function loadWallet(): Promise<void> {
|
||||
wallet.value = await $api.getWallet();
|
||||
}
|
||||
|
||||
async function loadEntries(page: number): Promise<void> {
|
||||
const result = await $api.listWalletEntries(page);
|
||||
entries.value = result.items;
|
||||
entryPage.value = result.page;
|
||||
entryTotal.value = result.total;
|
||||
entryPerPage.value = result.per_page;
|
||||
}
|
||||
|
||||
async function loadWithdrawals(): Promise<void> {
|
||||
withdrawals.value = await $api.listMyWithdrawals();
|
||||
}
|
||||
|
||||
/** Re-read backend truth after any mutation so no balance is local-only. */
|
||||
async function refreshAll(): Promise<void> {
|
||||
await Promise.all([loadWallet(), loadEntries(1), loadWithdrawals()]);
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
// Currencies carry the exponent (JPY is 0), so load them before formatting.
|
||||
await ensureCurrencies();
|
||||
await refreshAll();
|
||||
} catch {
|
||||
error.value = t("wallet.loadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeEntryPage(page: number): Promise<void> {
|
||||
entryError.value = "";
|
||||
try {
|
||||
await loadEntries(page);
|
||||
} catch {
|
||||
entryError.value = t("wallet.entriesFailed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Major-unit input to integer minor units using the account currency exponent. */
|
||||
function toMinor(major: string): number | null {
|
||||
const raw = Number(major);
|
||||
if (!Number.isFinite(raw) || raw <= 0) return null;
|
||||
const code = walletCurrency.value;
|
||||
if (!code) return null;
|
||||
const minor = Math.round(raw * 10 ** exponentFor(code));
|
||||
return Number.isSafeInteger(minor) && minor > 0 ? minor : null;
|
||||
}
|
||||
|
||||
async function submitRecharge(): Promise<void> {
|
||||
rechargeError.value = "";
|
||||
rechargeNotice.value = "";
|
||||
const amount = toMinor(rechargeMajor.value);
|
||||
if (amount === null) {
|
||||
rechargeError.value = t("wallet.validationRequired");
|
||||
return;
|
||||
}
|
||||
recharging.value = true;
|
||||
try {
|
||||
const result = await $api.rechargeWallet(amount);
|
||||
await refreshAll();
|
||||
rechargeNotice.value = t("wallet.rechargeSuccess", {
|
||||
amount: money(result.amount_minor, result.currency),
|
||||
});
|
||||
rechargeMajor.value = "";
|
||||
} catch (err: unknown) {
|
||||
const conflict = err instanceof ApiError && err.status === 409;
|
||||
rechargeError.value = conflict ? t("wallet.rechargeConflict") : t("wallet.rechargeFailed");
|
||||
// A conflict means the server state moved; converge on it instead of retrying.
|
||||
if (conflict) await refreshAll();
|
||||
} finally {
|
||||
recharging.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitWithdrawal(): Promise<void> {
|
||||
withdrawError.value = "";
|
||||
withdrawNotice.value = "";
|
||||
const amount = toMinor(withdrawMajor.value);
|
||||
const account = withdrawAccount.value.trim();
|
||||
if (amount === null || !withdrawMethod.value.trim() || !account) {
|
||||
withdrawError.value = t("wallet.validationRequired");
|
||||
return;
|
||||
}
|
||||
withdrawing.value = true;
|
||||
try {
|
||||
const holder = withdrawHolder.value.trim();
|
||||
await $api.applyWithdrawal(amount, {
|
||||
method: withdrawMethod.value,
|
||||
account,
|
||||
...(holder ? { holder } : {}),
|
||||
});
|
||||
await refreshAll();
|
||||
withdrawNotice.value = t("wallet.withdrawSuccess");
|
||||
withdrawMajor.value = "";
|
||||
withdrawAccount.value = "";
|
||||
withdrawHolder.value = "";
|
||||
} catch (err: unknown) {
|
||||
const conflict = err instanceof ApiError && err.status === 409;
|
||||
withdrawError.value = conflict ? t("wallet.withdrawConflict") : t("wallet.withdrawFailed");
|
||||
if (conflict) await refreshAll();
|
||||
} finally {
|
||||
withdrawing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<h1 class="text-text mb-4 text-xl font-bold">{{ t("wallet.title") }}</h1>
|
||||
<p
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
|
||||
<p v-else-if="!wallet" class="text-muted py-10">{{ t("wallet.loadFailed") }}</p>
|
||||
<template v-else>
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-2">
|
||||
<div class="border-border bg-primary-soft border p-[18px]">
|
||||
<span class="text-muted block text-xs">{{ t("wallet.available") }}</span>
|
||||
<strong class="text-primary mt-2.5 block text-xl">{{
|
||||
money(wallet.available_minor, wallet.currency)
|
||||
}}</strong>
|
||||
<span class="text-muted mt-1 block text-xs">{{ wallet.currency }}</span>
|
||||
</div>
|
||||
<div class="border-border bg-bg border p-[18px]">
|
||||
<span class="text-muted block text-xs">{{ t("wallet.frozen") }}</span>
|
||||
<strong class="text-warning mt-2.5 block text-xl">{{
|
||||
money(wallet.frozen_minor, wallet.currency)
|
||||
}}</strong>
|
||||
<span class="text-muted mt-1 block text-xs">{{ wallet.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="mb-6">
|
||||
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("wallet.entriesTitle") }}</h2>
|
||||
<p v-if="entryError" role="alert" class="text-danger mb-3 text-sm">{{ entryError }}</p>
|
||||
<UiEmptyState v-if="entries.length === 0" :text="t('wallet.entriesEmpty')" />
|
||||
<template v-else>
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("wallet.entryDate") }}</th>
|
||||
<th>{{ t("wallet.entryAccount") }}</th>
|
||||
<th>{{ t("wallet.entryReason") }}</th>
|
||||
<th class="text-right!">{{ t("wallet.entryDelta") }}</th>
|
||||
<th class="text-right!">{{ t("wallet.entryBalance") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in entries" :key="entry.id">
|
||||
<td class="text-muted text-xs whitespace-nowrap">
|
||||
{{ entry.created_at.slice(0, 10) }}
|
||||
</td>
|
||||
<td>
|
||||
{{
|
||||
entry.account_kind === "available"
|
||||
? t("wallet.accountAvailable")
|
||||
: t("wallet.accountFrozen")
|
||||
}}
|
||||
</td>
|
||||
<td>{{ reasonLabel(entry.reason) }}</td>
|
||||
<td
|
||||
class="text-right font-semibold"
|
||||
:class="entry.delta_minor < 0 ? 'text-danger' : 'text-success'"
|
||||
>
|
||||
{{ entry.delta_minor < 0 ? "−" : "+"
|
||||
}}{{ money(Math.abs(entry.delta_minor), wallet.currency) }}
|
||||
</td>
|
||||
<td class="text-right">{{ money(entry.balance_minor, wallet.currency) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
<UiPagination
|
||||
:page="entryPage"
|
||||
:total="entryTotal"
|
||||
:per-page="entryPerPage"
|
||||
@change="changeEntryPage"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="border-border mb-6 rounded-md border p-4">
|
||||
<div class="mb-1 flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-text m-0 text-lg font-semibold">{{ t("wallet.rechargeTitle") }}</h2>
|
||||
<VBadge tone="orange">{{ t("wallet.rechargeDemoBadge") }}</VBadge>
|
||||
</div>
|
||||
<p class="text-muted mt-1 mb-3 text-xs">{{ t("wallet.rechargeDemoNote") }}</p>
|
||||
<form class="grid max-w-[520px] gap-2" @submit.prevent="submitRecharge">
|
||||
<VField :label="t('wallet.rechargeAmount', { currency: wallet.currency })">
|
||||
<VInput v-model="rechargeMajor" inputmode="decimal" placeholder="0.00" />
|
||||
<p class="text-muted m-0 mt-1 text-xs">
|
||||
{{ t("wallet.rechargeAmountHint", { currency: wallet.currency }) }}
|
||||
</p>
|
||||
</VField>
|
||||
<p v-if="rechargeError" role="alert" class="text-danger m-0 text-xs">
|
||||
{{ rechargeError }}
|
||||
</p>
|
||||
<p v-if="rechargeNotice" role="status" class="text-success m-0 text-xs">
|
||||
{{ rechargeNotice }}
|
||||
</p>
|
||||
<VBtn class="w-fit" variant="primary" type="submit" :disabled="recharging">
|
||||
{{ t("wallet.rechargeSubmit") }}
|
||||
</VBtn>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="border-border mb-6 rounded-md border p-4">
|
||||
<h2 class="text-text mb-1 text-lg font-semibold">{{ t("wallet.withdrawTitle") }}</h2>
|
||||
<p class="text-muted mt-1 mb-3 text-xs">{{ t("wallet.withdrawHint") }}</p>
|
||||
<form class="grid max-w-[520px] gap-2" @submit.prevent="submitWithdrawal">
|
||||
<VField :label="t('wallet.withdrawAmount', { currency: wallet.currency })">
|
||||
<VInput v-model="withdrawMajor" inputmode="decimal" placeholder="0.00" />
|
||||
<p class="text-muted m-0 mt-1 text-xs">{{ t("wallet.withdrawAmountHint") }}</p>
|
||||
</VField>
|
||||
<VField :label="t('wallet.withdrawMethod')">
|
||||
<select
|
||||
v-model="withdrawMethod"
|
||||
class="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"
|
||||
>
|
||||
<option value="bank">{{ t("wallet.withdrawMethodBank") }}</option>
|
||||
<option value="alipay">{{ t("wallet.withdrawMethodAlipay") }}</option>
|
||||
<option value="wechat">{{ t("wallet.withdrawMethodWechat") }}</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="t('wallet.withdrawAccount')">
|
||||
<VInput v-model="withdrawAccount" :placeholder="t('wallet.withdrawAccountHint')" />
|
||||
</VField>
|
||||
<VField :label="t('wallet.withdrawHolder')">
|
||||
<VInput v-model="withdrawHolder" />
|
||||
</VField>
|
||||
<p v-if="withdrawError" role="alert" class="text-danger m-0 text-xs">
|
||||
{{ withdrawError }}
|
||||
</p>
|
||||
<p v-if="withdrawNotice" role="status" class="text-success m-0 text-xs">
|
||||
{{ withdrawNotice }}
|
||||
</p>
|
||||
<VBtn class="w-fit" variant="primary" type="submit" :disabled="withdrawing">
|
||||
{{ t("wallet.withdrawSubmit") }}
|
||||
</VBtn>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("wallet.historyTitle") }}</h2>
|
||||
<UiEmptyState v-if="withdrawals.length === 0" :text="t('wallet.historyEmpty')" />
|
||||
<VTable v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("wallet.historyCreatedAt") }}</th>
|
||||
<th>{{ t("wallet.historyAmount") }}</th>
|
||||
<th>{{ t("wallet.historyMethod") }}</th>
|
||||
<th>{{ t("wallet.historyAccount") }}</th>
|
||||
<th>{{ t("wallet.historyStatus") }}</th>
|
||||
<th>{{ t("wallet.historyNote") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in withdrawals" :key="row.id">
|
||||
<td class="text-muted text-xs whitespace-nowrap">
|
||||
{{ row.created_at.slice(0, 10) }}
|
||||
</td>
|
||||
<td class="font-semibold">{{ money(row.amount_minor, row.currency) }}</td>
|
||||
<td>{{ methodLabel(row.account_details.method) }}</td>
|
||||
<td class="text-xs">{{ row.account_details.account }}</td>
|
||||
<td>
|
||||
<VBadge :tone="statusTone(row.status)">{{ t(STATUS_KEYS[row.status]) }}</VBadge>
|
||||
</td>
|
||||
<td class="text-muted text-xs">
|
||||
{{ row.review_note ?? t("mall.notAvailable") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</section>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
Reference in New Issue
Block a user