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:
2026-09-25 15:25:29 +00:00
parent 772aafa3fb
commit 9904696e76
120 changed files with 14097 additions and 125 deletions
+421
View File
@@ -0,0 +1,421 @@
<script setup lang="ts">
import { ApiError } from "@vmall/shared";
import type { LocalizedText, MemberLevel, MemberLevelInput } from "@vmall/shared";
import MemberLevelForm from "../components/MemberLevelForm.vue";
definePageMeta({ middleware: "auth" });
/** Field bundle the form component reports on every change. */
interface LevelDraftFields {
nameEn: string;
nameZh: string;
icon: string;
growthThreshold: number;
benefitsEn: string;
benefitsZh: string;
}
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const levels = ref<MemberLevel[]>([]);
const loading = ref(true);
const loadError = ref("");
const feedback = ref("");
const deleteError = ref("");
/** Create form: reset to this after a successful POST. */
function emptyDraft(): LevelDraftFields {
return {
nameEn: "",
nameZh: "",
icon: "",
growthThreshold: 0,
benefitsEn: "",
benefitsZh: "",
};
}
function draftFromLevel(row: MemberLevel): LevelDraftFields {
return {
nameEn: row.name.en ?? "",
nameZh: row.name.zh ?? "",
icon: row.icon,
growthThreshold: row.growth_threshold,
benefitsEn: row.benefits.en ?? "",
benefitsZh: row.benefits.zh ?? "",
};
}
function bodyFromDraft(draft: LevelDraftFields): MemberLevelInput {
return {
name: { en: draft.nameEn.trim(), zh: draft.nameZh.trim() },
icon: draft.icon.trim(),
growth_threshold: Number(draft.growthThreshold),
benefits: { en: draft.benefitsEn.trim(), zh: draft.benefitsZh.trim() },
};
}
const createDraft = reactive<LevelDraftFields>(emptyDraft());
/** One draft and pristine baseline per open edit form. */
const editDrafts = reactive<Record<string, LevelDraftFields>>({});
const editBaselines = reactive<Record<string, LevelDraftFields>>({});
const openEditId = ref<string | null>(null);
const savingCreate = ref(false);
const savingEditId = ref<string | null>(null);
const deletingId = ref<string | null>(null);
/** Server feedback buckets, kept separate so every surface stays explicit. */
const createError = ref("");
const createNotice = ref("");
const editError = ref("");
function localizedName(name: LocalizedText): string {
return name[locale.value] ?? name.en ?? Object.values(name)[0] ?? "";
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function clearFeedback(): void {
feedback.value = "";
createError.value = "";
createNotice.value = "";
editError.value = "";
}
/** Duplicate thresholds (409) and validation (400) read differently from a generic failure. */
function errorText(error: unknown, keys: { conflict: string; invalid: string }): string {
const message = error instanceof Error ? error.message : t("common.error");
if (error instanceof ApiError && error.status === 409) return `${t(keys.conflict)}: ${message}`;
if (error instanceof ApiError && error.status === 400) return `${t(keys.invalid)}: ${message}`;
return message;
}
/** Mirrors the server rules so obvious mistakes never reach the network. */
function validateDraft(draft: LevelDraftFields, ownId: string | null): string {
if (
draft.nameEn.trim() === "" ||
draft.nameZh.trim() === "" ||
draft.benefitsEn.trim() === "" ||
draft.benefitsZh.trim() === ""
) {
return t("admin.memberLevelRequired");
}
if (draft.icon.trim() === "") return t("admin.memberLevelIconRequired");
const threshold = Number(draft.growthThreshold);
if (!Number.isInteger(threshold) || threshold < 0) {
return t("admin.memberLevelThresholdInvalid");
}
const duplicate = levels.value.some(
(row) => row.growth_threshold === threshold && row.id !== ownId,
);
if (duplicate) return t("admin.memberLevelThresholdDuplicate");
return "";
}
function applyCreateDraft(draft: LevelDraftFields): void {
Object.assign(createDraft, draft);
}
function resetCreateDraft(): void {
Object.assign(createDraft, emptyDraft());
}
function applyEditDraft(id: string, draft: LevelDraftFields): void {
if (editDrafts[id]) Object.assign(editDrafts[id], draft);
}
function hasUnsavedEdit(): boolean {
if (openEditId.value === null) return false;
const draft = editDrafts[openEditId.value];
const baseline = editBaselines[openEditId.value];
if (!draft || !baseline) return false;
return (
draft.nameEn !== baseline.nameEn ||
draft.nameZh !== baseline.nameZh ||
draft.icon !== baseline.icon ||
draft.growthThreshold !== baseline.growthThreshold ||
draft.benefitsEn !== baseline.benefitsEn ||
draft.benefitsZh !== baseline.benefitsZh
);
}
async function load(): Promise<void> {
loading.value = true;
loadError.value = "";
try {
levels.value = await $api.admin.listMemberLevels();
// An edited level may have moved in threshold order or been removed elsewhere.
if (openEditId.value !== null && !levels.value.some((row) => row.id === openEditId.value)) {
closeEdit();
}
} catch (error: unknown) {
loadError.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
/** Warn before a refresh replaces the list under half-edited input. */
async function refresh(): Promise<void> {
if (hasUnsavedEdit() && !confirm(t("admin.memberLevelDiscardEdit"))) return;
clearFeedback();
deleteError.value = "";
closeEdit();
await load();
}
async function createLevel(): Promise<void> {
clearFeedback();
deleteError.value = "";
const message = validateDraft(createDraft, null);
if (message !== "") {
createError.value = message;
return;
}
savingCreate.value = true;
try {
await $api.admin.createMemberLevel(bodyFromDraft(createDraft));
resetCreateDraft();
createNotice.value = t("admin.memberLevelCreated");
await load();
} catch (error: unknown) {
createError.value = errorText(error, {
conflict: t("admin.memberLevelConflict"),
invalid: t("admin.memberLevelInvalid"),
});
// A 409 means the threshold was taken elsewhere: converge to server state.
if (error instanceof ApiError && error.status === 409) await load();
} finally {
savingCreate.value = false;
}
}
function startEdit(row: MemberLevel): void {
const draft = draftFromLevel(row);
editDrafts[row.id] = { ...draft };
editBaselines[row.id] = { ...draft };
openEditId.value = row.id;
editError.value = "";
}
function closeEdit(): void {
const id = openEditId.value;
if (id !== null) {
delete editDrafts[id];
delete editBaselines[id];
}
openEditId.value = null;
editError.value = "";
}
function toggleEdit(row: MemberLevel): void {
if (openEditId.value === row.id) {
closeEdit();
return;
}
createError.value = "";
createNotice.value = "";
startEdit(row);
}
async function saveEdit(id: string): Promise<void> {
const draft = editDrafts[id];
if (!draft) return;
editError.value = "";
const message = validateDraft(draft, id);
if (message !== "") {
editError.value = message;
return;
}
savingEditId.value = id;
try {
await $api.admin.updateMemberLevel(id, bodyFromDraft(draft));
closeEdit();
feedback.value = t("admin.memberLevelUpdated");
await load();
} catch (error: unknown) {
editError.value = errorText(error, {
conflict: t("admin.memberLevelConflict"),
invalid: t("admin.memberLevelInvalid"),
});
if (error instanceof ApiError && (error.status === 409 || error.status === 404)) {
// Gone or taken elsewhere: close and converge instead of retrying stale input.
if (error.status === 404) feedback.value = t("admin.memberLevelGone");
closeEdit();
await load();
}
} finally {
savingEditId.value = null;
}
}
/**
* Levels held by at least one customer cannot be deleted: the API answers 409,
* and that rejection stays visible until acknowledged so the row never looks
* successfully removed.
*/
async function removeLevel(row: MemberLevel): Promise<void> {
deleteError.value = "";
createNotice.value = "";
if (!confirm(t("admin.memberLevelConfirmDelete", { name: localizedName(row.name) }))) return;
deletingId.value = row.id;
try {
await $api.admin.deleteMemberLevel(row.id);
if (openEditId.value === row.id) closeEdit();
feedback.value = t("admin.memberLevelDeleted");
await load();
} catch (error: unknown) {
if (error instanceof ApiError && error.status === 409) {
deleteError.value = `${t("admin.memberLevelDeleteInUse")}: ${error.message}`;
await load();
} else {
deleteError.value = error instanceof Error ? error.message : t("common.error");
}
} finally {
deletingId.value = null;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<VPage :title="$t('nav.memberLevels')">
<template #actions>
<span v-if="!loading" class="text-muted text-sm">{{ levels.length }}</span>
</template>
<p
v-if="feedback"
class="bg-success/10 text-success my-2 rounded-md px-3 py-2 text-sm"
role="status"
>
{{ feedback }}
</p>
<p v-if="createNotice" class="text-success my-2 text-sm" role="status">{{ createNotice }}</p>
<p
v-if="deleteError"
class="bg-danger/10 text-danger my-2 rounded-md px-3 py-2 text-sm"
role="alert"
>
{{ deleteError }}
</p>
<MemberLevelForm
class="mb-5"
scope="create"
:title="$t('admin.memberLevelCreateTitle')"
:hint="$t('admin.memberLevelHint')"
:name-en="createDraft.nameEn"
:name-zh="createDraft.nameZh"
:icon="createDraft.icon"
:growth-threshold="createDraft.growthThreshold"
:benefits-en="createDraft.benefitsEn"
:benefits-zh="createDraft.benefitsZh"
:error="createError"
:busy="savingCreate"
:submit-label="$t('admin.memberLevelCreate')"
@change="applyCreateDraft"
@submit="createLevel"
/>
<div class="mb-2 flex justify-end">
<VBtn size="sm" :disabled="loading" @click="refresh">{{ $t("admin.refresh") }}</VBtn>
</div>
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
<p v-else-if="loadError" class="text-danger my-2 text-sm" role="alert">{{ loadError }}</p>
<VCard v-else-if="levels.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
<div v-else class="overflow-x-auto">
<div class="min-w-[960px]">
<VTable>
<thead>
<tr>
<th>{{ $t("admin.memberLevelIcon") }}</th>
<th>{{ $t("admin.memberLevelNameEn") }}</th>
<th>{{ $t("admin.memberLevelNameZh") }}</th>
<th>{{ $t("admin.memberLevelGrowth") }}</th>
<th>{{ $t("admin.memberLevelBenefitsEn") }}</th>
<th>{{ $t("admin.memberLevelBenefitsZh") }}</th>
<th>{{ $t("admin.memberLevelUpdatedAt") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<template v-for="row in levels" :key="row.id">
<tr :class="openEditId === row.id ? 'bg-primary-soft/30' : ''">
<td>
<code class="bg-bg rounded px-1.5 py-0.5">{{ row.icon }}</code>
</td>
<td class="max-w-[220px] truncate" :title="row.name.en">{{ row.name.en }}</td>
<td class="max-w-[220px] truncate" :title="row.name.zh">{{ row.name.zh }}</td>
<td>
<VBadge tone="blue">{{ row.growth_threshold }}</VBadge>
</td>
<td class="max-w-[260px] truncate" :title="row.benefits.en">
{{ row.benefits.en }}
</td>
<td class="max-w-[260px] truncate" :title="row.benefits.zh">
{{ row.benefits.zh }}
</td>
<td class="text-muted">{{ formatDate(row.updated_at) }}</td>
<td>
<div class="flex gap-2">
<VBtn
size="sm"
:data-testid="`ml-edit-${row.id}`"
:disabled="savingEditId === row.id || deletingId === row.id"
@click="toggleEdit(row)"
>
{{ openEditId === row.id ? $t("common.cancel") : $t("common.edit") }}
</VBtn>
<VBtn
size="sm"
variant="danger"
:data-testid="`ml-delete-${row.id}`"
:disabled="deletingId === row.id || savingEditId === row.id"
@click="removeLevel(row)"
>
{{ deletingId === row.id ? $t("common.loading") : $t("common.delete") }}
</VBtn>
</div>
</td>
</tr>
<tr v-if="openEditId === row.id && editDrafts[row.id]" class="bg-bg">
<td colspan="8" class="p-0">
<div class="p-4">
<MemberLevelForm
:scope="`edit-${row.id}`"
:title="$t('admin.memberLevelEditTitle')"
:name-en="editDrafts[row.id].nameEn"
:name-zh="editDrafts[row.id].nameZh"
:icon="editDrafts[row.id].icon"
:growth-threshold="editDrafts[row.id].growthThreshold"
:benefits-en="editDrafts[row.id].benefitsEn"
:benefits-zh="editDrafts[row.id].benefitsZh"
:error="editError"
:busy="savingEditId === row.id"
:submit-label="$t('common.save')"
@change="applyEditDraft(row.id, $event)"
@submit="saveEdit(row.id)"
/>
</div>
</td>
</tr>
</template>
</tbody>
</VTable>
</div>
</div>
</VPage>
</template>
+608
View File
@@ -0,0 +1,608 @@
<script setup lang="ts">
import { ApiError, t as localizedText } from "@vmall/shared";
import type {
MerchantApplication,
MerchantApplicationStatus,
MerchantEntityType,
MerchantOwnerCredentials,
} from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const applications = ref<MerchantApplication[]>([]);
const statusFilter = ref<"" | MerchantApplicationStatus>("");
const page = ref(1);
const total = ref(0);
const perPage = ref(20);
const loading = ref(true);
const errorMessage = ref("");
const notice = ref("");
const conflictMessage = ref("");
const validationMessage = ref("");
const openId = ref<string | null>(null);
const detail = ref<MerchantApplication | null>(null);
const detailLoading = ref(false);
const detailError = ref("");
const acting = ref(false);
const rejectReason = ref("");
const rejectError = ref("");
const credentials = ref<MerchantOwnerCredentials | null>(null);
const statuses: MerchantApplicationStatus[] = ["pending", "approved", "rejected"];
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
function statusTone(status: MerchantApplicationStatus): "green" | "red" | "orange" {
if (status === "approved") return "green";
if (status === "rejected") return "red";
return "orange";
}
function statusLabel(status: MerchantApplicationStatus): string {
return t(`admin.merchantStatuses.${status}`);
}
function entityTypeLabel(entityType: MerchantEntityType): string {
return t(`admin.merchantEntityTypes.${entityType}`);
}
function entityName(row: MerchantApplication): string {
const name = row.entity_type === "personal" ? row.real_name : row.company_name;
return name && name.trim() !== "" ? name : "—";
}
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 extraMaterials(row: MerchantApplication): string[] {
return row.qualification.extra_materials ?? [];
}
function hasQualification(row: MerchantApplication): boolean {
const qualification = row.qualification;
return Boolean(
qualification.identity_document_url ||
qualification.business_license_url ||
qualification.business_license_no ||
extraMaterials(row).length > 0,
);
}
function errorText(error: unknown): string {
const message = error instanceof Error ? error.message : t("common.error");
return error instanceof ApiError && error.status === 409
? `${t("admin.merchantConflict")}: ${message}`
: message;
}
function clearFeedback(): void {
errorMessage.value = "";
notice.value = "";
conflictMessage.value = "";
validationMessage.value = "";
}
function closeDetail(): void {
openId.value = null;
detail.value = null;
detailError.value = "";
rejectReason.value = "";
rejectError.value = "";
}
async function load(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const paged = await $api.admin.listMerchantApplications({
status: statusFilter.value || undefined,
page: page.value,
per_page: perPage.value,
});
if (paged.items.length === 0 && paged.total > 0 && page.value > 1) {
page.value -= 1;
await load();
return;
}
applications.value = paged.items;
total.value = paged.total;
perPage.value = paged.per_page;
if (openId.value && !paged.items.some((row) => row.id === openId.value)) {
closeDetail();
}
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
async function changePage(nextPage: number): Promise<void> {
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return;
page.value = nextPage;
await load();
}
async function applyStatusFilter(): Promise<void> {
page.value = 1;
closeDetail();
await load();
}
async function openDetail(id: string): Promise<void> {
openId.value = id;
detail.value = null;
detailError.value = "";
rejectReason.value = "";
rejectError.value = "";
validationMessage.value = "";
notice.value = "";
detailLoading.value = true;
try {
detail.value = await $api.admin.getMerchantApplication(id);
} catch (error: unknown) {
detailError.value = errorText(error);
} finally {
detailLoading.value = false;
}
}
async function toggleDetail(id: string): Promise<void> {
if (openId.value === id) {
closeDetail();
return;
}
await openDetail(id);
}
function dismissCredentials(): void {
credentials.value = null;
}
async function approve(row: MerchantApplication): Promise<void> {
if (!confirm(t("admin.merchantConfirmApprove"))) return;
acting.value = true;
clearFeedback();
rejectError.value = "";
try {
const result = await $api.admin.approveMerchantApplication(row.id);
detail.value = result.application;
// The initial password exists only in this response: show it once, then discard.
credentials.value = result.credentials;
notice.value = t("admin.merchantApprovedNotice");
} catch (error: unknown) {
if (error instanceof ApiError && error.status === 409) {
// Already reviewed: converge to server state instead of retrying.
conflictMessage.value = errorText(error);
closeDetail();
} else if (error instanceof ApiError && error.status === 400) {
validationMessage.value = error instanceof Error ? error.message : t("common.error");
} else {
detailError.value = error instanceof Error ? error.message : t("common.error");
return;
}
} finally {
acting.value = false;
}
await load();
}
async function reject(row: MerchantApplication): Promise<void> {
const reason = rejectReason.value.trim();
rejectError.value = "";
validationMessage.value = "";
// The server rejects a blank reason with 400; block it before the request too.
if (reason === "") {
rejectError.value = t("admin.merchantRejectReasonRequired");
return;
}
if (!confirm(t("admin.merchantConfirmReject"))) return;
acting.value = true;
clearFeedback();
try {
detail.value = await $api.admin.rejectMerchantApplication(row.id, reason);
rejectReason.value = "";
notice.value = t("admin.merchantRejectedNotice");
} catch (error: unknown) {
if (error instanceof ApiError && error.status === 409) {
conflictMessage.value = errorText(error);
closeDetail();
} else if (error instanceof ApiError && error.status === 400) {
validationMessage.value = error instanceof Error ? error.message : t("common.error");
} else {
detailError.value = error instanceof Error ? error.message : t("common.error");
return;
}
} finally {
acting.value = false;
}
await load();
}
onMounted(() => {
void load();
});
</script>
<template>
<VPage :title="$t('nav.merchantApplications')">
<template #actions>
<span v-if="!loading" class="text-muted text-sm">{{ total }}</span>
</template>
<VCard class="mb-5">
<div class="flex flex-wrap items-end gap-3">
<label class="grid gap-1 text-sm font-medium" for="merchant-application-status">
{{ $t("common.status") }}
<select
id="merchant-application-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="applyStatusFilter"
>
<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="validationMessage" class="text-danger my-2 text-sm" role="alert">
{{ validationMessage }}
</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="applications.length === 0" class="text-muted">
{{ $t("common.empty") }}
</VCard>
<div v-else class="overflow-x-auto">
<div class="min-w-[1040px]">
<VTable>
<thead>
<tr>
<th>{{ $t("admin.merchantApplication") }}</th>
<th>{{ $t("admin.merchantApplicant") }}</th>
<th>{{ $t("admin.merchantEntityType") }}</th>
<th>{{ $t("admin.merchantEntityName") }}</th>
<th>{{ $t("admin.merchantSubmittedAt") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<template v-for="row in applications" :key="row.id">
<tr :class="openId === 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.applicant_email }}</td>
<td>
<VBadge :tone="row.entity_type === 'enterprise' ? 'blue' : 'gray'">
{{ entityTypeLabel(row.entity_type) }}
</VBadge>
</td>
<td>{{ entityName(row) }}</td>
<td>{{ formatDate(row.created_at) }}</td>
<td>
<VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge>
</td>
<td>
<VBtn size="sm" @click="toggleDetail(row.id)">
{{
openId === row.id ? $t("admin.merchantClose") : $t("admin.merchantView")
}}
</VBtn>
</td>
</tr>
<tr v-if="openId === row.id" class="bg-bg">
<td colspan="7" class="p-0">
<div class="p-4">
<p v-if="detailLoading" class="text-muted text-sm">
{{ $t("common.loading") }}
</p>
<p v-else-if="detailError" class="text-danger text-sm" role="alert">
{{ detailError }}
</p>
<VPanel v-else-if="detail" :title="$t('admin.merchantDetail')">
<template #actions>
<VBadge :tone="statusTone(detail.status)">{{
statusLabel(detail.status)
}}</VBadge>
<VBtn size="sm" class="ml-2" @click="closeDetail">{{
$t("admin.merchantClose")
}}</VBtn>
</template>
<dl class="mb-4 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-muted">{{ $t("admin.merchantApplication") }}</dt>
<dd><code class="break-all">{{ detail.id }}</code></dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.merchantApplicant") }}</dt>
<dd>{{ detail.applicant_email }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.orderUser") }}</dt>
<dd><code class="break-all">{{ detail.user_id }}</code></dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.merchantEntityType") }}</dt>
<dd>{{ entityTypeLabel(detail.entity_type) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.merchantSubmittedAt") }}</dt>
<dd>{{ formatDate(detail.created_at) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.merchantUpdated") }}</dt>
<dd>{{ formatDate(detail.updated_at) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.merchantReviewedAt") }}</dt>
<dd>
<span v-if="detail.reviewed_at">{{ formatDate(detail.reviewed_at) }}</span>
<span v-else class="text-muted">—</span>
</dd>
</div>
<div v-if="detail.created_shop_id">
<dt class="text-muted">{{ $t("admin.merchantCreatedShop") }}</dt>
<dd><code class="break-all">{{ detail.created_shop_id }}</code></dd>
</div>
</dl>
<div class="grid gap-4 lg:grid-cols-2">
<VCard :padded="true">
<h4 class="mb-2 text-sm font-semibold">
{{ $t("admin.merchantEntityInfo") }}
</h4>
<dl class="grid gap-1 text-sm">
<template v-if="detail.entity_type === 'personal'">
<div class="flex justify-between gap-3">
<dt class="text-muted">{{ $t("admin.merchantRealName") }}</dt>
<dd>{{ detail.real_name || "—" }}</dd>
</div>
</template>
<template v-else>
<div class="flex justify-between gap-3">
<dt class="text-muted">{{ $t("admin.merchantCompanyName") }}</dt>
<dd>{{ detail.company_name || "—" }}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-muted">
{{ $t("admin.merchantBusinessLicenseNo") }}
</dt>
<dd>{{ detail.business_license_no || "—" }}</dd>
</div>
</template>
</dl>
</VCard>
<VCard :padded="true">
<h4 class="mb-2 text-sm font-semibold">
{{ $t("admin.merchantContact") }}
</h4>
<dl class="grid gap-1 text-sm">
<div class="flex justify-between gap-3">
<dt class="text-muted">{{ $t("admin.merchantContactName") }}</dt>
<dd>{{ detail.contact.name }}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-muted">{{ $t("admin.merchantContactPhone") }}</dt>
<dd>{{ detail.contact.phone }}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-muted">{{ $t("admin.merchantContactEmail") }}</dt>
<dd class="break-all">{{ detail.contact.email }}</dd>
</div>
<div v-if="detail.contact.address" class="flex justify-between gap-3">
<dt class="text-muted">{{ $t("admin.merchantContactAddress") }}</dt>
<dd>{{ detail.contact.address }}</dd>
</div>
</dl>
</VCard>
<VCard :padded="true">
<h4 class="mb-2 text-sm font-semibold">
{{ $t("admin.merchantCategories") }}
</h4>
<ul v-if="detail.categories.length" class="flex flex-wrap gap-2 text-sm">
<li v-for="category in detail.categories" :key="category.id">
<VBadge tone="gray">{{
localizedText(category.name, locale)
}}</VBadge>
</li>
</ul>
<p v-else class="text-muted text-sm">
{{ $t("admin.merchantNoCategories") }}
</p>
</VCard>
<VCard :padded="true">
<h4 class="mb-2 text-sm font-semibold">
{{ $t("admin.merchantQualification") }}
</h4>
<div v-if="hasQualification(detail)" class="grid gap-2 text-sm">
<div v-if="detail.qualification.identity_document_url">
<span class="text-muted">{{ $t("admin.merchantIdentityDocument") }}</span>
<a
:href="detail.qualification.identity_document_url"
target="_blank"
rel="noopener noreferrer"
class="text-primary block break-all underline"
>{{ detail.qualification.identity_document_url }}</a
>
</div>
<div v-if="detail.qualification.business_license_url">
<span class="text-muted">{{ $t("admin.merchantBusinessLicense") }}</span>
<a
:href="detail.qualification.business_license_url"
target="_blank"
rel="noopener noreferrer"
class="text-primary block break-all underline"
>{{ detail.qualification.business_license_url }}</a
>
</div>
<div v-if="detail.qualification.business_license_no">
<span class="text-muted">
{{ $t("admin.merchantBusinessLicenseNo") }}
</span>
<span class="block">{{ detail.qualification.business_license_no }}</span>
</div>
<div v-if="extraMaterials(detail).length">
<span class="text-muted">{{ $t("admin.merchantExtraMaterials") }}</span>
<ul class="grid gap-1">
<li v-for="url in extraMaterials(detail)" :key="url">
<a
:href="url"
target="_blank"
rel="noopener noreferrer"
class="text-primary break-all underline"
>{{ url }}</a
>
</li>
</ul>
</div>
</div>
<p v-else class="text-muted text-sm">
{{ $t("admin.merchantNoQualification") }}
</p>
</VCard>
</div>
<p
v-if="detail.status === 'rejected'"
class="bg-danger/10 text-danger mt-4 rounded-md px-3 py-2 text-sm"
role="alert"
>
<span class="font-medium">{{ $t("admin.merchantRejectionReason") }}</span>
{{ detail.rejection_reason || "—" }}
</p>
<div v-if="detail.status === 'pending'" class="border-border mt-4 border-t pt-4">
<p class="text-muted mb-3 text-sm">
{{ $t("admin.merchantConfirmApprove") }}
</p>
<div class="flex flex-wrap gap-2">
<VBtn variant="primary" :disabled="acting" @click="approve(detail)">
{{ acting ? $t("common.loading") : $t("admin.merchantApprove") }}
</VBtn>
</div>
<div class="mt-4 max-w-md">
<VField
:label="$t('admin.merchantRejectReason')"
:error="rejectError"
>
<VInput
id="merchant-reject-reason"
v-model="rejectReason"
:placeholder="$t('admin.merchantRejectReasonPlaceholder')"
:disabled="acting"
/>
</VField>
<VBtn variant="danger" :disabled="acting" @click="reject(detail)">
{{ $t("admin.merchantReject") }}
</VBtn>
</div>
</div>
<p v-else-if="detail.status === 'approved'" class="text-success mt-4 text-sm">
{{ $t("admin.merchantOutcomeApproved") }}
</p>
<p v-else class="text-danger mt-4 text-sm">
{{ $t("admin.merchantOutcomeRejected") }}
</p>
</VPanel>
</div>
</td>
</tr>
</template>
</tbody>
</VTable>
</div>
</div>
<div
v-if="!loading && !errorMessage && applications.length > 0"
class="mt-4 flex items-center justify-center gap-3"
>
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{
$t("common.prev")
}}</VBtn>
<span class="text-muted text-sm">{{ page }} / {{ totalPages }}</span>
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{
$t("common.next")
}}</VBtn>
</div>
<div
v-if="credentials"
class="bg-text/40 fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="merchant-credentials-heading"
>
<VCard class="max-h-full w-full max-w-lg overflow-y-auto">
<h2 id="merchant-credentials-heading" class="text-text mb-2 text-base font-semibold">
{{ $t("admin.merchantCredentialsTitle") }}
</h2>
<p class="bg-warning/10 text-warning mb-4 rounded-md px-3 py-2 text-sm" role="alert">
{{ $t("admin.merchantCredentialsWarning") }}
</p>
<dl class="grid gap-2 text-sm">
<div class="flex flex-wrap justify-between gap-3">
<dt class="text-muted">{{ $t("admin.merchantCredentialsEmail") }}</dt>
<dd><code class="bg-bg rounded px-1.5 py-0.5 break-all">{{ credentials.email }}</code></dd>
</div>
<div class="flex flex-wrap justify-between gap-3">
<dt class="text-muted">{{ $t("admin.merchantCredentialsPassword") }}</dt>
<dd>
<code class="bg-bg rounded px-1.5 py-0.5 break-all">{{
credentials.initial_password
}}</code>
</dd>
</div>
<div class="flex flex-wrap justify-between gap-3">
<dt class="text-muted">{{ $t("admin.merchantCredentialsShopSlug") }}</dt>
<dd><code class="bg-bg rounded px-1.5 py-0.5 break-all">{{ credentials.shop_slug }}</code></dd>
</div>
<div class="flex flex-wrap justify-between gap-3">
<dt class="text-muted">{{ $t("admin.merchantCredentialsShopId") }}</dt>
<dd><code class="bg-bg rounded px-1.5 py-0.5 break-all">{{ credentials.shop_id }}</code></dd>
</div>
</dl>
<div class="mt-5 flex justify-end">
<VBtn variant="primary" @click="dismissCredentials">
{{ $t("admin.merchantCredentialsClose") }}
</VBtn>
</div>
</VCard>
</div>
</VPage>
</template>
+718
View File
@@ -0,0 +1,718 @@
<script setup lang="ts">
import { ApiError, formatMoney, t as localizedText } from "@vmall/shared";
import type {
Currency,
SettlementPeriodKind,
SettlementStatement,
SettlementStatementDetail,
SettlementStatus,
Shop,
} from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const currencies = ref<Currency[]>([]);
const shops = ref<Shop[]>([]);
// ---- commission rate ----
const commissionRate = ref<number | null>(null);
const rateDraft = ref<string | number>("");
const rateSaving = ref(false);
const rateError = ref("");
// ---- statement list ----
const statements = ref<SettlementStatement[]>([]);
const shopFilter = ref("");
const statusFilter = ref<"" | SettlementStatus>("");
const page = ref(1);
const perPage = ref(20);
const total = ref(0);
const loading = ref(true);
const listError = ref("");
// ---- manual generation ----
const generateShop = ref("");
const generateKind = ref<SettlementPeriodKind>("month");
const generateDate = ref(defaultPeriodDate());
const generating = ref(false);
const generateError = ref("");
const generatedStatement = ref<SettlementStatement | null>(null);
const generatedWasExisting = ref(false);
// ---- detail ----
const openId = ref<string | null>(null);
const detail = ref<SettlementStatementDetail | null>(null);
const detailLoading = ref(false);
const detailError = ref("");
const actingId = ref<string | null>(null);
const notice = ref("");
const conflictMessage = ref("");
const periodKinds: SettlementPeriodKind[] = ["week", "month"];
const settlementStatuses: SettlementStatus[] = ["pending", "confirmed"];
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
const currentRateLabel = computed(() =>
commissionRate.value === null
? "—"
: t("admin.commissionRateDisplay", {
bps: commissionRate.value,
percent: ratePercent(commissionRate.value),
}),
);
/** First day of the previous month: always inside a closed week/month period. */
function defaultPeriodDate(): string {
const now = new Date();
const firstOfPreviousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const month = String(firstOfPreviousMonth.getMonth() + 1).padStart(2, "0");
return `${firstOfPreviousMonth.getFullYear()}-${month}-01`;
}
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 | null): string {
if (!value) return "—";
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function shortId(value: string): string {
return value.slice(0, 8);
}
function shopLabel(statement: SettlementStatement): string {
const name = localizedText(statement.shop_name, locale.value);
return name === "" ? shortId(statement.shop_id) : name;
}
function periodKindLabel(kind: SettlementPeriodKind): string {
return t(`admin.settlementPeriodKinds.${kind}`);
}
function periodLabel(row: SettlementStatement): string {
return `${periodKindLabel(row.period_kind)} · ${row.period_start} – ${row.period_end}`;
}
function settlementStatusLabel(status: SettlementStatus): string {
return t(`admin.settlementStatuses.${status}`);
}
function settlementStatusTone(status: SettlementStatus): "green" | "orange" {
return status === "confirmed" ? "green" : "orange";
}
/** Display-only basis-point → percent conversion; never used for arithmetic. */
function ratePercent(bps: number): string {
return String(Number((bps / 100).toFixed(2)));
}
function shopOptionLabel(shop: Shop): string {
const name = localizedText(shop.name, locale.value);
return name === "" ? shop.slug : name;
}
/** Parse the draft as an integer basis-point value in 0..10000, or null when invalid. */
function parseBps(raw: string | number): number | null {
const text = typeof raw === "number" ? String(raw) : raw.trim();
if (text === "" || !/^\d+$/.test(text)) return null;
const value = Number(text);
return Number.isInteger(value) && value >= 0 && value <= 10000 ? value : null;
}
function isIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : t("common.error");
}
async function loadReference(): Promise<void> {
try {
const [currencyList, shopList] = await Promise.all([
$api.admin.listCurrencies(),
$api.admin.listShops(),
]);
currencies.value = currencyList;
shops.value = shopList;
} catch (error: unknown) {
listError.value = errorText(error);
}
}
async function loadCommissionRate(): Promise<void> {
try {
const rate = await $api.admin.getCommissionRate();
commissionRate.value = rate.commission_rate_bps;
rateDraft.value = rate.commission_rate_bps;
} catch (error: unknown) {
rateError.value = errorText(error);
}
}
async function loadStatements(): Promise<void> {
loading.value = true;
listError.value = "";
try {
const paged = await $api.admin.listSettlementStatements({
page: page.value,
shop_id: shopFilter.value || undefined,
status: statusFilter.value || undefined,
});
statements.value = paged.items;
total.value = paged.total;
perPage.value = paged.per_page;
} catch (error: unknown) {
listError.value = errorText(error);
} finally {
loading.value = false;
}
}
async function changePage(nextPage: number): Promise<void> {
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return;
page.value = nextPage;
await loadStatements();
}
async function applyFilters(): Promise<void> {
page.value = 1;
openId.value = null;
detail.value = null;
await loadStatements();
}
async function saveRate(): Promise<void> {
rateError.value = "";
notice.value = "";
conflictMessage.value = "";
const bps = parseBps(rateDraft.value);
if (bps === null) {
rateError.value = t("admin.commissionRateInvalid");
return;
}
rateSaving.value = true;
try {
const saved = await $api.admin.setCommissionRate(bps);
commissionRate.value = saved.commission_rate_bps;
rateDraft.value = saved.commission_rate_bps;
notice.value = t("admin.commissionRateSaved");
} catch (error: unknown) {
rateError.value = errorText(error);
} finally {
rateSaving.value = false;
}
}
/**
* All statement ids this shop already has, so a repeat generation call can be
* reported as "the existing statement was returned" (generation is idempotent).
*/
async function knownStatementIds(shopId: string): Promise<Set<string>> {
const ids = new Set<string>();
for (let current = 1; current <= 20; current += 1) {
const paged = await $api.admin.listSettlementStatements({
shop_id: shopId,
page: current,
per_page: 100,
});
for (const row of paged.items) ids.add(row.id);
if (paged.items.length === 0 || current * paged.per_page >= paged.total) break;
}
return ids;
}
async function generate(): Promise<void> {
generateError.value = "";
notice.value = "";
conflictMessage.value = "";
generatedStatement.value = null;
generatedWasExisting.value = false;
if (!generateShop.value) {
generateError.value = t("admin.settlementPickShop");
return;
}
if (!isIsoDate(generateDate.value)) {
generateError.value = t("admin.settlementPeriodDateInvalid");
return;
}
generating.value = true;
try {
const known = await knownStatementIds(generateShop.value);
const statement = await $api.admin.generateSettlementStatement({
shop_id: generateShop.value,
period_kind: generateKind.value,
period_start: generateDate.value,
});
generatedWasExisting.value = known.has(statement.id);
generatedStatement.value = statement;
notice.value = generatedWasExisting.value
? t("admin.settlementGeneratedExisting")
: t("admin.settlementGenerated");
await loadStatements();
} catch (error: unknown) {
// A period that is not closed yet is refused with a conflict.
if (error instanceof ApiError && error.status === 409) {
conflictMessage.value = `${t("admin.settlementPeriodNotClosed")}: ${error.message}`;
} else {
generateError.value = errorText(error);
}
} finally {
generating.value = false;
}
}
async function openDetail(id: string): Promise<void> {
openId.value = id;
detail.value = null;
detailError.value = "";
detailLoading.value = true;
try {
detail.value = await $api.admin.getSettlementStatement(id);
} catch (error: unknown) {
detailError.value = errorText(error);
} finally {
detailLoading.value = false;
}
}
async function toggleDetail(id: string): Promise<void> {
if (openId.value === id) {
openId.value = null;
detail.value = null;
detailError.value = "";
return;
}
await openDetail(id);
}
async function confirmPayout(row: SettlementStatement): Promise<void> {
if (!confirm(t("admin.settlementConfirmDialog"))) return;
actingId.value = row.id;
notice.value = "";
conflictMessage.value = "";
detailError.value = "";
try {
await $api.admin.confirmSettlementStatement(row.id);
notice.value = t("admin.settlementPayoutConfirmed");
await loadStatements();
if (openId.value === row.id) await openDetail(row.id);
} catch (error: unknown) {
// A repeat confirmation is a conflict: refresh so the UI converges to truth.
if (error instanceof ApiError && error.status === 409) {
conflictMessage.value = `${t("admin.settlementConflict")}: ${error.message}`;
await loadStatements();
if (openId.value === row.id) await openDetail(row.id);
} else {
detailError.value = errorText(error);
}
} finally {
actingId.value = null;
}
}
onMounted(() => {
void Promise.all([loadReference(), loadCommissionRate(), loadStatements()]);
});
</script>
<template>
<VPage :title="$t('nav.settlements')">
<template #actions>
<span v-if="!loading" class="text-muted text-sm">{{
$t("admin.pageOf", { page, total: totalPages })
}}</span>
</template>
<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>
<VCard class="mb-5">
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.commissionRateTitle") }}</h2>
<p class="text-muted mb-3 text-sm">
{{ $t("admin.commissionRateCurrent") }}: {{ currentRateLabel }}
</p>
<div class="flex flex-wrap items-start gap-3">
<VField
class="min-w-[240px] flex-1"
:label="$t('admin.commissionRate')"
:error="rateError"
>
<VInput
id="commission-rate-bps"
v-model="rateDraft"
type="number"
min="0"
max="10000"
step="1"
:disabled="rateSaving"
/>
</VField>
<VBtn variant="primary" :disabled="rateSaving" @click="saveRate">
{{ rateSaving ? $t("common.loading") : $t("admin.commissionRateSave") }}
</VBtn>
</div>
<p class="text-muted text-sm">{{ $t("admin.commissionRateHint") }}</p>
</VCard>
<VCard class="mb-5">
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.settlementGenerateTitle") }}</h2>
<div class="grid gap-3 md:grid-cols-3">
<label class="grid gap-1 text-sm font-medium" for="settlement-generate-shop">
{{ $t("admin.settlementShop") }}
<select
id="settlement-generate-shop"
v-model="generateShop"
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"
>
<option value="">—</option>
<option v-for="shop in shops" :key="shop.id" :value="shop.id">
{{ shopOptionLabel(shop) }}
</option>
</select>
</label>
<label class="grid gap-1 text-sm font-medium" for="settlement-generate-kind">
{{ $t("admin.settlementPeriodKind") }}
<select
id="settlement-generate-kind"
v-model="generateKind"
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"
>
<option v-for="kind in periodKinds" :key="kind" :value="kind">
{{ periodKindLabel(kind) }}
</option>
</select>
</label>
<VField :label="$t('admin.settlementPeriodDate')">
<VInput
id="settlement-generate-date"
v-model="generateDate"
type="date"
:disabled="generating"
/>
</VField>
</div>
<p class="text-muted mb-3 text-sm">{{ $t("admin.settlementPeriodDateHint") }}</p>
<p v-if="generateError" class="text-danger my-2 text-sm" role="alert">{{ generateError }}</p>
<VBtn variant="primary" :disabled="generating" @click="generate">
{{ generating ? $t("common.loading") : $t("admin.settlementGenerate") }}
</VBtn>
<p class="text-muted mt-3 text-sm">{{ $t("admin.settlementGenerateHint") }}</p>
<VPanel v-if="generatedStatement" class="mt-4" :title="$t('admin.settlementGeneratedResult')">
<template #actions>
<VBadge :tone="settlementStatusTone(generatedStatement.status)">
{{ settlementStatusLabel(generatedStatement.status) }}
</VBadge>
</template>
<dl class="grid gap-2 text-sm sm:grid-cols-3">
<div>
<dt class="text-muted">{{ $t("admin.settlementShop") }}</dt>
<dd>{{ shopLabel(generatedStatement) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementPeriod") }}</dt>
<dd>{{ periodLabel(generatedStatement) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementOrderCount") }}</dt>
<dd>{{ generatedStatement.order_count }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementGross") }}</dt>
<dd>{{ money(generatedStatement.gross_minor, generatedStatement.currency) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementRefunds") }}</dt>
<dd>{{ money(generatedStatement.refund_minor, generatedStatement.currency) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementCommission") }}</dt>
<dd>
{{
$t("admin.commissionRateDisplay", {
bps: generatedStatement.commission_rate_bps,
percent: ratePercent(generatedStatement.commission_rate_bps),
})
}}
·
{{ money(generatedStatement.commission_minor, generatedStatement.currency) }}
</dd>
</div>
<div class="font-semibold">
<dt class="text-muted">{{ $t("admin.settlementPayable") }}</dt>
<dd>{{ money(generatedStatement.payable_minor, generatedStatement.currency) }}</dd>
</div>
</dl>
</VPanel>
</VCard>
<VCard class="mb-5">
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.settlementListTitle") }}</h2>
<div class="flex flex-wrap items-end gap-3">
<label class="grid gap-1 text-sm font-medium" for="settlement-filter-shop">
{{ $t("admin.settlementShop") }}
<select
id="settlement-filter-shop"
v-model="shopFilter"
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="applyFilters"
>
<option value="">{{ $t("common.all") }}</option>
<option v-for="shop in shops" :key="shop.id" :value="shop.id">
{{ shopOptionLabel(shop) }}
</option>
</select>
</label>
<label class="grid gap-1 text-sm font-medium" for="settlement-filter-status">
{{ $t("common.status") }}
<select
id="settlement-filter-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="applyFilters"
>
<option value="">{{ $t("common.all") }}</option>
<option v-for="status in settlementStatuses" :key="status" :value="status">
{{ settlementStatusLabel(status) }}
</option>
</select>
</label>
<VBtn size="sm" :disabled="loading" @click="loadStatements">
{{ $t("admin.refresh") }}
</VBtn>
</div>
</VCard>
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
<p v-else-if="listError" class="text-danger my-2 text-sm" role="alert">{{ listError }}</p>
<VCard v-else-if="statements.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.settlementPeriod") }}</th>
<th>{{ $t("admin.settlementShop") }}</th>
<th>{{ $t("admin.settlementOrderCount") }}</th>
<th>{{ $t("admin.settlementGross") }}</th>
<th>{{ $t("admin.settlementRefunds") }}</th>
<th>{{ $t("admin.settlementCommission") }}</th>
<th>{{ $t("admin.settlementPayable") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<template v-for="row in statements" :key="row.id">
<tr :class="openId === row.id ? 'bg-primary-soft/30' : ''">
<td>
<span class="block text-sm">{{ periodLabel(row) }}</span>
<code class="text-muted text-xs" :title="row.id">{{ shortId(row.id) }}</code>
</td>
<td>
<span class="block">{{ shopLabel(row) }}</span>
<code class="text-muted text-xs" :title="row.shop_id">{{
shortId(row.shop_id)
}}</code>
</td>
<td>{{ row.order_count }}</td>
<td>{{ money(row.gross_minor, row.currency) }}</td>
<td>{{ money(row.refund_minor, row.currency) }}</td>
<td>
<span class="block text-sm">{{
$t("admin.commissionRateDisplay", {
bps: row.commission_rate_bps,
percent: ratePercent(row.commission_rate_bps),
})
}}</span>
<span class="text-muted block text-xs">{{
money(row.commission_minor, row.currency)
}}</span>
</td>
<td class="font-semibold">{{ money(row.payable_minor, row.currency) }}</td>
<td>
<VBadge :tone="settlementStatusTone(row.status)">{{
settlementStatusLabel(row.status)
}}</VBadge>
</td>
<td>
<VBtn size="sm" @click="toggleDetail(row.id)">
{{
openId === row.id ? $t("admin.settlementClose") : $t("admin.settlementDetail")
}}
</VBtn>
</td>
</tr>
<tr v-if="openId === row.id" class="bg-bg">
<td colspan="9" class="p-0">
<div class="p-4">
<p v-if="detailLoading" class="text-muted text-sm">
{{ $t("common.loading") }}
</p>
<p v-else-if="detailError" class="text-danger text-sm" role="alert">
{{ detailError }}
</p>
<VPanel v-else-if="detail" :title="$t('admin.settlementDetailTitle')">
<template #actions>
<VBadge :tone="settlementStatusTone(detail.status)">
{{ settlementStatusLabel(detail.status) }}
</VBadge>
</template>
<h4 class="mb-2 text-sm font-semibold">
{{ $t("admin.settlementSnapshot") }}
</h4>
<dl class="grid gap-2 text-sm sm:grid-cols-3">
<div>
<dt class="text-muted">{{ $t("admin.settlementShop") }}</dt>
<dd>{{ shopLabel(detail) }} · {{ detail.shop_id }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementPeriod") }}</dt>
<dd>{{ periodLabel(detail) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("common.currency") }}</dt>
<dd>{{ detail.currency }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementOrderCount") }}</dt>
<dd>{{ detail.order_count }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementGross") }}</dt>
<dd>{{ money(detail.gross_minor, detail.currency) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementRefunds") }}</dt>
<dd>{{ money(detail.refund_minor, detail.currency) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementCommission") }}</dt>
<dd>
{{
$t("admin.commissionRateDisplay", {
bps: detail.commission_rate_bps,
percent: ratePercent(detail.commission_rate_bps),
})
}}
· {{ money(detail.commission_minor, detail.currency) }}
</dd>
</div>
<div class="font-semibold">
<dt class="text-muted">{{ $t("admin.settlementPayable") }}</dt>
<dd>{{ money(detail.payable_minor, detail.currency) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementConfirmedAt") }}</dt>
<dd>{{ formatDate(detail.confirmed_at) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.created") }}</dt>
<dd>{{ formatDate(detail.created_at) }}</dd>
</div>
<div>
<dt class="text-muted">{{ $t("admin.settlementUpdated") }}</dt>
<dd>{{ formatDate(detail.updated_at) }}</dd>
</div>
</dl>
<h4 class="mt-5 mb-2 text-sm font-semibold">
{{ $t("admin.settlementOrders") }}
</h4>
<p v-if="detail.orders.length === 0" class="text-muted text-sm">
{{ $t("admin.settlementNoOrders") }}
</p>
<div v-else class="overflow-x-auto">
<div class="min-w-[720px]">
<VTable>
<thead>
<tr>
<th>{{ $t("admin.settlementOrderNo") }}</th>
<th>{{ $t("admin.settlementOrderCurrency") }}</th>
<th>{{ $t("admin.settlementGross") }}</th>
<th>{{ $t("admin.settlementRefunds") }}</th>
<th>{{ $t("admin.created") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="line in detail.orders" :key="line.order_id">
<td>{{ line.order_no }}</td>
<td>{{ line.order_currency }}</td>
<td>{{ money(line.gross_minor, detail.currency) }}</td>
<td>{{ money(line.refund_minor, detail.currency) }}</td>
<td>{{ formatDate(line.created_at) }}</td>
</tr>
</tbody>
</VTable>
</div>
</div>
<div class="border-border mt-4 border-t pt-4">
<template v-if="detail.status === 'pending'">
<p class="text-muted mb-3 text-sm">
{{ $t("admin.settlementConfirmPayoutHint") }}
</p>
<VBtn
variant="primary"
:disabled="actingId === detail.id"
@click="confirmPayout(detail)"
>
{{
actingId === detail.id
? $t("common.loading")
: $t("admin.settlementConfirmPayout")
}}
</VBtn>
</template>
<p v-else class="text-success text-sm">
{{ $t("admin.settlementPayoutConfirmed") }}
</p>
</div>
</VPanel>
</div>
</td>
</tr>
</template>
</tbody>
</VTable>
</div>
</div>
<div
v-if="!loading && !listError && statements.length > 0"
class="mt-4 flex items-center justify-center gap-3"
>
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{
$t("common.prev")
}}</VBtn>
<span class="text-muted text-sm">{{ page }} / {{ totalPages }}</span>
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{
$t("common.next")
}}</VBtn>
</div>
</VPage>
</template>
+321
View File
@@ -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>