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.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user