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:
@@ -22,6 +22,10 @@ const navItems = [
|
||||
{ to: "/currencies", label: "nav.currencies" },
|
||||
{ to: "/points-products", label: "nav.pointsProducts" },
|
||||
{ to: "/points-orders", label: "nav.pointsOrders" },
|
||||
{ to: "/withdrawals", label: "nav.withdrawals" },
|
||||
{ to: "/settlements", label: "nav.settlements" },
|
||||
{ to: "/merchant-applications", label: "nav.merchantApplications" },
|
||||
{ to: "/member-levels", label: "nav.memberLevels" },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -12,6 +12,10 @@ export const enExtra = {
|
||||
brands: "Brands",
|
||||
aftersales: "After-sales",
|
||||
reviews: "Reviews",
|
||||
withdrawals: "Withdrawals",
|
||||
settlements: "Settlements",
|
||||
merchantApplications: "Merchant applications",
|
||||
memberLevels: "Member levels",
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: "Platform overview",
|
||||
@@ -149,6 +153,91 @@ export const enExtra = {
|
||||
merchant: "Merchant",
|
||||
platform: "Platform",
|
||||
},
|
||||
withdrawalReviewTitle: "Withdrawal applications",
|
||||
withdrawalId: "Application",
|
||||
withdrawalBuyer: "Buyer email",
|
||||
withdrawalUser: "User ID",
|
||||
withdrawalAmount: "Amount",
|
||||
withdrawalPayout: "Payout details",
|
||||
withdrawalMethod: "Channel",
|
||||
withdrawalAccount: "Account",
|
||||
withdrawalHolder: "Holder",
|
||||
withdrawalReviewedAt: "Reviewed",
|
||||
withdrawalReview: "Review",
|
||||
withdrawalClose: "Close",
|
||||
withdrawalNote: "Review note (optional)",
|
||||
withdrawalNotePlaceholder: "Reason kept with the decision",
|
||||
withdrawalReviewNote: "Note",
|
||||
withdrawalApprove: "Approve",
|
||||
withdrawalReject: "Reject",
|
||||
withdrawalConfirmApprove:
|
||||
"Approve this withdrawal? The frozen amount is paid out and cannot be undone.",
|
||||
withdrawalConfirmReject:
|
||||
"Reject this withdrawal? The frozen amount returns to the buyer's available balance.",
|
||||
withdrawalApproved: "Withdrawal approved; the frozen balance is settled.",
|
||||
withdrawalRejected: "Withdrawal rejected; the amount is back in the buyer's balance.",
|
||||
withdrawalConflict: "This application was already reviewed; the list was refreshed",
|
||||
withdrawalStatuses: {
|
||||
pending: "Pending review",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
},
|
||||
commissionRateTitle: "Commission rate",
|
||||
commissionRate: "Commission rate (basis points)",
|
||||
commissionRateHint: "Integer basis points between 0 and 10000; 100 bps = 1%.",
|
||||
commissionRateDisplay: "{bps} bps ({percent}%)",
|
||||
commissionRateCurrent: "Effective rate",
|
||||
commissionRateSave: "Save rate",
|
||||
commissionRateInvalid: "Enter an integer between 0 and 10000 basis points.",
|
||||
commissionRateSaved: "Commission rate saved. New statements snapshot it.",
|
||||
settlementGenerateTitle: "Manual generation",
|
||||
settlementGenerateHint:
|
||||
"Generation is idempotent per shop and closed period: an existing statement is returned instead of a duplicate.",
|
||||
settlementShop: "Shop",
|
||||
settlementPeriodKind: "Period",
|
||||
settlementPeriodKinds: {
|
||||
week: "Week",
|
||||
month: "Month",
|
||||
},
|
||||
settlementPeriodDate: "Date inside the period",
|
||||
settlementPeriodDateHint:
|
||||
"Any date inside the target week/month; the server normalizes it to the period bounds.",
|
||||
settlementGenerate: "Generate statement",
|
||||
settlementGenerated: "Statement generated.",
|
||||
settlementGeneratedExisting:
|
||||
"An existing statement for this shop and period was returned; nothing was duplicated.",
|
||||
settlementGeneratedResult: "Returned statement",
|
||||
settlementPickShop: "Select a shop first.",
|
||||
settlementPeriodDateInvalid: "Enter a valid date (YYYY-MM-DD).",
|
||||
settlementPeriodNotClosed: "The period is not closed yet; generation was refused",
|
||||
settlementListTitle: "Statements",
|
||||
settlementPeriod: "Period",
|
||||
settlementOrderCount: "Orders",
|
||||
settlementGross: "Gross",
|
||||
settlementRefunds: "Refunds",
|
||||
settlementCommission: "Commission",
|
||||
settlementPayable: "Payable",
|
||||
settlementDetail: "Detail",
|
||||
settlementClose: "Close",
|
||||
settlementStatuses: {
|
||||
pending: "Pending payout",
|
||||
confirmed: "Confirmed",
|
||||
},
|
||||
settlementDetailTitle: "Statement detail",
|
||||
settlementSnapshot: "Snapshot",
|
||||
settlementOrders: "Contributing orders",
|
||||
settlementNoOrders: "No contributing orders in this period.",
|
||||
settlementOrderNo: "Order no.",
|
||||
settlementOrderCurrency: "Order currency",
|
||||
settlementConfirmedAt: "Confirmed",
|
||||
settlementUpdated: "Updated",
|
||||
settlementConfirmPayout: "Confirm payout",
|
||||
settlementConfirmPayoutHint:
|
||||
"Confirming credits the payable amount to the shop owner's balance exactly once.",
|
||||
settlementConfirmDialog:
|
||||
"Confirm this payout? The shop owner's balance is credited and the confirmation cannot be repeated.",
|
||||
settlementPayoutConfirmed: "Payout confirmed; the shop owner's ledger is credited.",
|
||||
settlementConflict: "This statement was already confirmed; the list was refreshed",
|
||||
reviewProduct: "Product",
|
||||
reviewShop: "Shop",
|
||||
reviewBuyer: "Buyer",
|
||||
@@ -167,6 +256,94 @@ export const enExtra = {
|
||||
visible: "Visible",
|
||||
hidden: "Hidden",
|
||||
},
|
||||
merchantApplication: "Application",
|
||||
merchantApplicant: "Applicant email",
|
||||
merchantEntityType: "Entity type",
|
||||
merchantEntityName: "Entity name",
|
||||
merchantSubmittedAt: "Submitted",
|
||||
merchantUpdated: "Updated",
|
||||
merchantReviewedAt: "Reviewed",
|
||||
merchantCreatedShop: "Created shop",
|
||||
merchantView: "Detail",
|
||||
merchantClose: "Close",
|
||||
merchantDetail: "Merchant application detail",
|
||||
merchantEntityInfo: "Entity information",
|
||||
merchantRealName: "Real name",
|
||||
merchantCompanyName: "Company name",
|
||||
merchantBusinessLicenseNo: "Business license no.",
|
||||
merchantContact: "Contact",
|
||||
merchantContactName: "Contact name",
|
||||
merchantContactPhone: "Phone",
|
||||
merchantContactEmail: "Contact email",
|
||||
merchantContactAddress: "Address",
|
||||
merchantCategories: "Operating categories",
|
||||
merchantNoCategories: "No categories submitted",
|
||||
merchantQualification: "Qualification",
|
||||
merchantIdentityDocument: "Identity document",
|
||||
merchantBusinessLicense: "Business license",
|
||||
merchantExtraMaterials: "Extra materials",
|
||||
merchantNoQualification: "No qualification material provided",
|
||||
merchantRejectionReason: "Rejection reason",
|
||||
merchantConfirmApprove:
|
||||
"Approve this application? A shop and a dedicated shop-owner login are provisioned; the initial password is shown once and cannot be retrieved again.",
|
||||
merchantConfirmReject:
|
||||
"Reject this application? The applicant can submit a new application afterwards.",
|
||||
merchantApprove: "Approve",
|
||||
merchantReject: "Reject",
|
||||
merchantRejectReason: "Rejection reason (required)",
|
||||
merchantRejectReasonPlaceholder: "Tell the applicant why the application was rejected",
|
||||
merchantRejectReasonRequired: "A rejection reason is required.",
|
||||
merchantApprovedNotice: "Application approved; the shop and its owner login were provisioned.",
|
||||
merchantRejectedNotice: "Application rejected.",
|
||||
merchantOutcomeApproved: "Approved: the shop and owner login were provisioned.",
|
||||
merchantOutcomeRejected: "Rejected: the applicant can submit a new application.",
|
||||
merchantConflict: "This application was already reviewed; the list was refreshed",
|
||||
merchantCredentialsTitle: "One-time initial credentials",
|
||||
merchantCredentialsWarning:
|
||||
"Copy these credentials now. The initial password is shown only once and cannot be retrieved again after this dialog is closed.",
|
||||
merchantCredentialsEmail: "Owner login",
|
||||
merchantCredentialsPassword: "Initial password",
|
||||
merchantCredentialsShopSlug: "Shop slug",
|
||||
merchantCredentialsShopId: "Shop ID",
|
||||
merchantCredentialsClose: "I have saved the credentials",
|
||||
merchantStatuses: {
|
||||
pending: "Pending review",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
},
|
||||
merchantEntityTypes: {
|
||||
personal: "Personal",
|
||||
enterprise: "Enterprise",
|
||||
},
|
||||
memberLevelCreateTitle: "Add member level",
|
||||
memberLevelEditTitle: "Edit member level",
|
||||
memberLevelHint:
|
||||
"Levels list in growth order. Both languages are required, and a growth threshold can be used by exactly one level.",
|
||||
memberLevelNameEn: "Level name (English)",
|
||||
memberLevelNameZh: "Level name (中文)",
|
||||
memberLevelIcon: "Icon",
|
||||
memberLevelGrowth: "Growth threshold",
|
||||
memberLevelBenefitsEn: "Benefits (English)",
|
||||
memberLevelBenefitsZh: "Benefits (中文)",
|
||||
memberLevelCreate: "Add level",
|
||||
memberLevelCreated: "Member level created.",
|
||||
memberLevelUpdated: "Member level updated.",
|
||||
memberLevelUpdatedAt: "Updated",
|
||||
memberLevelDeleted: "Member level deleted.",
|
||||
memberLevelRequired: "Both language names and benefits are required.",
|
||||
memberLevelIconRequired: "An icon is required.",
|
||||
memberLevelThresholdInvalid: "Use a growth threshold of zero or greater.",
|
||||
memberLevelThresholdDuplicate:
|
||||
"Another level already uses that growth threshold.",
|
||||
memberLevelConflict: "Growth threshold already in use",
|
||||
memberLevelInvalid: "The level was rejected by the server",
|
||||
memberLevelConfirmDelete:
|
||||
'Delete the level "{name}"? This cannot be undone.',
|
||||
memberLevelDeleteInUse:
|
||||
"This level is held by at least one customer, so it cannot be deleted",
|
||||
memberLevelDiscardEdit: "Discard the unsaved level edits and refresh?",
|
||||
memberLevelGone:
|
||||
"That member level no longer exists; the list was refreshed.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -182,6 +359,10 @@ export const zhExtra = {
|
||||
brands: "品牌",
|
||||
aftersales: "售后仲裁",
|
||||
reviews: "评价管理",
|
||||
withdrawals: "提现审核",
|
||||
settlements: "结算对账",
|
||||
merchantApplications: "商家入驻审核",
|
||||
memberLevels: "会员等级",
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: "平台概览",
|
||||
@@ -316,6 +497,85 @@ export const zhExtra = {
|
||||
merchant: "商家",
|
||||
platform: "平台",
|
||||
},
|
||||
withdrawalReviewTitle: "提现申请",
|
||||
withdrawalId: "申请单",
|
||||
withdrawalBuyer: "买家邮箱",
|
||||
withdrawalUser: "用户 ID",
|
||||
withdrawalAmount: "金额",
|
||||
withdrawalPayout: "收款信息",
|
||||
withdrawalMethod: "渠道",
|
||||
withdrawalAccount: "账号",
|
||||
withdrawalHolder: "户名",
|
||||
withdrawalReviewedAt: "审核时间",
|
||||
withdrawalReview: "审核",
|
||||
withdrawalClose: "收起",
|
||||
withdrawalNote: "审核备注(可选)",
|
||||
withdrawalNotePlaceholder: "随审核结果保留的说明",
|
||||
withdrawalReviewNote: "备注",
|
||||
withdrawalApprove: "通过",
|
||||
withdrawalReject: "驳回",
|
||||
withdrawalConfirmApprove: "确定通过该提现申请吗?冻结金额将打款且不可撤销。",
|
||||
withdrawalConfirmReject: "确定驳回该提现申请吗?冻结金额将退回买家可用余额。",
|
||||
withdrawalApproved: "提现已通过,冻结金额已结算。",
|
||||
withdrawalRejected: "提现已驳回,金额已退回买家余额。",
|
||||
withdrawalConflict: "该申请已被审核,列表已刷新",
|
||||
withdrawalStatuses: {
|
||||
pending: "待审核",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
},
|
||||
commissionRateTitle: "佣金比例",
|
||||
commissionRate: "佣金比例(基点)",
|
||||
commissionRateHint: "取 0 到 10000 的整数基点,100 基点 = 1%。",
|
||||
commissionRateDisplay: "{bps} 基点({percent}%)",
|
||||
commissionRateCurrent: "当前比例",
|
||||
commissionRateSave: "保存比例",
|
||||
commissionRateInvalid: "请输入 0 到 10000 之间的整数基点。",
|
||||
commissionRateSaved: "佣金比例已保存,之后生成的账单将采用新比例。",
|
||||
settlementGenerateTitle: "手动生成",
|
||||
settlementGenerateHint:
|
||||
"同一店铺同一周期幂等:已存在时返回原账单,不会重复生成。",
|
||||
settlementShop: "店铺",
|
||||
settlementPeriodKind: "周期",
|
||||
settlementPeriodKinds: {
|
||||
week: "周",
|
||||
month: "月",
|
||||
},
|
||||
settlementPeriodDate: "周期内日期",
|
||||
settlementPeriodDateHint: "填写目标周/月内的任意日期,服务端会归一化为周期边界。",
|
||||
settlementGenerate: "生成账单",
|
||||
settlementGenerated: "账单已生成。",
|
||||
settlementGeneratedExisting: "该店铺该周期已存在账单,已返回原账单,未重复生成。",
|
||||
settlementGeneratedResult: "返回的账单",
|
||||
settlementPickShop: "请先选择店铺。",
|
||||
settlementPeriodDateInvalid: "请输入有效日期(YYYY-MM-DD)。",
|
||||
settlementPeriodNotClosed: "该周期尚未结束,生成被拒绝",
|
||||
settlementListTitle: "结算账单",
|
||||
settlementPeriod: "周期",
|
||||
settlementOrderCount: "订单数",
|
||||
settlementGross: "交易总额",
|
||||
settlementRefunds: "退款",
|
||||
settlementCommission: "佣金",
|
||||
settlementPayable: "应付",
|
||||
settlementDetail: "详情",
|
||||
settlementClose: "收起",
|
||||
settlementStatuses: {
|
||||
pending: "待打款",
|
||||
confirmed: "已确认",
|
||||
},
|
||||
settlementDetailTitle: "账单详情",
|
||||
settlementSnapshot: "账单快照",
|
||||
settlementOrders: "关联订单",
|
||||
settlementNoOrders: "该周期没有关联订单。",
|
||||
settlementOrderNo: "订单号",
|
||||
settlementOrderCurrency: "下单币种",
|
||||
settlementConfirmedAt: "确认时间",
|
||||
settlementUpdated: "更新时间",
|
||||
settlementConfirmPayout: "确认打款",
|
||||
settlementConfirmPayoutHint: "确认后应付金额将一次性计入店主余额。",
|
||||
settlementConfirmDialog: "确定确认打款吗?店主余额将入账,且不可重复确认。",
|
||||
settlementPayoutConfirmed: "打款已确认,店主账户已入账。",
|
||||
settlementConflict: "该账单已确认,列表已刷新",
|
||||
reviewProduct: "商品",
|
||||
reviewShop: "店铺",
|
||||
reviewBuyer: "买家",
|
||||
@@ -334,5 +594,88 @@ export const zhExtra = {
|
||||
visible: "可见",
|
||||
hidden: "已隐藏",
|
||||
},
|
||||
merchantApplication: "申请单",
|
||||
merchantApplicant: "申请人邮箱",
|
||||
merchantEntityType: "主体类型",
|
||||
merchantEntityName: "主体名称",
|
||||
merchantSubmittedAt: "提交时间",
|
||||
merchantUpdated: "更新时间",
|
||||
merchantReviewedAt: "审核时间",
|
||||
merchantCreatedShop: "创建店铺",
|
||||
merchantView: "详情",
|
||||
merchantClose: "收起",
|
||||
merchantDetail: "商家入驻申请详情",
|
||||
merchantEntityInfo: "主体信息",
|
||||
merchantRealName: "真实姓名",
|
||||
merchantCompanyName: "企业名称",
|
||||
merchantBusinessLicenseNo: "营业执照号",
|
||||
merchantContact: "联系方式",
|
||||
merchantContactName: "联系人",
|
||||
merchantContactPhone: "联系电话",
|
||||
merchantContactEmail: "联系邮箱",
|
||||
merchantContactAddress: "联系地址",
|
||||
merchantCategories: "经营类目",
|
||||
merchantNoCategories: "未选择类目",
|
||||
merchantQualification: "资质材料",
|
||||
merchantIdentityDocument: "身份证明",
|
||||
merchantBusinessLicense: "营业执照",
|
||||
merchantExtraMaterials: "补充材料",
|
||||
merchantNoQualification: "未提供资质材料",
|
||||
merchantRejectionReason: "驳回原因",
|
||||
merchantConfirmApprove:
|
||||
"确定通过该入驻申请吗?将开通店铺并创建专属店主账号,初始密码仅显示一次且无法再次获取。",
|
||||
merchantConfirmReject: "确定驳回该入驻申请吗?申请人之后可以重新提交。",
|
||||
merchantApprove: "通过",
|
||||
merchantReject: "驳回",
|
||||
merchantRejectReason: "驳回原因(必填)",
|
||||
merchantRejectReasonPlaceholder: "请说明驳回原因,供申请人查看",
|
||||
merchantRejectReasonRequired: "驳回原因不能为空。",
|
||||
merchantApprovedNotice: "申请已通过,店铺与店主账号已开通。",
|
||||
merchantRejectedNotice: "申请已驳回。",
|
||||
merchantOutcomeApproved: "已通过:店铺与店主账号已开通。",
|
||||
merchantOutcomeRejected: "已驳回:申请人可重新提交。",
|
||||
merchantConflict: "该申请已被审核,列表已刷新",
|
||||
merchantCredentialsTitle: "一次性初始凭据",
|
||||
merchantCredentialsWarning:
|
||||
"请立即保存以下凭据。初始密码仅显示一次,关闭本弹窗后无法再次获取。",
|
||||
merchantCredentialsEmail: "店主登录账号",
|
||||
merchantCredentialsPassword: "初始密码",
|
||||
merchantCredentialsShopSlug: "店铺别名",
|
||||
merchantCredentialsShopId: "店铺 ID",
|
||||
merchantCredentialsClose: "我已保存凭据",
|
||||
merchantStatuses: {
|
||||
pending: "待审核",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
},
|
||||
merchantEntityTypes: {
|
||||
personal: "个人",
|
||||
enterprise: "企业",
|
||||
},
|
||||
memberLevelCreateTitle: "新增会员等级",
|
||||
memberLevelEditTitle: "编辑会员等级",
|
||||
memberLevelHint:
|
||||
"等级按成长值升序排列;中英文均为必填,且同一成长值只能用于一个等级。",
|
||||
memberLevelNameEn: "等级名称(英文)",
|
||||
memberLevelNameZh: "等级名称(中文)",
|
||||
memberLevelIcon: "图标",
|
||||
memberLevelGrowth: "成长值门槛",
|
||||
memberLevelBenefitsEn: "等级权益(英文)",
|
||||
memberLevelBenefitsZh: "等级权益(中文)",
|
||||
memberLevelCreate: "新增等级",
|
||||
memberLevelCreated: "会员等级已创建。",
|
||||
memberLevelUpdated: "会员等级已更新。",
|
||||
memberLevelUpdatedAt: "更新时间",
|
||||
memberLevelDeleted: "会员等级已删除。",
|
||||
memberLevelRequired: "中英文等级名称与权益均为必填项。",
|
||||
memberLevelIconRequired: "图标为必填项。",
|
||||
memberLevelThresholdInvalid: "成长值门槛必须为不小于 0 的整数。",
|
||||
memberLevelThresholdDuplicate: "已有其他等级使用该成长值门槛。",
|
||||
memberLevelConflict: "成长值门槛已被占用",
|
||||
memberLevelInvalid: "等级被服务端拒绝",
|
||||
memberLevelConfirmDelete: "确定删除等级“{name}”吗?此操作不可撤销。",
|
||||
memberLevelDeleteInUse: "该等级已被至少一位客户持有,无法删除",
|
||||
memberLevelDiscardEdit: "确定放弃未保存的等级修改并刷新吗?",
|
||||
memberLevelGone: "该会员等级已不存在,列表已刷新。",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
<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>
|
||||
@@ -0,0 +1,608 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError, t as localizedText } from "@vmall/shared";
|
||||
import type {
|
||||
MerchantApplication,
|
||||
MerchantApplicationStatus,
|
||||
MerchantEntityType,
|
||||
MerchantOwnerCredentials,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
const applications = ref<MerchantApplication[]>([]);
|
||||
const statusFilter = ref<"" | MerchantApplicationStatus>("");
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const perPage = ref(20);
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
const notice = ref("");
|
||||
const conflictMessage = ref("");
|
||||
const validationMessage = ref("");
|
||||
|
||||
const openId = ref<string | null>(null);
|
||||
const detail = ref<MerchantApplication | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const detailError = ref("");
|
||||
|
||||
const acting = ref(false);
|
||||
const rejectReason = ref("");
|
||||
const rejectError = ref("");
|
||||
|
||||
const credentials = ref<MerchantOwnerCredentials | null>(null);
|
||||
|
||||
const statuses: MerchantApplicationStatus[] = ["pending", "approved", "rejected"];
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
|
||||
|
||||
function statusTone(status: MerchantApplicationStatus): "green" | "red" | "orange" {
|
||||
if (status === "approved") return "green";
|
||||
if (status === "rejected") return "red";
|
||||
return "orange";
|
||||
}
|
||||
|
||||
function statusLabel(status: MerchantApplicationStatus): string {
|
||||
return t(`admin.merchantStatuses.${status}`);
|
||||
}
|
||||
|
||||
function entityTypeLabel(entityType: MerchantEntityType): string {
|
||||
return t(`admin.merchantEntityTypes.${entityType}`);
|
||||
}
|
||||
|
||||
function entityName(row: MerchantApplication): string {
|
||||
const name = row.entity_type === "personal" ? row.real_name : row.company_name;
|
||||
return name && name.trim() !== "" ? name : "—";
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function shortId(value: string): string {
|
||||
return value.slice(0, 8);
|
||||
}
|
||||
|
||||
function extraMaterials(row: MerchantApplication): string[] {
|
||||
return row.qualification.extra_materials ?? [];
|
||||
}
|
||||
|
||||
function hasQualification(row: MerchantApplication): boolean {
|
||||
const qualification = row.qualification;
|
||||
return Boolean(
|
||||
qualification.identity_document_url ||
|
||||
qualification.business_license_url ||
|
||||
qualification.business_license_no ||
|
||||
extraMaterials(row).length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : t("common.error");
|
||||
return error instanceof ApiError && error.status === 409
|
||||
? `${t("admin.merchantConflict")}: ${message}`
|
||||
: message;
|
||||
}
|
||||
|
||||
function clearFeedback(): void {
|
||||
errorMessage.value = "";
|
||||
notice.value = "";
|
||||
conflictMessage.value = "";
|
||||
validationMessage.value = "";
|
||||
}
|
||||
|
||||
function closeDetail(): void {
|
||||
openId.value = null;
|
||||
detail.value = null;
|
||||
detailError.value = "";
|
||||
rejectReason.value = "";
|
||||
rejectError.value = "";
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const paged = await $api.admin.listMerchantApplications({
|
||||
status: statusFilter.value || undefined,
|
||||
page: page.value,
|
||||
per_page: perPage.value,
|
||||
});
|
||||
if (paged.items.length === 0 && paged.total > 0 && page.value > 1) {
|
||||
page.value -= 1;
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
applications.value = paged.items;
|
||||
total.value = paged.total;
|
||||
perPage.value = paged.per_page;
|
||||
if (openId.value && !paged.items.some((row) => row.id === openId.value)) {
|
||||
closeDetail();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = error instanceof Error ? error.message : t("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(nextPage: number): Promise<void> {
|
||||
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return;
|
||||
page.value = nextPage;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function applyStatusFilter(): Promise<void> {
|
||||
page.value = 1;
|
||||
closeDetail();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function openDetail(id: string): Promise<void> {
|
||||
openId.value = id;
|
||||
detail.value = null;
|
||||
detailError.value = "";
|
||||
rejectReason.value = "";
|
||||
rejectError.value = "";
|
||||
validationMessage.value = "";
|
||||
notice.value = "";
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await $api.admin.getMerchantApplication(id);
|
||||
} catch (error: unknown) {
|
||||
detailError.value = errorText(error);
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDetail(id: string): Promise<void> {
|
||||
if (openId.value === id) {
|
||||
closeDetail();
|
||||
return;
|
||||
}
|
||||
await openDetail(id);
|
||||
}
|
||||
|
||||
function dismissCredentials(): void {
|
||||
credentials.value = null;
|
||||
}
|
||||
|
||||
async function approve(row: MerchantApplication): Promise<void> {
|
||||
if (!confirm(t("admin.merchantConfirmApprove"))) return;
|
||||
|
||||
acting.value = true;
|
||||
clearFeedback();
|
||||
rejectError.value = "";
|
||||
try {
|
||||
const result = await $api.admin.approveMerchantApplication(row.id);
|
||||
detail.value = result.application;
|
||||
// The initial password exists only in this response: show it once, then discard.
|
||||
credentials.value = result.credentials;
|
||||
notice.value = t("admin.merchantApprovedNotice");
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
// Already reviewed: converge to server state instead of retrying.
|
||||
conflictMessage.value = errorText(error);
|
||||
closeDetail();
|
||||
} else if (error instanceof ApiError && error.status === 400) {
|
||||
validationMessage.value = error instanceof Error ? error.message : t("common.error");
|
||||
} else {
|
||||
detailError.value = error instanceof Error ? error.message : t("common.error");
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
acting.value = false;
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
async function reject(row: MerchantApplication): Promise<void> {
|
||||
const reason = rejectReason.value.trim();
|
||||
rejectError.value = "";
|
||||
validationMessage.value = "";
|
||||
// The server rejects a blank reason with 400; block it before the request too.
|
||||
if (reason === "") {
|
||||
rejectError.value = t("admin.merchantRejectReasonRequired");
|
||||
return;
|
||||
}
|
||||
if (!confirm(t("admin.merchantConfirmReject"))) return;
|
||||
|
||||
acting.value = true;
|
||||
clearFeedback();
|
||||
try {
|
||||
detail.value = await $api.admin.rejectMerchantApplication(row.id, reason);
|
||||
rejectReason.value = "";
|
||||
notice.value = t("admin.merchantRejectedNotice");
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
conflictMessage.value = errorText(error);
|
||||
closeDetail();
|
||||
} else if (error instanceof ApiError && error.status === 400) {
|
||||
validationMessage.value = error instanceof Error ? error.message : t("common.error");
|
||||
} else {
|
||||
detailError.value = error instanceof Error ? error.message : t("common.error");
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
acting.value = false;
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('nav.merchantApplications')">
|
||||
<template #actions>
|
||||
<span v-if="!loading" class="text-muted text-sm">{{ total }}</span>
|
||||
</template>
|
||||
|
||||
<VCard class="mb-5">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="grid gap-1 text-sm font-medium" for="merchant-application-status">
|
||||
{{ $t("common.status") }}
|
||||
<select
|
||||
id="merchant-application-status"
|
||||
v-model="statusFilter"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
|
||||
@change="applyStatusFilter"
|
||||
>
|
||||
<option value="">{{ $t("common.all") }}</option>
|
||||
<option v-for="status in statuses" :key="status" :value="status">
|
||||
{{ statusLabel(status) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<VBtn size="sm" :disabled="loading" @click="load">{{ $t("admin.refresh") }}</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<p v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</p>
|
||||
<p
|
||||
v-if="conflictMessage"
|
||||
class="bg-warning/10 text-warning my-2 rounded-md px-3 py-2 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{{ conflictMessage }}
|
||||
</p>
|
||||
<p v-if="validationMessage" class="text-danger my-2 text-sm" role="alert">
|
||||
{{ validationMessage }}
|
||||
</p>
|
||||
|
||||
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="errorMessage" class="text-danger my-2 text-sm" role="alert">{{ errorMessage }}</p>
|
||||
<VCard v-else-if="applications.length === 0" class="text-muted">
|
||||
{{ $t("common.empty") }}
|
||||
</VCard>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<div class="min-w-[1040px]">
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("admin.merchantApplication") }}</th>
|
||||
<th>{{ $t("admin.merchantApplicant") }}</th>
|
||||
<th>{{ $t("admin.merchantEntityType") }}</th>
|
||||
<th>{{ $t("admin.merchantEntityName") }}</th>
|
||||
<th>{{ $t("admin.merchantSubmittedAt") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="row in applications" :key="row.id">
|
||||
<tr :class="openId === row.id ? 'bg-primary-soft/30' : ''">
|
||||
<td>
|
||||
<code class="bg-bg rounded px-1.5 py-0.5" :title="row.id">{{
|
||||
shortId(row.id)
|
||||
}}</code>
|
||||
</td>
|
||||
<td>{{ row.applicant_email }}</td>
|
||||
<td>
|
||||
<VBadge :tone="row.entity_type === 'enterprise' ? 'blue' : 'gray'">
|
||||
{{ entityTypeLabel(row.entity_type) }}
|
||||
</VBadge>
|
||||
</td>
|
||||
<td>{{ entityName(row) }}</td>
|
||||
<td>{{ formatDate(row.created_at) }}</td>
|
||||
<td>
|
||||
<VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge>
|
||||
</td>
|
||||
<td>
|
||||
<VBtn size="sm" @click="toggleDetail(row.id)">
|
||||
{{
|
||||
openId === row.id ? $t("admin.merchantClose") : $t("admin.merchantView")
|
||||
}}
|
||||
</VBtn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="openId === row.id" class="bg-bg">
|
||||
<td colspan="7" class="p-0">
|
||||
<div class="p-4">
|
||||
<p v-if="detailLoading" class="text-muted text-sm">
|
||||
{{ $t("common.loading") }}
|
||||
</p>
|
||||
<p v-else-if="detailError" class="text-danger text-sm" role="alert">
|
||||
{{ detailError }}
|
||||
</p>
|
||||
<VPanel v-else-if="detail" :title="$t('admin.merchantDetail')">
|
||||
<template #actions>
|
||||
<VBadge :tone="statusTone(detail.status)">{{
|
||||
statusLabel(detail.status)
|
||||
}}</VBadge>
|
||||
<VBtn size="sm" class="ml-2" @click="closeDetail">{{
|
||||
$t("admin.merchantClose")
|
||||
}}</VBtn>
|
||||
</template>
|
||||
|
||||
<dl class="mb-4 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.merchantApplication") }}</dt>
|
||||
<dd><code class="break-all">{{ detail.id }}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.merchantApplicant") }}</dt>
|
||||
<dd>{{ detail.applicant_email }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.orderUser") }}</dt>
|
||||
<dd><code class="break-all">{{ detail.user_id }}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.merchantEntityType") }}</dt>
|
||||
<dd>{{ entityTypeLabel(detail.entity_type) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.merchantSubmittedAt") }}</dt>
|
||||
<dd>{{ formatDate(detail.created_at) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.merchantUpdated") }}</dt>
|
||||
<dd>{{ formatDate(detail.updated_at) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.merchantReviewedAt") }}</dt>
|
||||
<dd>
|
||||
<span v-if="detail.reviewed_at">{{ formatDate(detail.reviewed_at) }}</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="detail.created_shop_id">
|
||||
<dt class="text-muted">{{ $t("admin.merchantCreatedShop") }}</dt>
|
||||
<dd><code class="break-all">{{ detail.created_shop_id }}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<VCard :padded="true">
|
||||
<h4 class="mb-2 text-sm font-semibold">
|
||||
{{ $t("admin.merchantEntityInfo") }}
|
||||
</h4>
|
||||
<dl class="grid gap-1 text-sm">
|
||||
<template v-if="detail.entity_type === 'personal'">
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted">{{ $t("admin.merchantRealName") }}</dt>
|
||||
<dd>{{ detail.real_name || "—" }}</dd>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted">{{ $t("admin.merchantCompanyName") }}</dt>
|
||||
<dd>{{ detail.company_name || "—" }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted">
|
||||
{{ $t("admin.merchantBusinessLicenseNo") }}
|
||||
</dt>
|
||||
<dd>{{ detail.business_license_no || "—" }}</dd>
|
||||
</div>
|
||||
</template>
|
||||
</dl>
|
||||
</VCard>
|
||||
|
||||
<VCard :padded="true">
|
||||
<h4 class="mb-2 text-sm font-semibold">
|
||||
{{ $t("admin.merchantContact") }}
|
||||
</h4>
|
||||
<dl class="grid gap-1 text-sm">
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted">{{ $t("admin.merchantContactName") }}</dt>
|
||||
<dd>{{ detail.contact.name }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted">{{ $t("admin.merchantContactPhone") }}</dt>
|
||||
<dd>{{ detail.contact.phone }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted">{{ $t("admin.merchantContactEmail") }}</dt>
|
||||
<dd class="break-all">{{ detail.contact.email }}</dd>
|
||||
</div>
|
||||
<div v-if="detail.contact.address" class="flex justify-between gap-3">
|
||||
<dt class="text-muted">{{ $t("admin.merchantContactAddress") }}</dt>
|
||||
<dd>{{ detail.contact.address }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</VCard>
|
||||
|
||||
<VCard :padded="true">
|
||||
<h4 class="mb-2 text-sm font-semibold">
|
||||
{{ $t("admin.merchantCategories") }}
|
||||
</h4>
|
||||
<ul v-if="detail.categories.length" class="flex flex-wrap gap-2 text-sm">
|
||||
<li v-for="category in detail.categories" :key="category.id">
|
||||
<VBadge tone="gray">{{
|
||||
localizedText(category.name, locale)
|
||||
}}</VBadge>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-muted text-sm">
|
||||
{{ $t("admin.merchantNoCategories") }}
|
||||
</p>
|
||||
</VCard>
|
||||
|
||||
<VCard :padded="true">
|
||||
<h4 class="mb-2 text-sm font-semibold">
|
||||
{{ $t("admin.merchantQualification") }}
|
||||
</h4>
|
||||
<div v-if="hasQualification(detail)" class="grid gap-2 text-sm">
|
||||
<div v-if="detail.qualification.identity_document_url">
|
||||
<span class="text-muted">{{ $t("admin.merchantIdentityDocument") }}</span>
|
||||
<a
|
||||
:href="detail.qualification.identity_document_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary block break-all underline"
|
||||
>{{ detail.qualification.identity_document_url }}</a
|
||||
>
|
||||
</div>
|
||||
<div v-if="detail.qualification.business_license_url">
|
||||
<span class="text-muted">{{ $t("admin.merchantBusinessLicense") }}</span>
|
||||
<a
|
||||
:href="detail.qualification.business_license_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary block break-all underline"
|
||||
>{{ detail.qualification.business_license_url }}</a
|
||||
>
|
||||
</div>
|
||||
<div v-if="detail.qualification.business_license_no">
|
||||
<span class="text-muted">
|
||||
{{ $t("admin.merchantBusinessLicenseNo") }}
|
||||
</span>
|
||||
<span class="block">{{ detail.qualification.business_license_no }}</span>
|
||||
</div>
|
||||
<div v-if="extraMaterials(detail).length">
|
||||
<span class="text-muted">{{ $t("admin.merchantExtraMaterials") }}</span>
|
||||
<ul class="grid gap-1">
|
||||
<li v-for="url in extraMaterials(detail)" :key="url">
|
||||
<a
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary break-all underline"
|
||||
>{{ url }}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-muted text-sm">
|
||||
{{ $t("admin.merchantNoQualification") }}
|
||||
</p>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="detail.status === 'rejected'"
|
||||
class="bg-danger/10 text-danger mt-4 rounded-md px-3 py-2 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
<span class="font-medium">{{ $t("admin.merchantRejectionReason") }}</span>
|
||||
{{ detail.rejection_reason || "—" }}
|
||||
</p>
|
||||
|
||||
<div v-if="detail.status === 'pending'" class="border-border mt-4 border-t pt-4">
|
||||
<p class="text-muted mb-3 text-sm">
|
||||
{{ $t("admin.merchantConfirmApprove") }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<VBtn variant="primary" :disabled="acting" @click="approve(detail)">
|
||||
{{ acting ? $t("common.loading") : $t("admin.merchantApprove") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="mt-4 max-w-md">
|
||||
<VField
|
||||
:label="$t('admin.merchantRejectReason')"
|
||||
:error="rejectError"
|
||||
>
|
||||
<VInput
|
||||
id="merchant-reject-reason"
|
||||
v-model="rejectReason"
|
||||
:placeholder="$t('admin.merchantRejectReasonPlaceholder')"
|
||||
:disabled="acting"
|
||||
/>
|
||||
</VField>
|
||||
<VBtn variant="danger" :disabled="acting" @click="reject(detail)">
|
||||
{{ $t("admin.merchantReject") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="detail.status === 'approved'" class="text-success mt-4 text-sm">
|
||||
{{ $t("admin.merchantOutcomeApproved") }}
|
||||
</p>
|
||||
<p v-else class="text-danger mt-4 text-sm">
|
||||
{{ $t("admin.merchantOutcomeRejected") }}
|
||||
</p>
|
||||
</VPanel>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!loading && !errorMessage && applications.length > 0"
|
||||
class="mt-4 flex items-center justify-center gap-3"
|
||||
>
|
||||
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{
|
||||
$t("common.prev")
|
||||
}}</VBtn>
|
||||
<span class="text-muted text-sm">{{ page }} / {{ totalPages }}</span>
|
||||
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{
|
||||
$t("common.next")
|
||||
}}</VBtn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="credentials"
|
||||
class="bg-text/40 fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="merchant-credentials-heading"
|
||||
>
|
||||
<VCard class="max-h-full w-full max-w-lg overflow-y-auto">
|
||||
<h2 id="merchant-credentials-heading" class="text-text mb-2 text-base font-semibold">
|
||||
{{ $t("admin.merchantCredentialsTitle") }}
|
||||
</h2>
|
||||
<p class="bg-warning/10 text-warning mb-4 rounded-md px-3 py-2 text-sm" role="alert">
|
||||
{{ $t("admin.merchantCredentialsWarning") }}
|
||||
</p>
|
||||
<dl class="grid gap-2 text-sm">
|
||||
<div class="flex flex-wrap justify-between gap-3">
|
||||
<dt class="text-muted">{{ $t("admin.merchantCredentialsEmail") }}</dt>
|
||||
<dd><code class="bg-bg rounded px-1.5 py-0.5 break-all">{{ credentials.email }}</code></dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-3">
|
||||
<dt class="text-muted">{{ $t("admin.merchantCredentialsPassword") }}</dt>
|
||||
<dd>
|
||||
<code class="bg-bg rounded px-1.5 py-0.5 break-all">{{
|
||||
credentials.initial_password
|
||||
}}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-3">
|
||||
<dt class="text-muted">{{ $t("admin.merchantCredentialsShopSlug") }}</dt>
|
||||
<dd><code class="bg-bg rounded px-1.5 py-0.5 break-all">{{ credentials.shop_slug }}</code></dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-3">
|
||||
<dt class="text-muted">{{ $t("admin.merchantCredentialsShopId") }}</dt>
|
||||
<dd><code class="bg-bg rounded px-1.5 py-0.5 break-all">{{ credentials.shop_id }}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<VBtn variant="primary" @click="dismissCredentials">
|
||||
{{ $t("admin.merchantCredentialsClose") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</VPage>
|
||||
</template>
|
||||
@@ -0,0 +1,718 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError, formatMoney, t as localizedText } from "@vmall/shared";
|
||||
import type {
|
||||
Currency,
|
||||
SettlementPeriodKind,
|
||||
SettlementStatement,
|
||||
SettlementStatementDetail,
|
||||
SettlementStatus,
|
||||
Shop,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
const currencies = ref<Currency[]>([]);
|
||||
const shops = ref<Shop[]>([]);
|
||||
|
||||
// ---- commission rate ----
|
||||
const commissionRate = ref<number | null>(null);
|
||||
const rateDraft = ref<string | number>("");
|
||||
const rateSaving = ref(false);
|
||||
const rateError = ref("");
|
||||
|
||||
// ---- statement list ----
|
||||
const statements = ref<SettlementStatement[]>([]);
|
||||
const shopFilter = ref("");
|
||||
const statusFilter = ref<"" | SettlementStatus>("");
|
||||
const page = ref(1);
|
||||
const perPage = ref(20);
|
||||
const total = ref(0);
|
||||
const loading = ref(true);
|
||||
const listError = ref("");
|
||||
|
||||
// ---- manual generation ----
|
||||
const generateShop = ref("");
|
||||
const generateKind = ref<SettlementPeriodKind>("month");
|
||||
const generateDate = ref(defaultPeriodDate());
|
||||
const generating = ref(false);
|
||||
const generateError = ref("");
|
||||
const generatedStatement = ref<SettlementStatement | null>(null);
|
||||
const generatedWasExisting = ref(false);
|
||||
|
||||
// ---- detail ----
|
||||
const openId = ref<string | null>(null);
|
||||
const detail = ref<SettlementStatementDetail | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const detailError = ref("");
|
||||
const actingId = ref<string | null>(null);
|
||||
|
||||
const notice = ref("");
|
||||
const conflictMessage = ref("");
|
||||
|
||||
const periodKinds: SettlementPeriodKind[] = ["week", "month"];
|
||||
const settlementStatuses: SettlementStatus[] = ["pending", "confirmed"];
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
|
||||
const currentRateLabel = computed(() =>
|
||||
commissionRate.value === null
|
||||
? "—"
|
||||
: t("admin.commissionRateDisplay", {
|
||||
bps: commissionRate.value,
|
||||
percent: ratePercent(commissionRate.value),
|
||||
}),
|
||||
);
|
||||
|
||||
/** First day of the previous month: always inside a closed week/month period. */
|
||||
function defaultPeriodDate(): string {
|
||||
const now = new Date();
|
||||
const firstOfPreviousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const month = String(firstOfPreviousMonth.getMonth() + 1).padStart(2, "0");
|
||||
return `${firstOfPreviousMonth.getFullYear()}-${month}-01`;
|
||||
}
|
||||
|
||||
function currencyExponent(code: string): number | undefined {
|
||||
return currencies.value.find((currency) => currency.code === code)?.exponent;
|
||||
}
|
||||
|
||||
function money(amountMinor: number, currency: string): string {
|
||||
const exponent = currencyExponent(currency);
|
||||
return exponent === undefined ? "—" : formatMoney(amountMinor, currency, exponent, locale.value);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function shortId(value: string): string {
|
||||
return value.slice(0, 8);
|
||||
}
|
||||
|
||||
function shopLabel(statement: SettlementStatement): string {
|
||||
const name = localizedText(statement.shop_name, locale.value);
|
||||
return name === "" ? shortId(statement.shop_id) : name;
|
||||
}
|
||||
|
||||
function periodKindLabel(kind: SettlementPeriodKind): string {
|
||||
return t(`admin.settlementPeriodKinds.${kind}`);
|
||||
}
|
||||
|
||||
function periodLabel(row: SettlementStatement): string {
|
||||
return `${periodKindLabel(row.period_kind)} · ${row.period_start} – ${row.period_end}`;
|
||||
}
|
||||
|
||||
function settlementStatusLabel(status: SettlementStatus): string {
|
||||
return t(`admin.settlementStatuses.${status}`);
|
||||
}
|
||||
|
||||
function settlementStatusTone(status: SettlementStatus): "green" | "orange" {
|
||||
return status === "confirmed" ? "green" : "orange";
|
||||
}
|
||||
|
||||
/** Display-only basis-point → percent conversion; never used for arithmetic. */
|
||||
function ratePercent(bps: number): string {
|
||||
return String(Number((bps / 100).toFixed(2)));
|
||||
}
|
||||
|
||||
function shopOptionLabel(shop: Shop): string {
|
||||
const name = localizedText(shop.name, locale.value);
|
||||
return name === "" ? shop.slug : name;
|
||||
}
|
||||
|
||||
/** Parse the draft as an integer basis-point value in 0..10000, or null when invalid. */
|
||||
function parseBps(raw: string | number): number | null {
|
||||
const text = typeof raw === "number" ? String(raw) : raw.trim();
|
||||
if (text === "" || !/^\d+$/.test(text)) return null;
|
||||
const value = Number(text);
|
||||
return Number.isInteger(value) && value >= 0 && value <= 10000 ? value : null;
|
||||
}
|
||||
|
||||
function isIsoDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : t("common.error");
|
||||
}
|
||||
|
||||
async function loadReference(): Promise<void> {
|
||||
try {
|
||||
const [currencyList, shopList] = await Promise.all([
|
||||
$api.admin.listCurrencies(),
|
||||
$api.admin.listShops(),
|
||||
]);
|
||||
currencies.value = currencyList;
|
||||
shops.value = shopList;
|
||||
} catch (error: unknown) {
|
||||
listError.value = errorText(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommissionRate(): Promise<void> {
|
||||
try {
|
||||
const rate = await $api.admin.getCommissionRate();
|
||||
commissionRate.value = rate.commission_rate_bps;
|
||||
rateDraft.value = rate.commission_rate_bps;
|
||||
} catch (error: unknown) {
|
||||
rateError.value = errorText(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStatements(): Promise<void> {
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
try {
|
||||
const paged = await $api.admin.listSettlementStatements({
|
||||
page: page.value,
|
||||
shop_id: shopFilter.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
});
|
||||
statements.value = paged.items;
|
||||
total.value = paged.total;
|
||||
perPage.value = paged.per_page;
|
||||
} catch (error: unknown) {
|
||||
listError.value = errorText(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(nextPage: number): Promise<void> {
|
||||
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return;
|
||||
page.value = nextPage;
|
||||
await loadStatements();
|
||||
}
|
||||
|
||||
async function applyFilters(): Promise<void> {
|
||||
page.value = 1;
|
||||
openId.value = null;
|
||||
detail.value = null;
|
||||
await loadStatements();
|
||||
}
|
||||
|
||||
async function saveRate(): Promise<void> {
|
||||
rateError.value = "";
|
||||
notice.value = "";
|
||||
conflictMessage.value = "";
|
||||
const bps = parseBps(rateDraft.value);
|
||||
if (bps === null) {
|
||||
rateError.value = t("admin.commissionRateInvalid");
|
||||
return;
|
||||
}
|
||||
rateSaving.value = true;
|
||||
try {
|
||||
const saved = await $api.admin.setCommissionRate(bps);
|
||||
commissionRate.value = saved.commission_rate_bps;
|
||||
rateDraft.value = saved.commission_rate_bps;
|
||||
notice.value = t("admin.commissionRateSaved");
|
||||
} catch (error: unknown) {
|
||||
rateError.value = errorText(error);
|
||||
} finally {
|
||||
rateSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All statement ids this shop already has, so a repeat generation call can be
|
||||
* reported as "the existing statement was returned" (generation is idempotent).
|
||||
*/
|
||||
async function knownStatementIds(shopId: string): Promise<Set<string>> {
|
||||
const ids = new Set<string>();
|
||||
for (let current = 1; current <= 20; current += 1) {
|
||||
const paged = await $api.admin.listSettlementStatements({
|
||||
shop_id: shopId,
|
||||
page: current,
|
||||
per_page: 100,
|
||||
});
|
||||
for (const row of paged.items) ids.add(row.id);
|
||||
if (paged.items.length === 0 || current * paged.per_page >= paged.total) break;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
generateError.value = "";
|
||||
notice.value = "";
|
||||
conflictMessage.value = "";
|
||||
generatedStatement.value = null;
|
||||
generatedWasExisting.value = false;
|
||||
|
||||
if (!generateShop.value) {
|
||||
generateError.value = t("admin.settlementPickShop");
|
||||
return;
|
||||
}
|
||||
if (!isIsoDate(generateDate.value)) {
|
||||
generateError.value = t("admin.settlementPeriodDateInvalid");
|
||||
return;
|
||||
}
|
||||
|
||||
generating.value = true;
|
||||
try {
|
||||
const known = await knownStatementIds(generateShop.value);
|
||||
const statement = await $api.admin.generateSettlementStatement({
|
||||
shop_id: generateShop.value,
|
||||
period_kind: generateKind.value,
|
||||
period_start: generateDate.value,
|
||||
});
|
||||
generatedWasExisting.value = known.has(statement.id);
|
||||
generatedStatement.value = statement;
|
||||
notice.value = generatedWasExisting.value
|
||||
? t("admin.settlementGeneratedExisting")
|
||||
: t("admin.settlementGenerated");
|
||||
await loadStatements();
|
||||
} catch (error: unknown) {
|
||||
// A period that is not closed yet is refused with a conflict.
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
conflictMessage.value = `${t("admin.settlementPeriodNotClosed")}: ${error.message}`;
|
||||
} else {
|
||||
generateError.value = errorText(error);
|
||||
}
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(id: string): Promise<void> {
|
||||
openId.value = id;
|
||||
detail.value = null;
|
||||
detailError.value = "";
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await $api.admin.getSettlementStatement(id);
|
||||
} catch (error: unknown) {
|
||||
detailError.value = errorText(error);
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDetail(id: string): Promise<void> {
|
||||
if (openId.value === id) {
|
||||
openId.value = null;
|
||||
detail.value = null;
|
||||
detailError.value = "";
|
||||
return;
|
||||
}
|
||||
await openDetail(id);
|
||||
}
|
||||
|
||||
async function confirmPayout(row: SettlementStatement): Promise<void> {
|
||||
if (!confirm(t("admin.settlementConfirmDialog"))) return;
|
||||
|
||||
actingId.value = row.id;
|
||||
notice.value = "";
|
||||
conflictMessage.value = "";
|
||||
detailError.value = "";
|
||||
try {
|
||||
await $api.admin.confirmSettlementStatement(row.id);
|
||||
notice.value = t("admin.settlementPayoutConfirmed");
|
||||
await loadStatements();
|
||||
if (openId.value === row.id) await openDetail(row.id);
|
||||
} catch (error: unknown) {
|
||||
// A repeat confirmation is a conflict: refresh so the UI converges to truth.
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
conflictMessage.value = `${t("admin.settlementConflict")}: ${error.message}`;
|
||||
await loadStatements();
|
||||
if (openId.value === row.id) await openDetail(row.id);
|
||||
} else {
|
||||
detailError.value = errorText(error);
|
||||
}
|
||||
} finally {
|
||||
actingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void Promise.all([loadReference(), loadCommissionRate(), loadStatements()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('nav.settlements')">
|
||||
<template #actions>
|
||||
<span v-if="!loading" class="text-muted text-sm">{{
|
||||
$t("admin.pageOf", { page, total: totalPages })
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<p v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</p>
|
||||
<p
|
||||
v-if="conflictMessage"
|
||||
class="bg-warning/10 text-warning my-2 rounded-md px-3 py-2 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{{ conflictMessage }}
|
||||
</p>
|
||||
|
||||
<VCard class="mb-5">
|
||||
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.commissionRateTitle") }}</h2>
|
||||
<p class="text-muted mb-3 text-sm">
|
||||
{{ $t("admin.commissionRateCurrent") }}: {{ currentRateLabel }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-start gap-3">
|
||||
<VField
|
||||
class="min-w-[240px] flex-1"
|
||||
:label="$t('admin.commissionRate')"
|
||||
:error="rateError"
|
||||
>
|
||||
<VInput
|
||||
id="commission-rate-bps"
|
||||
v-model="rateDraft"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
step="1"
|
||||
:disabled="rateSaving"
|
||||
/>
|
||||
</VField>
|
||||
<VBtn variant="primary" :disabled="rateSaving" @click="saveRate">
|
||||
{{ rateSaving ? $t("common.loading") : $t("admin.commissionRateSave") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<p class="text-muted text-sm">{{ $t("admin.commissionRateHint") }}</p>
|
||||
</VCard>
|
||||
|
||||
<VCard class="mb-5">
|
||||
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.settlementGenerateTitle") }}</h2>
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<label class="grid gap-1 text-sm font-medium" for="settlement-generate-shop">
|
||||
{{ $t("admin.settlementShop") }}
|
||||
<select
|
||||
id="settlement-generate-shop"
|
||||
v-model="generateShop"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option v-for="shop in shops" :key="shop.id" :value="shop.id">
|
||||
{{ shopOptionLabel(shop) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium" for="settlement-generate-kind">
|
||||
{{ $t("admin.settlementPeriodKind") }}
|
||||
<select
|
||||
id="settlement-generate-kind"
|
||||
v-model="generateKind"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
|
||||
>
|
||||
<option v-for="kind in periodKinds" :key="kind" :value="kind">
|
||||
{{ periodKindLabel(kind) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<VField :label="$t('admin.settlementPeriodDate')">
|
||||
<VInput
|
||||
id="settlement-generate-date"
|
||||
v-model="generateDate"
|
||||
type="date"
|
||||
:disabled="generating"
|
||||
/>
|
||||
</VField>
|
||||
</div>
|
||||
<p class="text-muted mb-3 text-sm">{{ $t("admin.settlementPeriodDateHint") }}</p>
|
||||
<p v-if="generateError" class="text-danger my-2 text-sm" role="alert">{{ generateError }}</p>
|
||||
<VBtn variant="primary" :disabled="generating" @click="generate">
|
||||
{{ generating ? $t("common.loading") : $t("admin.settlementGenerate") }}
|
||||
</VBtn>
|
||||
<p class="text-muted mt-3 text-sm">{{ $t("admin.settlementGenerateHint") }}</p>
|
||||
|
||||
<VPanel v-if="generatedStatement" class="mt-4" :title="$t('admin.settlementGeneratedResult')">
|
||||
<template #actions>
|
||||
<VBadge :tone="settlementStatusTone(generatedStatement.status)">
|
||||
{{ settlementStatusLabel(generatedStatement.status) }}
|
||||
</VBadge>
|
||||
</template>
|
||||
<dl class="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementShop") }}</dt>
|
||||
<dd>{{ shopLabel(generatedStatement) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementPeriod") }}</dt>
|
||||
<dd>{{ periodLabel(generatedStatement) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementOrderCount") }}</dt>
|
||||
<dd>{{ generatedStatement.order_count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementGross") }}</dt>
|
||||
<dd>{{ money(generatedStatement.gross_minor, generatedStatement.currency) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementRefunds") }}</dt>
|
||||
<dd>{{ money(generatedStatement.refund_minor, generatedStatement.currency) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementCommission") }}</dt>
|
||||
<dd>
|
||||
{{
|
||||
$t("admin.commissionRateDisplay", {
|
||||
bps: generatedStatement.commission_rate_bps,
|
||||
percent: ratePercent(generatedStatement.commission_rate_bps),
|
||||
})
|
||||
}}
|
||||
·
|
||||
{{ money(generatedStatement.commission_minor, generatedStatement.currency) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
<dt class="text-muted">{{ $t("admin.settlementPayable") }}</dt>
|
||||
<dd>{{ money(generatedStatement.payable_minor, generatedStatement.currency) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</VPanel>
|
||||
</VCard>
|
||||
|
||||
<VCard class="mb-5">
|
||||
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.settlementListTitle") }}</h2>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="grid gap-1 text-sm font-medium" for="settlement-filter-shop">
|
||||
{{ $t("admin.settlementShop") }}
|
||||
<select
|
||||
id="settlement-filter-shop"
|
||||
v-model="shopFilter"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
|
||||
@change="applyFilters"
|
||||
>
|
||||
<option value="">{{ $t("common.all") }}</option>
|
||||
<option v-for="shop in shops" :key="shop.id" :value="shop.id">
|
||||
{{ shopOptionLabel(shop) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium" for="settlement-filter-status">
|
||||
{{ $t("common.status") }}
|
||||
<select
|
||||
id="settlement-filter-status"
|
||||
v-model="statusFilter"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
|
||||
@change="applyFilters"
|
||||
>
|
||||
<option value="">{{ $t("common.all") }}</option>
|
||||
<option v-for="status in settlementStatuses" :key="status" :value="status">
|
||||
{{ settlementStatusLabel(status) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<VBtn size="sm" :disabled="loading" @click="loadStatements">
|
||||
{{ $t("admin.refresh") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="listError" class="text-danger my-2 text-sm" role="alert">{{ listError }}</p>
|
||||
<VCard v-else-if="statements.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<div class="min-w-[1180px]">
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("admin.settlementPeriod") }}</th>
|
||||
<th>{{ $t("admin.settlementShop") }}</th>
|
||||
<th>{{ $t("admin.settlementOrderCount") }}</th>
|
||||
<th>{{ $t("admin.settlementGross") }}</th>
|
||||
<th>{{ $t("admin.settlementRefunds") }}</th>
|
||||
<th>{{ $t("admin.settlementCommission") }}</th>
|
||||
<th>{{ $t("admin.settlementPayable") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="row in statements" :key="row.id">
|
||||
<tr :class="openId === row.id ? 'bg-primary-soft/30' : ''">
|
||||
<td>
|
||||
<span class="block text-sm">{{ periodLabel(row) }}</span>
|
||||
<code class="text-muted text-xs" :title="row.id">{{ shortId(row.id) }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="block">{{ shopLabel(row) }}</span>
|
||||
<code class="text-muted text-xs" :title="row.shop_id">{{
|
||||
shortId(row.shop_id)
|
||||
}}</code>
|
||||
</td>
|
||||
<td>{{ row.order_count }}</td>
|
||||
<td>{{ money(row.gross_minor, row.currency) }}</td>
|
||||
<td>{{ money(row.refund_minor, row.currency) }}</td>
|
||||
<td>
|
||||
<span class="block text-sm">{{
|
||||
$t("admin.commissionRateDisplay", {
|
||||
bps: row.commission_rate_bps,
|
||||
percent: ratePercent(row.commission_rate_bps),
|
||||
})
|
||||
}}</span>
|
||||
<span class="text-muted block text-xs">{{
|
||||
money(row.commission_minor, row.currency)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="font-semibold">{{ money(row.payable_minor, row.currency) }}</td>
|
||||
<td>
|
||||
<VBadge :tone="settlementStatusTone(row.status)">{{
|
||||
settlementStatusLabel(row.status)
|
||||
}}</VBadge>
|
||||
</td>
|
||||
<td>
|
||||
<VBtn size="sm" @click="toggleDetail(row.id)">
|
||||
{{
|
||||
openId === row.id ? $t("admin.settlementClose") : $t("admin.settlementDetail")
|
||||
}}
|
||||
</VBtn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="openId === row.id" class="bg-bg">
|
||||
<td colspan="9" class="p-0">
|
||||
<div class="p-4">
|
||||
<p v-if="detailLoading" class="text-muted text-sm">
|
||||
{{ $t("common.loading") }}
|
||||
</p>
|
||||
<p v-else-if="detailError" class="text-danger text-sm" role="alert">
|
||||
{{ detailError }}
|
||||
</p>
|
||||
<VPanel v-else-if="detail" :title="$t('admin.settlementDetailTitle')">
|
||||
<template #actions>
|
||||
<VBadge :tone="settlementStatusTone(detail.status)">
|
||||
{{ settlementStatusLabel(detail.status) }}
|
||||
</VBadge>
|
||||
</template>
|
||||
|
||||
<h4 class="mb-2 text-sm font-semibold">
|
||||
{{ $t("admin.settlementSnapshot") }}
|
||||
</h4>
|
||||
<dl class="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementShop") }}</dt>
|
||||
<dd>{{ shopLabel(detail) }} · {{ detail.shop_id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementPeriod") }}</dt>
|
||||
<dd>{{ periodLabel(detail) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("common.currency") }}</dt>
|
||||
<dd>{{ detail.currency }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementOrderCount") }}</dt>
|
||||
<dd>{{ detail.order_count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementGross") }}</dt>
|
||||
<dd>{{ money(detail.gross_minor, detail.currency) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementRefunds") }}</dt>
|
||||
<dd>{{ money(detail.refund_minor, detail.currency) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementCommission") }}</dt>
|
||||
<dd>
|
||||
{{
|
||||
$t("admin.commissionRateDisplay", {
|
||||
bps: detail.commission_rate_bps,
|
||||
percent: ratePercent(detail.commission_rate_bps),
|
||||
})
|
||||
}}
|
||||
· {{ money(detail.commission_minor, detail.currency) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
<dt class="text-muted">{{ $t("admin.settlementPayable") }}</dt>
|
||||
<dd>{{ money(detail.payable_minor, detail.currency) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementConfirmedAt") }}</dt>
|
||||
<dd>{{ formatDate(detail.confirmed_at) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.created") }}</dt>
|
||||
<dd>{{ formatDate(detail.created_at) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.settlementUpdated") }}</dt>
|
||||
<dd>{{ formatDate(detail.updated_at) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h4 class="mt-5 mb-2 text-sm font-semibold">
|
||||
{{ $t("admin.settlementOrders") }}
|
||||
</h4>
|
||||
<p v-if="detail.orders.length === 0" class="text-muted text-sm">
|
||||
{{ $t("admin.settlementNoOrders") }}
|
||||
</p>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<div class="min-w-[720px]">
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("admin.settlementOrderNo") }}</th>
|
||||
<th>{{ $t("admin.settlementOrderCurrency") }}</th>
|
||||
<th>{{ $t("admin.settlementGross") }}</th>
|
||||
<th>{{ $t("admin.settlementRefunds") }}</th>
|
||||
<th>{{ $t("admin.created") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="line in detail.orders" :key="line.order_id">
|
||||
<td>{{ line.order_no }}</td>
|
||||
<td>{{ line.order_currency }}</td>
|
||||
<td>{{ money(line.gross_minor, detail.currency) }}</td>
|
||||
<td>{{ money(line.refund_minor, detail.currency) }}</td>
|
||||
<td>{{ formatDate(line.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-border mt-4 border-t pt-4">
|
||||
<template v-if="detail.status === 'pending'">
|
||||
<p class="text-muted mb-3 text-sm">
|
||||
{{ $t("admin.settlementConfirmPayoutHint") }}
|
||||
</p>
|
||||
<VBtn
|
||||
variant="primary"
|
||||
:disabled="actingId === detail.id"
|
||||
@click="confirmPayout(detail)"
|
||||
>
|
||||
{{
|
||||
actingId === detail.id
|
||||
? $t("common.loading")
|
||||
: $t("admin.settlementConfirmPayout")
|
||||
}}
|
||||
</VBtn>
|
||||
</template>
|
||||
<p v-else class="text-success text-sm">
|
||||
{{ $t("admin.settlementPayoutConfirmed") }}
|
||||
</p>
|
||||
</div>
|
||||
</VPanel>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!loading && !listError && statements.length > 0"
|
||||
class="mt-4 flex items-center justify-center gap-3"
|
||||
>
|
||||
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{
|
||||
$t("common.prev")
|
||||
}}</VBtn>
|
||||
<span class="text-muted text-sm">{{ page }} / {{ totalPages }}</span>
|
||||
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{
|
||||
$t("common.next")
|
||||
}}</VBtn>
|
||||
</div>
|
||||
</VPage>
|
||||
</template>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError, formatMoney } from "@vmall/shared";
|
||||
import type {
|
||||
Currency,
|
||||
WalletWithdrawal,
|
||||
WithdrawalReviewOutcome,
|
||||
WithdrawalStatus,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
const withdrawals = ref<WalletWithdrawal[]>([]);
|
||||
const currencies = ref<Currency[]>([]);
|
||||
const statusFilter = ref<"" | WithdrawalStatus>("");
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
const notice = ref("");
|
||||
const conflictMessage = ref("");
|
||||
const actingId = ref<string | null>(null);
|
||||
|
||||
const reviewId = ref<string | null>(null);
|
||||
const reviewOutcome = ref<WithdrawalReviewOutcome>("approve");
|
||||
const reviewNote = ref("");
|
||||
|
||||
const statuses: WithdrawalStatus[] = ["pending", "approved", "rejected"];
|
||||
|
||||
function currencyExponent(code: string): number | undefined {
|
||||
return currencies.value.find((currency) => currency.code === code)?.exponent;
|
||||
}
|
||||
|
||||
function money(amountMinor: number, currency: string): string {
|
||||
const exponent = currencyExponent(currency);
|
||||
return exponent === undefined ? "—" : formatMoney(amountMinor, currency, exponent, locale.value);
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function shortId(value: string): string {
|
||||
return value.slice(0, 8);
|
||||
}
|
||||
|
||||
function statusTone(status: WithdrawalStatus): "green" | "red" | "orange" {
|
||||
if (status === "approved") return "green";
|
||||
if (status === "rejected") return "red";
|
||||
return "orange";
|
||||
}
|
||||
|
||||
function statusLabel(status: WithdrawalStatus): string {
|
||||
return t(`admin.withdrawalStatuses.${status}`);
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : t("common.error");
|
||||
return error instanceof ApiError && error.status === 409
|
||||
? `${t("admin.withdrawalConflict")}: ${message}`
|
||||
: message;
|
||||
}
|
||||
|
||||
function startReview(row: WalletWithdrawal, outcome: WithdrawalReviewOutcome): void {
|
||||
reviewId.value = row.id;
|
||||
reviewOutcome.value = outcome;
|
||||
reviewNote.value = "";
|
||||
errorMessage.value = "";
|
||||
conflictMessage.value = "";
|
||||
notice.value = "";
|
||||
}
|
||||
|
||||
function cancelReview(): void {
|
||||
reviewId.value = null;
|
||||
reviewNote.value = "";
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const [rows, currencyList] = await Promise.all([
|
||||
$api.admin.listWithdrawalApplications(statusFilter.value || undefined),
|
||||
$api.admin.listCurrencies(),
|
||||
]);
|
||||
withdrawals.value = rows;
|
||||
currencies.value = currencyList;
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = error instanceof Error ? error.message : t("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReview(row: WalletWithdrawal): Promise<void> {
|
||||
const outcome = reviewOutcome.value;
|
||||
const confirmation =
|
||||
outcome === "approve"
|
||||
? t("admin.withdrawalConfirmApprove")
|
||||
: t("admin.withdrawalConfirmReject");
|
||||
if (!confirm(confirmation)) return;
|
||||
|
||||
actingId.value = row.id;
|
||||
errorMessage.value = "";
|
||||
conflictMessage.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
const note = reviewNote.value.trim();
|
||||
const updated = await $api.admin.reviewWithdrawal(row.id, outcome, note === "" ? null : note);
|
||||
cancelReview();
|
||||
notice.value =
|
||||
updated.status === "approved"
|
||||
? t("admin.withdrawalApproved")
|
||||
: t("admin.withdrawalRejected");
|
||||
} catch (error: unknown) {
|
||||
// A repeat review is a conflict: converge to server state instead of retrying.
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
conflictMessage.value = errorText(error);
|
||||
cancelReview();
|
||||
} else {
|
||||
errorMessage.value = error instanceof Error ? error.message : t("common.error");
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
actingId.value = null;
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('nav.withdrawals')">
|
||||
<VCard class="mb-5">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="grid gap-1 text-sm font-medium" for="withdrawal-status">
|
||||
{{ $t("common.status") }}
|
||||
<select
|
||||
id="withdrawal-status"
|
||||
v-model="statusFilter"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
|
||||
@change="load"
|
||||
>
|
||||
<option value="">{{ $t("common.all") }}</option>
|
||||
<option v-for="status in statuses" :key="status" :value="status">
|
||||
{{ statusLabel(status) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<VBtn size="sm" :disabled="loading" @click="load">{{ $t("admin.refresh") }}</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<p v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</p>
|
||||
<p
|
||||
v-if="conflictMessage"
|
||||
class="bg-warning/10 text-warning my-2 rounded-md px-3 py-2 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{{ conflictMessage }}
|
||||
</p>
|
||||
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="errorMessage" class="text-danger my-2 text-sm" role="alert">{{ errorMessage }}</p>
|
||||
<VCard v-else-if="withdrawals.length === 0" class="text-muted">
|
||||
{{ $t("common.empty") }}
|
||||
</VCard>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<div class="min-w-[1180px]">
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("admin.withdrawalId") }}</th>
|
||||
<th>{{ $t("admin.withdrawalBuyer") }}</th>
|
||||
<th>{{ $t("admin.withdrawalUser") }}</th>
|
||||
<th>{{ $t("admin.withdrawalAmount") }}</th>
|
||||
<th>{{ $t("admin.withdrawalPayout") }}</th>
|
||||
<th>{{ $t("admin.created") }}</th>
|
||||
<th>{{ $t("admin.withdrawalReviewedAt") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="row in withdrawals" :key="row.id">
|
||||
<tr :class="reviewId === row.id ? 'bg-primary-soft/30' : ''">
|
||||
<td>
|
||||
<code class="bg-bg rounded px-1.5 py-0.5" :title="row.id">{{
|
||||
shortId(row.id)
|
||||
}}</code>
|
||||
</td>
|
||||
<td>{{ row.user_email || "—" }}</td>
|
||||
<td>
|
||||
<code class="bg-bg rounded px-1.5 py-0.5" :title="row.user_id">{{
|
||||
shortId(row.user_id)
|
||||
}}</code>
|
||||
</td>
|
||||
<td>{{ money(row.amount_minor, row.currency) }}</td>
|
||||
<td>
|
||||
<span class="block text-sm">{{ row.account_details.method }}</span>
|
||||
<code class="text-muted text-xs">{{ row.account_details.account }}</code>
|
||||
<span v-if="row.account_details.holder" class="text-muted block text-xs">
|
||||
{{ row.account_details.holder }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(row.created_at) }}</td>
|
||||
<td>
|
||||
<span v-if="row.reviewed_at">{{ formatDate(row.reviewed_at) }}</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
<span
|
||||
v-if="row.review_note"
|
||||
class="text-muted block max-w-[200px] truncate text-xs"
|
||||
:title="row.review_note"
|
||||
>
|
||||
{{ row.review_note }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="row.status === 'pending'" class="flex gap-2">
|
||||
<VBtn
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:disabled="actingId === row.id"
|
||||
@click="startReview(row, 'approve')"
|
||||
>{{ $t("admin.withdrawalApprove") }}</VBtn
|
||||
>
|
||||
<VBtn
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:disabled="actingId === row.id"
|
||||
@click="startReview(row, 'reject')"
|
||||
>{{ $t("admin.withdrawalReject") }}</VBtn
|
||||
>
|
||||
</div>
|
||||
<span v-else class="text-muted text-xs">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="reviewId === row.id" class="bg-bg">
|
||||
<td colspan="9" class="p-0">
|
||||
<div class="p-4">
|
||||
<VPanel :title="$t('admin.withdrawalReview')">
|
||||
<template #actions>
|
||||
<VBadge :tone="reviewOutcome === 'approve' ? 'green' : 'red'">
|
||||
{{
|
||||
reviewOutcome === "approve"
|
||||
? $t("admin.withdrawalApprove")
|
||||
: $t("admin.withdrawalReject")
|
||||
}}
|
||||
</VBadge>
|
||||
<VBtn size="sm" class="ml-2" @click="cancelReview">{{
|
||||
$t("admin.withdrawalClose")
|
||||
}}</VBtn>
|
||||
</template>
|
||||
<dl class="mb-4 grid gap-1 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.withdrawalBuyer") }}</dt>
|
||||
<dd>{{ row.user_email || "—" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.withdrawalAmount") }}</dt>
|
||||
<dd>{{ money(row.amount_minor, row.currency) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">{{ $t("admin.withdrawalPayout") }}</dt>
|
||||
<dd>
|
||||
{{ row.account_details.method }} · {{ row.account_details.account }}
|
||||
<span v-if="row.account_details.holder">
|
||||
· {{ row.account_details.holder }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<VField :label="$t('admin.withdrawalNote')">
|
||||
<VInput
|
||||
id="withdrawal-note"
|
||||
v-model="reviewNote"
|
||||
:placeholder="$t('admin.withdrawalNotePlaceholder')"
|
||||
:disabled="actingId === row.id"
|
||||
/>
|
||||
</VField>
|
||||
<p class="text-muted mb-3 text-sm">
|
||||
{{
|
||||
reviewOutcome === "approve"
|
||||
? $t("admin.withdrawalConfirmApprove")
|
||||
: $t("admin.withdrawalConfirmReject")
|
||||
}}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<VBtn
|
||||
:variant="reviewOutcome === 'approve' ? 'primary' : 'danger'"
|
||||
:disabled="actingId === row.id"
|
||||
@click="submitReview(row)"
|
||||
>
|
||||
{{
|
||||
actingId === row.id
|
||||
? $t("common.loading")
|
||||
: reviewOutcome === "approve"
|
||||
? $t("admin.withdrawalApprove")
|
||||
: $t("admin.withdrawalReject")
|
||||
}}
|
||||
</VBtn>
|
||||
<VBtn :disabled="actingId === row.id" @click="cancelReview">
|
||||
{{ $t("common.cancel") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VPanel>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
</div>
|
||||
</VPage>
|
||||
</template>
|
||||
Reference in New Issue
Block a user