Files
vmall/apps/admin/pages/withdrawals.vue
T
james 9904696e76 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.
2026-09-25 15:25:29 +00:00

322 lines
12 KiB
Vue

<script setup lang="ts">
import { ApiError, formatMoney } from "@vmall/shared";
import type {
Currency,
WalletWithdrawal,
WithdrawalReviewOutcome,
WithdrawalStatus,
} from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const withdrawals = ref<WalletWithdrawal[]>([]);
const currencies = ref<Currency[]>([]);
const statusFilter = ref<"" | WithdrawalStatus>("");
const loading = ref(true);
const errorMessage = ref("");
const notice = ref("");
const conflictMessage = ref("");
const actingId = ref<string | null>(null);
const reviewId = ref<string | null>(null);
const reviewOutcome = ref<WithdrawalReviewOutcome>("approve");
const reviewNote = ref("");
const statuses: WithdrawalStatus[] = ["pending", "approved", "rejected"];
function currencyExponent(code: string): number | undefined {
return currencies.value.find((currency) => currency.code === code)?.exponent;
}
function money(amountMinor: number, currency: string): string {
const exponent = currencyExponent(currency);
return exponent === undefined ? "—" : formatMoney(amountMinor, currency, exponent, locale.value);
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function shortId(value: string): string {
return value.slice(0, 8);
}
function statusTone(status: WithdrawalStatus): "green" | "red" | "orange" {
if (status === "approved") return "green";
if (status === "rejected") return "red";
return "orange";
}
function statusLabel(status: WithdrawalStatus): string {
return t(`admin.withdrawalStatuses.${status}`);
}
function errorText(error: unknown): string {
const message = error instanceof Error ? error.message : t("common.error");
return error instanceof ApiError && error.status === 409
? `${t("admin.withdrawalConflict")}: ${message}`
: message;
}
function startReview(row: WalletWithdrawal, outcome: WithdrawalReviewOutcome): void {
reviewId.value = row.id;
reviewOutcome.value = outcome;
reviewNote.value = "";
errorMessage.value = "";
conflictMessage.value = "";
notice.value = "";
}
function cancelReview(): void {
reviewId.value = null;
reviewNote.value = "";
}
async function load(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [rows, currencyList] = await Promise.all([
$api.admin.listWithdrawalApplications(statusFilter.value || undefined),
$api.admin.listCurrencies(),
]);
withdrawals.value = rows;
currencies.value = currencyList;
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
async function submitReview(row: WalletWithdrawal): Promise<void> {
const outcome = reviewOutcome.value;
const confirmation =
outcome === "approve"
? t("admin.withdrawalConfirmApprove")
: t("admin.withdrawalConfirmReject");
if (!confirm(confirmation)) return;
actingId.value = row.id;
errorMessage.value = "";
conflictMessage.value = "";
notice.value = "";
try {
const note = reviewNote.value.trim();
const updated = await $api.admin.reviewWithdrawal(row.id, outcome, note === "" ? null : note);
cancelReview();
notice.value =
updated.status === "approved"
? t("admin.withdrawalApproved")
: t("admin.withdrawalRejected");
} catch (error: unknown) {
// A repeat review is a conflict: converge to server state instead of retrying.
if (error instanceof ApiError && error.status === 409) {
conflictMessage.value = errorText(error);
cancelReview();
} else {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
return;
}
} finally {
actingId.value = null;
}
await load();
}
onMounted(() => {
void load();
});
</script>
<template>
<VPage :title="$t('nav.withdrawals')">
<VCard class="mb-5">
<div class="flex flex-wrap items-end gap-3">
<label class="grid gap-1 text-sm font-medium" for="withdrawal-status">
{{ $t("common.status") }}
<select
id="withdrawal-status"
v-model="statusFilter"
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
@change="load"
>
<option value="">{{ $t("common.all") }}</option>
<option v-for="status in statuses" :key="status" :value="status">
{{ statusLabel(status) }}
</option>
</select>
</label>
<VBtn size="sm" :disabled="loading" @click="load">{{ $t("admin.refresh") }}</VBtn>
</div>
</VCard>
<p v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</p>
<p
v-if="conflictMessage"
class="bg-warning/10 text-warning my-2 rounded-md px-3 py-2 text-sm"
role="alert"
>
{{ conflictMessage }}
</p>
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
<p v-else-if="errorMessage" class="text-danger my-2 text-sm" role="alert">{{ errorMessage }}</p>
<VCard v-else-if="withdrawals.length === 0" class="text-muted">
{{ $t("common.empty") }}
</VCard>
<div v-else class="overflow-x-auto">
<div class="min-w-[1180px]">
<VTable>
<thead>
<tr>
<th>{{ $t("admin.withdrawalId") }}</th>
<th>{{ $t("admin.withdrawalBuyer") }}</th>
<th>{{ $t("admin.withdrawalUser") }}</th>
<th>{{ $t("admin.withdrawalAmount") }}</th>
<th>{{ $t("admin.withdrawalPayout") }}</th>
<th>{{ $t("admin.created") }}</th>
<th>{{ $t("admin.withdrawalReviewedAt") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<template v-for="row in withdrawals" :key="row.id">
<tr :class="reviewId === row.id ? 'bg-primary-soft/30' : ''">
<td>
<code class="bg-bg rounded px-1.5 py-0.5" :title="row.id">{{
shortId(row.id)
}}</code>
</td>
<td>{{ row.user_email || "—" }}</td>
<td>
<code class="bg-bg rounded px-1.5 py-0.5" :title="row.user_id">{{
shortId(row.user_id)
}}</code>
</td>
<td>{{ money(row.amount_minor, row.currency) }}</td>
<td>
<span class="block text-sm">{{ row.account_details.method }}</span>
<code class="text-muted text-xs">{{ row.account_details.account }}</code>
<span v-if="row.account_details.holder" class="text-muted block text-xs">
{{ row.account_details.holder }}
</span>
</td>
<td>{{ formatDate(row.created_at) }}</td>
<td>
<span v-if="row.reviewed_at">{{ formatDate(row.reviewed_at) }}</span>
<span v-else class="text-muted">—</span>
<span
v-if="row.review_note"
class="text-muted block max-w-[200px] truncate text-xs"
:title="row.review_note"
>
{{ row.review_note }}
</span>
</td>
<td>
<VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge>
</td>
<td>
<div v-if="row.status === 'pending'" class="flex gap-2">
<VBtn
size="sm"
variant="primary"
:disabled="actingId === row.id"
@click="startReview(row, 'approve')"
>{{ $t("admin.withdrawalApprove") }}</VBtn
>
<VBtn
size="sm"
variant="danger"
:disabled="actingId === row.id"
@click="startReview(row, 'reject')"
>{{ $t("admin.withdrawalReject") }}</VBtn
>
</div>
<span v-else class="text-muted text-xs">—</span>
</td>
</tr>
<tr v-if="reviewId === row.id" class="bg-bg">
<td colspan="9" class="p-0">
<div class="p-4">
<VPanel :title="$t('admin.withdrawalReview')">
<template #actions>
<VBadge :tone="reviewOutcome === 'approve' ? 'green' : 'red'">
{{
reviewOutcome === "approve"
? $t("admin.withdrawalApprove")
: $t("admin.withdrawalReject")
}}
</VBadge>
<VBtn size="sm" class="ml-2" @click="cancelReview">{{
$t("admin.withdrawalClose")
}}</VBtn>
</template>
<dl class="mb-4 grid gap-1 text-sm sm:grid-cols-3">
<div>
<dt class="text-muted">{{ $t("admin.withdrawalBuyer") }}</dt>
<dd>{{ row.user_email || "—" }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.withdrawalAmount") }}</dt>
<dd>{{ money(row.amount_minor, row.currency) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.withdrawalPayout") }}</dt>
<dd>
{{ row.account_details.method }} · {{ row.account_details.account }}
<span v-if="row.account_details.holder">
· {{ row.account_details.holder }}
</span>
</dd>
</div>
</dl>
<VField :label="$t('admin.withdrawalNote')">
<VInput
id="withdrawal-note"
v-model="reviewNote"
:placeholder="$t('admin.withdrawalNotePlaceholder')"
:disabled="actingId === row.id"
/>
</VField>
<p class="text-muted mb-3 text-sm">
{{
reviewOutcome === "approve"
? $t("admin.withdrawalConfirmApprove")
: $t("admin.withdrawalConfirmReject")
}}
</p>
<div class="flex flex-wrap gap-2">
<VBtn
:variant="reviewOutcome === 'approve' ? 'primary' : 'danger'"
:disabled="actingId === row.id"
@click="submitReview(row)"
>
{{
actingId === row.id
? $t("common.loading")
: reviewOutcome === "approve"
? $t("admin.withdrawalApprove")
: $t("admin.withdrawalReject")
}}
</VBtn>
<VBtn :disabled="actingId === row.id" @click="cancelReview">
{{ $t("common.cancel") }}
</VBtn>
</div>
</VPanel>
</div>
</td>
</tr>
</template>
</tbody>
</VTable>
</div>
</div>
</VPage>
</template>