Files
vmall/apps/admin/pages/member-levels.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

422 lines
14 KiB
Vue

<script setup lang="ts">
import { ApiError } from "@vmall/shared";
import type { LocalizedText, MemberLevel, MemberLevelInput } from "@vmall/shared";
import MemberLevelForm from "../components/MemberLevelForm.vue";
definePageMeta({ middleware: "auth" });
/** Field bundle the form component reports on every change. */
interface LevelDraftFields {
nameEn: string;
nameZh: string;
icon: string;
growthThreshold: number;
benefitsEn: string;
benefitsZh: string;
}
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const levels = ref<MemberLevel[]>([]);
const loading = ref(true);
const loadError = ref("");
const feedback = ref("");
const deleteError = ref("");
/** Create form: reset to this after a successful POST. */
function emptyDraft(): LevelDraftFields {
return {
nameEn: "",
nameZh: "",
icon: "",
growthThreshold: 0,
benefitsEn: "",
benefitsZh: "",
};
}
function draftFromLevel(row: MemberLevel): LevelDraftFields {
return {
nameEn: row.name.en ?? "",
nameZh: row.name.zh ?? "",
icon: row.icon,
growthThreshold: row.growth_threshold,
benefitsEn: row.benefits.en ?? "",
benefitsZh: row.benefits.zh ?? "",
};
}
function bodyFromDraft(draft: LevelDraftFields): MemberLevelInput {
return {
name: { en: draft.nameEn.trim(), zh: draft.nameZh.trim() },
icon: draft.icon.trim(),
growth_threshold: Number(draft.growthThreshold),
benefits: { en: draft.benefitsEn.trim(), zh: draft.benefitsZh.trim() },
};
}
const createDraft = reactive<LevelDraftFields>(emptyDraft());
/** One draft and pristine baseline per open edit form. */
const editDrafts = reactive<Record<string, LevelDraftFields>>({});
const editBaselines = reactive<Record<string, LevelDraftFields>>({});
const openEditId = ref<string | null>(null);
const savingCreate = ref(false);
const savingEditId = ref<string | null>(null);
const deletingId = ref<string | null>(null);
/** Server feedback buckets, kept separate so every surface stays explicit. */
const createError = ref("");
const createNotice = ref("");
const editError = ref("");
function localizedName(name: LocalizedText): string {
return name[locale.value] ?? name.en ?? Object.values(name)[0] ?? "";
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function clearFeedback(): void {
feedback.value = "";
createError.value = "";
createNotice.value = "";
editError.value = "";
}
/** Duplicate thresholds (409) and validation (400) read differently from a generic failure. */
function errorText(error: unknown, keys: { conflict: string; invalid: string }): string {
const message = error instanceof Error ? error.message : t("common.error");
if (error instanceof ApiError && error.status === 409) return `${t(keys.conflict)}: ${message}`;
if (error instanceof ApiError && error.status === 400) return `${t(keys.invalid)}: ${message}`;
return message;
}
/** Mirrors the server rules so obvious mistakes never reach the network. */
function validateDraft(draft: LevelDraftFields, ownId: string | null): string {
if (
draft.nameEn.trim() === "" ||
draft.nameZh.trim() === "" ||
draft.benefitsEn.trim() === "" ||
draft.benefitsZh.trim() === ""
) {
return t("admin.memberLevelRequired");
}
if (draft.icon.trim() === "") return t("admin.memberLevelIconRequired");
const threshold = Number(draft.growthThreshold);
if (!Number.isInteger(threshold) || threshold < 0) {
return t("admin.memberLevelThresholdInvalid");
}
const duplicate = levels.value.some(
(row) => row.growth_threshold === threshold && row.id !== ownId,
);
if (duplicate) return t("admin.memberLevelThresholdDuplicate");
return "";
}
function applyCreateDraft(draft: LevelDraftFields): void {
Object.assign(createDraft, draft);
}
function resetCreateDraft(): void {
Object.assign(createDraft, emptyDraft());
}
function applyEditDraft(id: string, draft: LevelDraftFields): void {
if (editDrafts[id]) Object.assign(editDrafts[id], draft);
}
function hasUnsavedEdit(): boolean {
if (openEditId.value === null) return false;
const draft = editDrafts[openEditId.value];
const baseline = editBaselines[openEditId.value];
if (!draft || !baseline) return false;
return (
draft.nameEn !== baseline.nameEn ||
draft.nameZh !== baseline.nameZh ||
draft.icon !== baseline.icon ||
draft.growthThreshold !== baseline.growthThreshold ||
draft.benefitsEn !== baseline.benefitsEn ||
draft.benefitsZh !== baseline.benefitsZh
);
}
async function load(): Promise<void> {
loading.value = true;
loadError.value = "";
try {
levels.value = await $api.admin.listMemberLevels();
// An edited level may have moved in threshold order or been removed elsewhere.
if (openEditId.value !== null && !levels.value.some((row) => row.id === openEditId.value)) {
closeEdit();
}
} catch (error: unknown) {
loadError.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
/** Warn before a refresh replaces the list under half-edited input. */
async function refresh(): Promise<void> {
if (hasUnsavedEdit() && !confirm(t("admin.memberLevelDiscardEdit"))) return;
clearFeedback();
deleteError.value = "";
closeEdit();
await load();
}
async function createLevel(): Promise<void> {
clearFeedback();
deleteError.value = "";
const message = validateDraft(createDraft, null);
if (message !== "") {
createError.value = message;
return;
}
savingCreate.value = true;
try {
await $api.admin.createMemberLevel(bodyFromDraft(createDraft));
resetCreateDraft();
createNotice.value = t("admin.memberLevelCreated");
await load();
} catch (error: unknown) {
createError.value = errorText(error, {
conflict: t("admin.memberLevelConflict"),
invalid: t("admin.memberLevelInvalid"),
});
// A 409 means the threshold was taken elsewhere: converge to server state.
if (error instanceof ApiError && error.status === 409) await load();
} finally {
savingCreate.value = false;
}
}
function startEdit(row: MemberLevel): void {
const draft = draftFromLevel(row);
editDrafts[row.id] = { ...draft };
editBaselines[row.id] = { ...draft };
openEditId.value = row.id;
editError.value = "";
}
function closeEdit(): void {
const id = openEditId.value;
if (id !== null) {
delete editDrafts[id];
delete editBaselines[id];
}
openEditId.value = null;
editError.value = "";
}
function toggleEdit(row: MemberLevel): void {
if (openEditId.value === row.id) {
closeEdit();
return;
}
createError.value = "";
createNotice.value = "";
startEdit(row);
}
async function saveEdit(id: string): Promise<void> {
const draft = editDrafts[id];
if (!draft) return;
editError.value = "";
const message = validateDraft(draft, id);
if (message !== "") {
editError.value = message;
return;
}
savingEditId.value = id;
try {
await $api.admin.updateMemberLevel(id, bodyFromDraft(draft));
closeEdit();
feedback.value = t("admin.memberLevelUpdated");
await load();
} catch (error: unknown) {
editError.value = errorText(error, {
conflict: t("admin.memberLevelConflict"),
invalid: t("admin.memberLevelInvalid"),
});
if (error instanceof ApiError && (error.status === 409 || error.status === 404)) {
// Gone or taken elsewhere: close and converge instead of retrying stale input.
if (error.status === 404) feedback.value = t("admin.memberLevelGone");
closeEdit();
await load();
}
} finally {
savingEditId.value = null;
}
}
/**
* Levels held by at least one customer cannot be deleted: the API answers 409,
* and that rejection stays visible until acknowledged so the row never looks
* successfully removed.
*/
async function removeLevel(row: MemberLevel): Promise<void> {
deleteError.value = "";
createNotice.value = "";
if (!confirm(t("admin.memberLevelConfirmDelete", { name: localizedName(row.name) }))) return;
deletingId.value = row.id;
try {
await $api.admin.deleteMemberLevel(row.id);
if (openEditId.value === row.id) closeEdit();
feedback.value = t("admin.memberLevelDeleted");
await load();
} catch (error: unknown) {
if (error instanceof ApiError && error.status === 409) {
deleteError.value = `${t("admin.memberLevelDeleteInUse")}: ${error.message}`;
await load();
} else {
deleteError.value = error instanceof Error ? error.message : t("common.error");
}
} finally {
deletingId.value = null;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<VPage :title="$t('nav.memberLevels')">
<template #actions>
<span v-if="!loading" class="text-muted text-sm">{{ levels.length }}</span>
</template>
<p
v-if="feedback"
class="bg-success/10 text-success my-2 rounded-md px-3 py-2 text-sm"
role="status"
>
{{ feedback }}
</p>
<p v-if="createNotice" class="text-success my-2 text-sm" role="status">{{ createNotice }}</p>
<p
v-if="deleteError"
class="bg-danger/10 text-danger my-2 rounded-md px-3 py-2 text-sm"
role="alert"
>
{{ deleteError }}
</p>
<MemberLevelForm
class="mb-5"
scope="create"
:title="$t('admin.memberLevelCreateTitle')"
:hint="$t('admin.memberLevelHint')"
:name-en="createDraft.nameEn"
:name-zh="createDraft.nameZh"
:icon="createDraft.icon"
:growth-threshold="createDraft.growthThreshold"
:benefits-en="createDraft.benefitsEn"
:benefits-zh="createDraft.benefitsZh"
:error="createError"
:busy="savingCreate"
:submit-label="$t('admin.memberLevelCreate')"
@change="applyCreateDraft"
@submit="createLevel"
/>
<div class="mb-2 flex justify-end">
<VBtn size="sm" :disabled="loading" @click="refresh">{{ $t("admin.refresh") }}</VBtn>
</div>
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
<p v-else-if="loadError" class="text-danger my-2 text-sm" role="alert">{{ loadError }}</p>
<VCard v-else-if="levels.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
<div v-else class="overflow-x-auto">
<div class="min-w-[960px]">
<VTable>
<thead>
<tr>
<th>{{ $t("admin.memberLevelIcon") }}</th>
<th>{{ $t("admin.memberLevelNameEn") }}</th>
<th>{{ $t("admin.memberLevelNameZh") }}</th>
<th>{{ $t("admin.memberLevelGrowth") }}</th>
<th>{{ $t("admin.memberLevelBenefitsEn") }}</th>
<th>{{ $t("admin.memberLevelBenefitsZh") }}</th>
<th>{{ $t("admin.memberLevelUpdatedAt") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<template v-for="row in levels" :key="row.id">
<tr :class="openEditId === row.id ? 'bg-primary-soft/30' : ''">
<td>
<code class="bg-bg rounded px-1.5 py-0.5">{{ row.icon }}</code>
</td>
<td class="max-w-[220px] truncate" :title="row.name.en">{{ row.name.en }}</td>
<td class="max-w-[220px] truncate" :title="row.name.zh">{{ row.name.zh }}</td>
<td>
<VBadge tone="blue">{{ row.growth_threshold }}</VBadge>
</td>
<td class="max-w-[260px] truncate" :title="row.benefits.en">
{{ row.benefits.en }}
</td>
<td class="max-w-[260px] truncate" :title="row.benefits.zh">
{{ row.benefits.zh }}
</td>
<td class="text-muted">{{ formatDate(row.updated_at) }}</td>
<td>
<div class="flex gap-2">
<VBtn
size="sm"
:data-testid="`ml-edit-${row.id}`"
:disabled="savingEditId === row.id || deletingId === row.id"
@click="toggleEdit(row)"
>
{{ openEditId === row.id ? $t("common.cancel") : $t("common.edit") }}
</VBtn>
<VBtn
size="sm"
variant="danger"
:data-testid="`ml-delete-${row.id}`"
:disabled="deletingId === row.id || savingEditId === row.id"
@click="removeLevel(row)"
>
{{ deletingId === row.id ? $t("common.loading") : $t("common.delete") }}
</VBtn>
</div>
</td>
</tr>
<tr v-if="openEditId === row.id && editDrafts[row.id]" class="bg-bg">
<td colspan="8" class="p-0">
<div class="p-4">
<MemberLevelForm
:scope="`edit-${row.id}`"
:title="$t('admin.memberLevelEditTitle')"
:name-en="editDrafts[row.id].nameEn"
:name-zh="editDrafts[row.id].nameZh"
:icon="editDrafts[row.id].icon"
:growth-threshold="editDrafts[row.id].growthThreshold"
:benefits-en="editDrafts[row.id].benefitsEn"
:benefits-zh="editDrafts[row.id].benefitsZh"
:error="editError"
:busy="savingEditId === row.id"
:submit-label="$t('common.save')"
@change="applyEditDraft(row.id, $event)"
@submit="saveEdit(row.id)"
/>
</div>
</td>
</tr>
</template>
</tbody>
</VTable>
</div>
</div>
</VPage>
</template>