Files
vmall/apps/mall/pages/user/messages.vue
T
james 9904696e76 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.
2026-09-25 15:25:29 +00:00

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>