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:
@@ -0,0 +1,321 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user