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,205 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user