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.
151 lines
3.8 KiB
Vue
151 lines
3.8 KiB
Vue
<script setup lang="ts">
|
|
import { reactive, watch } from "vue";
|
|
|
|
/**
|
|
* Bilingual create/edit form for one membership level. The draft object is the
|
|
* single editable surface; it reports the whole draft on change so the page owns
|
|
* draft persistence.
|
|
*/
|
|
interface LevelDraftFields {
|
|
nameEn: string;
|
|
nameZh: string;
|
|
icon: string;
|
|
growthThreshold: number;
|
|
benefitsEn: string;
|
|
benefitsZh: string;
|
|
}
|
|
|
|
const props = withDefaults(
|
|
defineProps<
|
|
LevelDraftFields & {
|
|
title: string;
|
|
/** Namespaces the test hooks so several forms can coexist on one page. */
|
|
scope: string;
|
|
hint?: string;
|
|
error?: string;
|
|
busy?: boolean;
|
|
submitLabel: string;
|
|
}
|
|
>(),
|
|
{ hint: "", error: "", busy: false },
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
(e: "submit"): void;
|
|
(e: "change", value: LevelDraftFields): void;
|
|
}>();
|
|
|
|
// `type="number"` inputs hand back a string through v-model; normalize to the
|
|
// contract's integer growth value when reporting the draft.
|
|
const draft = reactive({
|
|
nameEn: props.nameEn,
|
|
nameZh: props.nameZh,
|
|
icon: props.icon,
|
|
growthThreshold: String(props.growthThreshold),
|
|
benefitsEn: props.benefitsEn,
|
|
benefitsZh: props.benefitsZh,
|
|
});
|
|
|
|
watch(
|
|
() => [
|
|
props.nameEn,
|
|
props.nameZh,
|
|
props.icon,
|
|
props.growthThreshold,
|
|
props.benefitsEn,
|
|
props.benefitsZh,
|
|
],
|
|
() => {
|
|
draft.nameEn = props.nameEn;
|
|
draft.nameZh = props.nameZh;
|
|
draft.icon = props.icon;
|
|
draft.growthThreshold = String(props.growthThreshold);
|
|
draft.benefitsEn = props.benefitsEn;
|
|
draft.benefitsZh = props.benefitsZh;
|
|
},
|
|
);
|
|
|
|
watch(
|
|
draft,
|
|
() => {
|
|
emit("change", {
|
|
nameEn: draft.nameEn,
|
|
nameZh: draft.nameZh,
|
|
icon: draft.icon,
|
|
growthThreshold: Number(draft.growthThreshold),
|
|
benefitsEn: draft.benefitsEn,
|
|
benefitsZh: draft.benefitsZh,
|
|
});
|
|
},
|
|
{ deep: true },
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<VPanel :title="props.title">
|
|
<p v-if="props.hint" class="text-muted mb-4 text-sm">{{ props.hint }}</p>
|
|
<div class="grid gap-3 md:grid-cols-3">
|
|
<VField :label="$t('admin.memberLevelNameEn')">
|
|
<VInput
|
|
v-model="draft.nameEn"
|
|
:data-testid="`ml-${props.scope}-name-en`"
|
|
required
|
|
:disabled="props.busy"
|
|
/>
|
|
</VField>
|
|
<VField :label="$t('admin.memberLevelNameZh')">
|
|
<VInput
|
|
v-model="draft.nameZh"
|
|
:data-testid="`ml-${props.scope}-name-zh`"
|
|
required
|
|
:disabled="props.busy"
|
|
/>
|
|
</VField>
|
|
<VField :label="$t('admin.memberLevelIcon')">
|
|
<VInput
|
|
v-model="draft.icon"
|
|
:data-testid="`ml-${props.scope}-icon`"
|
|
required
|
|
:disabled="props.busy"
|
|
/>
|
|
</VField>
|
|
<VField :label="$t('admin.memberLevelGrowth')">
|
|
<VInput
|
|
v-model="draft.growthThreshold"
|
|
:data-testid="`ml-${props.scope}-growth`"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
required
|
|
:disabled="props.busy"
|
|
/>
|
|
</VField>
|
|
<VField :label="$t('admin.memberLevelBenefitsEn')">
|
|
<VInput
|
|
v-model="draft.benefitsEn"
|
|
:data-testid="`ml-${props.scope}-benefits-en`"
|
|
required
|
|
:disabled="props.busy"
|
|
/>
|
|
</VField>
|
|
<VField :label="$t('admin.memberLevelBenefitsZh')">
|
|
<VInput
|
|
v-model="draft.benefitsZh"
|
|
:data-testid="`ml-${props.scope}-benefits-zh`"
|
|
required
|
|
:disabled="props.busy"
|
|
/>
|
|
</VField>
|
|
</div>
|
|
<p v-if="props.error" class="text-danger my-2 text-sm" role="alert">{{ props.error }}</p>
|
|
<VBtn
|
|
variant="primary"
|
|
:data-testid="`ml-${props.scope}-submit`"
|
|
:disabled="props.busy"
|
|
@click="emit('submit')"
|
|
>
|
|
{{ props.busy ? $t("common.loading") : props.submitLabel }}
|
|
</VBtn>
|
|
</VPanel>
|
|
</template>
|