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