Files
vmall/apps/mall/pages/merchant/status.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

206 lines
7.7 KiB
Vue

<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { MerchantApplication } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const rows = ref<MerchantApplication[]>([]);
const loading = ref(true);
const failed = ref(false);
const latest = computed(() => rows.value[0] ?? null);
const history = computed(() => rows.value.slice(1));
function statusTone(status: MerchantApplication["status"]): "green" | "orange" | "red" {
if (status === "approved") return "green";
if (status === "rejected") return "red";
return "orange";
}
function statusLabel(status: MerchantApplication["status"]): string {
return t(`merchant.status_${status}`);
}
function kindLabel(kind: MerchantApplication["entity_type"]): string {
return kind === "personal" ? t("merchant.kindPersonal") : t("merchant.kindEnterprise");
}
function entityName(row: MerchantApplication): string {
return row.company_name ?? row.real_name ?? "";
}
function categoryLabel(row: MerchantApplication): string {
return row.categories.map((category) => pick(category.name, locale.value)).join(", ");
}
function qualificationUrls(row: MerchantApplication): string[] {
const urls = [
row.qualification.identity_document_url,
row.qualification.business_license_url,
...(row.qualification.extra_materials ?? []),
];
return urls.filter((url): url is string => Boolean(url));
}
function formatTime(value: string | null): string {
return value ? new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US") : "";
}
async function load(): Promise<void> {
loading.value = true;
failed.value = false;
try {
rows.value = await $api.getMyMerchantApplications();
} catch {
failed.value = true;
} finally {
loading.value = false;
}
}
async function reapply(): Promise<void> {
await navigateTo("/merchant/join?reapply=1");
}
onMounted(() => void load());
</script>
<template>
<div class="max-w-mall mx-auto px-4">
<VPage :title="t('merchant.statusTitle')">
<p class="text-muted -mt-2 mb-4 max-w-[760px] text-sm">{{ t("merchant.statusSubtitle") }}</p>
<VCard class="min-h-[420px] max-w-[760px]">
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<p v-else-if="failed" role="alert" class="text-danger py-5 text-sm">
{{ t("merchant.statusLoadFailed") }}
</p>
<div v-else-if="!latest" class="py-5">
<UiEmptyState :text="t('merchant.noApplication')" />
<div class="text-center">
<NuxtLink
class="text-primary hover:text-primary-hover text-sm"
to="/merchant/join"
>
{{ t("merchant.startApplication") }}
</NuxtLink>
</div>
</div>
<template v-else>
<div class="mb-4 flex flex-wrap items-center gap-3">
<VBadge :tone="statusTone(latest.status)">{{ statusLabel(latest.status) }}</VBadge>
<span class="text-muted text-xs">{{ latest.applicant_email }}</span>
</div>
<dl class="grid gap-3 text-sm">
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.applicationId") }}</dt>
<dd class="text-text m-0 break-all">{{ latest.id }}</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.entityKind") }}</dt>
<dd class="text-text m-0">
{{ kindLabel(latest.entity_type) }}
<span v-if="entityName(latest)"> · {{ entityName(latest) }}</span>
</dd>
</div>
<div v-if="latest.business_license_no" class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.businessLicenseNo") }}</dt>
<dd class="text-text m-0">{{ latest.business_license_no }}</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.categories") }}</dt>
<dd class="text-text m-0">{{ categoryLabel(latest) }}</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.contactInfo") }}</dt>
<dd class="text-text m-0">
{{ latest.contact.name }} · {{ latest.contact.phone }} · {{ latest.contact.email }}
<span v-if="latest.contact.address"> · {{ latest.contact.address }}</span>
</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.qualification") }}</dt>
<dd class="m-0 grid gap-1">
<a
v-for="url in qualificationUrls(latest)"
:key="url"
class="text-primary hover:text-primary-hover break-all"
:href="url"
target="_blank"
rel="noopener noreferrer"
>
{{ url }}
</a>
</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.submittedAt") }}</dt>
<dd class="text-text m-0">{{ formatTime(latest.created_at) }}</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.updatedAt") }}</dt>
<dd class="text-text m-0">{{ formatTime(latest.updated_at) }}</dd>
</div>
<div v-if="latest.reviewed_at" class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.reviewedAt") }}</dt>
<dd class="text-text m-0">{{ formatTime(latest.reviewed_at) }}</dd>
</div>
<div v-if="latest.created_shop_id" class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.createdShop") }}</dt>
<dd class="text-text m-0 break-all">{{ latest.created_shop_id }}</dd>
</div>
</dl>
<div
v-if="latest.status === 'rejected'"
class="bg-danger/10 mt-5 rounded-md p-4"
role="alert"
>
<p class="text-danger m-0 text-sm font-semibold">{{ t("merchant.rejectionReason") }}</p>
<p class="text-text mt-1 mb-0 text-sm">{{ latest.rejection_reason }}</p>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<VBtn v-if="latest.status === 'rejected'" variant="primary" type="button" @click="reapply">
{{ t("merchant.reapply") }}
</VBtn>
<NuxtLink
class="text-primary hover:text-primary-hover self-center text-sm"
to="/merchant/join"
>
{{ t("merchant.backToJoin") }}
</NuxtLink>
</div>
</template>
</VCard>
<section v-if="history.length > 0" class="mt-6 max-w-[760px]">
<h2 class="text-text mb-1 text-base font-semibold">{{ t("merchant.historyTitle") }}</h2>
<p class="text-muted mb-3 text-xs">{{ t("merchant.historyHint") }}</p>
<VTable>
<thead>
<tr>
<th>{{ t("merchant.entityKind") }}</th>
<th>{{ t("merchant.submittedAt") }}</th>
<th>{{ t("common.status") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in history" :key="row.id">
<td>{{ kindLabel(row.entity_type) }}</td>
<td>{{ formatTime(row.created_at) }}</td>
<td>
<VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge>
</td>
</tr>
</tbody>
</VTable>
</section>
</VPage>
</div>
</template>