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.
255 lines
7.6 KiB
Vue
255 lines
7.6 KiB
Vue
<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>
|