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.
422 lines
14 KiB
Vue
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>
|