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
+212
View File
@@ -0,0 +1,212 @@
<script setup lang="ts">
import type { GrowthLogEntry, MembershipStatus } from "@vmall/shared";
import { t as localizedText } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const status = ref<MembershipStatus | null>(null);
const logs = ref<GrowthLogEntry[]>([]);
const logPage = ref(1);
const logTotal = ref(0);
const logPerPage = ref(20);
const loading = ref(true);
const error = ref("");
const logError = ref("");
const REASON_KEYS: Record<string, string> = {
order_complete: "membership.reason_order_complete",
};
function levelName(name: Record<string, string>): string {
return localizedText(name, locale.value);
}
function reasonLabel(reason: string): string {
const key = REASON_KEYS[reason];
return key ? t(key) : reason;
}
async function loadStatus(): Promise<void> {
status.value = await $api.getMembership();
}
async function loadLogs(page: number): Promise<void> {
const result = await $api.listGrowthLogs(page);
logs.value = result.items;
logPage.value = result.page;
logTotal.value = result.total;
logPerPage.value = result.per_page;
}
async function changeLogPage(page: number): Promise<void> {
logError.value = "";
try {
await loadLogs(page);
} catch {
logError.value = t("membership.historyFailed");
}
}
async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
await Promise.all([loadStatus(), loadLogs(1)]);
} catch {
error.value = t("membership.loadFailed");
} finally {
loading.value = false;
}
}
/**
* Progress inside the current band: from the level's own threshold to the next
* one. Below the lowest threshold the band starts at zero. Growth is a plain
* integer, so the percentage is integer arithmetic with one rounding.
*/
const progressPercent = computed<number | null>(() => {
const current = status.value;
if (!current || !current.next_level) return null;
const base = current.level?.growth_threshold ?? 0;
const span = current.next_level.growth_threshold - base;
if (span <= 0) return null;
const within = Math.min(Math.max(current.growth_total - base, 0), span);
return Math.round((within / span) * 100);
});
onMounted(() => void load());
</script>
<template>
<VCard class="min-h-[560px]">
<h1 class="text-text mb-4 text-xl font-bold">{{ t("membership.title") }}</h1>
<p
v-if="error"
role="alert"
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
>
{{ error }}
</p>
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
<p v-else-if="!status" class="text-muted py-10">{{ t("membership.loadFailed") }}</p>
<template v-else>
<div class="mb-6 grid gap-3 sm:grid-cols-2">
<div class="border-border bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("membership.currentLevel") }}</span>
<div v-if="status.level" class="mt-2.5 flex items-center gap-2.5">
<span
class="border-primary/40 text-primary flex h-9 w-9 items-center justify-center rounded-full border text-sm font-bold"
>{{ status.level.icon }}</span
>
<strong class="text-primary text-xl">{{ levelName(status.level.name) }}</strong>
</div>
<strong v-else class="text-muted mt-2.5 block text-xl">{{
t("membership.noLevel")
}}</strong>
<p v-if="status.level" class="text-muted mt-2 mb-0 text-xs">
{{ t("membership.benefits") }}: {{ levelName(status.level.benefits) }}
</p>
<p v-else-if="status.next_level" class="text-muted mt-2 mb-0 text-xs">
{{
t("membership.noLevelHint", {
threshold: status.next_level.growth_threshold,
unit: t("membership.growthUnit"),
level: levelName(status.next_level.name),
})
}}
</p>
</div>
<div class="border-border bg-bg border p-[18px]">
<span class="text-muted block text-xs">{{ t("membership.growthTotal") }}</span>
<strong class="text-primary mt-2.5 block text-xl">{{ status.growth_total }}</strong>
<span class="text-muted mt-1 block text-xs">{{ t("membership.growthUnit") }}</span>
</div>
</div>
<section class="border-border mb-6 rounded-md border p-4">
<template v-if="status.next_level">
<h2 class="text-text m-0 text-lg font-semibold">
{{ t("membership.progressTitle", { level: levelName(status.next_level.name) }) }}
</h2>
<p class="text-muted mt-1 mb-3 text-xs">
{{
t("membership.progressThreshold", {
current: status.growth_total,
threshold: status.next_level.growth_threshold,
unit: t("membership.growthUnit"),
})
}}
</p>
<div
class="bg-border h-2.5 w-full overflow-hidden rounded-full"
role="progressbar"
:aria-valuenow="progressPercent ?? 0"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="t('membership.progressTitle', { level: levelName(status.next_level.name) })"
>
<div
class="bg-primary h-full rounded-full transition-all"
:style="{ width: `${progressPercent ?? 0}%` }"
/>
</div>
<p class="text-primary mt-2 mb-0 text-xs font-medium">
{{
t("membership.remaining", {
remaining: status.next_level.remaining,
unit: t("membership.growthUnit"),
})
}}
</p>
</template>
<template v-else-if="status.level">
<h2 class="text-text m-0 text-lg font-semibold">{{ t("membership.topLevel") }}</h2>
<p class="text-muted mt-1 mb-0 text-xs">{{ t("membership.topLevelHint") }}</p>
</template>
<p v-else class="text-muted m-0 text-sm">{{ t("membership.noLevel") }}</p>
</section>
<section>
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("membership.historyTitle") }}</h2>
<p v-if="logError" role="alert" class="text-danger mb-3 text-sm">{{ logError }}</p>
<UiEmptyState v-if="logs.length === 0" :text="t('membership.historyEmpty')" />
<template v-else>
<VTable>
<thead>
<tr>
<th>{{ t("membership.historyDate") }}</th>
<th>{{ t("membership.historyReason") }}</th>
<th class="text-right!">{{ t("membership.historyDelta") }}</th>
<th class="text-right!">{{ t("membership.historyTotal") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in logs" :key="entry.id">
<td class="text-muted text-xs whitespace-nowrap">
{{ entry.created_at.slice(0, 10) }}
</td>
<td>{{ reasonLabel(entry.reason) }}</td>
<td
class="text-right font-semibold"
:class="entry.delta < 0 ? 'text-danger' : 'text-success'"
>
{{ entry.delta < 0 ? "−" : "+" }}{{ Math.abs(entry.delta) }}
</td>
<td class="text-right">{{ entry.growth_total }}</td>
</tr>
</tbody>
</VTable>
<UiPagination
:page="logPage"
:total="logTotal"
:per-page="logPerPage"
@change="changeLogPage"
/>
</template>
</section>
</template>
</VCard>
</template>