Files
vmall/apps/admin/components/MemberLevelForm.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

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>