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:
2026-09-25 15:25:29 +00:00
parent 772aafa3fb
commit 9904696e76
120 changed files with 14097 additions and 125 deletions
+8 -2
View File
@@ -24,6 +24,12 @@ const redirectTarget = computed((): string => {
: "/";
});
/** Carry the return path into registration so both entry points return here. */
const registerLink = computed(() => ({
path: "/register",
query: redirectTarget.value === "/" ? {} : { redirect: redirectTarget.value },
}));
function validate(): boolean {
if (!email.value || !password.value) {
errorKey.value = "auth.validationRequired";
@@ -62,7 +68,7 @@ async function submit(): Promise<void> {
<VCard class="mx-auto my-10 max-w-[420px] p-6 sm:p-10">
<div class="border-border mb-7 flex items-baseline gap-4 border-b pb-3.5">
<h1 class="m-0 text-xl font-semibold">{{ t("auth.loginTab") }}</h1>
<NuxtLink class="text-primary hover:text-primary-hover text-sm" to="/register">{{
<NuxtLink class="text-primary hover:text-primary-hover text-sm" :to="registerLink">{{
t("auth.registerTab")
}}</NuxtLink>
</div>
@@ -90,7 +96,7 @@ async function submit(): Promise<void> {
</form>
<div class="text-muted mt-5 flex flex-wrap items-center gap-2 text-xs">
<span>{{ t("auth.noAccount") }}</span>
<NuxtLink class="text-primary hover:text-primary-hover" to="/register">{{
<NuxtLink class="text-primary hover:text-primary-hover" :to="registerLink">{{
t("auth.registerNow")
}}</NuxtLink>
<NuxtLink class="text-primary hover:text-primary-hover" to="/forgot-password">{{
+672
View File
@@ -0,0 +1,672 @@
<script setup lang="ts">
import { ApiError, t as pick } from "@vmall/shared";
import type {
Category,
LocalizedText,
MerchantApplicationInput,
MerchantContact,
MerchantEntityType,
} from "@vmall/shared";
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const session = useSessionStore();
const route = useRoute();
const STEPS = ["merchant.stepKind", "merchant.stepEntity", "merchant.stepContact"];
const DRAFT_KEY = "vmall.merchant.draft";
const EMAIL_RE = /^\S+@\S+\.\S+$/;
const ENTITY_KINDS: MerchantEntityType[] = ["personal", "enterprise"];
const step = ref(0);
const entityType = ref<MerchantEntityType>("personal");
const realName = ref("");
const companyName = ref("");
const businessLicenseNo = ref("");
const categoryIds = ref<string[]>([]);
const contactName = ref("");
const contactPhone = ref("");
const contactEmail = ref("");
const contactAddress = ref("");
const identityDocumentUrl = ref("");
const businessLicenseUrl = ref("");
const extraMaterials = ref<string[]>([""]);
const categories = ref<Category[]>([]);
const categoriesFailed = ref(false);
const loadingCategories = ref(true);
const submitting = ref(false);
const success = ref(false);
const restoredDraft = ref(false);
const conflictKey = ref("");
const errorKey = ref("");
type DraftField =
| "realName"
| "companyName"
| "businessLicenseNo"
| "categoryIds"
| "contactName"
| "contactPhone"
| "contactEmail"
| "identityDocumentUrl"
| "businessLicenseUrl"
| "extraMaterials";
/** Values are i18n keys; the template resolves them so copy stays locale-aware. */
const errors = ref<Partial<Record<DraftField, string>>>({});
const stepLabels = computed(() => STEPS.map((key) => t(key)));
const isReapply = computed(() => route.query.reapply === "1");
interface CategoryOption {
id: string;
name: LocalizedText;
depth: number;
}
/** Flattened published tree, so any nesting depth renders as indented options. */
const categoryOptions = computed<CategoryOption[]>(() => {
const byParent = new Map<string | null, Category[]>();
for (const category of categories.value) {
const list = byParent.get(category.parent_id) ?? [];
list.push(category);
byParent.set(category.parent_id, list);
}
for (const list of byParent.values()) {
list.sort((a, b) => a.position - b.position || a.slug.localeCompare(b.slug));
}
const out: CategoryOption[] = [];
const walk = (parentId: string | null, depth: number): void => {
for (const category of byParent.get(parentId) ?? []) {
out.push({ id: category.id, name: category.name, depth });
walk(category.id, depth + 1);
}
};
walk(null, 0);
return out;
});
function depthClass(depth: number): string {
if (depth <= 0) return "";
if (depth === 1) return "pl-4";
return "pl-8";
}
/** i18n key -> localized message, so VField renders nothing when a field is clean. */
function fieldError(key: DraftField): string {
const code = errors.value[key];
return code ? t(code) : "";
}
const selectedCategoryLabel = computed(() =>
categoryOptions.value
.filter((option) => categoryIds.value.includes(option.id))
.map((option) => pick(option.name, locale.value))
.join(", "),
);
function toggleCategory(id: string): void {
categoryIds.value = categoryIds.value.includes(id)
? categoryIds.value.filter((entry) => entry !== id)
: [...categoryIds.value, id];
delete errors.value.categoryIds;
}
function isHttpUrl(value: string): boolean {
const trimmed = value.trim();
return (
(trimmed.startsWith("http://") || trimmed.startsWith("https://")) &&
trimmed.length > "https://".length &&
!/\s/.test(trimmed)
);
}
function nonEmptyMaterials(): string[] {
return extraMaterials.value.map((url) => url.trim()).filter(Boolean);
}
function validateEntity(): boolean {
const next: Partial<Record<DraftField, string>> = {};
if (entityType.value === "personal") {
// Switching kind drops the other kind's errors so a hidden field cannot block.
delete errors.value.companyName;
delete errors.value.businessLicenseNo;
if (!realName.value.trim()) next.realName = "merchant.errorRequired";
} else {
delete errors.value.realName;
if (!companyName.value.trim()) next.companyName = "merchant.errorRequired";
if (!businessLicenseNo.value.trim()) next.businessLicenseNo = "merchant.errorRequired";
}
if (categoryIds.value.length === 0) next.categoryIds = "merchant.errorCategories";
errors.value = { ...errors.value, ...next };
return Object.keys(next).length === 0;
}
function validateContact(): boolean {
const next: Partial<Record<DraftField, string>> = {};
if (!contactName.value.trim()) next.contactName = "merchant.errorRequired";
if (!contactPhone.value.trim()) next.contactPhone = "merchant.errorRequired";
if (!contactEmail.value.trim()) next.contactEmail = "merchant.errorRequired";
else if (!EMAIL_RE.test(contactEmail.value.trim())) next.contactEmail = "merchant.errorEmail";
if (entityType.value === "personal") {
delete errors.value.businessLicenseUrl;
if (!identityDocumentUrl.value.trim()) {
next.identityDocumentUrl = "merchant.errorRequired";
} else if (!isHttpUrl(identityDocumentUrl.value)) {
next.identityDocumentUrl = "merchant.errorUrl";
}
} else {
delete errors.value.identityDocumentUrl;
if (!businessLicenseUrl.value.trim()) {
next.businessLicenseUrl = "merchant.errorRequired";
} else if (!isHttpUrl(businessLicenseUrl.value)) {
next.businessLicenseUrl = "merchant.errorUrl";
}
}
if (nonEmptyMaterials().some((url) => !isHttpUrl(url))) {
next.extraMaterials = "merchant.errorUrl";
}
errors.value = { ...errors.value, ...next };
return Object.keys(next).length === 0;
}
function next(): void {
if (step.value === 0) {
// The kind step has no required input; entity validation starts on step 1.
step.value = 1;
return;
}
if (!validateEntity()) return;
step.value = Math.min(2, step.value + 1);
}
function previous(): void {
errorKey.value = "";
conflictKey.value = "";
step.value = Math.max(0, step.value - 1);
}
function buildInput(): MerchantApplicationInput {
const address = contactAddress.value.trim();
const contact: MerchantContact = {
name: contactName.value.trim(),
phone: contactPhone.value.trim(),
email: contactEmail.value.trim(),
...(address ? { address } : {}),
};
const extra = nonEmptyMaterials();
if (entityType.value === "personal") {
return {
entity_type: "personal",
real_name: realName.value.trim(),
category_ids: [...categoryIds.value],
contact,
qualification: {
identity_document_url: identityDocumentUrl.value.trim(),
...(extra.length ? { extra_materials: extra } : {}),
},
};
}
return {
entity_type: "enterprise",
company_name: companyName.value.trim(),
category_ids: [...categoryIds.value],
contact,
qualification: {
business_license_url: businessLicenseUrl.value.trim(),
business_license_no: businessLicenseNo.value.trim(),
...(extra.length ? { extra_materials: extra } : {}),
},
};
}
interface MerchantDraft {
/** Restored step, so a signed-out submit returns to the completed form. */
step: number;
entityType: MerchantEntityType;
realName: string;
companyName: string;
businessLicenseNo: string;
categoryIds: string[];
contactName: string;
contactPhone: string;
contactEmail: string;
contactAddress: string;
identityDocumentUrl: string;
businessLicenseUrl: string;
extraMaterials: string[];
}
function currentDraft(): MerchantDraft {
return {
step: step.value,
entityType: entityType.value,
realName: realName.value,
companyName: companyName.value,
businessLicenseNo: businessLicenseNo.value,
categoryIds: [...categoryIds.value],
contactName: contactName.value,
contactPhone: contactPhone.value,
contactEmail: contactEmail.value,
contactAddress: contactAddress.value,
identityDocumentUrl: identityDocumentUrl.value,
businessLicenseUrl: businessLicenseUrl.value,
extraMaterials: [...extraMaterials.value],
};
}
function applyDraft(draft: MerchantDraft): void {
step.value = Math.min(2, Math.max(0, draft.step));
entityType.value = draft.entityType;
realName.value = draft.realName;
companyName.value = draft.companyName;
businessLicenseNo.value = draft.businessLicenseNo;
categoryIds.value = [...draft.categoryIds];
contactName.value = draft.contactName;
contactPhone.value = draft.contactPhone;
contactEmail.value = draft.contactEmail;
contactAddress.value = draft.contactAddress;
identityDocumentUrl.value = draft.identityDocumentUrl;
businessLicenseUrl.value = draft.businessLicenseUrl;
extraMaterials.value = draft.extraMaterials.length ? [...draft.extraMaterials] : [""];
}
function saveDraft(): void {
if (!import.meta.client) return;
try {
sessionStorage.setItem(DRAFT_KEY, JSON.stringify(currentDraft()));
} catch {
/* storage unavailable: the completed form stays in memory until sign-in */
}
}
function clearDraft(): void {
if (!import.meta.client) return;
try {
sessionStorage.removeItem(DRAFT_KEY);
} catch {
/* storage unavailable */
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function draftText(source: Record<string, unknown>, key: string): string {
const value = source[key];
return typeof value === "string" ? value : "";
}
function draftList(source: Record<string, unknown>, key: string): string[] {
const value = source[key];
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
function draftNumber(source: Record<string, unknown>, key: string): number {
const value = source[key];
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function readDraft(): MerchantDraft | null {
if (!import.meta.client) return null;
try {
const raw = sessionStorage.getItem(DRAFT_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return null;
const kind = parsed.entityType;
if (kind !== "personal" && kind !== "enterprise") return null;
return {
step: draftNumber(parsed, "step"),
entityType: kind,
realName: draftText(parsed, "realName"),
companyName: draftText(parsed, "companyName"),
businessLicenseNo: draftText(parsed, "businessLicenseNo"),
categoryIds: draftList(parsed, "categoryIds"),
contactName: draftText(parsed, "contactName"),
contactPhone: draftText(parsed, "contactPhone"),
contactEmail: draftText(parsed, "contactEmail"),
contactAddress: draftText(parsed, "contactAddress"),
identityDocumentUrl: draftText(parsed, "identityDocumentUrl"),
businessLicenseUrl: draftText(parsed, "businessLicenseUrl"),
extraMaterials: draftList(parsed, "extraMaterials"),
};
} catch {
return null;
}
}
function addMaterial(): void {
extraMaterials.value.push("");
}
function removeMaterial(index: number): void {
if (extraMaterials.value.length === 1) {
extraMaterials.value[0] = "";
return;
}
extraMaterials.value.splice(index, 1);
}
async function loadCategories(): Promise<void> {
loadingCategories.value = true;
categoriesFailed.value = false;
try {
categories.value = await $api.listCategories();
} catch {
categories.value = [];
categoriesFailed.value = true;
} finally {
loadingCategories.value = false;
}
}
async function submit(): Promise<void> {
errorKey.value = "";
conflictKey.value = "";
const entityOk = validateEntity();
const contactOk = validateContact();
if (!entityOk || !contactOk) {
if (!entityOk) step.value = 1;
errorKey.value = "merchant.invalid";
return;
}
if (!session.isLoggedIn) {
// Keep the completed form, sign in, then land back here to submit.
saveDraft();
await navigateTo(signInPath("/merchant/join"));
return;
}
submitting.value = true;
try {
await $api.submitMerchantApplication(buildInput());
clearDraft();
success.value = true;
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
conflictKey.value = "merchant.conflict";
} else if (error instanceof ApiError && error.status === 401) {
saveDraft();
await navigateTo(signInPath("/merchant/join"));
} else if (error instanceof ApiError && error.status === 400) {
errorKey.value = "merchant.invalid";
} else {
errorKey.value = "merchant.submitFailed";
}
} finally {
submitting.value = false;
}
}
onMounted(() => {
if (!session.token) session.hydrate();
const restored = isReapply.value ? null : readDraft();
if (isReapply.value) clearDraft();
if (restored) {
applyDraft(restored);
restoredDraft.value = true;
} else if (!contactEmail.value && session.user?.email) {
contactEmail.value = session.user.email;
}
void loadCategories();
});
</script>
<template>
<div class="max-w-mall mx-auto px-4">
<VPage :title="t('merchant.joinTitle')">
<p class="text-muted -mt-2 mb-4 max-w-[760px] text-sm">{{ t("merchant.joinSubtitle") }}</p>
<VCard v-if="success" class="max-w-[640px]">
<h2 class="text-text m-0 text-lg font-semibold">{{ t("merchant.successTitle") }}</h2>
<p class="text-muted mt-2 text-sm">{{ t("merchant.successBody") }}</p>
<NuxtLink class="text-primary hover:text-primary-hover mt-4 inline-block text-sm" to="/merchant/status">
{{ t("merchant.viewStatus") }}
</NuxtLink>
</VCard>
<VCard v-else class="max-w-[760px]">
<UiStepBar :steps="stepLabels" :active="step" />
<p v-if="restoredDraft" role="status" class="text-success mb-3 text-sm">
{{ t("merchant.restoredDraft") }}
</p>
<!-- novalidate: our localized inline validation is authoritative, so the
browser's native bubbles never pre-empt it. -->
<form novalidate @submit.prevent="submit">
<!-- Step 1: entity kind -->
<fieldset v-if="step === 0" class="m-0 border-0 p-0">
<legend class="text-text mb-2 text-base font-semibold">
{{ t("merchant.stepKind") }}
</legend>
<p class="text-muted mb-3 text-xs">{{ t("merchant.kindHint") }}</p>
<div class="grid gap-2 sm:grid-cols-2">
<label
v-for="kind in ENTITY_KINDS"
:key="kind"
class="border-border flex cursor-pointer items-center gap-3 rounded-md border p-4 text-sm"
:class="entityType === kind ? 'border-primary bg-primary-soft' : ''"
>
<input v-model="entityType" type="radio" name="entityType" :value="kind" />
<span class="text-text font-medium">{{
kind === "personal" ? t("merchant.kindPersonal") : t("merchant.kindEnterprise")
}}</span>
</label>
</div>
</fieldset>
<!-- Step 2: entity information + operating categories -->
<fieldset v-else-if="step === 1" class="m-0 border-0 p-0">
<legend class="text-text mb-3 text-base font-semibold">
{{ t("merchant.entityInfo") }}
</legend>
<template v-if="entityType === 'personal'">
<VField
:label="t('merchant.realName')"
:error="fieldError('realName')"
>
<VInput
v-model="realName"
type="text"
:placeholder="t('merchant.realNamePlaceholder')"
/>
</VField>
</template>
<template v-else>
<VField
:label="t('merchant.companyName')"
:error="fieldError('companyName')"
>
<VInput
v-model="companyName"
type="text"
:placeholder="t('merchant.companyNamePlaceholder')"
/>
</VField>
<VField
:label="t('merchant.businessLicenseNo')"
:error="fieldError('businessLicenseNo')"
>
<VInput
v-model="businessLicenseNo"
type="text"
:placeholder="t('merchant.businessLicenseNoPlaceholder')"
/>
</VField>
</template>
<VField
:label="t('merchant.categories')"
:error="fieldError('categoryIds')"
>
<p class="text-muted mb-2 text-xs">{{ t("merchant.categoriesHint") }}</p>
<div
v-if="loadingCategories"
class="border-border text-muted rounded-md border p-3 text-sm"
>
{{ t("common.loading") }}
</div>
<p v-else-if="categoriesFailed" role="alert" class="text-danger m-0 text-sm">
{{ t("merchant.categoriesLoadFailed") }}
</p>
<div
v-else
class="border-border max-h-64 overflow-y-auto rounded-md border p-3"
role="group"
:aria-label="t('merchant.categories')"
>
<label
v-for="option in categoryOptions"
:key="option.id"
class="flex cursor-pointer items-center gap-2 py-1 text-sm"
:class="depthClass(option.depth)"
>
<input
type="checkbox"
:value="option.id"
:checked="categoryIds.includes(option.id)"
@change="toggleCategory(option.id)"
/>
<span class="text-text">{{ pick(option.name, locale) }}</span>
</label>
</div>
</VField>
</fieldset>
<!-- Step 3: contact details + qualification URLs -->
<fieldset v-else class="m-0 border-0 p-0">
<legend class="text-text mb-3 text-base font-semibold">
{{ t("merchant.contactInfo") }}
</legend>
<VField
:label="t('merchant.contactName')"
:error="fieldError('contactName')"
>
<VInput
v-model="contactName"
type="text"
:placeholder="t('merchant.contactNamePlaceholder')"
/>
</VField>
<VField
:label="t('merchant.contactPhone')"
:error="fieldError('contactPhone')"
>
<VInput
v-model="contactPhone"
type="text"
:placeholder="t('merchant.contactPhonePlaceholder')"
/>
</VField>
<VField
:label="t('merchant.contactEmail')"
:error="fieldError('contactEmail')"
>
<VInput
v-model.trim="contactEmail"
type="email"
:placeholder="t('merchant.contactEmailPlaceholder')"
/>
</VField>
<VField :label="t('merchant.contactAddress')">
<VInput
v-model="contactAddress"
type="text"
:placeholder="t('merchant.contactAddressPlaceholder')"
/>
</VField>
<h3 class="text-text mt-5 mb-1 text-base font-semibold">
{{ t("merchant.qualification") }}
</h3>
<p class="text-muted mb-3 text-xs">{{ t("merchant.qualificationHint") }}</p>
<VField
v-if="entityType === 'personal'"
:label="t('merchant.identityDocumentUrl')"
:error="fieldError('identityDocumentUrl')"
>
<VInput
v-model="identityDocumentUrl"
type="url"
:placeholder="t('merchant.identityDocumentUrlPlaceholder')"
/>
</VField>
<template v-else>
<VField
:label="t('merchant.businessLicenseUrl')"
:error="fieldError('businessLicenseUrl')"
>
<VInput
v-model="businessLicenseUrl"
type="url"
:placeholder="t('merchant.businessLicenseUrlPlaceholder')"
/>
</VField>
</template>
<VField
:label="t('merchant.extraMaterials')"
:error="fieldError('extraMaterials')"
>
<div class="grid gap-2">
<div v-for="(url, index) in extraMaterials" :key="index" class="flex gap-2">
<VInput v-model="extraMaterials[index]" :placeholder="t('merchant.extraMaterialPlaceholder')" />
<VBtn type="button" @click="removeMaterial(index)">
{{ t("merchant.extraMaterialRemove") }}
</VBtn>
</div>
<VBtn class="w-fit" type="button" @click="addMaterial">
{{ t("merchant.extraMaterialAdd") }}
</VBtn>
</div>
</VField>
<VPanel class="mt-4" :title="t('merchant.stepEntity')">
<dl class="text-muted m-0 grid gap-1 text-sm">
<div class="flex gap-2">
<dt>{{ t("merchant.entityKind") }}:</dt>
<dd class="text-text m-0">
{{
entityType === "personal"
? t("merchant.kindPersonal")
: t("merchant.kindEnterprise")
}}
</dd>
</div>
<div v-if="selectedCategoryLabel" class="flex gap-2">
<dt>{{ t("merchant.categories") }}:</dt>
<dd class="text-text m-0">{{ selectedCategoryLabel }}</dd>
</div>
</dl>
</VPanel>
</fieldset>
<p v-if="conflictKey" role="alert" class="text-danger mt-4 mb-0 text-sm">
{{ t(conflictKey) }}
</p>
<p v-else-if="errorKey" role="alert" class="text-danger mt-4 mb-0 text-sm">
{{ t(errorKey) }}
</p>
<div class="mt-4 flex flex-wrap items-center gap-2">
<VBtn v-if="step > 0" type="button" :disabled="submitting" @click="previous">
{{ t("merchant.previous") }}
</VBtn>
<VBtn v-if="step < 2" variant="primary" type="button" @click="next">
{{ t("merchant.next") }}
</VBtn>
<VBtn v-else variant="primary" type="submit" :disabled="submitting">
{{ submitting ? t("merchant.submitting") : t("merchant.submit") }}
</VBtn>
</div>
<p v-if="!session.isLoggedIn" class="text-muted mt-3 mb-0 text-xs">
{{ t("merchant.signInToSubmit") }}
</p>
</form>
</VCard>
</VPage>
</div>
</template>
+205
View File
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { MerchantApplication } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const rows = ref<MerchantApplication[]>([]);
const loading = ref(true);
const failed = ref(false);
const latest = computed(() => rows.value[0] ?? null);
const history = computed(() => rows.value.slice(1));
function statusTone(status: MerchantApplication["status"]): "green" | "orange" | "red" {
if (status === "approved") return "green";
if (status === "rejected") return "red";
return "orange";
}
function statusLabel(status: MerchantApplication["status"]): string {
return t(`merchant.status_${status}`);
}
function kindLabel(kind: MerchantApplication["entity_type"]): string {
return kind === "personal" ? t("merchant.kindPersonal") : t("merchant.kindEnterprise");
}
function entityName(row: MerchantApplication): string {
return row.company_name ?? row.real_name ?? "";
}
function categoryLabel(row: MerchantApplication): string {
return row.categories.map((category) => pick(category.name, locale.value)).join(", ");
}
function qualificationUrls(row: MerchantApplication): string[] {
const urls = [
row.qualification.identity_document_url,
row.qualification.business_license_url,
...(row.qualification.extra_materials ?? []),
];
return urls.filter((url): url is string => Boolean(url));
}
function formatTime(value: string | null): string {
return value ? new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US") : "";
}
async function load(): Promise<void> {
loading.value = true;
failed.value = false;
try {
rows.value = await $api.getMyMerchantApplications();
} catch {
failed.value = true;
} finally {
loading.value = false;
}
}
async function reapply(): Promise<void> {
await navigateTo("/merchant/join?reapply=1");
}
onMounted(() => void load());
</script>
<template>
<div class="max-w-mall mx-auto px-4">
<VPage :title="t('merchant.statusTitle')">
<p class="text-muted -mt-2 mb-4 max-w-[760px] text-sm">{{ t("merchant.statusSubtitle") }}</p>
<VCard class="min-h-[420px] max-w-[760px]">
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<p v-else-if="failed" role="alert" class="text-danger py-5 text-sm">
{{ t("merchant.statusLoadFailed") }}
</p>
<div v-else-if="!latest" class="py-5">
<UiEmptyState :text="t('merchant.noApplication')" />
<div class="text-center">
<NuxtLink
class="text-primary hover:text-primary-hover text-sm"
to="/merchant/join"
>
{{ t("merchant.startApplication") }}
</NuxtLink>
</div>
</div>
<template v-else>
<div class="mb-4 flex flex-wrap items-center gap-3">
<VBadge :tone="statusTone(latest.status)">{{ statusLabel(latest.status) }}</VBadge>
<span class="text-muted text-xs">{{ latest.applicant_email }}</span>
</div>
<dl class="grid gap-3 text-sm">
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.applicationId") }}</dt>
<dd class="text-text m-0 break-all">{{ latest.id }}</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.entityKind") }}</dt>
<dd class="text-text m-0">
{{ kindLabel(latest.entity_type) }}
<span v-if="entityName(latest)"> · {{ entityName(latest) }}</span>
</dd>
</div>
<div v-if="latest.business_license_no" class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.businessLicenseNo") }}</dt>
<dd class="text-text m-0">{{ latest.business_license_no }}</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.categories") }}</dt>
<dd class="text-text m-0">{{ categoryLabel(latest) }}</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.contactInfo") }}</dt>
<dd class="text-text m-0">
{{ latest.contact.name }} · {{ latest.contact.phone }} · {{ latest.contact.email }}
<span v-if="latest.contact.address"> · {{ latest.contact.address }}</span>
</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.qualification") }}</dt>
<dd class="m-0 grid gap-1">
<a
v-for="url in qualificationUrls(latest)"
:key="url"
class="text-primary hover:text-primary-hover break-all"
:href="url"
target="_blank"
rel="noopener noreferrer"
>
{{ url }}
</a>
</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.submittedAt") }}</dt>
<dd class="text-text m-0">{{ formatTime(latest.created_at) }}</dd>
</div>
<div class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.updatedAt") }}</dt>
<dd class="text-text m-0">{{ formatTime(latest.updated_at) }}</dd>
</div>
<div v-if="latest.reviewed_at" class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.reviewedAt") }}</dt>
<dd class="text-text m-0">{{ formatTime(latest.reviewed_at) }}</dd>
</div>
<div v-if="latest.created_shop_id" class="grid gap-1">
<dt class="text-muted text-xs">{{ t("merchant.createdShop") }}</dt>
<dd class="text-text m-0 break-all">{{ latest.created_shop_id }}</dd>
</div>
</dl>
<div
v-if="latest.status === 'rejected'"
class="bg-danger/10 mt-5 rounded-md p-4"
role="alert"
>
<p class="text-danger m-0 text-sm font-semibold">{{ t("merchant.rejectionReason") }}</p>
<p class="text-text mt-1 mb-0 text-sm">{{ latest.rejection_reason }}</p>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<VBtn v-if="latest.status === 'rejected'" variant="primary" type="button" @click="reapply">
{{ t("merchant.reapply") }}
</VBtn>
<NuxtLink
class="text-primary hover:text-primary-hover self-center text-sm"
to="/merchant/join"
>
{{ t("merchant.backToJoin") }}
</NuxtLink>
</div>
</template>
</VCard>
<section v-if="history.length > 0" class="mt-6 max-w-[760px]">
<h2 class="text-text mb-1 text-base font-semibold">{{ t("merchant.historyTitle") }}</h2>
<p class="text-muted mb-3 text-xs">{{ t("merchant.historyHint") }}</p>
<VTable>
<thead>
<tr>
<th>{{ t("merchant.entityKind") }}</th>
<th>{{ t("merchant.submittedAt") }}</th>
<th>{{ t("common.status") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in history" :key="row.id">
<td>{{ kindLabel(row.entity_type) }}</td>
<td>{{ formatTime(row.created_at) }}</td>
<td>
<VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge>
</td>
</tr>
</tbody>
</VTable>
</section>
</VPage>
</div>
</template>
+14 -1
View File
@@ -5,6 +5,7 @@ const { t } = useI18n();
const { $api } = useNuxtApp();
const session = useSessionStore();
const router = useRouter();
const route = useRoute();
const displayName = ref("");
const email = ref("");
@@ -13,6 +14,18 @@ const confirmPassword = ref("");
const errorKey = ref("");
const submitting = ref(false);
/**
* Where to go after registering. Same-origin absolute paths only, so a crafted
* query cannot turn registration into an open redirect.
*/
const redirectTarget = computed((): string => {
const raw = route.query.redirect;
const value = Array.isArray(raw) ? raw[0] : raw;
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
? value
: "/";
});
/** Mirrors the auth API's rule so a short password fails here, not as a 400. */
const MIN_PASSWORD_LENGTH = 8;
@@ -43,7 +56,7 @@ async function submit(): Promise<void> {
try {
const auth = await $api.register(email.value, password.value, displayName.value);
session.setAuth(auth);
await router.push("/");
await router.push(redirectTarget.value);
} catch (error) {
errorKey.value =
error instanceof ApiError && error.status === 409 ? "auth.emailTaken" : "auth.requestFailed";
+3
View File
@@ -14,12 +14,15 @@ const menuGroups = computed(() => [
{ label: t("user.addresses"), to: "/user/addresses" },
{ label: t("user.coupons"), to: "/user/coupons" },
{ label: t("user.aftersalesTitle"), to: "/user/aftersales" },
{ label: t("messaging.title"), to: "/user/messages" },
],
},
{
title: t("user.memberCenter"),
items: [
{ label: t("user.dashboard"), to: "/user" },
{ label: t("membership.entry"), to: "/user/membership" },
{ label: t("wallet.title"), to: "/user/wallet" },
{ label: t("user.favorites"), to: "/user/favorites" },
{ label: t("user.invoices"), to: "/user/invoices" },
],
+17
View File
@@ -134,6 +134,23 @@ const statusLinks = computed(() => [
><b class="text-primary text-lg font-semibold">{{ item.count }}</b></NuxtLink
>
</div>
<nav class="border-border flex flex-wrap items-center gap-x-6 gap-y-2 border-t py-3">
<NuxtLink
class="text-primary hover:text-primary-hover text-sm no-underline"
to="/user/wallet"
>{{ t("wallet.title") }} ›</NuxtLink
>
<NuxtLink
class="text-primary hover:text-primary-hover text-sm no-underline"
to="/user/membership"
>{{ t("membership.title") }} ›</NuxtLink
>
<NuxtLink
class="text-primary hover:text-primary-hover text-sm no-underline"
to="/user/messages"
>{{ t("messaging.title") }} ›</NuxtLink
>
</nav>
</VCard>
<VCard>
+212
View File
@@ -0,0 +1,212 @@
<script setup lang="ts">
import type { GrowthLogEntry, MembershipStatus } from "@vmall/shared";
import { t as localizedText } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const status = ref<MembershipStatus | null>(null);
const logs = ref<GrowthLogEntry[]>([]);
const logPage = ref(1);
const logTotal = ref(0);
const logPerPage = ref(20);
const loading = ref(true);
const error = ref("");
const logError = ref("");
const REASON_KEYS: Record<string, string> = {
order_complete: "membership.reason_order_complete",
};
function levelName(name: Record<string, string>): string {
return localizedText(name, locale.value);
}
function reasonLabel(reason: string): string {
const key = REASON_KEYS[reason];
return key ? t(key) : reason;
}
async function loadStatus(): Promise<void> {
status.value = await $api.getMembership();
}
async function loadLogs(page: number): Promise<void> {
const result = await $api.listGrowthLogs(page);
logs.value = result.items;
logPage.value = result.page;
logTotal.value = result.total;
logPerPage.value = result.per_page;
}
async function changeLogPage(page: number): Promise<void> {
logError.value = "";
try {
await loadLogs(page);
} catch {
logError.value = t("membership.historyFailed");
}
}
async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
await Promise.all([loadStatus(), loadLogs(1)]);
} catch {
error.value = t("membership.loadFailed");
} finally {
loading.value = false;
}
}
/**
* Progress inside the current band: from the level's own threshold to the next
* one. Below the lowest threshold the band starts at zero. Growth is a plain
* integer, so the percentage is integer arithmetic with one rounding.
*/
const progressPercent = computed<number | null>(() => {
const current = status.value;
if (!current || !current.next_level) return null;
const base = current.level?.growth_threshold ?? 0;
const span = current.next_level.growth_threshold - base;
if (span <= 0) return null;
const within = Math.min(Math.max(current.growth_total - base, 0), span);
return Math.round((within / span) * 100);
});
onMounted(() => void load());
</script>
<template>
<VCard class="min-h-[560px]">
<h1 class="text-text mb-4 text-xl font-bold">{{ t("membership.title") }}</h1>
<p
v-if="error"
role="alert"
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
>
{{ error }}
</p>
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
<p v-else-if="!status" class="text-muted py-10">{{ t("membership.loadFailed") }}</p>
<template v-else>
<div class="mb-6 grid gap-3 sm:grid-cols-2">
<div class="border-border bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("membership.currentLevel") }}</span>
<div v-if="status.level" class="mt-2.5 flex items-center gap-2.5">
<span
class="border-primary/40 text-primary flex h-9 w-9 items-center justify-center rounded-full border text-sm font-bold"
>{{ status.level.icon }}</span
>
<strong class="text-primary text-xl">{{ levelName(status.level.name) }}</strong>
</div>
<strong v-else class="text-muted mt-2.5 block text-xl">{{
t("membership.noLevel")
}}</strong>
<p v-if="status.level" class="text-muted mt-2 mb-0 text-xs">
{{ t("membership.benefits") }}: {{ levelName(status.level.benefits) }}
</p>
<p v-else-if="status.next_level" class="text-muted mt-2 mb-0 text-xs">
{{
t("membership.noLevelHint", {
threshold: status.next_level.growth_threshold,
unit: t("membership.growthUnit"),
level: levelName(status.next_level.name),
})
}}
</p>
</div>
<div class="border-border bg-bg border p-[18px]">
<span class="text-muted block text-xs">{{ t("membership.growthTotal") }}</span>
<strong class="text-primary mt-2.5 block text-xl">{{ status.growth_total }}</strong>
<span class="text-muted mt-1 block text-xs">{{ t("membership.growthUnit") }}</span>
</div>
</div>
<section class="border-border mb-6 rounded-md border p-4">
<template v-if="status.next_level">
<h2 class="text-text m-0 text-lg font-semibold">
{{ t("membership.progressTitle", { level: levelName(status.next_level.name) }) }}
</h2>
<p class="text-muted mt-1 mb-3 text-xs">
{{
t("membership.progressThreshold", {
current: status.growth_total,
threshold: status.next_level.growth_threshold,
unit: t("membership.growthUnit"),
})
}}
</p>
<div
class="bg-border h-2.5 w-full overflow-hidden rounded-full"
role="progressbar"
:aria-valuenow="progressPercent ?? 0"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="t('membership.progressTitle', { level: levelName(status.next_level.name) })"
>
<div
class="bg-primary h-full rounded-full transition-all"
:style="{ width: `${progressPercent ?? 0}%` }"
/>
</div>
<p class="text-primary mt-2 mb-0 text-xs font-medium">
{{
t("membership.remaining", {
remaining: status.next_level.remaining,
unit: t("membership.growthUnit"),
})
}}
</p>
</template>
<template v-else-if="status.level">
<h2 class="text-text m-0 text-lg font-semibold">{{ t("membership.topLevel") }}</h2>
<p class="text-muted mt-1 mb-0 text-xs">{{ t("membership.topLevelHint") }}</p>
</template>
<p v-else class="text-muted m-0 text-sm">{{ t("membership.noLevel") }}</p>
</section>
<section>
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("membership.historyTitle") }}</h2>
<p v-if="logError" role="alert" class="text-danger mb-3 text-sm">{{ logError }}</p>
<UiEmptyState v-if="logs.length === 0" :text="t('membership.historyEmpty')" />
<template v-else>
<VTable>
<thead>
<tr>
<th>{{ t("membership.historyDate") }}</th>
<th>{{ t("membership.historyReason") }}</th>
<th class="text-right!">{{ t("membership.historyDelta") }}</th>
<th class="text-right!">{{ t("membership.historyTotal") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in logs" :key="entry.id">
<td class="text-muted text-xs whitespace-nowrap">
{{ entry.created_at.slice(0, 10) }}
</td>
<td>{{ reasonLabel(entry.reason) }}</td>
<td
class="text-right font-semibold"
:class="entry.delta < 0 ? 'text-danger' : 'text-success'"
>
{{ entry.delta < 0 ? "−" : "+" }}{{ Math.abs(entry.delta) }}
</td>
<td class="text-right">{{ entry.growth_total }}</td>
</tr>
</tbody>
</VTable>
<UiPagination
:page="logPage"
:total="logTotal"
:per-page="logPerPage"
@change="changeLogPage"
/>
</template>
</section>
</template>
</VCard>
</template>
+254
View File
@@ -0,0 +1,254 @@
<script setup lang="ts">
import type { Message, MessageKind } from "@vmall/shared";
import { t as localizedText } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const { unread, refresh: refreshUnread } = useUnreadMessages();
const messages = ref<Message[]>([]);
const page = ref(1);
const total = ref(0);
const perPage = ref(20);
const unreadOnly = ref(false);
const expandedId = ref<string | null>(null);
const loading = ref(true);
const busy = ref(false);
const error = ref("");
const actionError = ref("");
const actionNotice = ref("");
const KIND_KEYS: Record<MessageKind, string> = {
order_paid: "messaging.kind_order_paid",
order_shipped: "messaging.kind_order_shipped",
refund_completed: "messaging.kind_refund_completed",
};
const KIND_TONES: Record<MessageKind, "blue" | "green" | "orange"> = {
order_paid: "blue",
order_shipped: "green",
refund_completed: "orange",
};
function kindLabel(kind: MessageKind): string {
return t(KIND_KEYS[kind]);
}
function title(message: Message): string {
return localizedText(message.title, locale.value);
}
function body(message: Message): string {
return localizedText(message.body, locale.value);
}
function formatTime(createdAt: string): string {
return createdAt.slice(0, 16).replace("T", " ");
}
async function load(targetPage: number): Promise<void> {
const result = await $api.listMessages({
page: targetPage,
unread_only: unreadOnly.value,
});
messages.value = result.items;
page.value = result.page;
total.value = result.total;
perPage.value = result.per_page;
}
/** Re-read both the list and the badge count after any mutation. */
async function refreshAll(targetPage = page.value): Promise<void> {
await Promise.all([load(targetPage), refreshUnread()]);
}
async function initialLoad(): Promise<void> {
loading.value = true;
error.value = "";
try {
await refreshAll(1);
} catch {
error.value = t("messaging.loadFailed");
} finally {
loading.value = false;
}
}
async function changePage(targetPage: number): Promise<void> {
actionError.value = "";
try {
await load(targetPage);
} catch {
actionError.value = t("messaging.listFailed");
}
}
async function setUnreadOnly(value: boolean): Promise<void> {
unreadOnly.value = value;
expandedId.value = null;
actionError.value = "";
try {
await load(1);
} catch {
actionError.value = t("messaging.listFailed");
}
}
/** Opening an unread message is what marks it read. */
async function toggle(message: Message): Promise<void> {
if (expandedId.value === message.id) {
expandedId.value = null;
return;
}
expandedId.value = message.id;
if (message.status === "unread") await markRead(message);
}
async function markRead(message: Message): Promise<void> {
if (message.status === "read") return;
actionError.value = "";
actionNotice.value = "";
try {
await $api.markMessageRead(message.id);
await refreshAll();
} catch {
actionError.value = t("messaging.markReadFailed");
}
}
async function markAllRead(): Promise<void> {
actionError.value = "";
actionNotice.value = "";
busy.value = true;
try {
const result = await $api.markAllMessagesRead();
expandedId.value = null;
await refreshAll(1);
actionNotice.value = t("messaging.markAllSuccess", { count: result.updated });
} catch {
actionError.value = t("messaging.markAllFailed");
} finally {
busy.value = false;
}
}
async function remove(message: Message): Promise<void> {
if (!confirm(t("messaging.deleteConfirm"))) return;
actionError.value = "";
actionNotice.value = "";
busy.value = true;
try {
await $api.deleteMessage(message.id);
if (expandedId.value === message.id) expandedId.value = null;
const target = messages.value.length === 1 && page.value > 1 ? page.value - 1 : page.value;
await refreshAll(target);
} catch {
actionError.value = t("messaging.deleteFailed");
} finally {
busy.value = false;
}
}
onMounted(() => void initialLoad());
</script>
<template>
<VCard class="min-h-[560px]">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<h1 class="text-text m-0 text-xl font-bold">{{ t("messaging.title") }}</h1>
<div class="flex flex-wrap items-center gap-3">
<label class="text-muted flex cursor-pointer items-center gap-1.5 text-xs">
<input
type="checkbox"
class="accent-primary"
:checked="unreadOnly"
@change="setUnreadOnly(($event.target as HTMLInputElement).checked)"
/>
{{ t("messaging.unreadOnly") }}
</label>
<VBtn
variant="primary"
size="sm"
:disabled="busy || unread === 0"
@click="markAllRead"
>
{{ t("messaging.markAllRead") }}
</VBtn>
</div>
</div>
<p
v-if="error"
role="alert"
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
>
{{ error }}
</p>
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
<template v-else>
<p v-if="actionError" role="alert" class="text-danger mb-3 text-sm">{{ actionError }}</p>
<p v-if="actionNotice" role="status" class="text-success mb-3 text-sm">
{{ actionNotice }}
</p>
<UiEmptyState
v-if="messages.length === 0"
:text="unreadOnly ? t('messaging.emptyUnread') : t('messaging.empty')"
/>
<template v-else>
<div class="space-y-3">
<article
v-for="message in messages"
:key="message.id"
class="border-border border"
:class="message.status === 'unread' ? 'bg-primary-soft' : 'bg-surface'"
>
<header class="flex flex-wrap items-center gap-2.5 px-3 py-2.5">
<VBadge :tone="KIND_TONES[message.kind]">{{ kindLabel(message.kind) }}</VBadge>
<VBadge :tone="message.status === 'unread' ? 'orange' : 'gray'">
{{
message.status === "unread"
? t("messaging.statusUnread")
: t("messaging.statusRead")
}}
</VBadge>
<button
type="button"
class="text-text hover:text-primary min-w-0 flex-1 cursor-pointer truncate text-left text-sm font-medium"
@click="toggle(message)"
>
{{ title(message) }}
</button>
<span class="text-muted text-xs whitespace-nowrap">{{
formatTime(message.created_at)
}}</span>
<VBtn size="sm" @click="toggle(message)">
{{ expandedId === message.id ? t("messaging.close") : t("messaging.open") }}
</VBtn>
<VBtn
v-if="message.status === 'unread'"
size="sm"
:disabled="busy"
@click="markRead(message)"
>
{{ t("messaging.markRead") }}
</VBtn>
<VBtn variant="danger" size="sm" :disabled="busy" @click="remove(message)">
{{ t("messaging.delete") }}
</VBtn>
</header>
<p
v-if="expandedId === message.id"
class="border-border text-text m-0 border-t px-3 py-3 text-sm"
>
{{ body(message) }}
</p>
</article>
</div>
<UiPagination :page="page" :total="total" :per-page="perPage" @change="changePage" />
</template>
</template>
</VCard>
</template>
+370
View File
@@ -0,0 +1,370 @@
<script setup lang="ts">
import { ApiError, formatMoney } from "@vmall/shared";
import type { WalletEntry, WalletSummary, WalletWithdrawal, WithdrawalStatus } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const { ensureCurrencies, exponentFor } = usePrice();
const wallet = ref<WalletSummary | null>(null);
const entries = ref<WalletEntry[]>([]);
const entryPage = ref(1);
const entryTotal = ref(0);
const entryPerPage = ref(20);
const withdrawals = ref<WalletWithdrawal[]>([]);
const loading = ref(true);
const error = ref("");
const entryError = ref("");
// Demo recharge form state.
const rechargeMajor = ref("");
const recharging = ref(false);
const rechargeError = ref("");
const rechargeNotice = ref("");
// Withdrawal request form state.
const withdrawMajor = ref("");
const withdrawMethod = ref("bank");
const withdrawAccount = ref("");
const withdrawHolder = ref("");
const withdrawing = ref(false);
const withdrawError = ref("");
const withdrawNotice = ref("");
const walletCurrency = computed(() => wallet.value?.currency ?? "");
const REASON_KEYS: Record<string, string> = {
wallet_recharge: "wallet.reason_wallet_recharge",
wallet_withdrawal_freeze: "wallet.reason_wallet_withdrawal_freeze",
wallet_withdrawal_approved: "wallet.reason_wallet_withdrawal_approved",
wallet_withdrawal_rejected: "wallet.reason_wallet_withdrawal_rejected",
order_payment: "wallet.reason_order_payment",
aftersale_refund: "wallet.reason_aftersale_refund",
settlement_payout: "wallet.reason_settlement_payout",
opening_balance: "wallet.reason_opening_balance",
};
const METHOD_KEYS: Record<string, string> = {
bank: "wallet.withdrawMethodBank",
alipay: "wallet.withdrawMethodAlipay",
wechat: "wallet.withdrawMethodWechat",
};
const STATUS_KEYS: Record<WithdrawalStatus, string> = {
pending: "wallet.status_pending",
approved: "wallet.status_approved",
rejected: "wallet.status_rejected",
};
/** Currency-aware formatting; the exponent comes from the currency table. */
function money(amountMinor: number, code: string): string {
if (!code) return String(amountMinor);
return formatMoney(amountMinor, code, exponentFor(code), locale.value);
}
function reasonLabel(reason: string): string {
const key = REASON_KEYS[reason];
return key ? t(key) : reason;
}
function methodLabel(method: string): string {
const key = METHOD_KEYS[method];
return key ? t(key) : method;
}
function statusTone(status: WithdrawalStatus): "green" | "orange" | "red" {
if (status === "approved") return "green";
if (status === "rejected") return "red";
return "orange";
}
async function loadWallet(): Promise<void> {
wallet.value = await $api.getWallet();
}
async function loadEntries(page: number): Promise<void> {
const result = await $api.listWalletEntries(page);
entries.value = result.items;
entryPage.value = result.page;
entryTotal.value = result.total;
entryPerPage.value = result.per_page;
}
async function loadWithdrawals(): Promise<void> {
withdrawals.value = await $api.listMyWithdrawals();
}
/** Re-read backend truth after any mutation so no balance is local-only. */
async function refreshAll(): Promise<void> {
await Promise.all([loadWallet(), loadEntries(1), loadWithdrawals()]);
}
async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
// Currencies carry the exponent (JPY is 0), so load them before formatting.
await ensureCurrencies();
await refreshAll();
} catch {
error.value = t("wallet.loadFailed");
} finally {
loading.value = false;
}
}
async function changeEntryPage(page: number): Promise<void> {
entryError.value = "";
try {
await loadEntries(page);
} catch {
entryError.value = t("wallet.entriesFailed");
}
}
/** Major-unit input to integer minor units using the account currency exponent. */
function toMinor(major: string): number | null {
const raw = Number(major);
if (!Number.isFinite(raw) || raw <= 0) return null;
const code = walletCurrency.value;
if (!code) return null;
const minor = Math.round(raw * 10 ** exponentFor(code));
return Number.isSafeInteger(minor) && minor > 0 ? minor : null;
}
async function submitRecharge(): Promise<void> {
rechargeError.value = "";
rechargeNotice.value = "";
const amount = toMinor(rechargeMajor.value);
if (amount === null) {
rechargeError.value = t("wallet.validationRequired");
return;
}
recharging.value = true;
try {
const result = await $api.rechargeWallet(amount);
await refreshAll();
rechargeNotice.value = t("wallet.rechargeSuccess", {
amount: money(result.amount_minor, result.currency),
});
rechargeMajor.value = "";
} catch (err: unknown) {
const conflict = err instanceof ApiError && err.status === 409;
rechargeError.value = conflict ? t("wallet.rechargeConflict") : t("wallet.rechargeFailed");
// A conflict means the server state moved; converge on it instead of retrying.
if (conflict) await refreshAll();
} finally {
recharging.value = false;
}
}
async function submitWithdrawal(): Promise<void> {
withdrawError.value = "";
withdrawNotice.value = "";
const amount = toMinor(withdrawMajor.value);
const account = withdrawAccount.value.trim();
if (amount === null || !withdrawMethod.value.trim() || !account) {
withdrawError.value = t("wallet.validationRequired");
return;
}
withdrawing.value = true;
try {
const holder = withdrawHolder.value.trim();
await $api.applyWithdrawal(amount, {
method: withdrawMethod.value,
account,
...(holder ? { holder } : {}),
});
await refreshAll();
withdrawNotice.value = t("wallet.withdrawSuccess");
withdrawMajor.value = "";
withdrawAccount.value = "";
withdrawHolder.value = "";
} catch (err: unknown) {
const conflict = err instanceof ApiError && err.status === 409;
withdrawError.value = conflict ? t("wallet.withdrawConflict") : t("wallet.withdrawFailed");
if (conflict) await refreshAll();
} finally {
withdrawing.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<VCard class="min-h-[560px]">
<h1 class="text-text mb-4 text-xl font-bold">{{ t("wallet.title") }}</h1>
<p
v-if="error"
role="alert"
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
>
{{ error }}
</p>
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
<p v-else-if="!wallet" class="text-muted py-10">{{ t("wallet.loadFailed") }}</p>
<template v-else>
<div class="mb-6 grid gap-3 sm:grid-cols-2">
<div class="border-border bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("wallet.available") }}</span>
<strong class="text-primary mt-2.5 block text-xl">{{
money(wallet.available_minor, wallet.currency)
}}</strong>
<span class="text-muted mt-1 block text-xs">{{ wallet.currency }}</span>
</div>
<div class="border-border bg-bg border p-[18px]">
<span class="text-muted block text-xs">{{ t("wallet.frozen") }}</span>
<strong class="text-warning mt-2.5 block text-xl">{{
money(wallet.frozen_minor, wallet.currency)
}}</strong>
<span class="text-muted mt-1 block text-xs">{{ wallet.currency }}</span>
</div>
</div>
<section class="mb-6">
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("wallet.entriesTitle") }}</h2>
<p v-if="entryError" role="alert" class="text-danger mb-3 text-sm">{{ entryError }}</p>
<UiEmptyState v-if="entries.length === 0" :text="t('wallet.entriesEmpty')" />
<template v-else>
<VTable>
<thead>
<tr>
<th>{{ t("wallet.entryDate") }}</th>
<th>{{ t("wallet.entryAccount") }}</th>
<th>{{ t("wallet.entryReason") }}</th>
<th class="text-right!">{{ t("wallet.entryDelta") }}</th>
<th class="text-right!">{{ t("wallet.entryBalance") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in entries" :key="entry.id">
<td class="text-muted text-xs whitespace-nowrap">
{{ entry.created_at.slice(0, 10) }}
</td>
<td>
{{
entry.account_kind === "available"
? t("wallet.accountAvailable")
: t("wallet.accountFrozen")
}}
</td>
<td>{{ reasonLabel(entry.reason) }}</td>
<td
class="text-right font-semibold"
:class="entry.delta_minor < 0 ? 'text-danger' : 'text-success'"
>
{{ entry.delta_minor < 0 ? "−" : "+"
}}{{ money(Math.abs(entry.delta_minor), wallet.currency) }}
</td>
<td class="text-right">{{ money(entry.balance_minor, wallet.currency) }}</td>
</tr>
</tbody>
</VTable>
<UiPagination
:page="entryPage"
:total="entryTotal"
:per-page="entryPerPage"
@change="changeEntryPage"
/>
</template>
</section>
<section class="border-border mb-6 rounded-md border p-4">
<div class="mb-1 flex flex-wrap items-center gap-2">
<h2 class="text-text m-0 text-lg font-semibold">{{ t("wallet.rechargeTitle") }}</h2>
<VBadge tone="orange">{{ t("wallet.rechargeDemoBadge") }}</VBadge>
</div>
<p class="text-muted mt-1 mb-3 text-xs">{{ t("wallet.rechargeDemoNote") }}</p>
<form class="grid max-w-[520px] gap-2" @submit.prevent="submitRecharge">
<VField :label="t('wallet.rechargeAmount', { currency: wallet.currency })">
<VInput v-model="rechargeMajor" inputmode="decimal" placeholder="0.00" />
<p class="text-muted m-0 mt-1 text-xs">
{{ t("wallet.rechargeAmountHint", { currency: wallet.currency }) }}
</p>
</VField>
<p v-if="rechargeError" role="alert" class="text-danger m-0 text-xs">
{{ rechargeError }}
</p>
<p v-if="rechargeNotice" role="status" class="text-success m-0 text-xs">
{{ rechargeNotice }}
</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="recharging">
{{ t("wallet.rechargeSubmit") }}
</VBtn>
</form>
</section>
<section class="border-border mb-6 rounded-md border p-4">
<h2 class="text-text mb-1 text-lg font-semibold">{{ t("wallet.withdrawTitle") }}</h2>
<p class="text-muted mt-1 mb-3 text-xs">{{ t("wallet.withdrawHint") }}</p>
<form class="grid max-w-[520px] gap-2" @submit.prevent="submitWithdrawal">
<VField :label="t('wallet.withdrawAmount', { currency: wallet.currency })">
<VInput v-model="withdrawMajor" inputmode="decimal" placeholder="0.00" />
<p class="text-muted m-0 mt-1 text-xs">{{ t("wallet.withdrawAmountHint") }}</p>
</VField>
<VField :label="t('wallet.withdrawMethod')">
<select
v-model="withdrawMethod"
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
>
<option value="bank">{{ t("wallet.withdrawMethodBank") }}</option>
<option value="alipay">{{ t("wallet.withdrawMethodAlipay") }}</option>
<option value="wechat">{{ t("wallet.withdrawMethodWechat") }}</option>
</select>
</VField>
<VField :label="t('wallet.withdrawAccount')">
<VInput v-model="withdrawAccount" :placeholder="t('wallet.withdrawAccountHint')" />
</VField>
<VField :label="t('wallet.withdrawHolder')">
<VInput v-model="withdrawHolder" />
</VField>
<p v-if="withdrawError" role="alert" class="text-danger m-0 text-xs">
{{ withdrawError }}
</p>
<p v-if="withdrawNotice" role="status" class="text-success m-0 text-xs">
{{ withdrawNotice }}
</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="withdrawing">
{{ t("wallet.withdrawSubmit") }}
</VBtn>
</form>
</section>
<section>
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("wallet.historyTitle") }}</h2>
<UiEmptyState v-if="withdrawals.length === 0" :text="t('wallet.historyEmpty')" />
<VTable v-else>
<thead>
<tr>
<th>{{ t("wallet.historyCreatedAt") }}</th>
<th>{{ t("wallet.historyAmount") }}</th>
<th>{{ t("wallet.historyMethod") }}</th>
<th>{{ t("wallet.historyAccount") }}</th>
<th>{{ t("wallet.historyStatus") }}</th>
<th>{{ t("wallet.historyNote") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in withdrawals" :key="row.id">
<td class="text-muted text-xs whitespace-nowrap">
{{ row.created_at.slice(0, 10) }}
</td>
<td class="font-semibold">{{ money(row.amount_minor, row.currency) }}</td>
<td>{{ methodLabel(row.account_details.method) }}</td>
<td class="text-xs">{{ row.account_details.account }}</td>
<td>
<VBadge :tone="statusTone(row.status)">{{ t(STATUS_KEYS[row.status]) }}</VBadge>
</td>
<td class="text-muted text-xs">
{{ row.review_note ?? t("mall.notAvailable") }}
</td>
</tr>
</tbody>
</VTable>
</section>
</template>
</VCard>
</template>