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:
@@ -27,6 +27,11 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScroll));
|
||||
<li>{{ t("shell.footer.v2") }}</li>
|
||||
<li>{{ t("shell.footer.v3") }}</li>
|
||||
<li>{{ t("shell.footer.v4") }}</li>
|
||||
<li>
|
||||
<NuxtLink to="/merchant/join" class="hover:text-primary">{{
|
||||
t("shell.footer.sellerJoin")
|
||||
}}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="text-muted pt-[30px] pb-5 text-[12px] leading-7">
|
||||
<p class="m-0">{{ t("shell.footer.copyright") }}</p>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
const { t, locale, locales, setLocale } = useI18n();
|
||||
const { currency } = usePrefs();
|
||||
const cart = useCartStore();
|
||||
const session = useSessionStore();
|
||||
const { unread, refresh: refreshUnread, setCount } = useUnreadMessages();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -28,8 +30,26 @@ onMounted(async () => {
|
||||
/* keep default */
|
||||
}
|
||||
void cart.refresh();
|
||||
if (session.isLoggedIn) void refreshUnread();
|
||||
});
|
||||
|
||||
// The session is restored from localStorage after hydration.
|
||||
watch(
|
||||
() => session.isLoggedIn,
|
||||
(loggedIn) => {
|
||||
if (loggedIn) void refreshUnread();
|
||||
else setCount(0);
|
||||
},
|
||||
);
|
||||
|
||||
// The shell outlives route changes, so page entry is what re-reads the count.
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (session.isLoggedIn) void refreshUnread();
|
||||
},
|
||||
);
|
||||
|
||||
const menuOpen = ref(false);
|
||||
const isHome = computed(() => route.path === "/");
|
||||
// The home page pins the category menu in the hero row instead, so the header
|
||||
@@ -86,6 +106,23 @@ const showMenu = computed(() => menuOpen.value && !isHome.value);
|
||||
>{{ cart.count }}</span
|
||||
>
|
||||
</NuxtLink>
|
||||
<!-- Anonymous shoppers have no message center, so this entry is
|
||||
client-only until the localStorage session is restored. -->
|
||||
<ClientOnly>
|
||||
<NuxtLink
|
||||
v-if="session.isLoggedIn"
|
||||
to="/user/messages"
|
||||
class="text-muted hover:text-primary relative no-underline transition-colors"
|
||||
:aria-label="t('messaging.unreadBadge', { count: unread })"
|
||||
>
|
||||
{{ t("messaging.title") }}
|
||||
<span
|
||||
v-if="unread > 0"
|
||||
class="bg-danger absolute -top-3 -right-3 rounded-sm px-1 text-[11px] leading-4 text-white"
|
||||
>{{ unread }}</span
|
||||
>
|
||||
</NuxtLink>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ const { t } = useI18n();
|
||||
</ClientOnly>
|
||||
<li class="text-border px-1">|</li>
|
||||
<li>
|
||||
<NuxtLink to="/stores" class="text-primary hover:text-primary-hover px-1">{{
|
||||
<NuxtLink to="/merchant/join" class="text-primary hover:text-primary-hover px-1">{{
|
||||
t("shell.sellerJoin")
|
||||
}}</NuxtLink>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The signed-in shopper's unread message count, shared between the shell badge
|
||||
* and the message center. Every read/mark-all/delete action refreshes the count
|
||||
* through `$api.getUnreadCount()` so the badge never drifts from the server.
|
||||
*/
|
||||
export function useUnreadMessages() {
|
||||
const { $api } = useNuxtApp();
|
||||
const session = useSessionStore();
|
||||
const unread = useState<number>("unread-messages", () => 0);
|
||||
|
||||
/** Anonymous shoppers have no message center, so the count stays at zero. */
|
||||
async function refresh(): Promise<void> {
|
||||
if (!session.isLoggedIn) {
|
||||
unread.value = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
unread.value = (await $api.getUnreadCount()).unread;
|
||||
} catch {
|
||||
unread.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Let a mutating page push the authoritative number without a second call. */
|
||||
function setCount(count: number): void {
|
||||
unread.value = Math.max(0, count);
|
||||
}
|
||||
|
||||
return { unread, refresh, setCount };
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import auth from "./locales/auth";
|
||||
import user from "./locales/user";
|
||||
import stores from "./locales/stores";
|
||||
import marketing from "./locales/marketing";
|
||||
import wallet from "./locales/wallet";
|
||||
import merchant from "./locales/merchant";
|
||||
import membership from "./locales/membership";
|
||||
import messaging from "./locales/messaging";
|
||||
|
||||
type Tree = Record<string, unknown>;
|
||||
|
||||
@@ -42,7 +46,22 @@ const legacyZh: Tree = {
|
||||
},
|
||||
};
|
||||
|
||||
const domains = [shell, home, search, product, cart, checkout, auth, user, stores, marketing];
|
||||
const domains = [
|
||||
shell,
|
||||
home,
|
||||
search,
|
||||
product,
|
||||
cart,
|
||||
checkout,
|
||||
auth,
|
||||
user,
|
||||
stores,
|
||||
marketing,
|
||||
wallet,
|
||||
merchant,
|
||||
membership,
|
||||
messaging,
|
||||
];
|
||||
|
||||
export const enExtra: Tree = domains.reduce<Tree>(
|
||||
(acc, m) => deepMerge(acc, m.en as Tree),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Buyer membership center: level, growth total, progress and growth history.
|
||||
export default {
|
||||
en: {
|
||||
membership: {
|
||||
title: "Membership",
|
||||
entry: "My level",
|
||||
loadFailed: "Unable to load your membership.",
|
||||
currentLevel: "Current level",
|
||||
benefits: "Level benefits",
|
||||
growthTotal: "Growth total",
|
||||
growthUnit: "growth",
|
||||
noLevel: "No level yet",
|
||||
noLevelHint: "Reach {threshold} {unit} to unlock {level}.",
|
||||
topLevel: "Top level reached",
|
||||
topLevelHint: "You are at the highest level — your benefits stay as they are.",
|
||||
progressTitle: "Progress to {level}",
|
||||
progressThreshold: "{current} / {threshold} {unit}",
|
||||
remaining: "{remaining} {unit} to go",
|
||||
historyTitle: "Growth history",
|
||||
historyEmpty: "No growth entries yet.",
|
||||
historyFailed: "Unable to load growth history.",
|
||||
historyDate: "Time",
|
||||
historyReason: "Reason",
|
||||
historyDelta: "Growth",
|
||||
historyTotal: "Total after",
|
||||
reason_order_complete: "Order completed",
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
membership: {
|
||||
title: "会员中心",
|
||||
entry: "我的等级",
|
||||
loadFailed: "会员信息加载失败。",
|
||||
currentLevel: "当前等级",
|
||||
benefits: "等级权益",
|
||||
growthTotal: "成长值",
|
||||
growthUnit: "成长值",
|
||||
noLevel: "暂无等级",
|
||||
noLevelHint: "再获得 {threshold} {unit} 即可解锁 {level}。",
|
||||
topLevel: "已是最高等级",
|
||||
topLevelHint: "您已达到最高等级,权益保持不变。",
|
||||
progressTitle: "距离 {level}",
|
||||
progressThreshold: "{current} / {threshold} {unit}",
|
||||
remaining: "还差 {remaining} {unit}",
|
||||
historyTitle: "成长值明细",
|
||||
historyEmpty: "暂无成长值记录。",
|
||||
historyFailed: "成长值明细加载失败。",
|
||||
historyDate: "时间",
|
||||
historyReason: "原因",
|
||||
historyDelta: "成长值",
|
||||
historyTotal: "变动后累计",
|
||||
reason_order_complete: "订单完成",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
// Merchant onboarding: application form and applicant status page.
|
||||
export default {
|
||||
en: {
|
||||
merchant: {
|
||||
joinTitle: "Sell on VMall",
|
||||
joinSubtitle:
|
||||
"Submit a merchant onboarding application. The platform team reviews it and provisions your shop and owner account.",
|
||||
stepKind: "Entity type",
|
||||
stepEntity: "Entity & categories",
|
||||
stepContact: "Contact & qualification",
|
||||
kindPersonal: "Individual",
|
||||
kindEnterprise: "Enterprise",
|
||||
kindHint: "Choose the entity type that will own the shop. Required fields change with it.",
|
||||
entityInfo: "Entity information",
|
||||
realName: "Legal name",
|
||||
realNamePlaceholder: "Name shown on the identity document",
|
||||
companyName: "Company name",
|
||||
companyNamePlaceholder: "Registered company name",
|
||||
businessLicenseNo: "Business license number",
|
||||
businessLicenseNoPlaceholder: "Unified social credit code",
|
||||
categories: "Operating categories",
|
||||
categoriesHint: "Select one or more categories from the published category tree.",
|
||||
categoriesLoadFailed: "Unable to load the category tree.",
|
||||
contactInfo: "Contact details",
|
||||
contactName: "Contact name",
|
||||
contactPhone: "Contact phone",
|
||||
contactEmail: "Contact email",
|
||||
contactAddress: "Contact address (optional)",
|
||||
contactNamePlaceholder: "Who should we contact?",
|
||||
contactPhonePlaceholder: "Phone number with area code",
|
||||
contactEmailPlaceholder: "you{'@'}example.com",
|
||||
contactAddressPlaceholder: "Street, city, region",
|
||||
qualification: "Qualification materials",
|
||||
qualificationHint: "Provide public http(s) URLs only; this MVP has no file upload.",
|
||||
identityDocumentUrl: "Identity document URL",
|
||||
identityDocumentUrlPlaceholder: "https://example.com/id.jpg",
|
||||
businessLicenseUrl: "Business license URL",
|
||||
businessLicenseUrlPlaceholder: "https://example.com/license.jpg",
|
||||
extraMaterials: "Extra material URLs (optional)",
|
||||
extraMaterialPlaceholder: "https://example.com/extra.jpg",
|
||||
extraMaterialAdd: "Add material URL",
|
||||
extraMaterialRemove: "Remove",
|
||||
previous: "Back",
|
||||
next: "Next",
|
||||
submit: "Submit application",
|
||||
submitting: "Submitting…",
|
||||
signInToSubmit: "Sign in or create an account to submit — your answers are kept.",
|
||||
restoredDraft: "Welcome back. Your saved answers were restored; review them and submit.",
|
||||
successTitle: "Application submitted",
|
||||
successBody:
|
||||
"Your merchant application is now pending review. You can track its state at any time.",
|
||||
viewStatus: "View application status",
|
||||
conflict:
|
||||
"You already have a pending or approved merchant application. Track it on the status page instead of submitting another.",
|
||||
invalid: "Please check the highlighted fields and try again.",
|
||||
submitFailed: "Unable to submit the application. Please try again.",
|
||||
errorRequired: "This field is required.",
|
||||
errorEmail: "Enter a valid email address.",
|
||||
errorUrl: "Enter a valid http(s) URL.",
|
||||
errorCategories: "Select at least one operating category.",
|
||||
statusTitle: "Merchant application status",
|
||||
statusSubtitle: "The latest merchant application submitted by your account.",
|
||||
statusLoadFailed: "Unable to load your merchant applications.",
|
||||
noApplication: "You have not submitted a merchant application yet.",
|
||||
startApplication: "Start an application",
|
||||
status_pending: "Pending review",
|
||||
status_approved: "Approved",
|
||||
status_rejected: "Rejected",
|
||||
applicationId: "Application ID",
|
||||
entityKind: "Entity type",
|
||||
submittedAt: "Submitted",
|
||||
updatedAt: "Last updated",
|
||||
reviewedAt: "Reviewed",
|
||||
rejectionReason: "Rejection reason",
|
||||
createdShop: "Provisioned shop",
|
||||
reapply: "Apply again",
|
||||
historyTitle: "Application history",
|
||||
historyHint: "Earlier applications from this account.",
|
||||
backToJoin: "Back to the application form",
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
merchant: {
|
||||
joinTitle: "商家入驻",
|
||||
joinSubtitle: "提交商家入驻申请,平台审核通过后将为您开通店铺和店主账号。",
|
||||
stepKind: "主体类型",
|
||||
stepEntity: "主体与经营类目",
|
||||
stepContact: "联系人与资质",
|
||||
kindPersonal: "个人",
|
||||
kindEnterprise: "企业",
|
||||
kindHint: "请选择店铺的经营主体类型,选择后需要填写的字段会随之变化。",
|
||||
entityInfo: "主体信息",
|
||||
realName: "真实姓名",
|
||||
realNamePlaceholder: "证件上的姓名",
|
||||
companyName: "企业名称",
|
||||
companyNamePlaceholder: "营业执照上的企业名称",
|
||||
businessLicenseNo: "营业执照号",
|
||||
businessLicenseNoPlaceholder: "统一社会信用代码",
|
||||
categories: "经营类目",
|
||||
categoriesHint: "请从已发布的类目树中选择一个或多个类目。",
|
||||
categoriesLoadFailed: "类目加载失败。",
|
||||
contactInfo: "联系信息",
|
||||
contactName: "联系人姓名",
|
||||
contactPhone: "联系电话",
|
||||
contactEmail: "联系邮箱",
|
||||
contactAddress: "联系地址(选填)",
|
||||
contactNamePlaceholder: "请填写联系人姓名",
|
||||
contactPhonePlaceholder: "请填写含区号的电话号码",
|
||||
contactEmailPlaceholder: "you{'@'}example.com",
|
||||
contactAddressPlaceholder: "省/市/区详细地址",
|
||||
qualification: "资质材料",
|
||||
qualificationHint: "仅支持填写公开的 http(s) 链接,本版本不提供文件上传。",
|
||||
identityDocumentUrl: "身份证件链接",
|
||||
identityDocumentUrlPlaceholder: "https://example.com/id.jpg",
|
||||
businessLicenseUrl: "营业执照链接",
|
||||
businessLicenseUrlPlaceholder: "https://example.com/license.jpg",
|
||||
extraMaterials: "其他材料链接(选填)",
|
||||
extraMaterialPlaceholder: "https://example.com/extra.jpg",
|
||||
extraMaterialAdd: "添加材料链接",
|
||||
extraMaterialRemove: "删除",
|
||||
previous: "上一步",
|
||||
next: "下一步",
|
||||
submit: "提交申请",
|
||||
submitting: "提交中…",
|
||||
signInToSubmit: "请先登录或注册后提交,已填写的内容会为您保留。",
|
||||
restoredDraft: "欢迎回来,已恢复您填写的申请内容,请确认后提交。",
|
||||
successTitle: "申请已提交",
|
||||
successBody: "您的入驻申请已提交,正在等待平台审核。您可以随时查看审核状态。",
|
||||
viewStatus: "查看申请状态",
|
||||
conflict: "您已有一条待审核或已通过的入驻申请,请前往状态页查看,无需重复提交。",
|
||||
invalid: "请检查标注的字段后重试。",
|
||||
submitFailed: "申请提交失败,请稍后重试。",
|
||||
errorRequired: "该项为必填。",
|
||||
errorEmail: "请填写有效的邮箱地址。",
|
||||
errorUrl: "请填写有效的 http(s) 链接。",
|
||||
errorCategories: "请至少选择一个经营类目。",
|
||||
statusTitle: "入驻申请状态",
|
||||
statusSubtitle: "您账号下最新提交的入驻申请。",
|
||||
statusLoadFailed: "入驻申请加载失败。",
|
||||
noApplication: "您还没有提交过入驻申请。",
|
||||
startApplication: "开始申请",
|
||||
status_pending: "待审核",
|
||||
status_approved: "已通过",
|
||||
status_rejected: "已拒绝",
|
||||
applicationId: "申请编号",
|
||||
entityKind: "主体类型",
|
||||
submittedAt: "提交时间",
|
||||
updatedAt: "更新时间",
|
||||
reviewedAt: "审核时间",
|
||||
rejectionReason: "拒绝原因",
|
||||
createdShop: "已开通店铺",
|
||||
reapply: "重新申请",
|
||||
historyTitle: "申请记录",
|
||||
historyHint: "该账号更早的申请记录。",
|
||||
backToJoin: "返回入驻申请",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// Buyer message center: list, filters, read/delete actions and the unread badge.
|
||||
export default {
|
||||
en: {
|
||||
messaging: {
|
||||
title: "Messages",
|
||||
loadFailed: "Unable to load your messages.",
|
||||
listFailed: "Unable to load messages.",
|
||||
empty: "No messages yet.",
|
||||
emptyUnread: "No unread messages.",
|
||||
unreadOnly: "Unread only",
|
||||
unreadBadge: "{count} unread messages",
|
||||
markAllRead: "Mark all as read",
|
||||
markAllSuccess: "Marked {count} message(s) as read.",
|
||||
markAllFailed: "Unable to mark all messages as read.",
|
||||
markRead: "Mark as read",
|
||||
markReadFailed: "Unable to mark the message as read.",
|
||||
open: "Open",
|
||||
close: "Close",
|
||||
delete: "Delete",
|
||||
deleteConfirm: "Delete this message?",
|
||||
deleteFailed: "Unable to delete the message.",
|
||||
createdAt: "Time",
|
||||
kind_order_paid: "Payment received",
|
||||
kind_order_shipped: "Order shipped",
|
||||
kind_refund_completed: "Refund completed",
|
||||
statusUnread: "Unread",
|
||||
statusRead: "Read",
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
messaging: {
|
||||
title: "消息中心",
|
||||
loadFailed: "消息加载失败。",
|
||||
listFailed: "消息列表加载失败。",
|
||||
empty: "暂无消息。",
|
||||
emptyUnread: "暂无未读消息。",
|
||||
unreadOnly: "仅看未读",
|
||||
unreadBadge: "{count} 条未读消息",
|
||||
markAllRead: "全部标记为已读",
|
||||
markAllSuccess: "已将 {count} 条消息标记为已读。",
|
||||
markAllFailed: "全部标记已读失败。",
|
||||
markRead: "标记已读",
|
||||
markReadFailed: "标记已读失败。",
|
||||
open: "展开",
|
||||
close: "收起",
|
||||
delete: "删除",
|
||||
deleteConfirm: "确定删除这条消息吗?",
|
||||
deleteFailed: "删除消息失败。",
|
||||
createdAt: "时间",
|
||||
kind_order_paid: "付款成功",
|
||||
kind_order_shipped: "订单已发货",
|
||||
kind_refund_completed: "退款完成",
|
||||
statusUnread: "未读",
|
||||
statusRead: "已读",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -34,6 +34,7 @@ export default {
|
||||
v4: "448 service centers nationwide",
|
||||
copyright: "VMall Demo Mall — all content is fixed mock data.",
|
||||
icp: "Mock ICP 2026-0001",
|
||||
sellerJoin: "Become a Seller",
|
||||
},
|
||||
backToTop: "Top",
|
||||
},
|
||||
@@ -72,6 +73,7 @@ export default {
|
||||
v4: "448家维修网点 全国联保",
|
||||
copyright: "VMall 演示商城 —— 所有内容均为固定 mock 数据。",
|
||||
icp: "模拟备案号 2026-0001",
|
||||
sellerJoin: "成为商家",
|
||||
},
|
||||
backToTop: "顶部",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
export default {
|
||||
en: {
|
||||
wallet: {
|
||||
title: "My wallet",
|
||||
available: "Available balance",
|
||||
frozen: "Frozen funds",
|
||||
loadFailed: "Unable to load your wallet.",
|
||||
entriesTitle: "Fund entries",
|
||||
entriesEmpty: "No fund entries yet.",
|
||||
entriesFailed: "Unable to load fund entries.",
|
||||
entryDate: "Date",
|
||||
entryAccount: "Account",
|
||||
entryReason: "Reason",
|
||||
entryDelta: "Change",
|
||||
entryBalance: "Balance after",
|
||||
accountAvailable: "Available",
|
||||
accountFrozen: "Frozen",
|
||||
rechargeTitle: "Recharge",
|
||||
rechargeDemoBadge: "Simulated · demo",
|
||||
rechargeDemoNote:
|
||||
"This is a demo recharge: no payment provider is contacted and no real money moves.",
|
||||
rechargeAmount: "Recharge amount ({currency})",
|
||||
rechargeAmountHint:
|
||||
"Enter a major-unit amount in {currency}; it is credited to your available balance immediately.",
|
||||
rechargeSubmit: "Simulate recharge",
|
||||
rechargeSuccess: "Simulated recharge credited: {amount}.",
|
||||
rechargeFailed: "Recharge failed. Please try again.",
|
||||
rechargeConflict:
|
||||
"The recharge conflicted with the current account state, so it was not applied. Balances have been refreshed.",
|
||||
withdrawTitle: "Withdraw funds",
|
||||
withdrawHint:
|
||||
"A withdrawal leaves your available balance immediately and is held as frozen funds until the platform reviews the request.",
|
||||
withdrawAmount: "Withdrawal amount ({currency})",
|
||||
withdrawAmountHint: "It cannot exceed the available balance.",
|
||||
withdrawMethod: "Payout method",
|
||||
withdrawMethodBank: "Bank transfer",
|
||||
withdrawMethodAlipay: "Alipay",
|
||||
withdrawMethodWechat: "WeChat Pay",
|
||||
withdrawAccount: "Payout account",
|
||||
withdrawAccountHint: "Bank account number or payment handle",
|
||||
withdrawHolder: "Account holder (optional)",
|
||||
withdrawSubmit: "Submit withdrawal request",
|
||||
withdrawSuccess: "Withdrawal request submitted; the amount is now frozen and pending review.",
|
||||
withdrawConflict:
|
||||
"Insufficient available balance: a withdrawal freezes the full amount, so this request was rejected as a conflict. The balances below show the current server state.",
|
||||
withdrawFailed: "Unable to submit the withdrawal request.",
|
||||
validationRequired: "Enter a positive amount, a payout method and a payout account.",
|
||||
historyTitle: "Withdrawal requests",
|
||||
historyEmpty: "No withdrawal requests yet.",
|
||||
historyCreatedAt: "Requested",
|
||||
historyAmount: "Amount",
|
||||
historyMethod: "Method",
|
||||
historyAccount: "Payout account",
|
||||
historyStatus: "Status",
|
||||
historyNote: "Review note",
|
||||
historyReviewedAt: "Reviewed",
|
||||
status_pending: "Pending review",
|
||||
status_approved: "Approved",
|
||||
status_rejected: "Rejected",
|
||||
reason_wallet_recharge: "Simulated recharge",
|
||||
reason_wallet_withdrawal_freeze: "Withdrawal held",
|
||||
reason_wallet_withdrawal_approved: "Withdrawal paid out",
|
||||
reason_wallet_withdrawal_rejected: "Withdrawal returned",
|
||||
reason_order_payment: "Order payment",
|
||||
reason_aftersale_refund: "After-sale refund",
|
||||
reason_settlement_payout: "Settlement payout",
|
||||
reason_opening_balance: "Opening balance",
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
wallet: {
|
||||
title: "我的钱包",
|
||||
available: "可用余额",
|
||||
frozen: "冻结资金",
|
||||
loadFailed: "钱包数据加载失败。",
|
||||
entriesTitle: "资金明细",
|
||||
entriesEmpty: "暂无资金明细。",
|
||||
entriesFailed: "资金明细加载失败。",
|
||||
entryDate: "时间",
|
||||
entryAccount: "账户",
|
||||
entryReason: "原因",
|
||||
entryDelta: "变动",
|
||||
entryBalance: "变动后余额",
|
||||
accountAvailable: "可用",
|
||||
accountFrozen: "冻结",
|
||||
rechargeTitle: "充值",
|
||||
rechargeDemoBadge: "模拟 · 演示",
|
||||
rechargeDemoNote: "这是模拟充值:不会接入任何支付渠道,也不会发生真实资金流转。",
|
||||
rechargeAmount: "充值金额({currency})",
|
||||
rechargeAmountHint: "请输入 {currency} 主单位金额,将立即计入可用余额。",
|
||||
rechargeSubmit: "模拟充值",
|
||||
rechargeSuccess: "模拟充值已到账:{amount}。",
|
||||
rechargeFailed: "充值失败,请重试。",
|
||||
rechargeConflict: "充值因账户状态冲突未生效,余额已重新加载。",
|
||||
withdrawTitle: "申请提现",
|
||||
withdrawHint: "提现会立即从可用余额中扣除并计为冻结资金,等待平台审核。",
|
||||
withdrawAmount: "提现金额({currency})",
|
||||
withdrawAmountHint: "不得超过可用余额。",
|
||||
withdrawMethod: "提现方式",
|
||||
withdrawMethodBank: "银行转账",
|
||||
withdrawMethodAlipay: "支付宝",
|
||||
withdrawMethodWechat: "微信支付",
|
||||
withdrawAccount: "收款账户",
|
||||
withdrawAccountHint: "银行卡号或收款账号",
|
||||
withdrawHolder: "开户人(选填)",
|
||||
withdrawSubmit: "提交提现申请",
|
||||
withdrawSuccess: "提现申请已提交,金额已冻结并等待审核。",
|
||||
withdrawConflict:
|
||||
"可用余额不足:提现会冻结全额资金,因此本次申请被拒绝(冲突)。下方余额为服务器当前状态。",
|
||||
withdrawFailed: "提现申请提交失败。",
|
||||
validationRequired: "请填写正确的金额、提现方式与收款账户。",
|
||||
historyTitle: "提现记录",
|
||||
historyEmpty: "暂无提现申请。",
|
||||
historyCreatedAt: "申请时间",
|
||||
historyAmount: "金额",
|
||||
historyMethod: "方式",
|
||||
historyAccount: "收款账户",
|
||||
historyStatus: "状态",
|
||||
historyNote: "审核备注",
|
||||
historyReviewedAt: "审核时间",
|
||||
status_pending: "待审核",
|
||||
status_approved: "已通过",
|
||||
status_rejected: "已拒绝",
|
||||
reason_wallet_recharge: "模拟充值",
|
||||
reason_wallet_withdrawal_freeze: "提现冻结",
|
||||
reason_wallet_withdrawal_approved: "提现已打款",
|
||||
reason_wallet_withdrawal_rejected: "提现已退回",
|
||||
reason_order_payment: "订单支付",
|
||||
reason_aftersale_refund: "售后退款",
|
||||
reason_settlement_payout: "结算打款",
|
||||
reason_opening_balance: "期初余额",
|
||||
},
|
||||
},
|
||||
};
|
||||
+961
-2
File diff suppressed because it is too large
Load Diff
@@ -907,7 +907,7 @@ export const MOCK_QUICK_LINKS: MockQuickLink[] = [
|
||||
{ label: L("Group Buy", "优惠团购"), url: "/collective", glyph: GLYPHS.gift },
|
||||
{ label: L("Flash Sale", "秒杀活动"), url: "/seckill", glyph: GLYPHS.bolt },
|
||||
{ label: L("Notice", "商城公告"), url: "/user", glyph: GLYPHS.bell },
|
||||
{ label: L("Become a Seller", "入驻商家"), url: "/stores", glyph: GLYPHS.shop },
|
||||
{ label: L("Become a Seller", "入驻商家"), url: "/merchant/join", glyph: GLYPHS.shop },
|
||||
];
|
||||
|
||||
export const MOCK_PROMOS: MockPromo[] = [
|
||||
|
||||
@@ -32,6 +32,10 @@ export default defineNuxtConfig({
|
||||
"favorites",
|
||||
"aftersales",
|
||||
"reviews",
|
||||
"wallet",
|
||||
"membership",
|
||||
"messaging",
|
||||
"merchantOnboarding",
|
||||
],
|
||||
appName: "mall",
|
||||
},
|
||||
|
||||
@@ -24,6 +24,12 @@ const redirectTarget = computed((): string => {
|
||||
: "/";
|
||||
});
|
||||
|
||||
/** Carry the return path into registration so both entry points return here. */
|
||||
const registerLink = computed(() => ({
|
||||
path: "/register",
|
||||
query: redirectTarget.value === "/" ? {} : { redirect: redirectTarget.value },
|
||||
}));
|
||||
|
||||
function validate(): boolean {
|
||||
if (!email.value || !password.value) {
|
||||
errorKey.value = "auth.validationRequired";
|
||||
@@ -62,7 +68,7 @@ async function submit(): Promise<void> {
|
||||
<VCard class="mx-auto my-10 max-w-[420px] p-6 sm:p-10">
|
||||
<div class="border-border mb-7 flex items-baseline gap-4 border-b pb-3.5">
|
||||
<h1 class="m-0 text-xl font-semibold">{{ t("auth.loginTab") }}</h1>
|
||||
<NuxtLink class="text-primary hover:text-primary-hover text-sm" to="/register">{{
|
||||
<NuxtLink class="text-primary hover:text-primary-hover text-sm" :to="registerLink">{{
|
||||
t("auth.registerTab")
|
||||
}}</NuxtLink>
|
||||
</div>
|
||||
@@ -90,7 +96,7 @@ async function submit(): Promise<void> {
|
||||
</form>
|
||||
<div class="text-muted mt-5 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span>{{ t("auth.noAccount") }}</span>
|
||||
<NuxtLink class="text-primary hover:text-primary-hover" to="/register">{{
|
||||
<NuxtLink class="text-primary hover:text-primary-hover" :to="registerLink">{{
|
||||
t("auth.registerNow")
|
||||
}}</NuxtLink>
|
||||
<NuxtLink class="text-primary hover:text-primary-hover" to="/forgot-password">{{
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError, t as pick } from "@vmall/shared";
|
||||
import type {
|
||||
Category,
|
||||
LocalizedText,
|
||||
MerchantApplicationInput,
|
||||
MerchantContact,
|
||||
MerchantEntityType,
|
||||
} from "@vmall/shared";
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const session = useSessionStore();
|
||||
const route = useRoute();
|
||||
|
||||
const STEPS = ["merchant.stepKind", "merchant.stepEntity", "merchant.stepContact"];
|
||||
const DRAFT_KEY = "vmall.merchant.draft";
|
||||
const EMAIL_RE = /^\S+@\S+\.\S+$/;
|
||||
const ENTITY_KINDS: MerchantEntityType[] = ["personal", "enterprise"];
|
||||
|
||||
const step = ref(0);
|
||||
const entityType = ref<MerchantEntityType>("personal");
|
||||
const realName = ref("");
|
||||
const companyName = ref("");
|
||||
const businessLicenseNo = ref("");
|
||||
const categoryIds = ref<string[]>([]);
|
||||
const contactName = ref("");
|
||||
const contactPhone = ref("");
|
||||
const contactEmail = ref("");
|
||||
const contactAddress = ref("");
|
||||
const identityDocumentUrl = ref("");
|
||||
const businessLicenseUrl = ref("");
|
||||
const extraMaterials = ref<string[]>([""]);
|
||||
|
||||
const categories = ref<Category[]>([]);
|
||||
const categoriesFailed = ref(false);
|
||||
const loadingCategories = ref(true);
|
||||
const submitting = ref(false);
|
||||
const success = ref(false);
|
||||
const restoredDraft = ref(false);
|
||||
const conflictKey = ref("");
|
||||
const errorKey = ref("");
|
||||
|
||||
type DraftField =
|
||||
| "realName"
|
||||
| "companyName"
|
||||
| "businessLicenseNo"
|
||||
| "categoryIds"
|
||||
| "contactName"
|
||||
| "contactPhone"
|
||||
| "contactEmail"
|
||||
| "identityDocumentUrl"
|
||||
| "businessLicenseUrl"
|
||||
| "extraMaterials";
|
||||
|
||||
/** Values are i18n keys; the template resolves them so copy stays locale-aware. */
|
||||
const errors = ref<Partial<Record<DraftField, string>>>({});
|
||||
|
||||
const stepLabels = computed(() => STEPS.map((key) => t(key)));
|
||||
const isReapply = computed(() => route.query.reapply === "1");
|
||||
|
||||
interface CategoryOption {
|
||||
id: string;
|
||||
name: LocalizedText;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/** Flattened published tree, so any nesting depth renders as indented options. */
|
||||
const categoryOptions = computed<CategoryOption[]>(() => {
|
||||
const byParent = new Map<string | null, Category[]>();
|
||||
for (const category of categories.value) {
|
||||
const list = byParent.get(category.parent_id) ?? [];
|
||||
list.push(category);
|
||||
byParent.set(category.parent_id, list);
|
||||
}
|
||||
for (const list of byParent.values()) {
|
||||
list.sort((a, b) => a.position - b.position || a.slug.localeCompare(b.slug));
|
||||
}
|
||||
const out: CategoryOption[] = [];
|
||||
const walk = (parentId: string | null, depth: number): void => {
|
||||
for (const category of byParent.get(parentId) ?? []) {
|
||||
out.push({ id: category.id, name: category.name, depth });
|
||||
walk(category.id, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(null, 0);
|
||||
return out;
|
||||
});
|
||||
|
||||
function depthClass(depth: number): string {
|
||||
if (depth <= 0) return "";
|
||||
if (depth === 1) return "pl-4";
|
||||
return "pl-8";
|
||||
}
|
||||
|
||||
/** i18n key -> localized message, so VField renders nothing when a field is clean. */
|
||||
function fieldError(key: DraftField): string {
|
||||
const code = errors.value[key];
|
||||
return code ? t(code) : "";
|
||||
}
|
||||
|
||||
const selectedCategoryLabel = computed(() =>
|
||||
categoryOptions.value
|
||||
.filter((option) => categoryIds.value.includes(option.id))
|
||||
.map((option) => pick(option.name, locale.value))
|
||||
.join(", "),
|
||||
);
|
||||
|
||||
function toggleCategory(id: string): void {
|
||||
categoryIds.value = categoryIds.value.includes(id)
|
||||
? categoryIds.value.filter((entry) => entry !== id)
|
||||
: [...categoryIds.value, id];
|
||||
delete errors.value.categoryIds;
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return (
|
||||
(trimmed.startsWith("http://") || trimmed.startsWith("https://")) &&
|
||||
trimmed.length > "https://".length &&
|
||||
!/\s/.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
function nonEmptyMaterials(): string[] {
|
||||
return extraMaterials.value.map((url) => url.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function validateEntity(): boolean {
|
||||
const next: Partial<Record<DraftField, string>> = {};
|
||||
if (entityType.value === "personal") {
|
||||
// Switching kind drops the other kind's errors so a hidden field cannot block.
|
||||
delete errors.value.companyName;
|
||||
delete errors.value.businessLicenseNo;
|
||||
if (!realName.value.trim()) next.realName = "merchant.errorRequired";
|
||||
} else {
|
||||
delete errors.value.realName;
|
||||
if (!companyName.value.trim()) next.companyName = "merchant.errorRequired";
|
||||
if (!businessLicenseNo.value.trim()) next.businessLicenseNo = "merchant.errorRequired";
|
||||
}
|
||||
if (categoryIds.value.length === 0) next.categoryIds = "merchant.errorCategories";
|
||||
errors.value = { ...errors.value, ...next };
|
||||
return Object.keys(next).length === 0;
|
||||
}
|
||||
|
||||
function validateContact(): boolean {
|
||||
const next: Partial<Record<DraftField, string>> = {};
|
||||
if (!contactName.value.trim()) next.contactName = "merchant.errorRequired";
|
||||
if (!contactPhone.value.trim()) next.contactPhone = "merchant.errorRequired";
|
||||
if (!contactEmail.value.trim()) next.contactEmail = "merchant.errorRequired";
|
||||
else if (!EMAIL_RE.test(contactEmail.value.trim())) next.contactEmail = "merchant.errorEmail";
|
||||
if (entityType.value === "personal") {
|
||||
delete errors.value.businessLicenseUrl;
|
||||
if (!identityDocumentUrl.value.trim()) {
|
||||
next.identityDocumentUrl = "merchant.errorRequired";
|
||||
} else if (!isHttpUrl(identityDocumentUrl.value)) {
|
||||
next.identityDocumentUrl = "merchant.errorUrl";
|
||||
}
|
||||
} else {
|
||||
delete errors.value.identityDocumentUrl;
|
||||
if (!businessLicenseUrl.value.trim()) {
|
||||
next.businessLicenseUrl = "merchant.errorRequired";
|
||||
} else if (!isHttpUrl(businessLicenseUrl.value)) {
|
||||
next.businessLicenseUrl = "merchant.errorUrl";
|
||||
}
|
||||
}
|
||||
if (nonEmptyMaterials().some((url) => !isHttpUrl(url))) {
|
||||
next.extraMaterials = "merchant.errorUrl";
|
||||
}
|
||||
errors.value = { ...errors.value, ...next };
|
||||
return Object.keys(next).length === 0;
|
||||
}
|
||||
|
||||
function next(): void {
|
||||
if (step.value === 0) {
|
||||
// The kind step has no required input; entity validation starts on step 1.
|
||||
step.value = 1;
|
||||
return;
|
||||
}
|
||||
if (!validateEntity()) return;
|
||||
step.value = Math.min(2, step.value + 1);
|
||||
}
|
||||
|
||||
function previous(): void {
|
||||
errorKey.value = "";
|
||||
conflictKey.value = "";
|
||||
step.value = Math.max(0, step.value - 1);
|
||||
}
|
||||
|
||||
function buildInput(): MerchantApplicationInput {
|
||||
const address = contactAddress.value.trim();
|
||||
const contact: MerchantContact = {
|
||||
name: contactName.value.trim(),
|
||||
phone: contactPhone.value.trim(),
|
||||
email: contactEmail.value.trim(),
|
||||
...(address ? { address } : {}),
|
||||
};
|
||||
const extra = nonEmptyMaterials();
|
||||
if (entityType.value === "personal") {
|
||||
return {
|
||||
entity_type: "personal",
|
||||
real_name: realName.value.trim(),
|
||||
category_ids: [...categoryIds.value],
|
||||
contact,
|
||||
qualification: {
|
||||
identity_document_url: identityDocumentUrl.value.trim(),
|
||||
...(extra.length ? { extra_materials: extra } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
entity_type: "enterprise",
|
||||
company_name: companyName.value.trim(),
|
||||
category_ids: [...categoryIds.value],
|
||||
contact,
|
||||
qualification: {
|
||||
business_license_url: businessLicenseUrl.value.trim(),
|
||||
business_license_no: businessLicenseNo.value.trim(),
|
||||
...(extra.length ? { extra_materials: extra } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface MerchantDraft {
|
||||
/** Restored step, so a signed-out submit returns to the completed form. */
|
||||
step: number;
|
||||
entityType: MerchantEntityType;
|
||||
realName: string;
|
||||
companyName: string;
|
||||
businessLicenseNo: string;
|
||||
categoryIds: string[];
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
contactEmail: string;
|
||||
contactAddress: string;
|
||||
identityDocumentUrl: string;
|
||||
businessLicenseUrl: string;
|
||||
extraMaterials: string[];
|
||||
}
|
||||
|
||||
function currentDraft(): MerchantDraft {
|
||||
return {
|
||||
step: step.value,
|
||||
entityType: entityType.value,
|
||||
realName: realName.value,
|
||||
companyName: companyName.value,
|
||||
businessLicenseNo: businessLicenseNo.value,
|
||||
categoryIds: [...categoryIds.value],
|
||||
contactName: contactName.value,
|
||||
contactPhone: contactPhone.value,
|
||||
contactEmail: contactEmail.value,
|
||||
contactAddress: contactAddress.value,
|
||||
identityDocumentUrl: identityDocumentUrl.value,
|
||||
businessLicenseUrl: businessLicenseUrl.value,
|
||||
extraMaterials: [...extraMaterials.value],
|
||||
};
|
||||
}
|
||||
|
||||
function applyDraft(draft: MerchantDraft): void {
|
||||
step.value = Math.min(2, Math.max(0, draft.step));
|
||||
entityType.value = draft.entityType;
|
||||
realName.value = draft.realName;
|
||||
companyName.value = draft.companyName;
|
||||
businessLicenseNo.value = draft.businessLicenseNo;
|
||||
categoryIds.value = [...draft.categoryIds];
|
||||
contactName.value = draft.contactName;
|
||||
contactPhone.value = draft.contactPhone;
|
||||
contactEmail.value = draft.contactEmail;
|
||||
contactAddress.value = draft.contactAddress;
|
||||
identityDocumentUrl.value = draft.identityDocumentUrl;
|
||||
businessLicenseUrl.value = draft.businessLicenseUrl;
|
||||
extraMaterials.value = draft.extraMaterials.length ? [...draft.extraMaterials] : [""];
|
||||
}
|
||||
|
||||
function saveDraft(): void {
|
||||
if (!import.meta.client) return;
|
||||
try {
|
||||
sessionStorage.setItem(DRAFT_KEY, JSON.stringify(currentDraft()));
|
||||
} catch {
|
||||
/* storage unavailable: the completed form stays in memory until sign-in */
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(): void {
|
||||
if (!import.meta.client) return;
|
||||
try {
|
||||
sessionStorage.removeItem(DRAFT_KEY);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function draftText(source: Record<string, unknown>, key: string): string {
|
||||
const value = source[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function draftList(source: Record<string, unknown>, key: string): string[] {
|
||||
const value = source[key];
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function draftNumber(source: Record<string, unknown>, key: string): number {
|
||||
const value = source[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function readDraft(): MerchantDraft | null {
|
||||
if (!import.meta.client) return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) return null;
|
||||
const kind = parsed.entityType;
|
||||
if (kind !== "personal" && kind !== "enterprise") return null;
|
||||
return {
|
||||
step: draftNumber(parsed, "step"),
|
||||
entityType: kind,
|
||||
realName: draftText(parsed, "realName"),
|
||||
companyName: draftText(parsed, "companyName"),
|
||||
businessLicenseNo: draftText(parsed, "businessLicenseNo"),
|
||||
categoryIds: draftList(parsed, "categoryIds"),
|
||||
contactName: draftText(parsed, "contactName"),
|
||||
contactPhone: draftText(parsed, "contactPhone"),
|
||||
contactEmail: draftText(parsed, "contactEmail"),
|
||||
contactAddress: draftText(parsed, "contactAddress"),
|
||||
identityDocumentUrl: draftText(parsed, "identityDocumentUrl"),
|
||||
businessLicenseUrl: draftText(parsed, "businessLicenseUrl"),
|
||||
extraMaterials: draftList(parsed, "extraMaterials"),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addMaterial(): void {
|
||||
extraMaterials.value.push("");
|
||||
}
|
||||
|
||||
function removeMaterial(index: number): void {
|
||||
if (extraMaterials.value.length === 1) {
|
||||
extraMaterials.value[0] = "";
|
||||
return;
|
||||
}
|
||||
extraMaterials.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function loadCategories(): Promise<void> {
|
||||
loadingCategories.value = true;
|
||||
categoriesFailed.value = false;
|
||||
try {
|
||||
categories.value = await $api.listCategories();
|
||||
} catch {
|
||||
categories.value = [];
|
||||
categoriesFailed.value = true;
|
||||
} finally {
|
||||
loadingCategories.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
errorKey.value = "";
|
||||
conflictKey.value = "";
|
||||
const entityOk = validateEntity();
|
||||
const contactOk = validateContact();
|
||||
if (!entityOk || !contactOk) {
|
||||
if (!entityOk) step.value = 1;
|
||||
errorKey.value = "merchant.invalid";
|
||||
return;
|
||||
}
|
||||
if (!session.isLoggedIn) {
|
||||
// Keep the completed form, sign in, then land back here to submit.
|
||||
saveDraft();
|
||||
await navigateTo(signInPath("/merchant/join"));
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await $api.submitMerchantApplication(buildInput());
|
||||
clearDraft();
|
||||
success.value = true;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
conflictKey.value = "merchant.conflict";
|
||||
} else if (error instanceof ApiError && error.status === 401) {
|
||||
saveDraft();
|
||||
await navigateTo(signInPath("/merchant/join"));
|
||||
} else if (error instanceof ApiError && error.status === 400) {
|
||||
errorKey.value = "merchant.invalid";
|
||||
} else {
|
||||
errorKey.value = "merchant.submitFailed";
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!session.token) session.hydrate();
|
||||
const restored = isReapply.value ? null : readDraft();
|
||||
if (isReapply.value) clearDraft();
|
||||
if (restored) {
|
||||
applyDraft(restored);
|
||||
restoredDraft.value = true;
|
||||
} else if (!contactEmail.value && session.user?.email) {
|
||||
contactEmail.value = session.user.email;
|
||||
}
|
||||
void loadCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-mall mx-auto px-4">
|
||||
<VPage :title="t('merchant.joinTitle')">
|
||||
<p class="text-muted -mt-2 mb-4 max-w-[760px] text-sm">{{ t("merchant.joinSubtitle") }}</p>
|
||||
|
||||
<VCard v-if="success" class="max-w-[640px]">
|
||||
<h2 class="text-text m-0 text-lg font-semibold">{{ t("merchant.successTitle") }}</h2>
|
||||
<p class="text-muted mt-2 text-sm">{{ t("merchant.successBody") }}</p>
|
||||
<NuxtLink class="text-primary hover:text-primary-hover mt-4 inline-block text-sm" to="/merchant/status">
|
||||
{{ t("merchant.viewStatus") }}
|
||||
</NuxtLink>
|
||||
</VCard>
|
||||
|
||||
<VCard v-else class="max-w-[760px]">
|
||||
<UiStepBar :steps="stepLabels" :active="step" />
|
||||
<p v-if="restoredDraft" role="status" class="text-success mb-3 text-sm">
|
||||
{{ t("merchant.restoredDraft") }}
|
||||
</p>
|
||||
|
||||
<!-- novalidate: our localized inline validation is authoritative, so the
|
||||
browser's native bubbles never pre-empt it. -->
|
||||
<form novalidate @submit.prevent="submit">
|
||||
<!-- Step 1: entity kind -->
|
||||
<fieldset v-if="step === 0" class="m-0 border-0 p-0">
|
||||
<legend class="text-text mb-2 text-base font-semibold">
|
||||
{{ t("merchant.stepKind") }}
|
||||
</legend>
|
||||
<p class="text-muted mb-3 text-xs">{{ t("merchant.kindHint") }}</p>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="kind in ENTITY_KINDS"
|
||||
:key="kind"
|
||||
class="border-border flex cursor-pointer items-center gap-3 rounded-md border p-4 text-sm"
|
||||
:class="entityType === kind ? 'border-primary bg-primary-soft' : ''"
|
||||
>
|
||||
<input v-model="entityType" type="radio" name="entityType" :value="kind" />
|
||||
<span class="text-text font-medium">{{
|
||||
kind === "personal" ? t("merchant.kindPersonal") : t("merchant.kindEnterprise")
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Step 2: entity information + operating categories -->
|
||||
<fieldset v-else-if="step === 1" class="m-0 border-0 p-0">
|
||||
<legend class="text-text mb-3 text-base font-semibold">
|
||||
{{ t("merchant.entityInfo") }}
|
||||
</legend>
|
||||
<template v-if="entityType === 'personal'">
|
||||
<VField
|
||||
:label="t('merchant.realName')"
|
||||
:error="fieldError('realName')"
|
||||
>
|
||||
<VInput
|
||||
v-model="realName"
|
||||
type="text"
|
||||
:placeholder="t('merchant.realNamePlaceholder')"
|
||||
/>
|
||||
</VField>
|
||||
</template>
|
||||
<template v-else>
|
||||
<VField
|
||||
:label="t('merchant.companyName')"
|
||||
:error="fieldError('companyName')"
|
||||
>
|
||||
<VInput
|
||||
v-model="companyName"
|
||||
type="text"
|
||||
:placeholder="t('merchant.companyNamePlaceholder')"
|
||||
/>
|
||||
</VField>
|
||||
<VField
|
||||
:label="t('merchant.businessLicenseNo')"
|
||||
:error="fieldError('businessLicenseNo')"
|
||||
>
|
||||
<VInput
|
||||
v-model="businessLicenseNo"
|
||||
type="text"
|
||||
:placeholder="t('merchant.businessLicenseNoPlaceholder')"
|
||||
/>
|
||||
</VField>
|
||||
</template>
|
||||
|
||||
<VField
|
||||
:label="t('merchant.categories')"
|
||||
:error="fieldError('categoryIds')"
|
||||
>
|
||||
<p class="text-muted mb-2 text-xs">{{ t("merchant.categoriesHint") }}</p>
|
||||
<div
|
||||
v-if="loadingCategories"
|
||||
class="border-border text-muted rounded-md border p-3 text-sm"
|
||||
>
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<p v-else-if="categoriesFailed" role="alert" class="text-danger m-0 text-sm">
|
||||
{{ t("merchant.categoriesLoadFailed") }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
class="border-border max-h-64 overflow-y-auto rounded-md border p-3"
|
||||
role="group"
|
||||
:aria-label="t('merchant.categories')"
|
||||
>
|
||||
<label
|
||||
v-for="option in categoryOptions"
|
||||
:key="option.id"
|
||||
class="flex cursor-pointer items-center gap-2 py-1 text-sm"
|
||||
:class="depthClass(option.depth)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="option.id"
|
||||
:checked="categoryIds.includes(option.id)"
|
||||
@change="toggleCategory(option.id)"
|
||||
/>
|
||||
<span class="text-text">{{ pick(option.name, locale) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</VField>
|
||||
</fieldset>
|
||||
|
||||
<!-- Step 3: contact details + qualification URLs -->
|
||||
<fieldset v-else class="m-0 border-0 p-0">
|
||||
<legend class="text-text mb-3 text-base font-semibold">
|
||||
{{ t("merchant.contactInfo") }}
|
||||
</legend>
|
||||
<VField
|
||||
:label="t('merchant.contactName')"
|
||||
:error="fieldError('contactName')"
|
||||
>
|
||||
<VInput
|
||||
v-model="contactName"
|
||||
type="text"
|
||||
:placeholder="t('merchant.contactNamePlaceholder')"
|
||||
/>
|
||||
</VField>
|
||||
<VField
|
||||
:label="t('merchant.contactPhone')"
|
||||
:error="fieldError('contactPhone')"
|
||||
>
|
||||
<VInput
|
||||
v-model="contactPhone"
|
||||
type="text"
|
||||
:placeholder="t('merchant.contactPhonePlaceholder')"
|
||||
/>
|
||||
</VField>
|
||||
<VField
|
||||
:label="t('merchant.contactEmail')"
|
||||
:error="fieldError('contactEmail')"
|
||||
>
|
||||
<VInput
|
||||
v-model.trim="contactEmail"
|
||||
type="email"
|
||||
:placeholder="t('merchant.contactEmailPlaceholder')"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="t('merchant.contactAddress')">
|
||||
<VInput
|
||||
v-model="contactAddress"
|
||||
type="text"
|
||||
:placeholder="t('merchant.contactAddressPlaceholder')"
|
||||
/>
|
||||
</VField>
|
||||
|
||||
<h3 class="text-text mt-5 mb-1 text-base font-semibold">
|
||||
{{ t("merchant.qualification") }}
|
||||
</h3>
|
||||
<p class="text-muted mb-3 text-xs">{{ t("merchant.qualificationHint") }}</p>
|
||||
<VField
|
||||
v-if="entityType === 'personal'"
|
||||
:label="t('merchant.identityDocumentUrl')"
|
||||
:error="fieldError('identityDocumentUrl')"
|
||||
>
|
||||
<VInput
|
||||
v-model="identityDocumentUrl"
|
||||
type="url"
|
||||
:placeholder="t('merchant.identityDocumentUrlPlaceholder')"
|
||||
/>
|
||||
</VField>
|
||||
<template v-else>
|
||||
<VField
|
||||
:label="t('merchant.businessLicenseUrl')"
|
||||
:error="fieldError('businessLicenseUrl')"
|
||||
>
|
||||
<VInput
|
||||
v-model="businessLicenseUrl"
|
||||
type="url"
|
||||
:placeholder="t('merchant.businessLicenseUrlPlaceholder')"
|
||||
/>
|
||||
</VField>
|
||||
</template>
|
||||
<VField
|
||||
:label="t('merchant.extraMaterials')"
|
||||
:error="fieldError('extraMaterials')"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<div v-for="(url, index) in extraMaterials" :key="index" class="flex gap-2">
|
||||
<VInput v-model="extraMaterials[index]" :placeholder="t('merchant.extraMaterialPlaceholder')" />
|
||||
<VBtn type="button" @click="removeMaterial(index)">
|
||||
{{ t("merchant.extraMaterialRemove") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<VBtn class="w-fit" type="button" @click="addMaterial">
|
||||
{{ t("merchant.extraMaterialAdd") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VField>
|
||||
|
||||
<VPanel class="mt-4" :title="t('merchant.stepEntity')">
|
||||
<dl class="text-muted m-0 grid gap-1 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<dt>{{ t("merchant.entityKind") }}:</dt>
|
||||
<dd class="text-text m-0">
|
||||
{{
|
||||
entityType === "personal"
|
||||
? t("merchant.kindPersonal")
|
||||
: t("merchant.kindEnterprise")
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="selectedCategoryLabel" class="flex gap-2">
|
||||
<dt>{{ t("merchant.categories") }}:</dt>
|
||||
<dd class="text-text m-0">{{ selectedCategoryLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</VPanel>
|
||||
</fieldset>
|
||||
|
||||
<p v-if="conflictKey" role="alert" class="text-danger mt-4 mb-0 text-sm">
|
||||
{{ t(conflictKey) }}
|
||||
</p>
|
||||
<p v-else-if="errorKey" role="alert" class="text-danger mt-4 mb-0 text-sm">
|
||||
{{ t(errorKey) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<VBtn v-if="step > 0" type="button" :disabled="submitting" @click="previous">
|
||||
{{ t("merchant.previous") }}
|
||||
</VBtn>
|
||||
<VBtn v-if="step < 2" variant="primary" type="button" @click="next">
|
||||
{{ t("merchant.next") }}
|
||||
</VBtn>
|
||||
<VBtn v-else variant="primary" type="submit" :disabled="submitting">
|
||||
{{ submitting ? t("merchant.submitting") : t("merchant.submit") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<p v-if="!session.isLoggedIn" class="text-muted mt-3 mb-0 text-xs">
|
||||
{{ t("merchant.signInToSubmit") }}
|
||||
</p>
|
||||
</form>
|
||||
</VCard>
|
||||
</VPage>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import type { MerchantApplication } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const rows = ref<MerchantApplication[]>([]);
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
|
||||
const latest = computed(() => rows.value[0] ?? null);
|
||||
const history = computed(() => rows.value.slice(1));
|
||||
|
||||
function statusTone(status: MerchantApplication["status"]): "green" | "orange" | "red" {
|
||||
if (status === "approved") return "green";
|
||||
if (status === "rejected") return "red";
|
||||
return "orange";
|
||||
}
|
||||
|
||||
function statusLabel(status: MerchantApplication["status"]): string {
|
||||
return t(`merchant.status_${status}`);
|
||||
}
|
||||
|
||||
function kindLabel(kind: MerchantApplication["entity_type"]): string {
|
||||
return kind === "personal" ? t("merchant.kindPersonal") : t("merchant.kindEnterprise");
|
||||
}
|
||||
|
||||
function entityName(row: MerchantApplication): string {
|
||||
return row.company_name ?? row.real_name ?? "";
|
||||
}
|
||||
|
||||
function categoryLabel(row: MerchantApplication): string {
|
||||
return row.categories.map((category) => pick(category.name, locale.value)).join(", ");
|
||||
}
|
||||
|
||||
function qualificationUrls(row: MerchantApplication): string[] {
|
||||
const urls = [
|
||||
row.qualification.identity_document_url,
|
||||
row.qualification.business_license_url,
|
||||
...(row.qualification.extra_materials ?? []),
|
||||
];
|
||||
return urls.filter((url): url is string => Boolean(url));
|
||||
}
|
||||
|
||||
function formatTime(value: string | null): string {
|
||||
return value ? new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US") : "";
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
rows.value = await $api.getMyMerchantApplications();
|
||||
} catch {
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reapply(): Promise<void> {
|
||||
await navigateTo("/merchant/join?reapply=1");
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-mall mx-auto px-4">
|
||||
<VPage :title="t('merchant.statusTitle')">
|
||||
<p class="text-muted -mt-2 mb-4 max-w-[760px] text-sm">{{ t("merchant.statusSubtitle") }}</p>
|
||||
|
||||
<VCard class="min-h-[420px] max-w-[760px]">
|
||||
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
|
||||
<p v-else-if="failed" role="alert" class="text-danger py-5 text-sm">
|
||||
{{ t("merchant.statusLoadFailed") }}
|
||||
</p>
|
||||
<div v-else-if="!latest" class="py-5">
|
||||
<UiEmptyState :text="t('merchant.noApplication')" />
|
||||
<div class="text-center">
|
||||
<NuxtLink
|
||||
class="text-primary hover:text-primary-hover text-sm"
|
||||
to="/merchant/join"
|
||||
>
|
||||
{{ t("merchant.startApplication") }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<VBadge :tone="statusTone(latest.status)">{{ statusLabel(latest.status) }}</VBadge>
|
||||
<span class="text-muted text-xs">{{ latest.applicant_email }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="grid gap-3 text-sm">
|
||||
<div class="grid gap-1">
|
||||
<dt class="text-muted text-xs">{{ t("merchant.applicationId") }}</dt>
|
||||
<dd class="text-text m-0 break-all">{{ latest.id }}</dd>
|
||||
</div>
|
||||
<div class="grid gap-1">
|
||||
<dt class="text-muted text-xs">{{ t("merchant.entityKind") }}</dt>
|
||||
<dd class="text-text m-0">
|
||||
{{ kindLabel(latest.entity_type) }}
|
||||
<span v-if="entityName(latest)"> · {{ entityName(latest) }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="latest.business_license_no" class="grid gap-1">
|
||||
<dt class="text-muted text-xs">{{ t("merchant.businessLicenseNo") }}</dt>
|
||||
<dd class="text-text m-0">{{ latest.business_license_no }}</dd>
|
||||
</div>
|
||||
<div class="grid gap-1">
|
||||
<dt class="text-muted text-xs">{{ t("merchant.categories") }}</dt>
|
||||
<dd class="text-text m-0">{{ categoryLabel(latest) }}</dd>
|
||||
</div>
|
||||
<div class="grid gap-1">
|
||||
<dt class="text-muted text-xs">{{ t("merchant.contactInfo") }}</dt>
|
||||
<dd class="text-text m-0">
|
||||
{{ latest.contact.name }} · {{ latest.contact.phone }} · {{ latest.contact.email }}
|
||||
<span v-if="latest.contact.address"> · {{ latest.contact.address }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid gap-1">
|
||||
<dt class="text-muted text-xs">{{ t("merchant.qualification") }}</dt>
|
||||
<dd class="m-0 grid gap-1">
|
||||
<a
|
||||
v-for="url in qualificationUrls(latest)"
|
||||
:key="url"
|
||||
class="text-primary hover:text-primary-hover break-all"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ url }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid gap-1">
|
||||
<dt class="text-muted text-xs">{{ t("merchant.submittedAt") }}</dt>
|
||||
<dd class="text-text m-0">{{ formatTime(latest.created_at) }}</dd>
|
||||
</div>
|
||||
<div class="grid gap-1">
|
||||
<dt class="text-muted text-xs">{{ t("merchant.updatedAt") }}</dt>
|
||||
<dd class="text-text m-0">{{ formatTime(latest.updated_at) }}</dd>
|
||||
</div>
|
||||
<div v-if="latest.reviewed_at" class="grid gap-1">
|
||||
<dt class="text-muted text-xs">{{ t("merchant.reviewedAt") }}</dt>
|
||||
<dd class="text-text m-0">{{ formatTime(latest.reviewed_at) }}</dd>
|
||||
</div>
|
||||
<div v-if="latest.created_shop_id" class="grid gap-1">
|
||||
<dt class="text-muted text-xs">{{ t("merchant.createdShop") }}</dt>
|
||||
<dd class="text-text m-0 break-all">{{ latest.created_shop_id }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
v-if="latest.status === 'rejected'"
|
||||
class="bg-danger/10 mt-5 rounded-md p-4"
|
||||
role="alert"
|
||||
>
|
||||
<p class="text-danger m-0 text-sm font-semibold">{{ t("merchant.rejectionReason") }}</p>
|
||||
<p class="text-text mt-1 mb-0 text-sm">{{ latest.rejection_reason }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-2">
|
||||
<VBtn v-if="latest.status === 'rejected'" variant="primary" type="button" @click="reapply">
|
||||
{{ t("merchant.reapply") }}
|
||||
</VBtn>
|
||||
<NuxtLink
|
||||
class="text-primary hover:text-primary-hover self-center text-sm"
|
||||
to="/merchant/join"
|
||||
>
|
||||
{{ t("merchant.backToJoin") }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</VCard>
|
||||
|
||||
<section v-if="history.length > 0" class="mt-6 max-w-[760px]">
|
||||
<h2 class="text-text mb-1 text-base font-semibold">{{ t("merchant.historyTitle") }}</h2>
|
||||
<p class="text-muted mb-3 text-xs">{{ t("merchant.historyHint") }}</p>
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("merchant.entityKind") }}</th>
|
||||
<th>{{ t("merchant.submittedAt") }}</th>
|
||||
<th>{{ t("common.status") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in history" :key="row.id">
|
||||
<td>{{ kindLabel(row.entity_type) }}</td>
|
||||
<td>{{ formatTime(row.created_at) }}</td>
|
||||
<td>
|
||||
<VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</section>
|
||||
</VPage>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ const { t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const session = useSessionStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const displayName = ref("");
|
||||
const email = ref("");
|
||||
@@ -13,6 +14,18 @@ const confirmPassword = ref("");
|
||||
const errorKey = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
/**
|
||||
* Where to go after registering. Same-origin absolute paths only, so a crafted
|
||||
* query cannot turn registration into an open redirect.
|
||||
*/
|
||||
const redirectTarget = computed((): string => {
|
||||
const raw = route.query.redirect;
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
|
||||
? value
|
||||
: "/";
|
||||
});
|
||||
|
||||
/** Mirrors the auth API's rule so a short password fails here, not as a 400. */
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
@@ -43,7 +56,7 @@ async function submit(): Promise<void> {
|
||||
try {
|
||||
const auth = await $api.register(email.value, password.value, displayName.value);
|
||||
session.setAuth(auth);
|
||||
await router.push("/");
|
||||
await router.push(redirectTarget.value);
|
||||
} catch (error) {
|
||||
errorKey.value =
|
||||
error instanceof ApiError && error.status === 409 ? "auth.emailTaken" : "auth.requestFailed";
|
||||
|
||||
@@ -14,12 +14,15 @@ const menuGroups = computed(() => [
|
||||
{ label: t("user.addresses"), to: "/user/addresses" },
|
||||
{ label: t("user.coupons"), to: "/user/coupons" },
|
||||
{ label: t("user.aftersalesTitle"), to: "/user/aftersales" },
|
||||
{ label: t("messaging.title"), to: "/user/messages" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("user.memberCenter"),
|
||||
items: [
|
||||
{ label: t("user.dashboard"), to: "/user" },
|
||||
{ label: t("membership.entry"), to: "/user/membership" },
|
||||
{ label: t("wallet.title"), to: "/user/wallet" },
|
||||
{ label: t("user.favorites"), to: "/user/favorites" },
|
||||
{ label: t("user.invoices"), to: "/user/invoices" },
|
||||
],
|
||||
|
||||
@@ -134,6 +134,23 @@ const statusLinks = computed(() => [
|
||||
><b class="text-primary text-lg font-semibold">{{ item.count }}</b></NuxtLink
|
||||
>
|
||||
</div>
|
||||
<nav class="border-border flex flex-wrap items-center gap-x-6 gap-y-2 border-t py-3">
|
||||
<NuxtLink
|
||||
class="text-primary hover:text-primary-hover text-sm no-underline"
|
||||
to="/user/wallet"
|
||||
>{{ t("wallet.title") }} ›</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
class="text-primary hover:text-primary-hover text-sm no-underline"
|
||||
to="/user/membership"
|
||||
>{{ t("membership.title") }} ›</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
class="text-primary hover:text-primary-hover text-sm no-underline"
|
||||
to="/user/messages"
|
||||
>{{ t("messaging.title") }} ›</NuxtLink
|
||||
>
|
||||
</nav>
|
||||
</VCard>
|
||||
|
||||
<VCard>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import type { GrowthLogEntry, MembershipStatus } from "@vmall/shared";
|
||||
import { t as localizedText } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
|
||||
const status = ref<MembershipStatus | null>(null);
|
||||
const logs = ref<GrowthLogEntry[]>([]);
|
||||
const logPage = ref(1);
|
||||
const logTotal = ref(0);
|
||||
const logPerPage = ref(20);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const logError = ref("");
|
||||
|
||||
const REASON_KEYS: Record<string, string> = {
|
||||
order_complete: "membership.reason_order_complete",
|
||||
};
|
||||
|
||||
function levelName(name: Record<string, string>): string {
|
||||
return localizedText(name, locale.value);
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string): string {
|
||||
const key = REASON_KEYS[reason];
|
||||
return key ? t(key) : reason;
|
||||
}
|
||||
|
||||
async function loadStatus(): Promise<void> {
|
||||
status.value = await $api.getMembership();
|
||||
}
|
||||
|
||||
async function loadLogs(page: number): Promise<void> {
|
||||
const result = await $api.listGrowthLogs(page);
|
||||
logs.value = result.items;
|
||||
logPage.value = result.page;
|
||||
logTotal.value = result.total;
|
||||
logPerPage.value = result.per_page;
|
||||
}
|
||||
|
||||
async function changeLogPage(page: number): Promise<void> {
|
||||
logError.value = "";
|
||||
try {
|
||||
await loadLogs(page);
|
||||
} catch {
|
||||
logError.value = t("membership.historyFailed");
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await Promise.all([loadStatus(), loadLogs(1)]);
|
||||
} catch {
|
||||
error.value = t("membership.loadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress inside the current band: from the level's own threshold to the next
|
||||
* one. Below the lowest threshold the band starts at zero. Growth is a plain
|
||||
* integer, so the percentage is integer arithmetic with one rounding.
|
||||
*/
|
||||
const progressPercent = computed<number | null>(() => {
|
||||
const current = status.value;
|
||||
if (!current || !current.next_level) return null;
|
||||
const base = current.level?.growth_threshold ?? 0;
|
||||
const span = current.next_level.growth_threshold - base;
|
||||
if (span <= 0) return null;
|
||||
const within = Math.min(Math.max(current.growth_total - base, 0), span);
|
||||
return Math.round((within / span) * 100);
|
||||
});
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<h1 class="text-text mb-4 text-xl font-bold">{{ t("membership.title") }}</h1>
|
||||
<p
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
|
||||
<p v-else-if="!status" class="text-muted py-10">{{ t("membership.loadFailed") }}</p>
|
||||
<template v-else>
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-2">
|
||||
<div class="border-border bg-primary-soft border p-[18px]">
|
||||
<span class="text-muted block text-xs">{{ t("membership.currentLevel") }}</span>
|
||||
<div v-if="status.level" class="mt-2.5 flex items-center gap-2.5">
|
||||
<span
|
||||
class="border-primary/40 text-primary flex h-9 w-9 items-center justify-center rounded-full border text-sm font-bold"
|
||||
>{{ status.level.icon }}</span
|
||||
>
|
||||
<strong class="text-primary text-xl">{{ levelName(status.level.name) }}</strong>
|
||||
</div>
|
||||
<strong v-else class="text-muted mt-2.5 block text-xl">{{
|
||||
t("membership.noLevel")
|
||||
}}</strong>
|
||||
<p v-if="status.level" class="text-muted mt-2 mb-0 text-xs">
|
||||
{{ t("membership.benefits") }}: {{ levelName(status.level.benefits) }}
|
||||
</p>
|
||||
<p v-else-if="status.next_level" class="text-muted mt-2 mb-0 text-xs">
|
||||
{{
|
||||
t("membership.noLevelHint", {
|
||||
threshold: status.next_level.growth_threshold,
|
||||
unit: t("membership.growthUnit"),
|
||||
level: levelName(status.next_level.name),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-border bg-bg border p-[18px]">
|
||||
<span class="text-muted block text-xs">{{ t("membership.growthTotal") }}</span>
|
||||
<strong class="text-primary mt-2.5 block text-xl">{{ status.growth_total }}</strong>
|
||||
<span class="text-muted mt-1 block text-xs">{{ t("membership.growthUnit") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="border-border mb-6 rounded-md border p-4">
|
||||
<template v-if="status.next_level">
|
||||
<h2 class="text-text m-0 text-lg font-semibold">
|
||||
{{ t("membership.progressTitle", { level: levelName(status.next_level.name) }) }}
|
||||
</h2>
|
||||
<p class="text-muted mt-1 mb-3 text-xs">
|
||||
{{
|
||||
t("membership.progressThreshold", {
|
||||
current: status.growth_total,
|
||||
threshold: status.next_level.growth_threshold,
|
||||
unit: t("membership.growthUnit"),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="bg-border h-2.5 w-full overflow-hidden rounded-full"
|
||||
role="progressbar"
|
||||
:aria-valuenow="progressPercent ?? 0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-label="t('membership.progressTitle', { level: levelName(status.next_level.name) })"
|
||||
>
|
||||
<div
|
||||
class="bg-primary h-full rounded-full transition-all"
|
||||
:style="{ width: `${progressPercent ?? 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-primary mt-2 mb-0 text-xs font-medium">
|
||||
{{
|
||||
t("membership.remaining", {
|
||||
remaining: status.next_level.remaining,
|
||||
unit: t("membership.growthUnit"),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="status.level">
|
||||
<h2 class="text-text m-0 text-lg font-semibold">{{ t("membership.topLevel") }}</h2>
|
||||
<p class="text-muted mt-1 mb-0 text-xs">{{ t("membership.topLevelHint") }}</p>
|
||||
</template>
|
||||
<p v-else class="text-muted m-0 text-sm">{{ t("membership.noLevel") }}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("membership.historyTitle") }}</h2>
|
||||
<p v-if="logError" role="alert" class="text-danger mb-3 text-sm">{{ logError }}</p>
|
||||
<UiEmptyState v-if="logs.length === 0" :text="t('membership.historyEmpty')" />
|
||||
<template v-else>
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("membership.historyDate") }}</th>
|
||||
<th>{{ t("membership.historyReason") }}</th>
|
||||
<th class="text-right!">{{ t("membership.historyDelta") }}</th>
|
||||
<th class="text-right!">{{ t("membership.historyTotal") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in logs" :key="entry.id">
|
||||
<td class="text-muted text-xs whitespace-nowrap">
|
||||
{{ entry.created_at.slice(0, 10) }}
|
||||
</td>
|
||||
<td>{{ reasonLabel(entry.reason) }}</td>
|
||||
<td
|
||||
class="text-right font-semibold"
|
||||
:class="entry.delta < 0 ? 'text-danger' : 'text-success'"
|
||||
>
|
||||
{{ entry.delta < 0 ? "−" : "+" }}{{ Math.abs(entry.delta) }}
|
||||
</td>
|
||||
<td class="text-right">{{ entry.growth_total }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
<UiPagination
|
||||
:page="logPage"
|
||||
:total="logTotal"
|
||||
:per-page="logPerPage"
|
||||
@change="changeLogPage"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message, MessageKind } from "@vmall/shared";
|
||||
import { t as localizedText } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const { unread, refresh: refreshUnread } = useUnreadMessages();
|
||||
|
||||
const messages = ref<Message[]>([]);
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const perPage = ref(20);
|
||||
const unreadOnly = ref(false);
|
||||
const expandedId = ref<string | null>(null);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
const actionError = ref("");
|
||||
const actionNotice = ref("");
|
||||
|
||||
const KIND_KEYS: Record<MessageKind, string> = {
|
||||
order_paid: "messaging.kind_order_paid",
|
||||
order_shipped: "messaging.kind_order_shipped",
|
||||
refund_completed: "messaging.kind_refund_completed",
|
||||
};
|
||||
|
||||
const KIND_TONES: Record<MessageKind, "blue" | "green" | "orange"> = {
|
||||
order_paid: "blue",
|
||||
order_shipped: "green",
|
||||
refund_completed: "orange",
|
||||
};
|
||||
|
||||
function kindLabel(kind: MessageKind): string {
|
||||
return t(KIND_KEYS[kind]);
|
||||
}
|
||||
|
||||
function title(message: Message): string {
|
||||
return localizedText(message.title, locale.value);
|
||||
}
|
||||
|
||||
function body(message: Message): string {
|
||||
return localizedText(message.body, locale.value);
|
||||
}
|
||||
|
||||
function formatTime(createdAt: string): string {
|
||||
return createdAt.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
async function load(targetPage: number): Promise<void> {
|
||||
const result = await $api.listMessages({
|
||||
page: targetPage,
|
||||
unread_only: unreadOnly.value,
|
||||
});
|
||||
messages.value = result.items;
|
||||
page.value = result.page;
|
||||
total.value = result.total;
|
||||
perPage.value = result.per_page;
|
||||
}
|
||||
|
||||
/** Re-read both the list and the badge count after any mutation. */
|
||||
async function refreshAll(targetPage = page.value): Promise<void> {
|
||||
await Promise.all([load(targetPage), refreshUnread()]);
|
||||
}
|
||||
|
||||
async function initialLoad(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await refreshAll(1);
|
||||
} catch {
|
||||
error.value = t("messaging.loadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(targetPage: number): Promise<void> {
|
||||
actionError.value = "";
|
||||
try {
|
||||
await load(targetPage);
|
||||
} catch {
|
||||
actionError.value = t("messaging.listFailed");
|
||||
}
|
||||
}
|
||||
|
||||
async function setUnreadOnly(value: boolean): Promise<void> {
|
||||
unreadOnly.value = value;
|
||||
expandedId.value = null;
|
||||
actionError.value = "";
|
||||
try {
|
||||
await load(1);
|
||||
} catch {
|
||||
actionError.value = t("messaging.listFailed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Opening an unread message is what marks it read. */
|
||||
async function toggle(message: Message): Promise<void> {
|
||||
if (expandedId.value === message.id) {
|
||||
expandedId.value = null;
|
||||
return;
|
||||
}
|
||||
expandedId.value = message.id;
|
||||
if (message.status === "unread") await markRead(message);
|
||||
}
|
||||
|
||||
async function markRead(message: Message): Promise<void> {
|
||||
if (message.status === "read") return;
|
||||
actionError.value = "";
|
||||
actionNotice.value = "";
|
||||
try {
|
||||
await $api.markMessageRead(message.id);
|
||||
await refreshAll();
|
||||
} catch {
|
||||
actionError.value = t("messaging.markReadFailed");
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead(): Promise<void> {
|
||||
actionError.value = "";
|
||||
actionNotice.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
const result = await $api.markAllMessagesRead();
|
||||
expandedId.value = null;
|
||||
await refreshAll(1);
|
||||
actionNotice.value = t("messaging.markAllSuccess", { count: result.updated });
|
||||
} catch {
|
||||
actionError.value = t("messaging.markAllFailed");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(message: Message): Promise<void> {
|
||||
if (!confirm(t("messaging.deleteConfirm"))) return;
|
||||
actionError.value = "";
|
||||
actionNotice.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
await $api.deleteMessage(message.id);
|
||||
if (expandedId.value === message.id) expandedId.value = null;
|
||||
const target = messages.value.length === 1 && page.value > 1 ? page.value - 1 : page.value;
|
||||
await refreshAll(target);
|
||||
} catch {
|
||||
actionError.value = t("messaging.deleteFailed");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void initialLoad());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-text m-0 text-xl font-bold">{{ t("messaging.title") }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="text-muted flex cursor-pointer items-center gap-1.5 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary"
|
||||
:checked="unreadOnly"
|
||||
@change="setUnreadOnly(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ t("messaging.unreadOnly") }}
|
||||
</label>
|
||||
<VBtn
|
||||
variant="primary"
|
||||
size="sm"
|
||||
:disabled="busy || unread === 0"
|
||||
@click="markAllRead"
|
||||
>
|
||||
{{ t("messaging.markAllRead") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
|
||||
<template v-else>
|
||||
<p v-if="actionError" role="alert" class="text-danger mb-3 text-sm">{{ actionError }}</p>
|
||||
<p v-if="actionNotice" role="status" class="text-success mb-3 text-sm">
|
||||
{{ actionNotice }}
|
||||
</p>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="messages.length === 0"
|
||||
:text="unreadOnly ? t('messaging.emptyUnread') : t('messaging.empty')"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<article
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="border-border border"
|
||||
:class="message.status === 'unread' ? 'bg-primary-soft' : 'bg-surface'"
|
||||
>
|
||||
<header class="flex flex-wrap items-center gap-2.5 px-3 py-2.5">
|
||||
<VBadge :tone="KIND_TONES[message.kind]">{{ kindLabel(message.kind) }}</VBadge>
|
||||
<VBadge :tone="message.status === 'unread' ? 'orange' : 'gray'">
|
||||
{{
|
||||
message.status === "unread"
|
||||
? t("messaging.statusUnread")
|
||||
: t("messaging.statusRead")
|
||||
}}
|
||||
</VBadge>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary min-w-0 flex-1 cursor-pointer truncate text-left text-sm font-medium"
|
||||
@click="toggle(message)"
|
||||
>
|
||||
{{ title(message) }}
|
||||
</button>
|
||||
<span class="text-muted text-xs whitespace-nowrap">{{
|
||||
formatTime(message.created_at)
|
||||
}}</span>
|
||||
<VBtn size="sm" @click="toggle(message)">
|
||||
{{ expandedId === message.id ? t("messaging.close") : t("messaging.open") }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="message.status === 'unread'"
|
||||
size="sm"
|
||||
:disabled="busy"
|
||||
@click="markRead(message)"
|
||||
>
|
||||
{{ t("messaging.markRead") }}
|
||||
</VBtn>
|
||||
<VBtn variant="danger" size="sm" :disabled="busy" @click="remove(message)">
|
||||
{{ t("messaging.delete") }}
|
||||
</VBtn>
|
||||
</header>
|
||||
<p
|
||||
v-if="expandedId === message.id"
|
||||
class="border-border text-text m-0 border-t px-3 py-3 text-sm"
|
||||
>
|
||||
{{ body(message) }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<UiPagination :page="page" :total="total" :per-page="perPage" @change="changePage" />
|
||||
</template>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError, formatMoney } from "@vmall/shared";
|
||||
import type { WalletEntry, WalletSummary, WalletWithdrawal, WithdrawalStatus } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const { ensureCurrencies, exponentFor } = usePrice();
|
||||
|
||||
const wallet = ref<WalletSummary | null>(null);
|
||||
const entries = ref<WalletEntry[]>([]);
|
||||
const entryPage = ref(1);
|
||||
const entryTotal = ref(0);
|
||||
const entryPerPage = ref(20);
|
||||
const withdrawals = ref<WalletWithdrawal[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const entryError = ref("");
|
||||
|
||||
// Demo recharge form state.
|
||||
const rechargeMajor = ref("");
|
||||
const recharging = ref(false);
|
||||
const rechargeError = ref("");
|
||||
const rechargeNotice = ref("");
|
||||
|
||||
// Withdrawal request form state.
|
||||
const withdrawMajor = ref("");
|
||||
const withdrawMethod = ref("bank");
|
||||
const withdrawAccount = ref("");
|
||||
const withdrawHolder = ref("");
|
||||
const withdrawing = ref(false);
|
||||
const withdrawError = ref("");
|
||||
const withdrawNotice = ref("");
|
||||
|
||||
const walletCurrency = computed(() => wallet.value?.currency ?? "");
|
||||
|
||||
const REASON_KEYS: Record<string, string> = {
|
||||
wallet_recharge: "wallet.reason_wallet_recharge",
|
||||
wallet_withdrawal_freeze: "wallet.reason_wallet_withdrawal_freeze",
|
||||
wallet_withdrawal_approved: "wallet.reason_wallet_withdrawal_approved",
|
||||
wallet_withdrawal_rejected: "wallet.reason_wallet_withdrawal_rejected",
|
||||
order_payment: "wallet.reason_order_payment",
|
||||
aftersale_refund: "wallet.reason_aftersale_refund",
|
||||
settlement_payout: "wallet.reason_settlement_payout",
|
||||
opening_balance: "wallet.reason_opening_balance",
|
||||
};
|
||||
|
||||
const METHOD_KEYS: Record<string, string> = {
|
||||
bank: "wallet.withdrawMethodBank",
|
||||
alipay: "wallet.withdrawMethodAlipay",
|
||||
wechat: "wallet.withdrawMethodWechat",
|
||||
};
|
||||
|
||||
const STATUS_KEYS: Record<WithdrawalStatus, string> = {
|
||||
pending: "wallet.status_pending",
|
||||
approved: "wallet.status_approved",
|
||||
rejected: "wallet.status_rejected",
|
||||
};
|
||||
|
||||
/** Currency-aware formatting; the exponent comes from the currency table. */
|
||||
function money(amountMinor: number, code: string): string {
|
||||
if (!code) return String(amountMinor);
|
||||
return formatMoney(amountMinor, code, exponentFor(code), locale.value);
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string): string {
|
||||
const key = REASON_KEYS[reason];
|
||||
return key ? t(key) : reason;
|
||||
}
|
||||
|
||||
function methodLabel(method: string): string {
|
||||
const key = METHOD_KEYS[method];
|
||||
return key ? t(key) : method;
|
||||
}
|
||||
|
||||
function statusTone(status: WithdrawalStatus): "green" | "orange" | "red" {
|
||||
if (status === "approved") return "green";
|
||||
if (status === "rejected") return "red";
|
||||
return "orange";
|
||||
}
|
||||
|
||||
async function loadWallet(): Promise<void> {
|
||||
wallet.value = await $api.getWallet();
|
||||
}
|
||||
|
||||
async function loadEntries(page: number): Promise<void> {
|
||||
const result = await $api.listWalletEntries(page);
|
||||
entries.value = result.items;
|
||||
entryPage.value = result.page;
|
||||
entryTotal.value = result.total;
|
||||
entryPerPage.value = result.per_page;
|
||||
}
|
||||
|
||||
async function loadWithdrawals(): Promise<void> {
|
||||
withdrawals.value = await $api.listMyWithdrawals();
|
||||
}
|
||||
|
||||
/** Re-read backend truth after any mutation so no balance is local-only. */
|
||||
async function refreshAll(): Promise<void> {
|
||||
await Promise.all([loadWallet(), loadEntries(1), loadWithdrawals()]);
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
// Currencies carry the exponent (JPY is 0), so load them before formatting.
|
||||
await ensureCurrencies();
|
||||
await refreshAll();
|
||||
} catch {
|
||||
error.value = t("wallet.loadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeEntryPage(page: number): Promise<void> {
|
||||
entryError.value = "";
|
||||
try {
|
||||
await loadEntries(page);
|
||||
} catch {
|
||||
entryError.value = t("wallet.entriesFailed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Major-unit input to integer minor units using the account currency exponent. */
|
||||
function toMinor(major: string): number | null {
|
||||
const raw = Number(major);
|
||||
if (!Number.isFinite(raw) || raw <= 0) return null;
|
||||
const code = walletCurrency.value;
|
||||
if (!code) return null;
|
||||
const minor = Math.round(raw * 10 ** exponentFor(code));
|
||||
return Number.isSafeInteger(minor) && minor > 0 ? minor : null;
|
||||
}
|
||||
|
||||
async function submitRecharge(): Promise<void> {
|
||||
rechargeError.value = "";
|
||||
rechargeNotice.value = "";
|
||||
const amount = toMinor(rechargeMajor.value);
|
||||
if (amount === null) {
|
||||
rechargeError.value = t("wallet.validationRequired");
|
||||
return;
|
||||
}
|
||||
recharging.value = true;
|
||||
try {
|
||||
const result = await $api.rechargeWallet(amount);
|
||||
await refreshAll();
|
||||
rechargeNotice.value = t("wallet.rechargeSuccess", {
|
||||
amount: money(result.amount_minor, result.currency),
|
||||
});
|
||||
rechargeMajor.value = "";
|
||||
} catch (err: unknown) {
|
||||
const conflict = err instanceof ApiError && err.status === 409;
|
||||
rechargeError.value = conflict ? t("wallet.rechargeConflict") : t("wallet.rechargeFailed");
|
||||
// A conflict means the server state moved; converge on it instead of retrying.
|
||||
if (conflict) await refreshAll();
|
||||
} finally {
|
||||
recharging.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitWithdrawal(): Promise<void> {
|
||||
withdrawError.value = "";
|
||||
withdrawNotice.value = "";
|
||||
const amount = toMinor(withdrawMajor.value);
|
||||
const account = withdrawAccount.value.trim();
|
||||
if (amount === null || !withdrawMethod.value.trim() || !account) {
|
||||
withdrawError.value = t("wallet.validationRequired");
|
||||
return;
|
||||
}
|
||||
withdrawing.value = true;
|
||||
try {
|
||||
const holder = withdrawHolder.value.trim();
|
||||
await $api.applyWithdrawal(amount, {
|
||||
method: withdrawMethod.value,
|
||||
account,
|
||||
...(holder ? { holder } : {}),
|
||||
});
|
||||
await refreshAll();
|
||||
withdrawNotice.value = t("wallet.withdrawSuccess");
|
||||
withdrawMajor.value = "";
|
||||
withdrawAccount.value = "";
|
||||
withdrawHolder.value = "";
|
||||
} catch (err: unknown) {
|
||||
const conflict = err instanceof ApiError && err.status === 409;
|
||||
withdrawError.value = conflict ? t("wallet.withdrawConflict") : t("wallet.withdrawFailed");
|
||||
if (conflict) await refreshAll();
|
||||
} finally {
|
||||
withdrawing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<h1 class="text-text mb-4 text-xl font-bold">{{ t("wallet.title") }}</h1>
|
||||
<p
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
|
||||
<p v-else-if="!wallet" class="text-muted py-10">{{ t("wallet.loadFailed") }}</p>
|
||||
<template v-else>
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-2">
|
||||
<div class="border-border bg-primary-soft border p-[18px]">
|
||||
<span class="text-muted block text-xs">{{ t("wallet.available") }}</span>
|
||||
<strong class="text-primary mt-2.5 block text-xl">{{
|
||||
money(wallet.available_minor, wallet.currency)
|
||||
}}</strong>
|
||||
<span class="text-muted mt-1 block text-xs">{{ wallet.currency }}</span>
|
||||
</div>
|
||||
<div class="border-border bg-bg border p-[18px]">
|
||||
<span class="text-muted block text-xs">{{ t("wallet.frozen") }}</span>
|
||||
<strong class="text-warning mt-2.5 block text-xl">{{
|
||||
money(wallet.frozen_minor, wallet.currency)
|
||||
}}</strong>
|
||||
<span class="text-muted mt-1 block text-xs">{{ wallet.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="mb-6">
|
||||
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("wallet.entriesTitle") }}</h2>
|
||||
<p v-if="entryError" role="alert" class="text-danger mb-3 text-sm">{{ entryError }}</p>
|
||||
<UiEmptyState v-if="entries.length === 0" :text="t('wallet.entriesEmpty')" />
|
||||
<template v-else>
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("wallet.entryDate") }}</th>
|
||||
<th>{{ t("wallet.entryAccount") }}</th>
|
||||
<th>{{ t("wallet.entryReason") }}</th>
|
||||
<th class="text-right!">{{ t("wallet.entryDelta") }}</th>
|
||||
<th class="text-right!">{{ t("wallet.entryBalance") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in entries" :key="entry.id">
|
||||
<td class="text-muted text-xs whitespace-nowrap">
|
||||
{{ entry.created_at.slice(0, 10) }}
|
||||
</td>
|
||||
<td>
|
||||
{{
|
||||
entry.account_kind === "available"
|
||||
? t("wallet.accountAvailable")
|
||||
: t("wallet.accountFrozen")
|
||||
}}
|
||||
</td>
|
||||
<td>{{ reasonLabel(entry.reason) }}</td>
|
||||
<td
|
||||
class="text-right font-semibold"
|
||||
:class="entry.delta_minor < 0 ? 'text-danger' : 'text-success'"
|
||||
>
|
||||
{{ entry.delta_minor < 0 ? "−" : "+"
|
||||
}}{{ money(Math.abs(entry.delta_minor), wallet.currency) }}
|
||||
</td>
|
||||
<td class="text-right">{{ money(entry.balance_minor, wallet.currency) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
<UiPagination
|
||||
:page="entryPage"
|
||||
:total="entryTotal"
|
||||
:per-page="entryPerPage"
|
||||
@change="changeEntryPage"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="border-border mb-6 rounded-md border p-4">
|
||||
<div class="mb-1 flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-text m-0 text-lg font-semibold">{{ t("wallet.rechargeTitle") }}</h2>
|
||||
<VBadge tone="orange">{{ t("wallet.rechargeDemoBadge") }}</VBadge>
|
||||
</div>
|
||||
<p class="text-muted mt-1 mb-3 text-xs">{{ t("wallet.rechargeDemoNote") }}</p>
|
||||
<form class="grid max-w-[520px] gap-2" @submit.prevent="submitRecharge">
|
||||
<VField :label="t('wallet.rechargeAmount', { currency: wallet.currency })">
|
||||
<VInput v-model="rechargeMajor" inputmode="decimal" placeholder="0.00" />
|
||||
<p class="text-muted m-0 mt-1 text-xs">
|
||||
{{ t("wallet.rechargeAmountHint", { currency: wallet.currency }) }}
|
||||
</p>
|
||||
</VField>
|
||||
<p v-if="rechargeError" role="alert" class="text-danger m-0 text-xs">
|
||||
{{ rechargeError }}
|
||||
</p>
|
||||
<p v-if="rechargeNotice" role="status" class="text-success m-0 text-xs">
|
||||
{{ rechargeNotice }}
|
||||
</p>
|
||||
<VBtn class="w-fit" variant="primary" type="submit" :disabled="recharging">
|
||||
{{ t("wallet.rechargeSubmit") }}
|
||||
</VBtn>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="border-border mb-6 rounded-md border p-4">
|
||||
<h2 class="text-text mb-1 text-lg font-semibold">{{ t("wallet.withdrawTitle") }}</h2>
|
||||
<p class="text-muted mt-1 mb-3 text-xs">{{ t("wallet.withdrawHint") }}</p>
|
||||
<form class="grid max-w-[520px] gap-2" @submit.prevent="submitWithdrawal">
|
||||
<VField :label="t('wallet.withdrawAmount', { currency: wallet.currency })">
|
||||
<VInput v-model="withdrawMajor" inputmode="decimal" placeholder="0.00" />
|
||||
<p class="text-muted m-0 mt-1 text-xs">{{ t("wallet.withdrawAmountHint") }}</p>
|
||||
</VField>
|
||||
<VField :label="t('wallet.withdrawMethod')">
|
||||
<select
|
||||
v-model="withdrawMethod"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
|
||||
>
|
||||
<option value="bank">{{ t("wallet.withdrawMethodBank") }}</option>
|
||||
<option value="alipay">{{ t("wallet.withdrawMethodAlipay") }}</option>
|
||||
<option value="wechat">{{ t("wallet.withdrawMethodWechat") }}</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="t('wallet.withdrawAccount')">
|
||||
<VInput v-model="withdrawAccount" :placeholder="t('wallet.withdrawAccountHint')" />
|
||||
</VField>
|
||||
<VField :label="t('wallet.withdrawHolder')">
|
||||
<VInput v-model="withdrawHolder" />
|
||||
</VField>
|
||||
<p v-if="withdrawError" role="alert" class="text-danger m-0 text-xs">
|
||||
{{ withdrawError }}
|
||||
</p>
|
||||
<p v-if="withdrawNotice" role="status" class="text-success m-0 text-xs">
|
||||
{{ withdrawNotice }}
|
||||
</p>
|
||||
<VBtn class="w-fit" variant="primary" type="submit" :disabled="withdrawing">
|
||||
{{ t("wallet.withdrawSubmit") }}
|
||||
</VBtn>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("wallet.historyTitle") }}</h2>
|
||||
<UiEmptyState v-if="withdrawals.length === 0" :text="t('wallet.historyEmpty')" />
|
||||
<VTable v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("wallet.historyCreatedAt") }}</th>
|
||||
<th>{{ t("wallet.historyAmount") }}</th>
|
||||
<th>{{ t("wallet.historyMethod") }}</th>
|
||||
<th>{{ t("wallet.historyAccount") }}</th>
|
||||
<th>{{ t("wallet.historyStatus") }}</th>
|
||||
<th>{{ t("wallet.historyNote") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in withdrawals" :key="row.id">
|
||||
<td class="text-muted text-xs whitespace-nowrap">
|
||||
{{ row.created_at.slice(0, 10) }}
|
||||
</td>
|
||||
<td class="font-semibold">{{ money(row.amount_minor, row.currency) }}</td>
|
||||
<td>{{ methodLabel(row.account_details.method) }}</td>
|
||||
<td class="text-xs">{{ row.account_details.account }}</td>
|
||||
<td>
|
||||
<VBadge :tone="statusTone(row.status)">{{ t(STATUS_KEYS[row.status]) }}</VBadge>
|
||||
</td>
|
||||
<td class="text-muted text-xs">
|
||||
{{ row.review_note ?? t("mall.notAvailable") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</section>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -26,7 +26,11 @@ type LiveDomain =
|
||||
| "groupBuying"
|
||||
| "favorites"
|
||||
| "aftersales"
|
||||
| "reviews";
|
||||
| "reviews"
|
||||
| "wallet"
|
||||
| "membership"
|
||||
| "messaging"
|
||||
| "merchantOnboarding";
|
||||
|
||||
/**
|
||||
* Explicit per-domain method picks rather than a string allowlist: indexing
|
||||
@@ -111,6 +115,28 @@ const LIVE_PICKS = {
|
||||
listReviewableItems: a.listReviewableItems,
|
||||
createReview: a.createReview,
|
||||
}),
|
||||
wallet: (a: ApiClient) => ({
|
||||
getWallet: a.getWallet,
|
||||
listWalletEntries: a.listWalletEntries,
|
||||
rechargeWallet: a.rechargeWallet,
|
||||
applyWithdrawal: a.applyWithdrawal,
|
||||
listMyWithdrawals: a.listMyWithdrawals,
|
||||
}),
|
||||
membership: (a: ApiClient) => ({
|
||||
getMembership: a.getMembership,
|
||||
listGrowthLogs: a.listGrowthLogs,
|
||||
}),
|
||||
messaging: (a: ApiClient) => ({
|
||||
listMessages: a.listMessages,
|
||||
markMessageRead: a.markMessageRead,
|
||||
markAllMessagesRead: a.markAllMessagesRead,
|
||||
deleteMessage: a.deleteMessage,
|
||||
getUnreadCount: a.getUnreadCount,
|
||||
}),
|
||||
merchantOnboarding: (a: ApiClient) => ({
|
||||
submitMerchantApplication: a.submitMerchantApplication,
|
||||
getMyMerchantApplications: a.getMyMerchantApplications,
|
||||
}),
|
||||
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
|
||||
|
||||
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
|
||||
@@ -136,6 +162,10 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
|
||||
"favorites",
|
||||
"aftersales",
|
||||
"reviews",
|
||||
"wallet",
|
||||
"membership",
|
||||
"messaging",
|
||||
"merchantOnboarding",
|
||||
];
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
|
||||
Reference in New Issue
Block a user