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,254 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message, MessageKind } from "@vmall/shared";
|
||||
import { t as localizedText } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const { unread, refresh: refreshUnread } = useUnreadMessages();
|
||||
|
||||
const messages = ref<Message[]>([]);
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const perPage = ref(20);
|
||||
const unreadOnly = ref(false);
|
||||
const expandedId = ref<string | null>(null);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
const actionError = ref("");
|
||||
const actionNotice = ref("");
|
||||
|
||||
const KIND_KEYS: Record<MessageKind, string> = {
|
||||
order_paid: "messaging.kind_order_paid",
|
||||
order_shipped: "messaging.kind_order_shipped",
|
||||
refund_completed: "messaging.kind_refund_completed",
|
||||
};
|
||||
|
||||
const KIND_TONES: Record<MessageKind, "blue" | "green" | "orange"> = {
|
||||
order_paid: "blue",
|
||||
order_shipped: "green",
|
||||
refund_completed: "orange",
|
||||
};
|
||||
|
||||
function kindLabel(kind: MessageKind): string {
|
||||
return t(KIND_KEYS[kind]);
|
||||
}
|
||||
|
||||
function title(message: Message): string {
|
||||
return localizedText(message.title, locale.value);
|
||||
}
|
||||
|
||||
function body(message: Message): string {
|
||||
return localizedText(message.body, locale.value);
|
||||
}
|
||||
|
||||
function formatTime(createdAt: string): string {
|
||||
return createdAt.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
async function load(targetPage: number): Promise<void> {
|
||||
const result = await $api.listMessages({
|
||||
page: targetPage,
|
||||
unread_only: unreadOnly.value,
|
||||
});
|
||||
messages.value = result.items;
|
||||
page.value = result.page;
|
||||
total.value = result.total;
|
||||
perPage.value = result.per_page;
|
||||
}
|
||||
|
||||
/** Re-read both the list and the badge count after any mutation. */
|
||||
async function refreshAll(targetPage = page.value): Promise<void> {
|
||||
await Promise.all([load(targetPage), refreshUnread()]);
|
||||
}
|
||||
|
||||
async function initialLoad(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await refreshAll(1);
|
||||
} catch {
|
||||
error.value = t("messaging.loadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(targetPage: number): Promise<void> {
|
||||
actionError.value = "";
|
||||
try {
|
||||
await load(targetPage);
|
||||
} catch {
|
||||
actionError.value = t("messaging.listFailed");
|
||||
}
|
||||
}
|
||||
|
||||
async function setUnreadOnly(value: boolean): Promise<void> {
|
||||
unreadOnly.value = value;
|
||||
expandedId.value = null;
|
||||
actionError.value = "";
|
||||
try {
|
||||
await load(1);
|
||||
} catch {
|
||||
actionError.value = t("messaging.listFailed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Opening an unread message is what marks it read. */
|
||||
async function toggle(message: Message): Promise<void> {
|
||||
if (expandedId.value === message.id) {
|
||||
expandedId.value = null;
|
||||
return;
|
||||
}
|
||||
expandedId.value = message.id;
|
||||
if (message.status === "unread") await markRead(message);
|
||||
}
|
||||
|
||||
async function markRead(message: Message): Promise<void> {
|
||||
if (message.status === "read") return;
|
||||
actionError.value = "";
|
||||
actionNotice.value = "";
|
||||
try {
|
||||
await $api.markMessageRead(message.id);
|
||||
await refreshAll();
|
||||
} catch {
|
||||
actionError.value = t("messaging.markReadFailed");
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead(): Promise<void> {
|
||||
actionError.value = "";
|
||||
actionNotice.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
const result = await $api.markAllMessagesRead();
|
||||
expandedId.value = null;
|
||||
await refreshAll(1);
|
||||
actionNotice.value = t("messaging.markAllSuccess", { count: result.updated });
|
||||
} catch {
|
||||
actionError.value = t("messaging.markAllFailed");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(message: Message): Promise<void> {
|
||||
if (!confirm(t("messaging.deleteConfirm"))) return;
|
||||
actionError.value = "";
|
||||
actionNotice.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
await $api.deleteMessage(message.id);
|
||||
if (expandedId.value === message.id) expandedId.value = null;
|
||||
const target = messages.value.length === 1 && page.value > 1 ? page.value - 1 : page.value;
|
||||
await refreshAll(target);
|
||||
} catch {
|
||||
actionError.value = t("messaging.deleteFailed");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void initialLoad());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-text m-0 text-xl font-bold">{{ t("messaging.title") }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="text-muted flex cursor-pointer items-center gap-1.5 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary"
|
||||
:checked="unreadOnly"
|
||||
@change="setUnreadOnly(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ t("messaging.unreadOnly") }}
|
||||
</label>
|
||||
<VBtn
|
||||
variant="primary"
|
||||
size="sm"
|
||||
:disabled="busy || unread === 0"
|
||||
@click="markAllRead"
|
||||
>
|
||||
{{ t("messaging.markAllRead") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<template v-else>
|
||||
<p v-if="actionError" role="alert" class="text-danger mb-3 text-sm">{{ actionError }}</p>
|
||||
<p v-if="actionNotice" role="status" class="text-success mb-3 text-sm">
|
||||
{{ actionNotice }}
|
||||
</p>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="messages.length === 0"
|
||||
:text="unreadOnly ? t('messaging.emptyUnread') : t('messaging.empty')"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<article
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="border-border border"
|
||||
:class="message.status === 'unread' ? 'bg-primary-soft' : 'bg-surface'"
|
||||
>
|
||||
<header class="flex flex-wrap items-center gap-2.5 px-3 py-2.5">
|
||||
<VBadge :tone="KIND_TONES[message.kind]">{{ kindLabel(message.kind) }}</VBadge>
|
||||
<VBadge :tone="message.status === 'unread' ? 'orange' : 'gray'">
|
||||
{{
|
||||
message.status === "unread"
|
||||
? t("messaging.statusUnread")
|
||||
: t("messaging.statusRead")
|
||||
}}
|
||||
</VBadge>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary min-w-0 flex-1 cursor-pointer truncate text-left text-sm font-medium"
|
||||
@click="toggle(message)"
|
||||
>
|
||||
{{ title(message) }}
|
||||
</button>
|
||||
<span class="text-muted text-xs whitespace-nowrap">{{
|
||||
formatTime(message.created_at)
|
||||
}}</span>
|
||||
<VBtn size="sm" @click="toggle(message)">
|
||||
{{ expandedId === message.id ? t("messaging.close") : t("messaging.open") }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="message.status === 'unread'"
|
||||
size="sm"
|
||||
:disabled="busy"
|
||||
@click="markRead(message)"
|
||||
>
|
||||
{{ t("messaging.markRead") }}
|
||||
</VBtn>
|
||||
<VBtn variant="danger" size="sm" :disabled="busy" @click="remove(message)">
|
||||
{{ t("messaging.delete") }}
|
||||
</VBtn>
|
||||
</header>
|
||||
<p
|
||||
v-if="expandedId === message.id"
|
||||
class="border-border text-text m-0 border-t px-3 py-3 text-sm"
|
||||
>
|
||||
{{ body(message) }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<UiPagination :page="page" :total="total" :per-page="perPage" @change="changePage" />
|
||||
</template>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
Reference in New Issue
Block a user