Compare commits

...

3 Commits

Author SHA1 Message Date
zzh
4f96f7459f feat: 落地滚动会员与券邀请分成,列表状态改为 Tab
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m56s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 17:40:24 +08:00
zzh
63c7e4ab6f feat: 查价页优化,销售产品可改代码,分区导入跳过重复
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 15:37:02 +08:00
zzh
0d5ebdb591 feat: 管理端登录视觉调整,并减轻客户登录首屏等待
登录页不再预热 VXE/Element,顾问 CTA 预热 /login,角色与全量语言包延后加载。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 14:29:29 +08:00
39 changed files with 1591 additions and 942 deletions

View File

@ -752,6 +752,30 @@
return omsUrl(preferOmsH5() ? '/h5/login' : '/login'); return omsUrl(preferOmsH5() ? '/h5/login' : '/login');
} }
let omsLoginWarmed = false;
function warmupOmsLogin() {
if (omsLoginWarmed) return;
omsLoginWarmed = true;
try {
const url = loginPortalUrl();
const origin = resolveOmsOrigin();
const preconnect = document.createElement('link');
preconnect.rel = 'preconnect';
preconnect.href = origin;
document.head.appendChild(preconnect);
const prefetch = document.createElement('link');
prefetch.rel = 'prefetch';
prefetch.href = url;
document.head.appendChild(prefetch);
if (/localhost|127\.0\.0\.1/i.test(new URL(origin).hostname)) {
fetch(new URL('/src/views/login/index.vue', origin), {
credentials: 'omit',
mode: 'cors',
}).catch(function () {});
}
} catch (e) {}
}
function registerUrl(dest) { function registerUrl(dest) {
// 官网「注册询价」走客户登录页微信扫码(注册/登录同一入口) // 官网「注册询价」走客户登录页微信扫码(注册/登录同一入口)
const url = new URL(loginPortalUrl()); const url = new URL(loginPortalUrl());
@ -2654,7 +2678,13 @@
if (heroCta) { if (heroCta) {
heroCta.textContent = t('heroCta'); heroCta.textContent = t('heroCta');
heroCta.href = loginPortalUrl(); heroCta.href = loginPortalUrl();
if (!heroCta.dataset.omsWarm) {
heroCta.dataset.omsWarm = '1';
heroCta.addEventListener('pointerenter', warmupOmsLogin, { once: true });
heroCta.addEventListener('focus', warmupOmsLogin, { once: true });
}
} }
warmupOmsLogin();
const welcomeHuman = shell.querySelector('.ai-welcome-human'); const welcomeHuman = shell.querySelector('.ai-welcome-human');
if (welcomeHuman) welcomeHuman.textContent = t('human') + ' →'; if (welcomeHuman) welcomeHuman.textContent = t('human') + ' →';
renderWelcomeStats(); renderWelcomeStats();

View File

@ -66,6 +66,14 @@
: 'IDE客户中心' : 'IDE客户中心'
: 'IDE I Faster & Simpler'; : 'IDE I Faster & Simpler';
} catch (e2) {} } catch (e2) {}
if (path === '/login') {
var plane = document.createElement('link');
plane.rel = 'preload';
plane.as = 'image';
plane.href = '/login-assets/hero-plane.webp';
plane.type = 'image/webp';
document.head.appendChild(plane);
}
})(); })();
</script> </script>
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@ -117,6 +117,7 @@ export async function postForewarningServiceKpiAreaGetKpiAreaInfos(
export type KpiAreaInfoImportResult = { export type KpiAreaInfoImportResult = {
successCount: number successCount: number
failCount: number failCount: number
skipCount: number
failures: Array<{ rowNumber: number; reason: string }> failures: Array<{ rowNumber: number; reason: string }>
} }
@ -261,6 +262,7 @@ export async function importKpiAreaInfo(
return { return {
successCount: Number(d.successCount ?? d.SuccessCount ?? 0), successCount: Number(d.successCount ?? d.SuccessCount ?? 0),
failCount: Number(d.failCount ?? d.FailCount ?? 0), failCount: Number(d.failCount ?? d.FailCount ?? 0),
skipCount: Number(d.skipCount ?? d.SkipCount ?? 0),
failures, failures,
} }
} }

View File

@ -62,6 +62,11 @@ export async function updateSalesProduct(body: Record<string, any> & { id: numbe
assertBusinessOk(res.data) assertBusinessOk(res.data)
} }
export async function updateSalesProductCode(body: { id: number | string; productCode: string }) {
const res = await axiosInstance.post(omsUrl('/channel/sales-product/product-code/update'), body)
assertBusinessOk(res.data)
}
export async function deleteSalesProduct(id: number | string) { export async function deleteSalesProduct(id: number | string) {
const res = await axiosInstance.post(omsUrl('/channel/sales-product/delete'), { id }) const res = await axiosInstance.post(omsUrl('/channel/sales-product/delete'), { id })
assertBusinessOk(res.data) assertBusinessOk(res.data)

View File

@ -656,6 +656,7 @@ const CORE_GROUPS: PageButtonGroup[] = [
{ code: 'AddProduct', name: '新增产品' }, { code: 'AddProduct', name: '新增产品' },
{ code: 'Create', name: '新增' }, { code: 'Create', name: '新增' },
{ code: 'Update', name: '修改' }, { code: 'Update', name: '修改' },
{ code: 'UpdateCode', name: '修改产品代码' },
{ code: 'Delete', name: '删除' }, { code: 'Delete', name: '删除' },
{ code: 'EditPrice', name: '编辑' }, { code: 'EditPrice', name: '编辑' },
{ code: 'Save', name: '保存' }, { code: 'Save', name: '保存' },

View File

@ -66,10 +66,10 @@ export default {
'menu.marketing': 'Marketing', 'menu.marketing': 'Marketing',
'menu.marketing.memberLevel': 'Member Levels', 'menu.marketing.memberLevel': 'Member Levels',
'marketing.memberLevel.pageTip': 'marketing.memberLevel.pageTip':
'Member levels control points upgrades and optional extra discounts. Price tier keys are maintained on the customer and are independent of level upgrades.', 'Levels are based on rolling 12-month paid base-freight points. Discount applies to base freight only; coupons do not stack. V0–V3 never downgrade; V4/V5 step down one level. Floor is 5% off.',
'marketing.memberLevel.searchPlaceholder': 'Search code / name', 'marketing.memberLevel.searchPlaceholder': 'Search code / name',
'marketing.memberLevel.modalTip': 'marketing.memberLevel.modalTip':
'Reach the points threshold to upgrade; extra discount stacks after sell-price tier. Configure price tiers on the customer.', 'Threshold is rolling 12-month points. 0.01 = 1% off base freight (99 fold), cap 0.05. Enable downgrade only for V4/V5 (one step).',
'marketing.memberLevel.modal.add': 'Add level', 'marketing.memberLevel.modal.add': 'Add level',
'marketing.memberLevel.modal.edit': 'Edit level', 'marketing.memberLevel.modal.edit': 'Edit level',
'marketing.memberLevel.deleteConfirm': 'Delete level "{name}"? {count} customers use it.', 'marketing.memberLevel.deleteConfirm': 'Delete level "{name}"? {count} customers use it.',
@ -78,10 +78,14 @@ export default {
'marketing.memberLevel.form.levelCodeExtra': 'Immutable after create; prefer uppercase codes', 'marketing.memberLevel.form.levelCodeExtra': 'Immutable after create; prefer uppercase codes',
'marketing.memberLevel.form.levelName': 'Level name', 'marketing.memberLevel.form.levelName': 'Level name',
'marketing.memberLevel.form.levelNamePlaceholder': 'e.g. Standard, Silver', 'marketing.memberLevel.form.levelNamePlaceholder': 'e.g. Standard, Silver',
'marketing.memberLevel.form.minPoints': 'Min points', 'marketing.memberLevel.form.minPoints': 'Rolling points min',
'marketing.memberLevel.form.minPointsExtra': 'Total points required to reach this level', 'marketing.memberLevel.form.minPointsExtra': 'Rolling 12-month paid base-freight points to reach this level',
'marketing.memberLevel.form.extraDiscountRate': 'Extra discount rate', 'marketing.memberLevel.form.extraDiscountRate': 'Base-freight off rate',
'marketing.memberLevel.form.extraDiscountRateExtra': 'e.g. 0.01 = extra 1% off after tier price', 'marketing.memberLevel.form.extraDiscountRateExtra': '0.01 = 1% off base freight; cap 0.05',
'marketing.memberLevel.form.canDowngrade': 'Allow downgrade',
'marketing.memberLevel.form.canDowngradeExtra': 'V4/V5 only; daily job steps down one level. V0–V3 stay.',
'marketing.memberLevel.discount.original': 'List price',
'marketing.memberLevel.discount.fold': '{fold}% of freight',
'marketing.memberLevel.form.sort': 'Sort', 'marketing.memberLevel.form.sort': 'Sort',
'marketing.memberLevel.form.sortExtra': 'Smaller number ranks first', 'marketing.memberLevel.form.sortExtra': 'Smaller number ranks first',
'marketing.memberLevel.form.status': 'Status', 'marketing.memberLevel.form.status': 'Status',
@ -94,8 +98,8 @@ export default {
'marketing.memberLevel.form.requiredHint': 'Level code and name are required', 'marketing.memberLevel.form.requiredHint': 'Level code and name are required',
'marketing.memberLevel.section.basic': 'Basic info', 'marketing.memberLevel.section.basic': 'Basic info',
'marketing.memberLevel.section.basicHint': 'Code cannot change after create; name and status can', 'marketing.memberLevel.section.basicHint': 'Code cannot change after create; name and status can',
'marketing.memberLevel.section.pricing': 'Points & discount', 'marketing.memberLevel.section.pricing': 'Threshold & freight discount',
'marketing.memberLevel.section.pricingHint': 'Upgrade threshold and extra discount', 'marketing.memberLevel.section.pricingHint': 'Rolling 12-month points; discount on base freight only',
'marketing.memberLevel.section.display': 'Display & benefits', 'marketing.memberLevel.section.display': 'Display & benefits',
'marketing.memberLevel.section.displayHint': 'Portal display only; does not affect pricing', 'marketing.memberLevel.section.displayHint': 'Portal display only; does not affect pricing',
'marketing.memberLevel.preview.nameFallback': 'Untitled level', 'marketing.memberLevel.preview.nameFallback': 'Untitled level',
@ -2368,7 +2372,7 @@ export default {
'order.priceCheck.form.destinationCountry.placeholder': 'Please select', 'order.priceCheck.form.destinationCountry.placeholder': 'Please select',
'order.priceCheck.form.shippingMethod': 'Shipping method', 'order.priceCheck.form.shippingMethod': 'Shipping method',
'order.priceCheck.form.shippingMethod.placeholder': 'Please select', 'order.priceCheck.form.shippingMethod.placeholder': 'Please select',
'order.priceCheck.form.channel': 'Channel', 'order.priceCheck.form.channel': 'Route',
'order.priceCheck.form.channel.placeholder': 'All', 'order.priceCheck.form.channel.placeholder': 'All',
'order.priceCheck.form.cargoWeight': 'Cargo weight', 'order.priceCheck.form.cargoWeight': 'Cargo weight',
'order.priceCheck.form.cargoWeight.placeholder': 'Please enter weight (KG)', 'order.priceCheck.form.cargoWeight.placeholder': 'Please enter weight (KG)',
@ -2389,7 +2393,7 @@ export default {
'order.priceCheck.form.postalCode': 'Postal code', 'order.priceCheck.form.postalCode': 'Postal code',
'order.priceCheck.form.postalCode.tip': 'more accurate', 'order.priceCheck.form.postalCode.tip': 'more accurate',
'order.priceCheck.form.postalCode.placeholder': 'Optional — improves zoning', 'order.priceCheck.form.postalCode.placeholder': 'Optional — improves zoning',
'order.priceCheck.form.moreFilters': 'More filters', 'order.priceCheck.form.moreFilters': 'More',
'order.priceCheck.form.activeFilters': '{count} selected', 'order.priceCheck.form.activeFilters': '{count} selected',
'order.priceCheck.form.clearFilters': 'Clear', 'order.priceCheck.form.clearFilters': 'Clear',
'order.priceCheck.form.removeFilter': 'Remove filter', 'order.priceCheck.form.removeFilter': 'Remove filter',
@ -2465,9 +2469,9 @@ export default {
'order.priceCheck.table.viewDetail': 'Details', 'order.priceCheck.table.viewDetail': 'Details',
'order.priceCheck.table.viewGuide': 'Channel guide', 'order.priceCheck.table.viewGuide': 'Channel guide',
'order.priceCheck.table.viewGuideHint': 'Click to view route guide', 'order.priceCheck.table.viewGuideHint': 'Click to view route guide',
'order.priceCheck.table.customsOrderNow': 'Place order', 'order.priceCheck.table.customsOrderNow': 'Go to order',
'order.priceCheck.table.orderNow': 'Place order', 'order.priceCheck.table.orderNow': 'Go to order',
'order.priceCheck.table.askAi': 'Ask AI', 'order.priceCheck.table.askAi': 'Ask advisor',
'order.priceCheck.table.askAiCanShip': 'Can this ship?', 'order.priceCheck.table.askAiCanShip': 'Can this ship?',
'order.priceCheck.table.askAiCheaper': 'Anything cheaper?', 'order.priceCheck.table.askAiCheaper': 'Anything cheaper?',
'order.priceCheck.table.empty': 'No matching quotes', 'order.priceCheck.table.empty': 'No matching quotes',
@ -4670,7 +4674,7 @@ export default {
'user.customer.tip.accountBalance': 'Available balance — adjust carefully', 'user.customer.tip.accountBalance': 'Available balance — adjust carefully',
'user.customer.tip.totalQuota': 'Credit limit', 'user.customer.tip.totalQuota': 'Credit limit',
'user.customer.tip.usedQuota': 'Used quota, usually accumulated by the system', 'user.customer.tip.usedQuota': 'Used quota, usually accumulated by the system',
'user.customer.tip.memberLevel': 'Used for points upgrade and optional extra discount', 'user.customer.tip.memberLevel': 'Auto-assigned from rolling 12-month paid base-freight points',
'user.customer.tip.priceTierKey': 'Controls quote price tier; independent of member level upgrade', 'user.customer.tip.priceTierKey': 'Controls quote price tier; independent of member level upgrade',
'user.customer.modal.addTitle': 'Add Customer', 'user.customer.modal.addTitle': 'Add Customer',
'user.customer.modal.editTitle': 'Edit Customer', 'user.customer.modal.editTitle': 'Edit Customer',
@ -5171,7 +5175,13 @@ export default {
'channel.salesProducts.placeholder.productDescription': 'Customer-facing description (optional)', 'channel.salesProducts.placeholder.productDescription': 'Customer-facing description (optional)',
'channel.salesProducts.placeholder.preStorageScript': 'Script before order persistence (optional)', 'channel.salesProducts.placeholder.preStorageScript': 'Script before order persistence (optional)',
'channel.salesProducts.tip.productCode': 'channel.salesProducts.tip.productCode':
'Rule: region/country code + 01 line/02 express/03 postal/04 packet + other.\nExample: US04-5DAY\nNot editable after create; uppercase letters, digits, and - only', 'Rule: region/country code + 01 line/02 express/03 postal/04 packet + other.\nExample: US04-5DAY\nUppercase letters, digits, and - only; can be changed later via the edit action',
'channel.salesProducts.form.editProductCode': 'Edit',
'channel.salesProducts.modal.editProductCodeTitle': 'Edit product code',
'channel.salesProducts.modal.editProductCodeHint':
'Updates the sales-product master code only. Historical orders keep the old code; quoting and place-order use the new one.',
'channel.salesProducts.messages.productCodeRequired': 'Enter a product code',
'channel.salesProducts.messages.productCodeUpdated': 'Product code updated',
'channel.salesProducts.tip.productName': 'channel.salesProducts.tip.productName':
'Rule: region/country + product type description + other.\nExample: US air packet-5 days', 'Rule: region/country + product type description + other.\nExample: US air packet-5 days',
'channel.salesProducts.tip.group': 'Used for website/admin grouping, e.g. express parcel', 'channel.salesProducts.tip.group': 'Used for website/admin grouping, e.g. express parcel',
@ -5558,7 +5568,7 @@ export default {
'channel.area.message.deleteFailed': 'Delete failed. Please try again later.', 'channel.area.message.deleteFailed': 'Delete failed. Please try again later.',
'channel.area.import.modalTitle': 'Import area details', 'channel.area.import.modalTitle': 'Import area details',
'channel.area.import.tip': 'channel.area.import.tip':
'Download the template first. Country code is required (country name is filled from the code). Put match dimension/mode codes from the Enum sheet. One row, one dimension: postcode rules if filled; state only = state match; state+city = city match; empty = country fallback.', 'Download the template first. Import is incremental: exact duplicate rules in this area are skipped, not overwritten. Country code is required (country name is filled from the code). Put match dimension/mode codes from the Enum sheet. One row, one dimension: postcode rules if filled; state only = state match; state+city = city match; empty = country fallback.',
'channel.area.import.downloadTemplate': 'Download template', 'channel.area.import.downloadTemplate': 'Download template',
'channel.area.import.submit': 'Import', 'channel.area.import.submit': 'Import',
'channel.area.import.downloadSuccess': 'Template downloaded', 'channel.area.import.downloadSuccess': 'Template downloaded',
@ -5569,7 +5579,7 @@ export default {
'channel.area.import.failed': 'Import failed', 'channel.area.import.failed': 'Import failed',
'channel.area.import.partialSuccess': 'Some rows failed. See details below.', 'channel.area.import.partialSuccess': 'Some rows failed. See details below.',
'channel.area.import.allFailed': 'Import failed. Please check the file.', 'channel.area.import.allFailed': 'Import failed. Please check the file.',
'channel.area.import.resultSummary': '{success} succeeded, {fail} failed', 'channel.area.import.resultSummary': '{success} succeeded, {skip} duplicate skipped, {fail} failed',
'channel.area.import.failRow': 'Row {row}: {reason}', 'channel.area.import.failRow': 'Row {row}: {reason}',
'channel.area.export.success': 'Exported {count} row(s)', 'channel.area.export.success': 'Exported {count} row(s)',
'channel.area.export.failed': 'Export failed', 'channel.area.export.failed': 'Export failed',

View File

@ -26,20 +26,19 @@ export default {
'Grant "{templateName}" to {count} customers ({total} coupons total). Est. max discount {amount}. Cannot be undone.', 'Grant "{templateName}" to {count} customers ({total} coupons total). Est. max discount {amount}. Cannot be undone.',
'marketing.pointsRule.pageTip': 'marketing.pointsRule.pageTip':
'Points are earned after payment. Rule changes do not affect past orders. Use the Ledger tab for list/search/manual adjustments (reason ≥10 chars).', '1 paid yuan of base freight = 1 point. Surcharges and coupon amounts do not earn points. Points are for rolling 12-month leveling only and cannot offset freight.',
'marketing.pointsRule.tab.rule': 'Rules', 'marketing.pointsRule.sectionTitle': 'Points rules',
'marketing.pointsRule.tab.flow': 'Ledger', 'marketing.pointsRule.tab.flow': 'Ledger',
'marketing.pointsRule.form.ruleName': 'Rule name', 'marketing.pointsRule.form.ruleName': 'Rule name',
'marketing.pointsRule.form.pointsPerYuan': 'Points per yuan', 'marketing.pointsRule.form.pointsPerYuan': 'Points per base-freight yuan',
'marketing.pointsRule.form.pointsPerYuanExtra': 'Based on payable amount', 'marketing.pointsRule.form.pointsPerYuanExtra': 'Paid base freight only; surcharges and coupons excluded',
'marketing.pointsRule.form.firstOrderMultiplier': 'First-order multiplier',
'marketing.pointsRule.form.maxPointsPerOrder': 'Max points per order',
'marketing.pointsRule.form.status': 'Status', 'marketing.pointsRule.form.status': 'Status',
'marketing.pointsRule.saveConfirmTitle': 'Save points rule', 'marketing.pointsRule.saveConfirmTitle': 'Save points rule',
'marketing.pointsRule.saveConfirmContent': 'Changes apply to future orders only.', 'marketing.pointsRule.saveConfirmContent':
'Applies to future paid orders only. Surcharges do not earn points; points cannot offset freight.',
'marketing.pointsFlow.pageTip': 'marketing.pointsFlow.pageTip':
'Ledger includes earn, clawback, and manual adjustments. Filter by customer code; reason ≥10 chars. Use Rules button for grant ratio settings.', 'Ledger of base-freight grants, refund/delete clawbacks, and adjustments. Leveling uses rolling 12 months. Points cannot offset freight.',
'marketing.pointsFlow.searchPlaceholder': 'Search remark / biz no.', 'marketing.pointsFlow.searchPlaceholder': 'Search remark / biz no.',
'marketing.pointsFlow.customerCodePlaceholder': 'Customer code', 'marketing.pointsFlow.customerCodePlaceholder': 'Customer code',
'marketing.pointsFlow.changeTypeFilter': 'Change type', 'marketing.pointsFlow.changeTypeFilter': 'Change type',
@ -69,7 +68,7 @@ export default {
'marketing.pointsFlow.adjustModal.remarkMin': 'Reason must be at least 10 characters', 'marketing.pointsFlow.adjustModal.remarkMin': 'Reason must be at least 10 characters',
'marketing.couponTemplate.pageTip': 'marketing.couponTemplate.pageTip':
'Published templates can be claimed; ending issuance does not invalidate issued coupons. Total quantity can only increase.', 'Coupons deduct base freight only and are mutex with member discount. Newcomer pack auto-grants on register; second-order coupon unlocks after first delivery success.',
'marketing.couponTemplate.searchPlaceholder': 'Search code / name', 'marketing.couponTemplate.searchPlaceholder': 'Search code / name',
'marketing.couponTemplate.statusFilter': 'Status', 'marketing.couponTemplate.statusFilter': 'Status',
'marketing.couponTemplate.status.draft': 'Draft', 'marketing.couponTemplate.status.draft': 'Draft',
@ -77,13 +76,13 @@ export default {
'marketing.couponTemplate.status.ended': 'Ended', 'marketing.couponTemplate.status.ended': 'Ended',
'marketing.couponTemplate.modal.add': 'Add template', 'marketing.couponTemplate.modal.add': 'Add template',
'marketing.couponTemplate.modal.edit': 'Edit template', 'marketing.couponTemplate.modal.edit': 'Edit template',
'marketing.couponTemplate.modalTip': 'Min order threshold uses AmountAfterLevel. One coupon per order.', 'marketing.couponTemplate.modalTip': 'Threshold uses paid base freight. One coupon per order; not stacked with member discount.',
'marketing.couponTemplate.form.templateCode': 'Template code', 'marketing.couponTemplate.form.templateCode': 'Template code',
'marketing.couponTemplate.form.templateName': 'Name', 'marketing.couponTemplate.form.templateName': 'Name',
'marketing.couponTemplate.form.couponType': 'Type', 'marketing.couponTemplate.form.couponType': 'Type',
'marketing.couponTemplate.form.grantChannel': 'Issue type', 'marketing.couponTemplate.form.grantChannel': 'Issue type',
'marketing.couponTemplate.form.grantChannelTip': 'marketing.couponTemplate.form.grantChannelTip':
'Choose Newcomer to auto-grant every published newcomer template on successful registration', 'Newcomer: grant on register, second coupon unlocks after first delivery. Referral: grant both sides after invitee first delivery. Route: country codes. Festival: bulk grant.',
'marketing.couponTemplate.form.faceValue': 'Face value', 'marketing.couponTemplate.form.faceValue': 'Face value',
'marketing.couponTemplate.form.discountRate': 'Discount rate', 'marketing.couponTemplate.form.discountRate': 'Discount rate',
'marketing.couponTemplate.form.discountRateExtra': 'e.g. 0.95 = 5% off', 'marketing.couponTemplate.form.discountRateExtra': 'e.g. 0.95 = 5% off',
@ -91,14 +90,16 @@ export default {
'marketing.couponTemplate.form.maxDiscountAmountExtra': 'Optional cap for percent-off; empty = no cap', 'marketing.couponTemplate.form.maxDiscountAmountExtra': 'Optional cap for percent-off; empty = no cap',
'marketing.couponTemplate.form.maxDiscountAmountPlaceholder': 'Optional', 'marketing.couponTemplate.form.maxDiscountAmountPlaceholder': 'Optional',
'marketing.couponTemplate.form.minOrderAmount': 'Min order amount', 'marketing.couponTemplate.form.minOrderAmount': 'Min order amount',
'marketing.couponTemplate.form.minOrderAmountExtra': 'Threshold after level discount', 'marketing.couponTemplate.form.minOrderAmountExtra': 'Threshold is paid base freight; 0 = no min',
'marketing.couponTemplate.form.totalQuantity': 'Total quantity', 'marketing.couponTemplate.form.totalQuantity': 'Total quantity',
'marketing.couponTemplate.form.totalQuantityExtra': '0 = unlimited', 'marketing.couponTemplate.form.totalQuantityExtra': '0 = unlimited',
'marketing.couponTemplate.form.perCustomerLimit': 'Per customer limit', 'marketing.couponTemplate.form.perCustomerLimit': 'Per customer limit',
'marketing.couponTemplate.form.validType': 'Validity type', 'marketing.couponTemplate.form.validType': 'Validity type',
'marketing.couponTemplate.form.validStartTime': 'Start time', 'marketing.couponTemplate.form.validStartTime': 'Start time',
'marketing.couponTemplate.form.validEndTime': 'End time', 'marketing.couponTemplate.form.validEndTime': 'End time',
'marketing.couponTemplate.form.validDaysAfterReceive': 'Valid days after claim', 'marketing.couponTemplate.form.validDaysAfterReceive': 'Valid days after claim/unlock',
'marketing.couponTemplate.form.applyCountries': 'Country codes',
'marketing.couponTemplate.form.applyCountriesExtra': 'For route coupons, e.g. MY,SG; empty = all countries',
'marketing.couponTemplate.form.remark': 'Remark', 'marketing.couponTemplate.form.remark': 'Remark',
'marketing.couponTemplate.form.requiredHint': 'Template code, name and type are required', 'marketing.couponTemplate.form.requiredHint': 'Template code, name and type are required',
'marketing.couponTemplate.columns.issued': 'Issued', 'marketing.couponTemplate.columns.issued': 'Issued',
@ -111,8 +112,12 @@ export default {
'marketing.couponTemplate.grantChannel.manual': 'Manual grant', 'marketing.couponTemplate.grantChannel.manual': 'Manual grant',
'marketing.couponTemplate.grantChannel.newcomer': 'Newcomer', 'marketing.couponTemplate.grantChannel.newcomer': 'Newcomer',
'marketing.couponTemplate.grantChannel.selfReceive': 'Self claim', 'marketing.couponTemplate.grantChannel.selfReceive': 'Self claim',
'marketing.couponTemplate.grantChannel.referral': 'Referral',
'marketing.couponTemplate.grantChannel.route': 'Route',
'marketing.couponTemplate.grantChannel.festival': 'Festival',
'marketing.couponTemplate.validType.fixed': 'Fixed dates', 'marketing.couponTemplate.validType.fixed': 'Fixed dates',
'marketing.couponTemplate.validType.afterReceive': 'N days after claim', 'marketing.couponTemplate.validType.afterReceive': 'N days after claim',
'marketing.couponTemplate.validType.afterUnlock': 'N days after unlock',
'marketing.couponTemplate.actions.publish': 'Publish', 'marketing.couponTemplate.actions.publish': 'Publish',
'marketing.couponTemplate.actions.end': 'End', 'marketing.couponTemplate.actions.end': 'End',
'marketing.couponTemplate.publishConfirmTitle': 'Publish template', 'marketing.couponTemplate.publishConfirmTitle': 'Publish template',
@ -121,7 +126,7 @@ export default {
'marketing.couponTemplate.endConfirmContent': 'No new claims; existing coupons remain valid.', 'marketing.couponTemplate.endConfirmContent': 'No new claims; existing coupons remain valid.',
'marketing.couponGrant.pageTip': 'marketing.couponGrant.pageTip':
'List shows issued coupon instances. Use Bulk grant to issue coupons; sub-accounts map to main customer. >100 customers or >5000 est. discount requires confirm phrase.', 'Issued coupon instances. Festival and route coupons can be bulk-granted here. Coupons deduct base freight only and are mutex with member discount.',
'marketing.couponGrant.tab.grant': 'Bulk grant', 'marketing.couponGrant.tab.grant': 'Bulk grant',
'marketing.couponGrant.tab.records': 'Records', 'marketing.couponGrant.tab.records': 'Records',
'marketing.couponGrant.modalTip': 'Grants cannot be undone. Preview first, then confirm.', 'marketing.couponGrant.modalTip': 'Grants cannot be undone. Preview first, then confirm.',
@ -159,28 +164,37 @@ export default {
'marketing.couponGrant.records.columns.validEndTime': 'Valid until', 'marketing.couponGrant.records.columns.validEndTime': 'Valid until',
'marketing.couponGrant.records.columns.useTime': 'Used at', 'marketing.couponGrant.records.columns.useTime': 'Used at',
'marketing.couponGrant.instanceStatus.available': 'Available', 'marketing.couponGrant.instanceStatus.available': 'Available',
'marketing.couponGrant.instanceStatus.pendingUnlock': 'Locked (unlock)',
'marketing.couponGrant.instanceStatus.locked': 'Locked', 'marketing.couponGrant.instanceStatus.locked': 'Locked',
'marketing.couponGrant.instanceStatus.used': 'Used', 'marketing.couponGrant.instanceStatus.used': 'Used',
'marketing.couponGrant.instanceStatus.expired': 'Expired', 'marketing.couponGrant.instanceStatus.expired': 'Expired',
'marketing.commissionRule.pageTip': 'marketing.commissionRule.pageTip':
'Commission is calculated only after deliverySuccess. Example: ¥1000→¥2; ¥999 payable = ¥0. Changes do not retroactively affect settled rows.', 'Invite rewards use paid base freight after deliverySuccess: 5% on the 1st order (cap 50), 3% on orders 2–6 (cap 30, within 90 days of register). Below the paid-base-freight floor earns nothing. Credits freight balance after T+3 business days; refunds claw back. Invitees get a ¥5 coupon on register. Rule changes are not retroactive.',
'marketing.commissionRule.form.ruleName': 'Rule name', 'marketing.commissionRule.form.ruleName': 'Rule name',
'marketing.commissionRule.form.baseAmount': 'Base amount', 'marketing.commissionRule.form.minInviteLevelCode': 'Min invite level',
'marketing.commissionRule.form.baseAmountExtra': 'Floor segment size', 'marketing.commissionRule.form.minInviteLevelCodeExtra': 'Default V1; V0 cannot invite',
'marketing.commissionRule.form.commissionAmount': 'Commission per segment', 'marketing.commissionRule.form.minPaidBaseFreight': 'Min paid base freight',
'marketing.commissionRule.form.commissionAmountExtra': 'e.g. 1000→2, 999 payable = 0', 'marketing.commissionRule.form.minPaidBaseFreightExtra': 'Paid base freight only, excluding surcharges',
'marketing.commissionRule.form.freezeDays': 'Freeze days', 'marketing.commissionRule.form.firstOrderRate': '1st-order rate (%)',
'marketing.commissionRule.form.freezeDaysExtra': 'Days after deliverySuccess before settle', 'marketing.commissionRule.form.firstOrderRateExtra': 'First delivered order',
'marketing.commissionRule.form.firstOrderCap': '1st-order cap',
'marketing.commissionRule.form.followOrderRate': 'Follow-on rate (%)',
'marketing.commissionRule.form.followOrderRateExtra': 'Orders 2–N',
'marketing.commissionRule.form.followOrderCap': 'Follow-on cap',
'marketing.commissionRule.form.followWindowDays': 'Follow-on window (days)',
'marketing.commissionRule.form.maxRewardOrders': 'Max rewarded orders',
'marketing.commissionRule.form.settleBusinessDays': 'Settle business days',
'marketing.commissionRule.form.settleBusinessDaysExtra': 'T+N business days after deliverySuccess into freight balance',
'marketing.commissionRule.form.monthlyCap': 'Monthly cap', 'marketing.commissionRule.form.monthlyCap': 'Monthly cap',
'marketing.commissionRule.form.monthlyCapExtra': 'Empty = unlimited', 'marketing.commissionRule.form.monthlyCapExtra': 'Empty = unlimited',
'marketing.commissionRule.form.status': 'Status', 'marketing.commissionRule.form.status': 'Status',
'marketing.commissionRule.saveConfirmTitle': 'Save commission rule', 'marketing.commissionRule.saveConfirmTitle': 'Save commission rule',
'marketing.commissionRule.saveCompare': 'marketing.commissionRule.saveCompare':
'Base {oldBase}→{newBase}, commission {oldCommission}→{newCommission}/segment, freeze {oldFreeze}→{newFreeze} days. Settled rows unchanged.', '1st {oldRate}%→{newRate}%, follow {oldFollow}%→{newFollow}%, floor {oldMin}→{newMin}, settle {oldSettle}→{newSettle} business days. Settled rows unchanged.',
'marketing.commissionDetail.pageTip': 'marketing.commissionDetail.pageTip':
'Commission details. Use toolbar to adjust balance or edit commission rules. Do not set Pending to Settled.', 'Commission details. Use toolbar to adjust balance. Edit commission rules on the Points rules page. Do not set Pending to Settled.',
'marketing.commissionDetail.searchPlaceholder': 'Waybill no.', 'marketing.commissionDetail.searchPlaceholder': 'Waybill no.',
'marketing.commissionDetail.statusFilter': 'Status', 'marketing.commissionDetail.statusFilter': 'Status',
'marketing.commissionDetail.status.pending': 'Pending', 'marketing.commissionDetail.status.pending': 'Pending',
@ -190,8 +204,10 @@ export default {
'marketing.commissionDetail.columns.referrer': 'Referrer', 'marketing.commissionDetail.columns.referrer': 'Referrer',
'marketing.commissionDetail.columns.referee': 'Referee', 'marketing.commissionDetail.columns.referee': 'Referee',
'marketing.commissionDetail.columns.orderNo': 'Waybill', 'marketing.commissionDetail.columns.orderNo': 'Waybill',
'marketing.commissionDetail.columns.baseAmount': 'Base amount', 'marketing.commissionDetail.columns.baseAmount': 'Paid base freight',
'marketing.commissionDetail.columns.commission': 'Commission', 'marketing.commissionDetail.columns.commission': 'Reward',
'marketing.commissionDetail.columns.orderIndex': 'Order #',
'marketing.commissionDetail.columns.settleDue': 'Settle due',
'marketing.commissionDetail.columns.status': 'Status', 'marketing.commissionDetail.columns.status': 'Status',
'marketing.commissionDetail.columns.eligibleTime': 'Eligible time', 'marketing.commissionDetail.columns.eligibleTime': 'Eligible time',
'marketing.commissionDetail.columns.settleTime': 'Settle time', 'marketing.commissionDetail.columns.settleTime': 'Settle time',
@ -276,7 +292,8 @@ export default {
'marketing.portal.common.eyebrow': 'Marketing', 'marketing.portal.common.eyebrow': 'Marketing',
'marketing.portal.invite.title': 'Invite Friends', 'marketing.portal.invite.title': 'Invite Friends',
'marketing.portal.invite.subtitle': 'Share your link. After friends register and get delivered, you earn referral commission.', 'marketing.portal.invite.subtitle':
'V1+ can invite. Friends get ¥5 on register. After their orders are delivered and paid base freight meets the floor, you earn a percent into freight balance.',
'marketing.portal.invite.howTitle': '3 quick steps', 'marketing.portal.invite.howTitle': '3 quick steps',
'marketing.portal.invite.howHint': 'Share in about a minute', 'marketing.portal.invite.howHint': 'Share in about a minute',
'marketing.portal.invite.how1Title': 'Copy your invite', 'marketing.portal.invite.how1Title': 'Copy your invite',
@ -284,8 +301,14 @@ export default {
'marketing.portal.invite.how2Title': 'Friends register', 'marketing.portal.invite.how2Title': 'Friends register',
'marketing.portal.invite.how2Desc': 'marketing.portal.invite.how2Desc':
'Friends open the link in WeChat and finish official-account registration to bind the referral', 'Friends open the link in WeChat and finish official-account registration to bind the referral',
'marketing.portal.invite.how3Title': 'Earn after delivery', 'marketing.portal.invite.how3Title': 'Wait for delivered orders',
'marketing.portal.invite.how3Desc': 'When their order is delivered, commission goes to Referral Earnings', 'marketing.portal.invite.how3Desc':
'Friends get ¥5 on register. You earn 5% of paid base freight on their 1st delivered order and 3% on orders 2–6, credited to freight balance after T+3 business days.',
'marketing.portal.invite.lockedTitle': 'Upgrade to {level} to invite friends',
'marketing.portal.invite.lockedDesc': 'Your current level cannot invite. Place orders to earn points and upgrade.',
'marketing.portal.invite.pendingReward': 'Pending',
'marketing.portal.invite.settledReward': 'Credited',
'marketing.portal.invite.tag.reward': '{count} rewarded orders',
'marketing.portal.invite.yourCode': 'Your invite code', 'marketing.portal.invite.yourCode': 'Your invite code',
'marketing.portal.invite.code': 'Invite code', 'marketing.portal.invite.code': 'Invite code',
'marketing.portal.invite.link': 'Invite link', 'marketing.portal.invite.link': 'Invite link',
@ -302,7 +325,7 @@ export default {
'marketing.portal.invite.step3': 'After their order is delivered, you earn commission', 'marketing.portal.invite.step3': 'After their order is delivered, you earn commission',
'marketing.portal.invite.refereeCount': 'Invited friends', 'marketing.portal.invite.refereeCount': 'Invited friends',
'marketing.portal.invite.earnTitle': 'View earnings', 'marketing.portal.invite.earnTitle': 'View earnings',
'marketing.portal.invite.earnDesc': 'Commission settles after delivery success', 'marketing.portal.invite.earnDesc': 'Credits freight balance T+3 business days after delivery',
'marketing.portal.invite.levelTitle': 'My membership', 'marketing.portal.invite.levelTitle': 'My membership',
'marketing.portal.invite.levelDesc': 'Earn points from paid orders to upgrade membership', 'marketing.portal.invite.levelDesc': 'Earn points from paid orders to upgrade membership',
'marketing.portal.invite.refereeTitle': 'My referees', 'marketing.portal.invite.refereeTitle': 'My referees',
@ -314,28 +337,29 @@ export default {
'marketing.portal.invite.tag.delivered': 'Delivered', 'marketing.portal.invite.tag.delivered': 'Delivered',
'marketing.portal.member.title': 'My Level', 'marketing.portal.member.title': 'My Level',
'marketing.portal.member.subtitle': 'Points accumulate after successful payment. Higher levels unlock better extra discounts.', 'marketing.portal.member.subtitle':
'marketing.portal.member.howTitle': 'How to level up', 'Level is based on paid base freight in the last 12 months. V4 Diamond and V5 Crown are not protected; volume drops can downgrade. Surcharges are not discounted.',
'marketing.portal.member.howHint': 'More orders, more points', 'marketing.portal.member.howTitle': 'How levels work',
'marketing.portal.member.how1Title': 'Place and pay', 'marketing.portal.member.howHint': 'Rolling 12-month base-freight points',
'marketing.portal.member.how1Desc': 'Points are granted automatically after successful payment', 'marketing.portal.member.how1Title': 'Pay base freight',
'marketing.portal.member.how2Title': 'Accumulate to upgrade', 'marketing.portal.member.how1Desc': '1 yuan of paid base freight = 1 point; surcharges and coupons excluded',
'marketing.portal.member.how2Desc': 'Reach the next threshold to upgrade your membership level', 'marketing.portal.member.how2Title': 'Rolling 12-month level',
'marketing.portal.member.how3Title': 'Enjoy better rates', 'marketing.portal.member.how2Desc': 'Daily rebuild; upgrades apply immediately',
'marketing.portal.member.how3Desc': 'Higher levels unlock better extra discounts', 'marketing.portal.member.how3Title': 'Base-freight discount',
'marketing.portal.member.how3Desc': 'Up to 5% off base freight only; coupons do not stack — best of the two',
'marketing.portal.member.currentLevel': 'Current level', 'marketing.portal.member.currentLevel': 'Current level',
'marketing.portal.member.defaultLevel': 'Standard', 'marketing.portal.member.defaultLevel': 'Standard',
'marketing.portal.member.priceTier': 'Price tier', 'marketing.portal.member.priceTier': 'Price tier',
'marketing.portal.member.totalPoints': 'Total points', 'marketing.portal.member.totalPoints': '12-month points',
'marketing.portal.member.availablePoints': 'Available points', 'marketing.portal.member.availablePoints': 'Available points',
'marketing.portal.member.extraDiscount': 'Extra discount', 'marketing.portal.member.extraDiscount': 'Base-freight discount',
'marketing.portal.member.nextLevel': 'Next: {name}', 'marketing.portal.member.nextLevel': 'Next: {name}',
'marketing.portal.member.needPoints': '{points} more points to upgrade', 'marketing.portal.member.needPoints': '{points} more points to the next level',
'marketing.portal.member.topLevelTip': 'You are at the top level. Keep ordering to earn points.', 'marketing.portal.member.topLevelTip': 'Top level. V4/V5 can step down if volume drops.',
'marketing.portal.member.pointsFlowTitle': 'Points history', 'marketing.portal.member.pointsFlowTitle': 'Points history',
'marketing.portal.member.flowHint': 'Every grant and deduction shows here', 'marketing.portal.member.flowHint': 'For leveling only — cannot offset freight',
'marketing.portal.member.emptyFlowTitle': 'No points history yet', 'marketing.portal.member.emptyFlowTitle': 'No points history yet',
'marketing.portal.member.emptyFlowDesc': 'Points are granted after successful payment', 'marketing.portal.member.emptyFlowDesc': 'Points are granted from paid base freight',
'marketing.portal.member.goOrder': 'Place an order', 'marketing.portal.member.goOrder': 'Place an order',
'marketing.portal.member.flow.changeType': 'Type', 'marketing.portal.member.flow.changeType': 'Type',
'marketing.portal.member.flow.pointsDelta': 'Delta', 'marketing.portal.member.flow.pointsDelta': 'Delta',
@ -346,23 +370,26 @@ export default {
'marketing.portal.coupons.title': 'My Coupons', 'marketing.portal.coupons.title': 'My Coupons',
'marketing.portal.coupons.subtitle': 'One coupon per order. Non-transferable. Sub-accounts use the main account pack.', 'marketing.portal.coupons.subtitle': 'One coupon per order. Non-transferable. Sub-accounts use the main account pack.',
'marketing.portal.coupons.howTitle': 'How to use coupons', 'marketing.portal.coupons.howTitle': 'How to use coupons',
'marketing.portal.coupons.howHint': 'One per order at checkout', 'marketing.portal.coupons.howHint': 'One per order; base freight only; mutex with member discount',
'marketing.portal.coupons.how1Title': 'Check available', 'marketing.portal.coupons.how1Title': 'Check coupons',
'marketing.portal.coupons.how1Desc': 'Confirm face value, min order, and validity in Available', 'marketing.portal.coupons.how1Desc': 'Locked coupons unlock after first delivery; check face value and min order in Available',
'marketing.portal.coupons.how2Title': 'Select at checkout', 'marketing.portal.coupons.how2Title': 'Select at checkout',
'marketing.portal.coupons.how2Desc': 'Apply one coupon on the place-order settlement page', 'marketing.portal.coupons.how2Desc': 'Apply one coupon; it only deducts base freight, not surcharges',
'marketing.portal.coupons.how3Title': 'Watch expiry', 'marketing.portal.coupons.how3Title': 'Watch expiry',
'marketing.portal.coupons.how3Desc': 'Expiring-soon coupons are highlighted—use them soon', 'marketing.portal.coupons.how3Desc': 'You get an in-app notice 3 days before expiry',
'marketing.portal.coupons.tab.available': 'Available', 'marketing.portal.coupons.tab.available': 'Available',
'marketing.portal.coupons.tab.locked': 'Locked',
'marketing.portal.coupons.tab.used': 'Used', 'marketing.portal.coupons.tab.used': 'Used',
'marketing.portal.coupons.tab.expired': 'Expired', 'marketing.portal.coupons.tab.expired': 'Expired',
'marketing.portal.coupons.empty': 'No coupons', 'marketing.portal.coupons.empty': 'No coupons',
'marketing.portal.coupons.emptyTitle': 'No available coupons', 'marketing.portal.coupons.emptyTitle': 'No available coupons',
'marketing.portal.coupons.emptyDesc': 'Watch for campaigns, or place an order to check rates', 'marketing.portal.coupons.emptyDesc': 'Watch for campaigns, or place an order to check rates',
'marketing.portal.coupons.emptyLockedTitle': 'No locked coupons',
'marketing.portal.coupons.emptyUsedTitle': 'No usage history', 'marketing.portal.coupons.emptyUsedTitle': 'No usage history',
'marketing.portal.coupons.emptyExpiredTitle': 'No expired coupons', 'marketing.portal.coupons.emptyExpiredTitle': 'No expired coupons',
'marketing.portal.coupons.emptyOtherDesc': 'Switch to Available to see your current pack', 'marketing.portal.coupons.emptyOtherDesc': 'Switch to Available to see your current pack',
'marketing.portal.coupons.minOrder': 'Min order ¥{amount}', 'marketing.portal.coupons.minOrder': 'Min order ¥{amount}',
'marketing.portal.coupons.unlockHint': 'Complete first delivery to unlock the ¥15 coupon',
'marketing.portal.coupons.expiringSoon': 'Expiring soon', 'marketing.portal.coupons.expiringSoon': 'Expiring soon',
'marketing.portal.coupons.discountUnit': ' off', 'marketing.portal.coupons.discountUnit': ' off',
'marketing.portal.coupons.unnamed': 'Coupon', 'marketing.portal.coupons.unnamed': 'Coupon',
@ -425,10 +452,10 @@ export default {
'marketing.priceCheck.couponUnavailable': 'Unavailable', 'marketing.priceCheck.couponUnavailable': 'Unavailable',
'marketing.priceCheck.couponChanged': 'Price recalculated — confirm payable amount', 'marketing.priceCheck.couponChanged': 'Price recalculated — confirm payable amount',
'marketing.priceCheck.breakdownTitle': 'Price breakdown', 'marketing.priceCheck.breakdownTitle': 'Price breakdown',
'marketing.priceCheck.breakdown.receivableSubtotal': 'Subtotal after tier', 'marketing.priceCheck.breakdown.receivableSubtotal': 'Fee subtotal',
'marketing.priceCheck.breakdown.levelDiscount': 'Level extra discount', 'marketing.priceCheck.breakdown.levelDiscount': 'Member base-freight discount',
'marketing.priceCheck.breakdown.amountAfterLevel': 'Before coupon', 'marketing.priceCheck.breakdown.amountAfterLevel': 'After member discount',
'marketing.priceCheck.breakdown.couponDiscount': 'Coupon discount', 'marketing.priceCheck.breakdown.couponDiscount': 'Coupon (best of member vs coupon)',
'marketing.priceCheck.breakdown.payable': 'Payable', 'marketing.priceCheck.breakdown.payable': 'Payable',
'marketing.register.title': 'Register', 'marketing.register.title': 'Register',
@ -463,7 +490,7 @@ export default {
'profile.marketing.coupons': 'My coupons', 'profile.marketing.coupons': 'My coupons',
'profile.marketing.commission': 'Referral earnings', 'profile.marketing.commission': 'Referral earnings',
'profile.marketing.inviteDesc': 'Share link and grow referees', 'profile.marketing.inviteDesc': 'Share link and grow referees',
'profile.marketing.memberDesc': 'Points and membership level', 'profile.marketing.memberDesc': 'Rolling 12-month points and membership level',
'profile.marketing.couponsDesc': 'View available coupons', 'profile.marketing.couponsDesc': 'View available coupons',
'profile.marketing.commissionDesc': 'Commission, transfer, withdraw', 'profile.marketing.commissionDesc': 'Commission, transfer, withdraw',
'profile.marketing.navigateFailed': 'Failed to open page, please refresh and try again', 'profile.marketing.navigateFailed': 'Failed to open page, please refresh and try again',

View File

@ -26,20 +26,19 @@ export default {
'将向 {count} 个客户发放「{templateName}」共 {total} 张,预估最大抵扣 {amount} 元,发放后不可撤回。', '将向 {count} 个客户发放「{templateName}」共 {total} 张,预估最大抵扣 {amount} 元,发放后不可撤回。',
'marketing.pointsRule.pageTip': 'marketing.pointsRule.pageTip':
'积分在支付成功后按实付金额发放;修改比例不追溯历史。列表查询与人工调账见「积分流水」页签,调账原因不少于 10 字。', '实付基础运费 1 元 = 1 积分;燃油、偏远、清关、退件、关税、赔偿及优惠券抵扣不计积分。积分只用于滚动 12 个月定级,不可抵运费、兑礼或提现。',
'marketing.pointsRule.tab.rule': '规则配置', 'marketing.pointsRule.sectionTitle': '积分规则',
'marketing.pointsRule.tab.flow': '积分流水', 'marketing.pointsRule.tab.flow': '积分流水',
'marketing.pointsRule.form.ruleName': '规则名称', 'marketing.pointsRule.form.ruleName': '规则名称',
'marketing.pointsRule.form.pointsPerYuan': '每元得积分', 'marketing.pointsRule.form.pointsPerYuan': '每元基础运费积分',
'marketing.pointsRule.form.pointsPerYuanExtra': '基数为应付实付金额', 'marketing.pointsRule.form.pointsPerYuanExtra': '基数为实付基础运费,不含附加费与券抵扣',
'marketing.pointsRule.form.firstOrderMultiplier': '首单倍数',
'marketing.pointsRule.form.maxPointsPerOrder': '单笔积分上限',
'marketing.pointsRule.form.status': '状态', 'marketing.pointsRule.form.status': '状态',
'marketing.pointsRule.saveConfirmTitle': '确认保存积分规则', 'marketing.pointsRule.saveConfirmTitle': '确认保存积分规则',
'marketing.pointsRule.saveConfirmContent': '保存后仅影响之后的订单积分发放,不会追溯已支付订单。', 'marketing.pointsRule.saveConfirmContent':
'保存后仅影响之后支付的订单积分;改比例不追溯。附加费不计积分,积分不可抵扣运费。',
'marketing.pointsFlow.pageTip': 'marketing.pointsFlow.pageTip':
'积分流水记录支付发放、退款扣回与人工调账。可按客户编号筛选;调账原因不少于 10 字。规则配置请点工具栏「规则配置」。', '流水记录基础运费发放、退款/删单扣回与人工调账。定级看近 12 个月合计,不是终身累计。积分不可抵运费。',
'marketing.pointsFlow.searchPlaceholder': '搜索备注/业务号', 'marketing.pointsFlow.searchPlaceholder': '搜索备注/业务号',
'marketing.pointsFlow.customerCodePlaceholder': '客户编号', 'marketing.pointsFlow.customerCodePlaceholder': '客户编号',
'marketing.pointsFlow.changeTypeFilter': '变动类型', 'marketing.pointsFlow.changeTypeFilter': '变动类型',
@ -69,7 +68,7 @@ export default {
'marketing.pointsFlow.adjustModal.remarkMin': '调账原因不少于 10 字', 'marketing.pointsFlow.adjustModal.remarkMin': '调账原因不少于 10 字',
'marketing.couponTemplate.pageTip': 'marketing.couponTemplate.pageTip':
'券模板发布后客户可领取或使用;停发不影响已领券。面额/门槛修改不追溯已发券。总量只允许增大。', '券只抵基础运费,与会员折扣互斥取更优。新人券注册自动发;第二单券等首单派送成功后解锁。线路定向券填写国家代码。',
'marketing.couponTemplate.searchPlaceholder': '搜索编码/名称', 'marketing.couponTemplate.searchPlaceholder': '搜索编码/名称',
'marketing.couponTemplate.statusFilter': '状态', 'marketing.couponTemplate.statusFilter': '状态',
'marketing.couponTemplate.status.draft': '草稿', 'marketing.couponTemplate.status.draft': '草稿',
@ -77,13 +76,13 @@ export default {
'marketing.couponTemplate.status.ended': '已停发', 'marketing.couponTemplate.status.ended': '已停发',
'marketing.couponTemplate.modal.add': '新增券模板', 'marketing.couponTemplate.modal.add': '新增券模板',
'marketing.couponTemplate.modal.edit': '编辑券模板', 'marketing.couponTemplate.modal.edit': '编辑券模板',
'marketing.couponTemplate.modalTip': '满减门槛按等级折后金额(AmountAfterLevel)判断。每单限用 1 张券。', 'marketing.couponTemplate.modalTip': '门槛按实付基础运费判断,附加费不可用券。每单限用 1 张,不与会员折叠加。',
'marketing.couponTemplate.form.templateCode': '模板编码', 'marketing.couponTemplate.form.templateCode': '模板编码',
'marketing.couponTemplate.form.templateName': '券名称', 'marketing.couponTemplate.form.templateName': '券名称',
'marketing.couponTemplate.form.couponType': '券类型', 'marketing.couponTemplate.form.couponType': '券类型',
'marketing.couponTemplate.form.grantChannel': '发放类型', 'marketing.couponTemplate.form.grantChannel': '发放类型',
'marketing.couponTemplate.form.grantChannelTip': 'marketing.couponTemplate.form.grantChannelTip':
'选「新人券」后,客户注册成功会自动发放全部已发布的新人券模板(有多少发多少)', '新人券:注册发首单券/备货券,第二单券待派送成功解锁。裂变券:新客首单派送成功后给双方发券。线路定向:填适用国家。节日券:批量发放。',
'marketing.couponTemplate.form.faceValue': '面额', 'marketing.couponTemplate.form.faceValue': '面额',
'marketing.couponTemplate.form.discountRate': '折扣率', 'marketing.couponTemplate.form.discountRate': '折扣率',
'marketing.couponTemplate.form.discountRateExtra': '如 0.95 表示 95 折', 'marketing.couponTemplate.form.discountRateExtra': '如 0.95 表示 95 折',
@ -91,14 +90,16 @@ export default {
'marketing.couponTemplate.form.maxDiscountAmountExtra': '折扣券可选封顶金额,空表示不封顶', 'marketing.couponTemplate.form.maxDiscountAmountExtra': '折扣券可选封顶金额,空表示不封顶',
'marketing.couponTemplate.form.maxDiscountAmountPlaceholder': '可选', 'marketing.couponTemplate.form.maxDiscountAmountPlaceholder': '可选',
'marketing.couponTemplate.form.minOrderAmount': '使用门槛', 'marketing.couponTemplate.form.minOrderAmount': '使用门槛',
'marketing.couponTemplate.form.minOrderAmountExtra': '门槛基数为等级折后金额', 'marketing.couponTemplate.form.minOrderAmountExtra': '门槛基数为实付基础运费,0 表示无门槛',
'marketing.couponTemplate.form.totalQuantity': '发放总量', 'marketing.couponTemplate.form.totalQuantity': '发放总量',
'marketing.couponTemplate.form.totalQuantityExtra': '0 表示不限', 'marketing.couponTemplate.form.totalQuantityExtra': '0 表示不限',
'marketing.couponTemplate.form.perCustomerLimit': '每人上限', 'marketing.couponTemplate.form.perCustomerLimit': '每人上限',
'marketing.couponTemplate.form.validType': '有效期类型', 'marketing.couponTemplate.form.validType': '有效期类型',
'marketing.couponTemplate.form.validStartTime': '开始时间', 'marketing.couponTemplate.form.validStartTime': '开始时间',
'marketing.couponTemplate.form.validEndTime': '结束时间', 'marketing.couponTemplate.form.validEndTime': '结束时间',
'marketing.couponTemplate.form.validDaysAfterReceive': '领取后有效天数', 'marketing.couponTemplate.form.validDaysAfterReceive': '领取/解锁后有效天数',
'marketing.couponTemplate.form.applyCountries': '适用国家',
'marketing.couponTemplate.form.applyCountriesExtra': '线路定向券填写,如 MY,SG;空表示不限国家',
'marketing.couponTemplate.form.remark': '备注', 'marketing.couponTemplate.form.remark': '备注',
'marketing.couponTemplate.form.requiredHint': '请填写模板编码、名称与券类型', 'marketing.couponTemplate.form.requiredHint': '请填写模板编码、名称与券类型',
'marketing.couponTemplate.columns.issued': '已发', 'marketing.couponTemplate.columns.issued': '已发',
@ -111,8 +112,12 @@ export default {
'marketing.couponTemplate.grantChannel.manual': '手动发放', 'marketing.couponTemplate.grantChannel.manual': '手动发放',
'marketing.couponTemplate.grantChannel.newcomer': '新人券', 'marketing.couponTemplate.grantChannel.newcomer': '新人券',
'marketing.couponTemplate.grantChannel.selfReceive': '客户自领', 'marketing.couponTemplate.grantChannel.selfReceive': '客户自领',
'marketing.couponTemplate.grantChannel.referral': '裂变券',
'marketing.couponTemplate.grantChannel.route': '线路定向券',
'marketing.couponTemplate.grantChannel.festival': '节日券',
'marketing.couponTemplate.validType.fixed': '固定日期', 'marketing.couponTemplate.validType.fixed': '固定日期',
'marketing.couponTemplate.validType.afterReceive': '领取后 N 天', 'marketing.couponTemplate.validType.afterReceive': '领取后 N 天',
'marketing.couponTemplate.validType.afterUnlock': '解锁后 N 天',
'marketing.couponTemplate.actions.publish': '发布', 'marketing.couponTemplate.actions.publish': '发布',
'marketing.couponTemplate.actions.end': '停发', 'marketing.couponTemplate.actions.end': '停发',
'marketing.couponTemplate.publishConfirmTitle': '确认发布券模板', 'marketing.couponTemplate.publishConfirmTitle': '确认发布券模板',
@ -121,7 +126,7 @@ export default {
'marketing.couponTemplate.endConfirmContent': '停发后不可新领,已领券仍可使用。', 'marketing.couponTemplate.endConfirmContent': '停发后不可新领,已领券仍可使用。',
'marketing.couponGrant.pageTip': 'marketing.couponGrant.pageTip':
'列表为已发放的券实例。点「批量发放」可向客户发券;子账号编号自动归主客户。超过 100 人或预估抵扣超过 5000 元须输入「确认发放」。', '列表为已发放的券实例。节日券、线路定向券可在此批量发放。券只抵基础运费,与会员折扣互斥。',
'marketing.couponGrant.tab.grant': '批量发放', 'marketing.couponGrant.tab.grant': '批量发放',
'marketing.couponGrant.tab.records': '发放记录', 'marketing.couponGrant.tab.records': '发放记录',
'marketing.couponGrant.modalTip': '发券后不可撤回。请先预览人数与预估抵扣,再确认发放。', 'marketing.couponGrant.modalTip': '发券后不可撤回。请先预览人数与预估抵扣,再确认发放。',
@ -159,28 +164,37 @@ export default {
'marketing.couponGrant.records.columns.validEndTime': '有效期至', 'marketing.couponGrant.records.columns.validEndTime': '有效期至',
'marketing.couponGrant.records.columns.useTime': '使用时间', 'marketing.couponGrant.records.columns.useTime': '使用时间',
'marketing.couponGrant.instanceStatus.available': '可用', 'marketing.couponGrant.instanceStatus.available': '可用',
'marketing.couponGrant.instanceStatus.pendingUnlock': '未解锁',
'marketing.couponGrant.instanceStatus.locked': '预占中', 'marketing.couponGrant.instanceStatus.locked': '预占中',
'marketing.couponGrant.instanceStatus.used': '已使用', 'marketing.couponGrant.instanceStatus.used': '已使用',
'marketing.couponGrant.instanceStatus.expired': '已过期', 'marketing.couponGrant.instanceStatus.expired': '已过期',
'marketing.commissionRule.pageTip': 'marketing.commissionRule.pageTip':
'分成仅在订单状态为「派送成功」(deliverySuccess) 后计算并进入冻结期。例:每满 1000 元得 2 元,实付 999 元 = 0 分成。改规则不追溯历史。', '邀请奖励按被邀请人派送成功订单的实付基础运费计:第 1 单 5%(封顶 50 元),第 2–6 单 3%(封顶 30 元,注册 90 天内)。实付基础运费不足门槛不计奖。T+3 个工作日入运费余额,退款扣回。被邀请人注册即发 5 元券。改规则不追溯。',
'marketing.commissionRule.form.ruleName': '规则名称', 'marketing.commissionRule.form.ruleName': '规则名称',
'marketing.commissionRule.form.baseAmount': '基数金额(元)', 'marketing.commissionRule.form.minInviteLevelCode': '邀请资格最低等级',
'marketing.commissionRule.form.baseAmountExtra': 'floor 整段:实付每满此金额计一段', 'marketing.commissionRule.form.minInviteLevelCodeExtra': '默认 V1,V0 不可邀请',
'marketing.commissionRule.form.commissionAmount': '每段分成(元)', 'marketing.commissionRule.form.minPaidBaseFreight': '计奖门槛(实付基础运费)',
'marketing.commissionRule.form.commissionAmountExtra': '例:1000→2,999 元实付 = 0 分成', 'marketing.commissionRule.form.minPaidBaseFreightExtra': '按实付基础运费,不含附加费',
'marketing.commissionRule.form.freezeDays': '冻结天数', 'marketing.commissionRule.form.firstOrderRate': '首单比例(%)',
'marketing.commissionRule.form.freezeDaysExtra': '派送成功后可结算的等待天数', 'marketing.commissionRule.form.firstOrderRateExtra': '第 1 笔派送成功订单',
'marketing.commissionRule.form.firstOrderCap': '首单封顶(元)',
'marketing.commissionRule.form.followOrderRate': '后续单比例(%)',
'marketing.commissionRule.form.followOrderRateExtra': '第 2–N 单',
'marketing.commissionRule.form.followOrderCap': '后续单封顶(元)',
'marketing.commissionRule.form.followWindowDays': '后续单窗口(天)',
'marketing.commissionRule.form.maxRewardOrders': '最多计奖单数',
'marketing.commissionRule.form.settleBusinessDays': '结算工作日',
'marketing.commissionRule.form.settleBusinessDaysExtra': '派送成功后 T+N 个工作日入运费余额',
'marketing.commissionRule.form.monthlyCap': '单月封顶(元)', 'marketing.commissionRule.form.monthlyCap': '单月封顶(元)',
'marketing.commissionRule.form.monthlyCapExtra': '留空表示不限', 'marketing.commissionRule.form.monthlyCapExtra': '留空表示不限',
'marketing.commissionRule.form.status': '状态', 'marketing.commissionRule.form.status': '状态',
'marketing.commissionRule.saveConfirmTitle': '确认保存分成规则', 'marketing.commissionRule.saveConfirmTitle': '确认保存分成规则',
'marketing.commissionRule.saveCompare': 'marketing.commissionRule.saveCompare':
'基数 {oldBase}→{newBase} 元,分成 {oldCommission}→{newCommission} 元/段,冻结 {oldFreeze}→{newFreeze} 天。不影响已结算明细。', '首单 {oldRate}%→{newRate}%,后续 {oldFollow}%→{newFollow}%,门槛 {oldMin}→{newMin} 元,结算 {oldSettle}→{newSettle} 个工作日。不影响已结算明细。',
'marketing.commissionDetail.pageTip': 'marketing.commissionDetail.pageTip':
'查询分成明细。工具栏可人工调整余额,或打开分成规则配置。禁止将待结算直接改为已结算。', '查询分成明细。工具栏可人工调整余额。分成规则请到「积分规则」页配置。禁止将待结算直接改为已结算。',
'marketing.commissionDetail.searchPlaceholder': '运单号', 'marketing.commissionDetail.searchPlaceholder': '运单号',
'marketing.commissionDetail.statusFilter': '状态', 'marketing.commissionDetail.statusFilter': '状态',
'marketing.commissionDetail.status.pending': '待结算', 'marketing.commissionDetail.status.pending': '待结算',
@ -190,8 +204,10 @@ export default {
'marketing.commissionDetail.columns.referrer': '引荐人', 'marketing.commissionDetail.columns.referrer': '引荐人',
'marketing.commissionDetail.columns.referee': '下级', 'marketing.commissionDetail.columns.referee': '下级',
'marketing.commissionDetail.columns.orderNo': '运单号', 'marketing.commissionDetail.columns.orderNo': '运单号',
'marketing.commissionDetail.columns.baseAmount': '分成基数', 'marketing.commissionDetail.columns.baseAmount': '实付基础运费',
'marketing.commissionDetail.columns.commission': '分成金额', 'marketing.commissionDetail.columns.commission': '奖励金额',
'marketing.commissionDetail.columns.orderIndex': '第几单',
'marketing.commissionDetail.columns.settleDue': '计划结算',
'marketing.commissionDetail.columns.status': '状态', 'marketing.commissionDetail.columns.status': '状态',
'marketing.commissionDetail.columns.eligibleTime': '派送成功时间', 'marketing.commissionDetail.columns.eligibleTime': '派送成功时间',
'marketing.commissionDetail.columns.settleTime': '结算时间', 'marketing.commissionDetail.columns.settleTime': '结算时间',
@ -276,15 +292,20 @@ export default {
'marketing.portal.common.eyebrow': '营销中心', 'marketing.portal.common.eyebrow': '营销中心',
'marketing.portal.invite.title': '邀请好友', 'marketing.portal.invite.title': '邀请好友',
'marketing.portal.invite.subtitle': '分享专属链接,好友注册并下单后,您可获得推荐收益。', 'marketing.portal.invite.subtitle': 'V1 及以上可邀请。好友注册即得 5 元券;其订单派送成功且实付基础运费达标后,您按比例获得运费余额奖励。',
'marketing.portal.invite.howTitle': '三步上手', 'marketing.portal.invite.howTitle': '三步上手',
'marketing.portal.invite.howHint': '大约 1 分钟完成分享', 'marketing.portal.invite.howHint': '大约 1 分钟完成分享',
'marketing.portal.invite.how1Title': '复制邀请信息', 'marketing.portal.invite.how1Title': '复制邀请信息',
'marketing.portal.invite.how1Desc': '复制邀请码、链接,或保存右侧海报发给好友', 'marketing.portal.invite.how1Desc': '复制邀请码、链接,或保存右侧海报发给好友',
'marketing.portal.invite.how2Title': '好友完成注册', 'marketing.portal.invite.how2Title': '好友完成注册',
'marketing.portal.invite.how2Desc': '好友在微信内打开链接,完成公众号授权注册后自动绑定引荐关系', 'marketing.portal.invite.how2Desc': '好友在微信内打开链接,完成公众号授权注册后自动绑定引荐关系',
'marketing.portal.invite.how3Title': '坐等收益到账', 'marketing.portal.invite.how3Title': '等好友订单派送成功',
'marketing.portal.invite.how3Desc': '好友下单并派送成功后,分成进入推荐收益', 'marketing.portal.invite.how3Desc': '好友注册即得 5 元券。其第 1 单按实付基础运费 5%、第 2–6 单 3% 计入您的待结算奖励,T+3 工作日入运费余额。',
'marketing.portal.invite.lockedTitle': '升级到 {level} 后可邀请好友',
'marketing.portal.invite.lockedDesc': '当前等级暂无邀请资格,先下单累计积分升级会员。',
'marketing.portal.invite.pendingReward': '待入账',
'marketing.portal.invite.settledReward': '已入账',
'marketing.portal.invite.tag.reward': '奖励 {count} 单',
'marketing.portal.invite.yourCode': '您的专属邀请码', 'marketing.portal.invite.yourCode': '您的专属邀请码',
'marketing.portal.invite.code': '邀请码', 'marketing.portal.invite.code': '邀请码',
'marketing.portal.invite.link': '邀请链接', 'marketing.portal.invite.link': '邀请链接',
@ -301,7 +322,7 @@ export default {
'marketing.portal.invite.step3': '好友下单并派送成功后,您可获得分成', 'marketing.portal.invite.step3': '好友下单并派送成功后,您可获得分成',
'marketing.portal.invite.refereeCount': '已邀请好友', 'marketing.portal.invite.refereeCount': '已邀请好友',
'marketing.portal.invite.earnTitle': '查看推荐收益', 'marketing.portal.invite.earnTitle': '查看推荐收益',
'marketing.portal.invite.earnDesc': '派送成功后可结算到佣金账户', 'marketing.portal.invite.earnDesc': '派送成功后 T+3 工作日入运费余额',
'marketing.portal.invite.levelTitle': '我的会员等级', 'marketing.portal.invite.levelTitle': '我的会员等级',
'marketing.portal.invite.levelDesc': '下单累计积分,升级会员等级', 'marketing.portal.invite.levelDesc': '下单累计积分,升级会员等级',
'marketing.portal.invite.refereeTitle': '我的下级', 'marketing.portal.invite.refereeTitle': '我的下级',
@ -313,28 +334,29 @@ export default {
'marketing.portal.invite.tag.delivered': '已派送', 'marketing.portal.invite.tag.delivered': '已派送',
'marketing.portal.member.title': '我的等级', 'marketing.portal.member.title': '我的等级',
'marketing.portal.member.subtitle': '支付成功后累计积分,积分越高额外折扣越优惠。', 'marketing.portal.member.subtitle':
'marketing.portal.member.howTitle': '等级怎么升级', '会员等级根据近 12 个月实付基础运费自动计算;V4 钻石、V5 皇冠不享受保级,发货量下滑会触发降级;附加费不参与折扣。',
'marketing.portal.member.howHint': '下单越多,积分越高', 'marketing.portal.member.howTitle': '等级怎么计算',
'marketing.portal.member.how1Title': '下单并支付', 'marketing.portal.member.howHint': '滚动 12 个月基础运费积分',
'marketing.portal.member.how1Desc': '运单支付成功后,系统自动发放积分', 'marketing.portal.member.how1Title': '支付基础运费',
'marketing.portal.member.how2Title': '积分累计升级', 'marketing.portal.member.how1Desc': '实付基础运费 1 元 = 1 分;附加费和券抵扣不计',
'marketing.portal.member.how2Desc': '达到下一等级门槛后,自动升级会员等级', 'marketing.portal.member.how2Title': '滚动 12 个月定级',
'marketing.portal.member.how3Title': '享受更优运价', 'marketing.portal.member.how2Desc': '每天凌晨按近 12 个月积分更新等级;升级即时生效',
'marketing.portal.member.how3Desc': '等级越高,可叠加的额外折扣越优惠', 'marketing.portal.member.how3Title': '基础运费折扣',
'marketing.portal.member.how3Desc': '最高 95 折且只打基础运费;与优惠券取更优,不叠加',
'marketing.portal.member.currentLevel': '当前等级', 'marketing.portal.member.currentLevel': '当前等级',
'marketing.portal.member.defaultLevel': '普通会员', 'marketing.portal.member.defaultLevel': '普通会员',
'marketing.portal.member.priceTier': '价区', 'marketing.portal.member.priceTier': '价区',
'marketing.portal.member.totalPoints': '累计积分', 'marketing.portal.member.totalPoints': '近 12 个月积分',
'marketing.portal.member.availablePoints': '可用积分', 'marketing.portal.member.availablePoints': '可用积分',
'marketing.portal.member.extraDiscount': '额外折扣', 'marketing.portal.member.extraDiscount': '基础运费折扣',
'marketing.portal.member.nextLevel': '下一等级:{name}', 'marketing.portal.member.nextLevel': '下一等级:{name}',
'marketing.portal.member.needPoints': '再获得 {points} 积分即可升级', 'marketing.portal.member.needPoints': '再积 {points} 分可升到下一等级',
'marketing.portal.member.topLevelTip': '已达当前最高等级,继续下单可持续累积积分', 'marketing.portal.member.topLevelTip': '已达当前最高等级;V4/V5 发货量下滑可能降级',
'marketing.portal.member.pointsFlowTitle': '积分明细', 'marketing.portal.member.pointsFlowTitle': '积分明细',
'marketing.portal.member.flowHint': '每笔发放与扣减都会在此展示', 'marketing.portal.member.flowHint': '只用于定级,不可抵运费、兑礼或提现',
'marketing.portal.member.emptyFlowTitle': '暂无积分记录', 'marketing.portal.member.emptyFlowTitle': '暂无积分记录',
'marketing.portal.member.emptyFlowDesc': '下单支付成功后会自动发放积分', 'marketing.portal.member.emptyFlowDesc': '支付成功后按实付基础运费发放积分',
'marketing.portal.member.goOrder': '去下单', 'marketing.portal.member.goOrder': '去下单',
'marketing.portal.member.flow.changeType': '类型', 'marketing.portal.member.flow.changeType': '类型',
'marketing.portal.member.flow.pointsDelta': '变动', 'marketing.portal.member.flow.pointsDelta': '变动',
@ -345,23 +367,26 @@ export default {
'marketing.portal.coupons.title': '我的优惠券', 'marketing.portal.coupons.title': '我的优惠券',
'marketing.portal.coupons.subtitle': '下单时可选用一张;券不可转让,子账号下单使用主账号券包。', 'marketing.portal.coupons.subtitle': '下单时可选用一张;券不可转让,子账号下单使用主账号券包。',
'marketing.portal.coupons.howTitle': '优惠券怎么用', 'marketing.portal.coupons.howTitle': '优惠券怎么用',
'marketing.portal.coupons.howHint': '一单一张,结算时选用', 'marketing.portal.coupons.howHint': '一单一张,只抵基础运费,与会员折扣取更优',
'marketing.portal.coupons.how1Title': '查看可用券', 'marketing.portal.coupons.how1Title': '查看可用券',
'marketing.portal.coupons.how1Desc': '在「可用」列表确认面额、门槛与有效期', 'marketing.portal.coupons.how1Desc': '未解锁券需等首单派送成功;可用券确认面额、门槛与有效期',
'marketing.portal.coupons.how2Title': '下单时选择', 'marketing.portal.coupons.how2Title': '下单时选择',
'marketing.portal.coupons.how2Desc': '去下单结算页勾选一张优惠券抵扣运费', 'marketing.portal.coupons.how2Desc': '结算页勾选一张优惠券,仅抵基础运费,附加费不可用',
'marketing.portal.coupons.how3Title': '注意有效期', 'marketing.portal.coupons.how3Title': '注意有效期',
'marketing.portal.coupons.how3Desc': '即将过期的券会标红提醒,建议尽快使用', 'marketing.portal.coupons.how3Desc': '到期前 3 天会站内信提醒,即将过期的券会标红',
'marketing.portal.coupons.tab.available': '可用', 'marketing.portal.coupons.tab.available': '可用',
'marketing.portal.coupons.tab.locked': '未解锁',
'marketing.portal.coupons.tab.used': '已使用', 'marketing.portal.coupons.tab.used': '已使用',
'marketing.portal.coupons.tab.expired': '已过期', 'marketing.portal.coupons.tab.expired': '已过期',
'marketing.portal.coupons.empty': '暂无优惠券', 'marketing.portal.coupons.empty': '暂无优惠券',
'marketing.portal.coupons.emptyTitle': '暂无可用优惠券', 'marketing.portal.coupons.emptyTitle': '暂无可用优惠券',
'marketing.portal.coupons.emptyDesc': '关注活动发放,或直接去下单看看当前运费', 'marketing.portal.coupons.emptyDesc': '关注活动发放,或直接去下单看看当前运费',
'marketing.portal.coupons.emptyLockedTitle': '暂无未解锁优惠券',
'marketing.portal.coupons.emptyUsedTitle': '还没有使用记录', 'marketing.portal.coupons.emptyUsedTitle': '还没有使用记录',
'marketing.portal.coupons.emptyExpiredTitle': '暂无过期优惠券', 'marketing.portal.coupons.emptyExpiredTitle': '暂无过期优惠券',
'marketing.portal.coupons.emptyOtherDesc': '切换到「可用」查看当前券包', 'marketing.portal.coupons.emptyOtherDesc': '切换到「可用」查看当前券包',
'marketing.portal.coupons.minOrder': '满 {amount} 元可用', 'marketing.portal.coupons.minOrder': '满 {amount} 元可用',
'marketing.portal.coupons.unlockHint': '完成首单派送即可解锁 15 元券',
'marketing.portal.coupons.expiringSoon': '即将过期', 'marketing.portal.coupons.expiringSoon': '即将过期',
'marketing.portal.coupons.discountUnit': '折', 'marketing.portal.coupons.discountUnit': '折',
'marketing.portal.coupons.unnamed': '优惠券', 'marketing.portal.coupons.unnamed': '优惠券',
@ -424,10 +449,10 @@ export default {
'marketing.priceCheck.couponUnavailable': '不可用', 'marketing.priceCheck.couponUnavailable': '不可用',
'marketing.priceCheck.couponChanged': '已重新查价,请确认应付金额', 'marketing.priceCheck.couponChanged': '已重新查价,请确认应付金额',
'marketing.priceCheck.breakdownTitle': '价格明细', 'marketing.priceCheck.breakdownTitle': '价格明细',
'marketing.priceCheck.breakdown.receivableSubtotal': '等级价小计', 'marketing.priceCheck.breakdown.receivableSubtotal': '费用小计',
'marketing.priceCheck.breakdown.levelDiscount': '等级额外折扣', 'marketing.priceCheck.breakdown.levelDiscount': '会员基础运费折扣',
'marketing.priceCheck.breakdown.amountAfterLevel': '券前金额', 'marketing.priceCheck.breakdown.amountAfterLevel': '折后金额',
'marketing.priceCheck.breakdown.couponDiscount': '券抵扣', 'marketing.priceCheck.breakdown.couponDiscount': '券抵扣(与会员折取更优)',
'marketing.priceCheck.breakdown.payable': '应付实付', 'marketing.priceCheck.breakdown.payable': '应付实付',
'marketing.register.title': '客户注册', 'marketing.register.title': '客户注册',
@ -461,7 +486,7 @@ export default {
'profile.marketing.coupons': '我的优惠券', 'profile.marketing.coupons': '我的优惠券',
'profile.marketing.commission': '推荐收益', 'profile.marketing.commission': '推荐收益',
'profile.marketing.inviteDesc': '分享链接,积累下级', 'profile.marketing.inviteDesc': '分享链接,积累下级',
'profile.marketing.memberDesc': '积分与会员价区', 'profile.marketing.memberDesc': '近 12 个月基础运费积分与会员等级',
'profile.marketing.couponsDesc': '查看可用券包', 'profile.marketing.couponsDesc': '查看可用券包',
'profile.marketing.commissionDesc': '分成、转余额、提现', 'profile.marketing.commissionDesc': '分成、转余额、提现',
'profile.marketing.navigateFailed': '页面跳转失败,请刷新后重试', 'profile.marketing.navigateFailed': '页面跳转失败,请刷新后重试',

View File

@ -66,10 +66,10 @@ export default {
'menu.marketing': '营销', 'menu.marketing': '营销',
'menu.marketing.memberLevel': '会员等级', 'menu.marketing.memberLevel': '会员等级',
'marketing.memberLevel.pageTip': 'marketing.memberLevel.pageTip':
'会员等级用于积分升级与可选额外折扣。价区键在客户资料中单独维护,与等级升级无关。修改额外折扣率只影响之后的查价,不追溯历史订单。已被客户使用的等级不可删除,请改为禁用。', '会员等级按近 12 个月实付基础运费积分滚动定级。折扣只打基础运费,附加费原价;与优惠券取更优不叠加。V0–V3 只升不降,V4/V5 可逐级降。最高 95 折。',
'marketing.memberLevel.searchPlaceholder': '搜索编码/名称', 'marketing.memberLevel.searchPlaceholder': '搜索编码/名称',
'marketing.memberLevel.modalTip': 'marketing.memberLevel.modalTip':
'升级积分达到门槛后可升到本等级;额外折扣率在卖价款区价之后叠加。价区请到客户管理中配置。', '积分门槛为滚动 12 个月累计积分下限。折扣率 0.01 = 基础运费 99 折,封顶 0.05(95 折)。勾选「允许降级」后滚动积分不足只降一级。',
'marketing.memberLevel.modal.add': '新增会员等级', 'marketing.memberLevel.modal.add': '新增会员等级',
'marketing.memberLevel.modal.edit': '编辑会员等级', 'marketing.memberLevel.modal.edit': '编辑会员等级',
'marketing.memberLevel.deleteConfirm': 'marketing.memberLevel.deleteConfirm':
@ -79,10 +79,14 @@ export default {
'marketing.memberLevel.form.levelCodeExtra': '创建后不可修改,建议用英文大写', 'marketing.memberLevel.form.levelCodeExtra': '创建后不可修改,建议用英文大写',
'marketing.memberLevel.form.levelName': '等级名称', 'marketing.memberLevel.form.levelName': '等级名称',
'marketing.memberLevel.form.levelNamePlaceholder': '如 普通会员、银卡', 'marketing.memberLevel.form.levelNamePlaceholder': '如 普通会员、银卡',
'marketing.memberLevel.form.minPoints': '升级积分', 'marketing.memberLevel.form.minPoints': '滚动积分下限',
'marketing.memberLevel.form.minPointsExtra': '达到该累计积分后可升到本等级', 'marketing.memberLevel.form.minPointsExtra': '近 12 个月实付基础运费积分达到该值可升到本级',
'marketing.memberLevel.form.extraDiscountRate': '额外折扣率', 'marketing.memberLevel.form.extraDiscountRate': '基础运费折扣率',
'marketing.memberLevel.form.extraDiscountRateExtra': '如 0.01 表示在价区价基础上再减 1%', 'marketing.memberLevel.form.extraDiscountRateExtra': '0.01 = 99 折,仅作用于基础运费,最高 0.05(95 折)',
'marketing.memberLevel.form.canDowngrade': '允许降级',
'marketing.memberLevel.form.canDowngradeExtra': '仅 V4 钻石、V5 皇冠勾选;每天凌晨按滚动积分逐级降,V0–V3 保级',
'marketing.memberLevel.discount.original': '原价',
'marketing.memberLevel.discount.fold': '{fold} 折',
'marketing.memberLevel.form.sort': '排序', 'marketing.memberLevel.form.sort': '排序',
'marketing.memberLevel.form.sortExtra': '数字越小越靠前', 'marketing.memberLevel.form.sortExtra': '数字越小越靠前',
'marketing.memberLevel.form.status': '状态', 'marketing.memberLevel.form.status': '状态',
@ -95,8 +99,8 @@ export default {
'marketing.memberLevel.form.requiredHint': '请填写等级编码与名称', 'marketing.memberLevel.form.requiredHint': '请填写等级编码与名称',
'marketing.memberLevel.section.basic': '基础信息', 'marketing.memberLevel.section.basic': '基础信息',
'marketing.memberLevel.section.basicHint': '编码创建后不可改,名称与状态可随时调整', 'marketing.memberLevel.section.basicHint': '编码创建后不可改,名称与状态可随时调整',
'marketing.memberLevel.section.pricing': '积分与折扣', 'marketing.memberLevel.section.pricing': '积分门槛与运费折扣',
'marketing.memberLevel.section.pricingHint': '决定升级门槛与额外折扣', 'marketing.memberLevel.section.pricingHint': '滚动 12 个月积分定级;折扣只打基础运费',
'marketing.memberLevel.section.display': '展示与权益', 'marketing.memberLevel.section.display': '展示与权益',
'marketing.memberLevel.section.displayHint': '门户展示用,不影响计价逻辑', 'marketing.memberLevel.section.displayHint': '门户展示用,不影响计价逻辑',
'marketing.memberLevel.preview.nameFallback': '未命名等级', 'marketing.memberLevel.preview.nameFallback': '未命名等级',
@ -2254,7 +2258,7 @@ export default {
'order.priceCheck.form.destinationCountry.placeholder': '请选择', 'order.priceCheck.form.destinationCountry.placeholder': '请选择',
'order.priceCheck.form.shippingMethod': '运输方式', 'order.priceCheck.form.shippingMethod': '运输方式',
'order.priceCheck.form.shippingMethod.placeholder': '请选择', 'order.priceCheck.form.shippingMethod.placeholder': '请选择',
'order.priceCheck.form.channel': '渠道', 'order.priceCheck.form.channel': '线路',
'order.priceCheck.form.channel.placeholder': '全部', 'order.priceCheck.form.channel.placeholder': '全部',
'order.priceCheck.form.cargoWeight': '货物重量', 'order.priceCheck.form.cargoWeight': '货物重量',
'order.priceCheck.form.cargoWeight.placeholder': '请输入重量(KG)', 'order.priceCheck.form.cargoWeight.placeholder': '请输入重量(KG)',
@ -2275,7 +2279,7 @@ export default {
'order.priceCheck.form.postalCode': '邮编', 'order.priceCheck.form.postalCode': '邮编',
'order.priceCheck.form.postalCode.tip': '更准', 'order.priceCheck.form.postalCode.tip': '更准',
'order.priceCheck.form.postalCode.placeholder': '选填,补全更准确', 'order.priceCheck.form.postalCode.placeholder': '选填,补全更准确',
'order.priceCheck.form.moreFilters': '更多条件', 'order.priceCheck.form.moreFilters': '更多',
'order.priceCheck.form.activeFilters': '已选 {count} 项', 'order.priceCheck.form.activeFilters': '已选 {count} 项',
'order.priceCheck.form.clearFilters': '清空', 'order.priceCheck.form.clearFilters': '清空',
'order.priceCheck.form.removeFilter': '移除条件', 'order.priceCheck.form.removeFilter': '移除条件',
@ -2351,9 +2355,9 @@ export default {
'order.priceCheck.table.viewDetail': '详情', 'order.priceCheck.table.viewDetail': '详情',
'order.priceCheck.table.viewGuide': '渠道说明', 'order.priceCheck.table.viewGuide': '渠道说明',
'order.priceCheck.table.viewGuideHint': '点击查看线路说明', 'order.priceCheck.table.viewGuideHint': '点击查看线路说明',
'order.priceCheck.table.customsOrderNow': '立刻下单', 'order.priceCheck.table.customsOrderNow': '去下单',
'order.priceCheck.table.orderNow': '立刻下单', 'order.priceCheck.table.orderNow': '去下单',
'order.priceCheck.table.askAi': '问 AI', 'order.priceCheck.table.askAi': '问顾问',
'order.priceCheck.table.askAiCanShip': '这个能不能发?', 'order.priceCheck.table.askAiCanShip': '这个能不能发?',
'order.priceCheck.table.askAiCheaper': '有没有更便宜?', 'order.priceCheck.table.askAiCheaper': '有没有更便宜?',
'order.priceCheck.table.empty': '暂无匹配报价', 'order.priceCheck.table.empty': '暂无匹配报价',
@ -4704,7 +4708,7 @@ export default {
'user.customer.tip.accountBalance': '客户可用余额,谨慎手工调整', 'user.customer.tip.accountBalance': '客户可用余额,谨慎手工调整',
'user.customer.tip.totalQuota': '信用总额度上限', 'user.customer.tip.totalQuota': '信用总额度上限',
'user.customer.tip.usedQuota': '已占用额度,通常由系统累计', 'user.customer.tip.usedQuota': '已占用额度,通常由系统累计',
'user.customer.tip.memberLevel': '用于积分升级与可选额外折扣', 'user.customer.tip.memberLevel': '由近 12 个月实付基础运费积分自动定级,可手工调整',
'user.customer.tip.priceTierKey': '决定报价价区,与会员等级升级无关', 'user.customer.tip.priceTierKey': '决定报价价区,与会员等级升级无关',
'user.customer.modal.addTitle': '新增客户', 'user.customer.modal.addTitle': '新增客户',
'user.customer.modal.editTitle': '编辑客户', 'user.customer.modal.editTitle': '编辑客户',
@ -5206,7 +5210,13 @@ export default {
'channel.salesProducts.placeholder.productDescription': '面向客户的产品说明(选填)', 'channel.salesProducts.placeholder.productDescription': '面向客户的产品说明(选填)',
'channel.salesProducts.placeholder.preStorageScript': '订单落库前执行的脚本(选填)', 'channel.salesProducts.placeholder.preStorageScript': '订单落库前执行的脚本(选填)',
'channel.salesProducts.tip.productCode': 'channel.salesProducts.tip.productCode':
'命名规则:地区/国家代码 + 01专线/02快递/03邮政/04小包 + 其他。\n示例:US04-5DAY\n创建后不可修改;仅支持大写字母、数字和 -', '命名规则:地区/国家代码 + 01专线/02快递/03邮政/04小包 + 其他。\n示例:US04-5DAY\n仅支持大写字母、数字和 -;创建后可通过修改入口单独改代码',
'channel.salesProducts.form.editProductCode': '修改',
'channel.salesProducts.modal.editProductCodeTitle': '修改产品代码',
'channel.salesProducts.modal.editProductCodeHint':
'仅改销售产品主数据代码。历史订单仍保留原代码;下单、查价将使用新代码。',
'channel.salesProducts.messages.productCodeRequired': '请输入产品代码',
'channel.salesProducts.messages.productCodeUpdated': '产品代码已更新',
'channel.salesProducts.tip.productName': 'channel.salesProducts.tip.productName':
'命名规则:地区/国家 + 产品类型描述 + 其他。\n示例:美国空运小包-5日达', '命名规则:地区/国家 + 产品类型描述 + 其他。\n示例:美国空运小包-5日达',
'channel.salesProducts.tip.group': '用于官网/后台分组展示,如专线小包', 'channel.salesProducts.tip.group': '用于官网/后台分组展示,如专线小包',
@ -5569,7 +5579,7 @@ export default {
'channel.area.search.button.query': '查询', 'channel.area.search.button.query': '查询',
'channel.area.search.button.reset': '重置', 'channel.area.search.button.reset': '重置',
'channel.area.import.modalTitle': '导入分区明细', 'channel.area.import.modalTitle': '导入分区明细',
'channel.area.import.tip': '请先下载模板。国家代码必填(无需填国家名称,导入时按代码自动带出)。匹配维度、匹配方式请填 Sheet「枚举值」中的编码。一行一个维度:填邮编则为邮编规则(只填起始=前缀;起止相同=精确;起止不同=闭区间);不填邮编只填州=州匹配;州+城市=城市匹配;都空=仅国家兜底。', 'channel.area.import.tip': '请先下载模板。导入为增量写入:与本分区已有规则完全相同的行会跳过,不会覆盖。国家代码必填(无需填国家名称,导入时按代码自动带出)。匹配维度、匹配方式请填 Sheet「枚举值」中的编码。一行一个维度:填邮编则为邮编规则(只填起始=前缀;起止相同=精确;起止不同=闭区间);不填邮编只填州=州匹配;州+城市=城市匹配;都空=仅国家兜底。',
'channel.area.import.downloadTemplate': '下载模板', 'channel.area.import.downloadTemplate': '下载模板',
'channel.area.import.submit': '开始导入', 'channel.area.import.submit': '开始导入',
'channel.area.import.downloadSuccess': '模板下载成功', 'channel.area.import.downloadSuccess': '模板下载成功',
@ -5580,7 +5590,7 @@ export default {
'channel.area.import.failed': '导入失败', 'channel.area.import.failed': '导入失败',
'channel.area.import.partialSuccess': '部分行导入失败,请查看失败明细', 'channel.area.import.partialSuccess': '部分行导入失败,请查看失败明细',
'channel.area.import.allFailed': '导入失败,请检查文件内容', 'channel.area.import.allFailed': '导入失败,请检查文件内容',
'channel.area.import.resultSummary': '成功 {success} 条,失败 {fail} 条', 'channel.area.import.resultSummary': '成功 {success} 条,跳过重复 {skip} 条,失败 {fail} 条',
'channel.area.import.failRow': '第 {row} 行:{reason}', 'channel.area.import.failRow': '第 {row} 行:{reason}',
'channel.area.export.success': '已导出 {count} 条', 'channel.area.export.success': '已导出 {count} 条',
'channel.area.export.failed': '导出失败', 'channel.area.export.failed': '导出失败',

View File

@ -135,8 +135,8 @@ async function bootstrap() {
installWeChatH5HistoryGuard(router); installWeChatH5HistoryGuard(router);
initAuthKeepAlive(); initAuthKeepAlive();
// PC 登录/首页可后台预热;H5 全程跳过,避免全局 CSS 打乱首屏 // 登录页勿预热 VXE/Element:体积大且会注入全局 CSS,和首屏抢带宽
if ((light || isHomePath(entryPath)) && !isH5LikePath(entryPath)) { if (isHomePath(entryPath) && !isH5LikePath(entryPath) && !light) {
void ensureHeavyPlugins(); void ensureHeavyPlugins();
} }

View File

@ -93,6 +93,7 @@ body[arco-theme='dark'] {
.table-section, .table-section,
.warehouse-list-page-card .status-tabs, .warehouse-list-page-card .status-tabs,
.mkt-status-tabs,
.tabs-actions-section { .tabs-actions-section {
border-color: var(--app-card-border) !important; border-color: var(--app-card-border) !important;
} }

View File

@ -34,6 +34,7 @@
{{ {{
t('channel.area.import.resultSummary', { t('channel.area.import.resultSummary', {
success: importResult.successCount, success: importResult.successCount,
skip: importResult.skipCount,
fail: importResult.failCount, fail: importResult.failCount,
}) })
}} }}
@ -117,7 +118,7 @@
try { try {
const result = await importKpiAreaInfo(areaId.value, selectedFile.value); const result = await importKpiAreaInfo(areaId.value, selectedFile.value);
importResult.value = result; importResult.value = result;
if (result.successCount > 0) { if (result.successCount > 0 || result.skipCount > 0) {
emit('success'); emit('success');
} }
if (result.failCount === 0) { if (result.failCount === 0) {
@ -125,7 +126,7 @@
visible.value = false; visible.value = false;
return true; return true;
} }
if (result.successCount > 0) { if (result.successCount > 0 || result.skipCount > 0) {
Message.warning(t('channel.area.import.partialSuccess')); Message.warning(t('channel.area.import.partialSuccess'));
} else { } else {
Message.error(t('channel.area.import.allFailed')); Message.error(t('channel.area.import.allFailed'));

View File

@ -18,6 +18,7 @@
saveSalesProductCustomerGuide, saveSalesProductCustomerGuide,
toggleSalesProductPriceStatus, toggleSalesProductPriceStatus,
updateSalesProduct, updateSalesProduct,
updateSalesProductCode,
updateSalesProductPrice, updateSalesProductPrice,
} from '@/api/channel/salesProduct'; } from '@/api/channel/salesProduct';
import { getLabelTemplateList } from '@/api/channel/labelTemplate'; import { getLabelTemplateList } from '@/api/channel/labelTemplate';
@ -154,6 +155,9 @@
const customerGuideSaving = ref(false); const customerGuideSaving = ref(false);
const customerGuidePanelRef = ref<InstanceType<typeof MarkdownDocPanel> | null>(null); const customerGuidePanelRef = ref<InstanceType<typeof MarkdownDocPanel> | null>(null);
const savingConfig = ref(false); const savingConfig = ref(false);
const productCodeModalVisible = ref(false);
const productCodeDraft = ref('');
const savingProductCode = ref(false);
function parseAdditionalServiceCodes(raw: unknown): string[] { function parseAdditionalServiceCodes(raw: unknown): string[] {
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
@ -252,6 +256,42 @@
.replace(/[^A-Z0-9-]/g, ''); .replace(/[^A-Z0-9-]/g, '');
} }
function openProductCodeModal() {
if (selectedProduct.value == null) return;
productCodeDraft.value = normalizeBizCode(formData.productCode);
productCodeModalVisible.value = true;
}
async function handleSaveProductCode() {
const pid = selectedProduct.value;
if (pid == null) return;
const next = normalizeBizCode(productCodeDraft.value);
if (!next) {
Message.warning(t('channel.salesProducts.messages.productCodeRequired'));
return Promise.reject();
}
savingProductCode.value = true;
try {
await updateSalesProductCode({ id: pid, productCode: next });
formData.productCode = next;
const hit = productList.value.find((p: any) => p.id === pid);
if (hit?.raw) {
hit.raw.productCode = next;
hit.raw.ProductCode = next;
}
if (configFormBaseline.value) {
configFormBaseline.value = { ...configFormBaseline.value, productCode: next };
}
productCodeModalVisible.value = false;
Message.success(t('channel.salesProducts.messages.productCodeUpdated'));
} catch (e: any) {
Message.error(e?.message || '修改产品代码失败');
return Promise.reject(e);
} finally {
savingProductCode.value = false;
}
}
const selectedProductContext = computed(() => { const selectedProductContext = computed(() => {
const pid = selectedProduct.value; const pid = selectedProduct.value;
if (pid == null) return null; if (pid == null) return null;
@ -4014,10 +4054,18 @@
</template> </template>
<icon-question-circle class="tip-icon" /> <icon-question-circle class="tip-icon" />
</a-tooltip> </a-tooltip>
<button
type="button"
class="product-code-edit-icon"
:aria-label="t('channel.salesProducts.form.editProductCode')"
@click.stop="openProductCodeModal"
>
<icon-edit />
</button>
</label> </label>
<a-input <a-input
v-model="formData.productCode" v-model="formData.productCode"
:placeholder="t('channel.salesProducts.form.placeholder.input')" :placeholder="t('channel.salesProducts.placeholder.productCode')"
class="form-input" class="form-input"
disabled disabled
/> />
@ -5487,6 +5535,25 @@
</div> </div>
</a-modal> </a-modal>
<a-modal
v-model:visible="productCodeModalVisible"
:title="t('channel.salesProducts.modal.editProductCodeTitle')"
width="420px"
:mask-closable="false"
:ok-loading="savingProductCode"
@ok="handleSaveProductCode"
@cancel="productCodeModalVisible = false"
>
<p class="product-code-edit-hint">{{ t('channel.salesProducts.modal.editProductCodeHint') }}</p>
<a-input
:model-value="productCodeDraft"
:placeholder="t('channel.salesProducts.placeholder.productCode')"
allow-clear
@update:model-value="(v) => (productCodeDraft = normalizeBizCode(v))"
@press-enter="handleSaveProductCode"
/>
</a-modal>
<ChannelReferencePreviewModal ref="referencePreviewRef" :dict-label-fn="dictLabelByType" /> <ChannelReferencePreviewModal ref="referencePreviewRef" :dict-label-fn="dictLabelByType" />
</div> </div>
</template> </template>
@ -5691,6 +5758,31 @@
font-size: 14px; font-size: 14px;
} }
.product-code-edit-icon {
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: var(--color-text-3);
font-size: 14px;
line-height: 1;
cursor: pointer;
&:hover {
color: var(--app-primary-color, #007979);
}
}
.product-code-edit-hint {
margin: 0 0 12px;
color: var(--color-text-3);
font-size: 12px;
line-height: 1.5;
}
.form-input, .form-input,
.form-select, .form-select,
.form-textarea { .form-textarea {

View File

@ -55,13 +55,14 @@
'is-expiring': item.isExpiringSoon, 'is-expiring': item.isExpiringSoon,
'is-used': item.status === 3, 'is-used': item.status === 3,
'is-expired': item.status === 4, 'is-expired': item.status === 4,
'is-locked': item.status === 5,
}" }"
@click="item.status === 1 && goOrder()" @click="item.status === 1 && goOrder()"
> >
<span <span
v-if="item.status === 3 || item.status === 4" v-if="item.status === 3 || item.status === 4 || item.status === 5"
class="coupon-stamp" class="coupon-stamp"
:class="item.status === 3 ? 'is-used' : 'is-expired'" :class="item.status === 3 ? 'is-used' : item.status === 5 ? 'is-locked' : 'is-expired'"
aria-hidden="true" aria-hidden="true"
> >
{{ statusLabel(item.status) }} {{ statusLabel(item.status) }}
@ -99,7 +100,10 @@
<div class="meta-row meta-row--validity"> <div class="meta-row meta-row--validity">
<span class="meta-label">{{ t('h5.coupons.validLabel') }}</span> <span class="meta-label">{{ t('h5.coupons.validLabel') }}</span>
<strong class="meta-value"> <strong class="meta-value">
{{ formatDate(item.validStartTime) }} ~ {{ formatDate(item.validEndTime) }} <template v-if="item.status === 5">{{ t('h5.coupons.how.unlock') }}</template>
<template v-else>
{{ formatDate(item.validStartTime) }} ~ {{ formatDate(item.validEndTime) }}
</template>
</strong> </strong>
</div> </div>
@ -140,12 +144,12 @@ const emit = defineEmits<{
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const tabs = ['available', 'used', 'expired'] as const; const tabs = ['available', 'locked', 'used', 'expired'] as const;
const activeTab = ref<(typeof tabs)[number]>('available'); const activeTab = ref<(typeof tabs)[number]>('available');
const loading = ref(false); const loading = ref(false);
const rows = ref<any[]>([]); const rows = ref<any[]>([]);
const statusMap: Record<string, number> = { available: 1, used: 3, expired: 4 }; const statusMap: Record<string, number> = { available: 1, locked: 5, used: 3, expired: 4 };
const drawerStyle = { const drawerStyle = {
borderRadius: '20px 20px 0 0', borderRadius: '20px 20px 0 0',
@ -173,6 +177,7 @@ function formatDate(value: unknown) {
function statusLabel(status: number) { function statusLabel(status: number) {
if (status === 3) return t('h5.coupons.tab.used'); if (status === 3) return t('h5.coupons.tab.used');
if (status === 4) return t('h5.coupons.tab.expired'); if (status === 4) return t('h5.coupons.tab.expired');
if (status === 5) return t('h5.coupons.tab.locked');
return ''; return '';
} }
@ -180,7 +185,12 @@ function usageTip(item: {
couponType?: string; couponType?: string;
minOrderAmount?: number; minOrderAmount?: number;
faceValue?: number | null; faceValue?: number | null;
status?: number;
unlockHint?: string;
}) { }) {
if (item.status === 5) {
return item.unlockHint || t('h5.coupons.how.unlock');
}
const type = String(item.couponType || ''); const type = String(item.couponType || '');
const min = formatAmount(item.minOrderAmount ?? 0); const min = formatAmount(item.minOrderAmount ?? 0);
if (type === 'percent_off') { if (type === 'percent_off') {
@ -249,6 +259,7 @@ async function load() {
grantChannel, grantChannel,
isNewcomer: grantChannel === 'newcomer', isNewcomer: grantChannel === 'newcomer',
isExpiringSoon: isExpiringSoon(validEndTime, status), isExpiringSoon: isExpiringSoon(validEndTime, status),
unlockHint: raw.unlockHint ?? raw.UnlockHint ?? '',
}; };
}); });
} catch { } catch {
@ -475,6 +486,10 @@ watch(
&.is-expired { &.is-expired {
color: #c9cdd4; color: #c9cdd4;
} }
&.is-locked {
color: var(--app-primary-color, #1d7281);
}
} }
.coupon-ticket__face { .coupon-ticket__face {

View File

@ -53,7 +53,8 @@
/> />
<section v-else-if="invite && activeTab === 'share'" class="invite-hero"> <section v-else-if="invite && activeTab === 'share'" class="invite-hero">
<p class="invite-hero__lead">{{ t('h5.invite.lead') }}</p> <template v-if="canInvite">
<p class="invite-hero__lead">{{ t('h5.invite.lead') }}</p>
<p class="field-label">{{ t('h5.invite.code') }}</p> <p class="field-label">{{ t('h5.invite.code') }}</p>
<div class="focus-block"> <div class="focus-block">
@ -94,6 +95,8 @@
@ready="onPosterReady" @ready="onPosterReady"
/> />
</div> </div>
</template>
<p v-else class="invite-hero__lead">{{ t('h5.invite.locked') }}</p>
</section> </section>
<section v-else-if="invite && activeTab === 'referees'" class="card"> <section v-else-if="invite && activeTab === 'referees'" class="card">
@ -159,6 +162,11 @@ const drawerStyle = {
const inviteCode = computed(() => const inviteCode = computed(() =>
String(invite.value?.inviteCode ?? invite.value?.InviteCode ?? ''), String(invite.value?.inviteCode ?? invite.value?.InviteCode ?? ''),
); );
const canInvite = computed(() => {
const raw = invite.value?.canInvite ?? invite.value?.CanInvite;
return raw === true || raw === 1 || raw === 'true';
});
/** 落地公众号微信注册页,保证 ?ref= 经 OAuth 后仍能带到注册提交 */ /** 落地公众号微信注册页,保证 ?ref= 经 OAuth 后仍能带到注册提交 */
const inviteUrl = computed(() => buildInviteRegisterUrl(inviteCode.value)); const inviteUrl = computed(() => buildInviteRegisterUrl(inviteCode.value));
const shareText = computed(() => buildInviteShareText(inviteCode.value, inviteUrl.value)); const shareText = computed(() => buildInviteShareText(inviteCode.value, inviteUrl.value));

View File

@ -833,6 +833,7 @@ export default {
'h5.coupons.empty': 'No coupons', 'h5.coupons.empty': 'No coupons',
'h5.coupons.unnamed': 'Coupon', 'h5.coupons.unnamed': 'Coupon',
'h5.coupons.tab.available': 'Available', 'h5.coupons.tab.available': 'Available',
'h5.coupons.tab.locked': 'Locked',
'h5.coupons.tab.used': 'Used', 'h5.coupons.tab.used': 'Used',
'h5.coupons.tab.expired': 'Expired', 'h5.coupons.tab.expired': 'Expired',
'h5.coupons.minOrder': 'Min order {amount}', 'h5.coupons.minOrder': 'Min order {amount}',
@ -845,13 +846,15 @@ export default {
'h5.coupons.how.fixed': 'Select at checkout. Min order {amount} to redeem', 'h5.coupons.how.fixed': 'Select at checkout. Min order {amount} to redeem',
'h5.coupons.how.direct': 'Select at checkout for an instant face-value off', 'h5.coupons.how.direct': 'Select at checkout for an instant face-value off',
'h5.coupons.how.percent': 'Select at checkout. Min order {amount} for percent off', 'h5.coupons.how.percent': 'Select at checkout. Min order {amount} for percent off',
'h5.coupons.how.shipping': 'Select at checkout. Min order {amount} for shipping off', 'h5.coupons.how.shipping': 'Select at checkout. Min order {amount} off base freight',
'h5.coupons.how.unlock': 'Complete first delivery to unlock the ¥15 coupon',
'h5.invite.title': 'Invite friends', 'h5.invite.title': 'Invite friends',
'h5.invite.tab.share': 'Share invite', 'h5.invite.tab.share': 'Share invite',
'h5.invite.tab.referees': 'Invited', 'h5.invite.tab.referees': 'Invited',
'h5.invite.lead': 'h5.invite.lead':
'Share your code or QR. Friends open it in WeChat, finish official-account registration, and both unlock rewards.', 'Friends get ¥5 on register. After their orders are delivered, you earn a percent of paid base freight.',
'h5.invite.locked': 'Upgrade to V1 to invite friends',
'h5.invite.code': 'Invite code', 'h5.invite.code': 'Invite code',
'h5.invite.link': 'Invite link (open in WeChat)', 'h5.invite.link': 'Invite link (open in WeChat)',
'h5.invite.copy': 'Copy', 'h5.invite.copy': 'Copy',
@ -903,7 +906,7 @@ export default {
'h5.pay.later': 'Later', 'h5.pay.later': 'Later',
'h5.me.balance': 'Balance', 'h5.me.balance': 'Balance',
'h5.me.recharge': 'Recharge', 'h5.me.recharge': 'Recharge',
'h5.me.points': 'Points', 'h5.me.points': '12-month points',
'h5.me.pointsToNext': '{points} to {name}', 'h5.me.pointsToNext': '{points} to {name}',
'h5.me.pointsTopLevel': 'Top level reached', 'h5.me.pointsTopLevel': 'Top level reached',
'h5.me.lowBalance': 'Low balance — recharge soon', 'h5.me.lowBalance': 'Low balance — recharge soon',

View File

@ -828,15 +828,18 @@ export default {
'h5.coupons.how.fixed': '下单结算勾选本券,满{amount}元可抵扣', 'h5.coupons.how.fixed': '下单结算勾选本券,满{amount}元可抵扣',
'h5.coupons.how.direct': '下单结算勾选本券,直接减免面额', 'h5.coupons.how.direct': '下单结算勾选本券,直接减免面额',
'h5.coupons.how.percent': '下单结算勾选本券,满{amount}元按折扣抵扣', 'h5.coupons.how.percent': '下单结算勾选本券,满{amount}元按折扣抵扣',
'h5.coupons.how.shipping': '下单结算勾选本券,满{amount}元可抵运费', 'h5.coupons.how.shipping': '下单结算勾选本券,满{amount}元减免基础运费',
'h5.coupons.how.unlock': '完成首单派送即可解锁 15 元券',
'h5.coupons.tab.available': '可用', 'h5.coupons.tab.available': '可用',
'h5.coupons.tab.locked': '未解锁',
'h5.coupons.tab.used': '已使用', 'h5.coupons.tab.used': '已使用',
'h5.coupons.tab.expired': '已过期', 'h5.coupons.tab.expired': '已过期',
'h5.invite.title': '邀请好友', 'h5.invite.title': '邀请好友',
'h5.invite.tab.share': '分享邀请', 'h5.invite.tab.share': '分享邀请',
'h5.invite.tab.referees': '已邀请好友', 'h5.invite.tab.referees': '已邀请好友',
'h5.invite.lead': '分享邀请码或二维码,好友在微信内打开并完成公众号注册后,双方可享营销奖励', 'h5.invite.lead': '好友注册即得 5 元券;其订单派送成功后,您按实付基础运费获得奖励',
'h5.invite.locked': '升级到 V1 后可邀请好友',
'h5.invite.code': '邀请码', 'h5.invite.code': '邀请码',
'h5.invite.link': '邀请链接(微信内打开)', 'h5.invite.link': '邀请链接(微信内打开)',
'h5.invite.copy': '复制', 'h5.invite.copy': '复制',
@ -888,7 +891,7 @@ export default {
'h5.pay.later': '稍后再说', 'h5.pay.later': '稍后再说',
'h5.me.balance': '账户余额', 'h5.me.balance': '账户余额',
'h5.me.recharge': '去充值', 'h5.me.recharge': '去充值',
'h5.me.points': '积分', 'h5.me.points': '近12个月积分',
'h5.me.pointsToNext': '距{name}还差 {points}', 'h5.me.pointsToNext': '距{name}还差 {points}',
'h5.me.pointsTopLevel': '已达最高等级', 'h5.me.pointsTopLevel': '已达最高等级',
'h5.me.lowBalance': '余额偏低,建议尽快充值', 'h5.me.lowBalance': '余额偏低,建议尽快充值',

View File

@ -418,7 +418,13 @@ const memberLevelName = computed(() =>
const levelBadge = computed(() => memberLevelName.value || customerGroupLabel.value); const levelBadge = computed(() => memberLevelName.value || customerGroupLabel.value);
const totalPoints = computed(() => const totalPoints = computed(() =>
Number(memberOverview.value?.totalPoints ?? memberOverview.value?.TotalPoints ?? 0), Number(
memberOverview.value?.rollingPoints ??
memberOverview.value?.RollingPoints ??
memberOverview.value?.totalPoints ??
memberOverview.value?.TotalPoints ??
0,
),
); );
const nextLevelName = computed(() => const nextLevelName = computed(() =>
String(memberOverview.value?.nextLevelName ?? memberOverview.value?.NextLevelName ?? ''), String(memberOverview.value?.nextLevelName ?? memberOverview.value?.NextLevelName ?? ''),

View File

@ -26,8 +26,6 @@
<template v-else> <template v-else>
<div class="login-hero__admin-stack"> <div class="login-hero__admin-stack">
<p class="login-hero__brief">{{ t('login.hero.admin.brief') }}</p>
<ol <ol
class="login-hero__principles" class="login-hero__principles"
@mouseenter="hovered = true" @mouseenter="hovered = true"
@ -38,12 +36,12 @@
<li <li
v-for="(pillar, index) in adminPillars" v-for="(pillar, index) in adminPillars"
:key="pillar.key" :key="pillar.key"
class="login-hero__principle" class="login-hero__principle login-hero__spine-item login-hero__spine-item--left"
:class="{ :class="{
'is-active': activePrinciple === index, 'is-active': activePrinciple === index,
'is-dim': activePrinciple >= 0 && activePrinciple !== index, 'is-dim': activePrinciple >= 0 && activePrinciple !== index,
}" }"
:style="{ '--i': index }" :style="{ '--i': index, '--t': pillar.t }"
tabindex="0" tabindex="0"
@mouseenter="activePrinciple = index" @mouseenter="activePrinciple = index"
@focus="activePrinciple = index" @focus="activePrinciple = index"
@ -61,27 +59,23 @@
</li> </li>
</ol> </ol>
<div class="login-hero__ai" role="note"> <div
class="login-hero__ai login-hero__spine-item login-hero__spine-item--left"
role="note"
style="--t: 0.72"
>
<span class="login-hero__ai-badge"> <span class="login-hero__ai-badge">
<icon-robot :size="14" /> <icon-robot :size="14" />
AI AI
</span> </span>
<p class="login-hero__ai-text">{{ t('login.hero.admin.ai') }}</p> <p class="login-hero__ai-text">{{ t('login.hero.admin.ai') }}</p>
</div> </div>
<p class="login-hero__flow" aria-hidden="true">
<span>{{ t('login.hero.admin.flow.pickup') }}</span>
<i />
<span>{{ t('login.hero.admin.flow.transit') }}</span>
<i />
<span>{{ t('login.hero.admin.flow.delivery') }}</span>
</p>
</div> </div>
</template> </template>
</div> </div>
<div class="login-hero__visual"> <div class="login-hero__visual">
<picture> <picture v-if="isCustomer">
<source srcset="/login-assets/hero-plane.webp" type="image/webp" /> <source srcset="/login-assets/hero-plane.webp" type="image/webp" />
<img <img
class="login-hero__plane" class="login-hero__plane"
@ -90,6 +84,13 @@
draggable="false" draggable="false"
/> />
</picture> </picture>
<img
v-else
class="login-hero__truck"
src="/login-assets/hero-truck.png"
alt=""
draggable="false"
/>
</div> </div>
</div> </div>
</div> </div>
@ -116,8 +117,37 @@ interface AdminPillar {
icon: Component; icon: Component;
titleKey: string; titleKey: string;
descKey: string; descKey: string;
side: 'left' | 'right';
t: number;
} }
const adminPillars: AdminPillar[] = [
{
key: 'care',
icon: IconCheckCircle,
titleKey: 'login.hero.admin.pillar.care.title',
descKey: 'login.hero.admin.pillar.care.desc',
side: 'left',
t: 0.24,
},
{
key: 'speed',
icon: IconThunderbolt,
titleKey: 'login.hero.admin.pillar.speed.title',
descKey: 'login.hero.admin.pillar.speed.desc',
side: 'left',
t: 0.40,
},
{
key: 'reach',
icon: IconMindMapping,
titleKey: 'login.hero.admin.pillar.reach.title',
descKey: 'login.hero.admin.pillar.reach.desc',
side: 'left',
t: 0.56,
},
];
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
light?: boolean; light?: boolean;
@ -138,27 +168,6 @@ let cycleTimer: ReturnType<typeof window.setInterval> | null = null;
const isCustomer = computed(() => props.portal === 'customer'); const isCustomer = computed(() => props.portal === 'customer');
const adminPillars: AdminPillar[] = [
{
key: 'care',
icon: IconCheckCircle,
titleKey: 'login.hero.admin.pillar.care.title',
descKey: 'login.hero.admin.pillar.care.desc',
},
{
key: 'speed',
icon: IconThunderbolt,
titleKey: 'login.hero.admin.pillar.speed.title',
descKey: 'login.hero.admin.pillar.speed.desc',
},
{
key: 'reach',
icon: IconMindMapping,
titleKey: 'login.hero.admin.pillar.reach.title',
descKey: 'login.hero.admin.pillar.reach.desc',
},
];
function startCycle() { function startCycle() {
stopCycle(); stopCycle();
if (isCustomer.value) return; if (isCustomer.value) return;
@ -258,25 +267,75 @@ onBeforeUnmount(() => {
max-width: 520px; max-width: 520px;
} }
.login-hero--admin .login-hero__inner { .login-hero--admin {
justify-content: flex-start; width: 100%;
align-items: center; height: 100%;
} }
.login-hero--admin .login-hero__content { .login-hero--admin .login-hero__inner {
flex: 1 1 auto;
width: 100%;
max-width: 520px;
display: block; display: block;
position: relative;
width: 100%;
height: 100%;
}
.login-hero--admin .login-hero__content,
.login-hero--admin .login-hero__admin-stack,
.login-hero--admin .login-hero__principles {
display: contents;
}
.login-hero--admin .login-hero__spine-item {
--split-x: calc(
var(--login-split-top) + (var(--login-split-bottom) - var(--login-split-top)) * var(--t, 0.5)
);
position: absolute;
top: calc(var(--t, 0.5) * 100%);
left: auto;
right: calc(100% - var(--split-x) + 20px);
width: min(360px, 30vw);
margin: 0;
padding-right: 0;
pointer-events: auto;
}
.login-hero--admin .login-hero__spine-item--left {
transform: translateY(-50%);
}
.login-hero--admin .login-hero__principle {
animation-name: login-hero-principle-in-admin;
transition:
background 0.25s ease,
opacity 0.25s ease;
&::before {
display: none;
}
&.is-active {
transform: translateY(-50%);
}
}
@keyframes login-hero-principle-in-admin {
from {
opacity: 0;
transform: translateY(-50%);
}
to {
opacity: 1;
transform: translateY(-50%);
}
} }
.login-hero__admin-stack { .login-hero__admin-stack {
width: 100%; width: 100%;
max-width: 460px; max-width: 440px;
} }
.login-hero__brief { .login-hero__brief {
margin: 0 0 22px; margin: 0 0 18px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@ -299,8 +358,8 @@ onBeforeUnmount(() => {
display: grid; display: grid;
grid-template-columns: 44px minmax(0, 1fr) 36px; grid-template-columns: 44px minmax(0, 1fr) 36px;
align-items: center; align-items: center;
gap: 14px; gap: 12px;
padding: 16px 12px 16px 4px; padding: 12px 8px 12px 2px;
border-radius: 10px; border-radius: 10px;
cursor: default; cursor: default;
outline: none; outline: none;
@ -371,8 +430,8 @@ onBeforeUnmount(() => {
} }
.login-hero__principle-title { .login-hero__principle-title {
margin: 0 0 6px; margin: 0 0 4px;
font-size: 22px; font-size: 20px;
font-weight: 700; font-weight: 700;
line-height: 1.2; line-height: 1.2;
letter-spacing: 0.04em; letter-spacing: 0.04em;
@ -409,7 +468,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
margin: 22px 0 0; margin: 16px 0 0;
padding: 12px 14px; padding: 12px 14px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.14);
@ -443,29 +502,20 @@ onBeforeUnmount(() => {
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.72);
} }
.login-hero__flow { .login-hero--admin .login-hero__visual {
position: absolute;
left: calc((var(--login-split-top) + var(--login-split-bottom)) / 4 - 150px);
top: 50%;
bottom: auto;
z-index: 1;
width: min(380px, 32vw);
margin: 0;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 10px; transform: translate(-50%, -50%) scale(1.3);
margin: 20px 0 0; justify-content: center;
padding-top: 16px; align-self: auto;
border-top: 1px solid rgba(255, 255, 255, 0.12);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.45);
i {
flex: 1;
height: 1px;
min-width: 16px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0.35),
rgba(255, 255, 255, 0.08)
);
}
} }
@keyframes login-hero-principle-in { @keyframes login-hero-principle-in {
@ -542,6 +592,17 @@ onBeforeUnmount(() => {
user-select: none; user-select: none;
} }
.login-hero--admin .login-hero__truck {
width: 100%;
max-width: none;
height: auto;
display: block;
margin: 0;
transform: none;
pointer-events: none;
user-select: none;
}
.login-hero--light { .login-hero--light {
color: rgba(26, 45, 74, 0.88); color: rgba(26, 45, 74, 0.88);
} }
@ -607,30 +668,26 @@ onBeforeUnmount(() => {
color: rgba(26, 45, 74, 0.68); color: rgba(26, 45, 74, 0.68);
} }
.login-hero--light .login-hero__flow {
border-top-color: rgba(26, 45, 74, 0.1);
color: rgba(26, 45, 74, 0.42);
i {
background: linear-gradient(
90deg,
rgba(26, 45, 74, 0.04),
rgba(26, 45, 74, 0.22),
rgba(26, 45, 74, 0.04)
);
}
}
@media screen and (max-width: 1250px) { @media screen and (max-width: 1250px) {
.login-hero__visual { .login-hero__visual {
display: none; display: none;
} }
.login-hero--admin .login-hero__visual {
display: flex;
}
.login-hero__content { .login-hero__content {
flex: 1 1 100%; flex: 1 1 100%;
} }
} }
@media screen and (max-width: 900px) {
.login-hero--admin .login-hero__visual {
display: none;
}
}
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.login-hero__title { .login-hero__title {
font-size: 2rem; font-size: 2rem;
@ -653,10 +710,6 @@ onBeforeUnmount(() => {
.login-hero__principle-title { .login-hero__principle-title {
font-size: 18px; font-size: 18px;
} }
.login-hero__flow {
letter-spacing: 0.06em;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@ -24,19 +24,36 @@
:class="{ 'login-brand-corner--light': isLightMode }" :class="{ 'login-brand-corner--light': isLightMode }"
> >
<span class="login-brand-corner__name">{{ t('brand.name') }}</span> <span class="login-brand-corner__name">{{ t('brand.name') }}</span>
<span class="login-brand-corner__suffix">I {{ t('brand.fullName') }}</span> <span class="login-brand-corner__suffix">· {{ t('brand.fullName') }}</span>
</div> </div>
<aside :class="['login-hero-panel', { 'login-hero-panel--admin': !isCustomerLogin }]"> <aside v-if="isCustomerLogin" class="login-hero-panel">
<LoginHero <LoginHero
:light="isLightMode" :light="isLightMode"
:portal="isCustomerLogin ? 'customer' : 'admin'" portal="customer"
/> />
</aside> </aside>
<div class="login-characters-panel"> <div v-if="isCustomerLogin" class="login-characters-panel">
<LoginCharacters v-if="showCharacters" @register="registerLoginCharacters" /> <LoginCharacters v-if="showCharacters" @register="registerLoginCharacters" />
</div> </div>
</div> </div>
<aside
v-if="shellReady && !isCustomerLogin"
class="login-hero-panel login-hero-panel--admin"
>
<LoginHero
:light="isLightMode"
portal="admin"
/>
</aside>
<div
v-if="shellReady && !isCustomerLogin"
class="login-characters-panel login-characters-panel--admin"
>
<LoginCharacters v-if="showCharacters" @register="registerLoginCharacters" />
</div>
<div v-if="shellReady" ref="rightPaneRef" class="login-pane login-pane--right"> <div v-if="shellReady" ref="rightPaneRef" class="login-pane login-pane--right">
<div ref="loginFormRef" :class="['login-form', { 'login-form--ready': shellReady }]"> <div ref="loginFormRef" :class="['login-form', { 'login-form--ready': shellReady }]">
<div class="login-form-toolbar"> <div class="login-form-toolbar">
@ -72,10 +89,6 @@
</div> </div>
</div> </div>
</div> </div>
<footer v-if="shellReady" class="login-footer">
{{ t('login.footer.copyright') }}
</footer>
</div> </div>
</template> </template>
@ -96,7 +109,6 @@ import { getLoginTypeFromRouteName } from '@/utils/login-portal';
import { IconSunFill, IconMoonFill } from '@arco-design/web-vue/es/icon'; import { IconSunFill, IconMoonFill } from '@arco-design/web-vue/es/icon';
import LoginForm from './components/pwd-login/index.vue'; import LoginForm from './components/pwd-login/index.vue';
import LoginHero from './components/LoginHero.vue'; import LoginHero from './components/LoginHero.vue';
import LoginCareBanner from './components/LoginCareBanner.vue';
import LoginLangToggle from './components/LoginLangToggle.vue'; import LoginLangToggle from './components/LoginLangToggle.vue';
import { import {
LOGIN_CHARACTERS_KEY, LOGIN_CHARACTERS_KEY,
@ -112,6 +124,9 @@ const WechatLogin = defineAsyncComponent(
const LoginCharacters = defineAsyncComponent( const LoginCharacters = defineAsyncComponent(
() => import('./components/LoginCharacters.vue'), () => import('./components/LoginCharacters.vue'),
); );
const LoginCareBanner = defineAsyncComponent(
() => import('./components/LoginCareBanner.vue'),
);
const LOGIN_APPEARANCE_KEY = 'login-appearance'; const LOGIN_APPEARANCE_KEY = 'login-appearance';
const LOGIN_PAGE_SCROLL_LOCK_CLASS = 'login-page-no-scroll'; const LOGIN_PAGE_SCROLL_LOCK_CLASS = 'login-page-no-scroll';
@ -271,14 +286,18 @@ function prefetchWechatQr() {
onMounted(() => { onMounted(() => {
document.documentElement.classList.add(LOGIN_PAGE_SCROLL_LOCK_CLASS); document.documentElement.classList.add(LOGIN_PAGE_SCROLL_LOCK_CLASS);
shellReady.value = true; shellReady.value = true;
// 首屏先出表单,下一帧再挂角色动画分包 const idle = window.requestIdleCallback
requestAnimationFrame(() => { ? (cb: () => void, timeout = 1200) => window.requestIdleCallback(cb, { timeout })
: (cb: () => void, timeout = 1200) => window.setTimeout(cb, Math.min(timeout, 400));
// 表单先出,空闲后再挂角色,避免和首屏 JS/图抢带宽
idle(() => {
showCharacters.value = true; showCharacters.value = true;
}); }, 1600);
// 后台预拉完整语言包,进系统时几乎不等 idle(() => {
void import('@/locale').then(({ ensureFullLocaleMessages }) => { void import('@/locale').then(({ ensureFullLocaleMessages }) => {
void ensureFullLocaleMessages(); void ensureFullLocaleMessages();
}); });
}, 2500);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@ -318,24 +337,6 @@ onBeforeUnmount(() => {
pointer-events: none; pointer-events: none;
} }
.login-footer {
position: fixed;
left: 0;
right: 0;
bottom: 16px;
z-index: 12;
text-align: center;
padding: 0 16px;
font-size: 12px;
line-height: 1.6;
color: rgba(148, 163, 184, 0.92);
pointer-events: none;
}
.login-container--light .login-footer {
color: rgba(100, 116, 139, 0.88);
}
.login-split { .login-split {
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -435,10 +436,22 @@ onBeforeUnmount(() => {
} }
.login-hero-panel--admin { .login-hero-panel--admin {
display: flex; inset: 0;
align-items: center; top: 0;
justify-content: center; left: 0;
width: min(800px, calc((var(--login-split-top) + var(--login-split-bottom)) / 2 - 16px)); bottom: 0;
right: 0;
transform: none;
z-index: 3;
display: block;
width: auto;
max-width: none;
pointer-events: none;
}
.login-hero-panel--admin .login-hero {
width: 100%;
height: 100%;
} }
.login-characters-panel { .login-characters-panel {
@ -451,6 +464,22 @@ onBeforeUnmount(() => {
padding: 0 0 16px 8px; padding: 0 0 16px 8px;
} }
.login-characters-panel--admin {
left: auto;
right: 0;
bottom: 0;
z-index: 6;
padding: 0;
transform: scale(0.82);
transform-origin: bottom right;
opacity: 1;
}
.login-characters-panel--admin :deep(.login-characters__stage) {
width: 450px;
transform-origin: bottom right;
}
.login-form-toolbar { .login-form-toolbar {
position: absolute; position: absolute;
top: 14px; top: 14px;

View File

@ -46,9 +46,6 @@
'login.hero.admin.pillar.reach.desc': 'Pickup to delivery · Clear flow · Fewer detours', 'login.hero.admin.pillar.reach.desc': 'Pickup to delivery · Clear flow · Fewer detours',
'login.hero.admin.ai': 'login.hero.admin.ai':
'Embrace AI: spot issues, protect SLAs, simplify flows — people decide, AI accelerates.', 'Embrace AI: spot issues, protect SLAs, simplify flows — people decide, AI accelerates.',
'login.hero.admin.flow.pickup': 'Pickup',
'login.hero.admin.flow.transit': 'In transit',
'login.hero.admin.flow.delivery': 'Delivered',
'login.care.period.morning': 'Good morning — a fresh start', 'login.care.period.morning': 'Good morning — a fresh start',
'login.care.period.afternoon': 'Good afternoon — steady wins', 'login.care.period.afternoon': 'Good afternoon — steady wins',
'login.care.period.evening': 'Good evening — thanks for today', 'login.care.period.evening': 'Good evening — thanks for today',

View File

@ -45,9 +45,6 @@
'login.hero.admin.pillar.reach.title': '更简单', 'login.hero.admin.pillar.reach.title': '更简单',
'login.hero.admin.pillar.reach.desc': '揽收到签收 · 链路清晰 · 少绕少错', 'login.hero.admin.pillar.reach.desc': '揽收到签收 · 链路清晰 · 少绕少错',
'login.hero.admin.ai': '积极拥抱 AI:帮你盯异常、催时效、简流程——人做决策,AI 做加速。', 'login.hero.admin.ai': '积极拥抱 AI:帮你盯异常、催时效、简流程——人做决策,AI 做加速。',
'login.hero.admin.flow.pickup': '揽收',
'login.hero.admin.flow.transit': '在途',
'login.hero.admin.flow.delivery': '签收',
'login.care.period.morning': '早上好,新的一天开始了', 'login.care.period.morning': '早上好,新的一天开始了',
'login.care.period.afternoon': '下午好,稳稳推进就好', 'login.care.period.afternoon': '下午好,稳稳推进就好',
'login.care.period.evening': '傍晚好,今天也辛苦了', 'login.care.period.evening': '傍晚好,今天也辛苦了',

View File

@ -1,5 +1,8 @@
<template> <template>
<MarketingPageShell :tip="t('marketing.commissionDetail.pageTip')"> <MarketingPageShell :tip="t('marketing.commissionDetail.pageTip')">
<div class="mkt-status-tabs">
<SlidingTabs v-model="statusTab" :items="statusTabs" @change="handleSearch" />
</div>
<div class="table-toolbar"> <div class="table-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<div class="toolbar-filters"> <div class="toolbar-filters">
@ -11,18 +14,6 @@
@press-enter="handleSearch" @press-enter="handleSearch"
@clear="handleSearch" @clear="handleSearch"
/> />
<a-select
v-model="statusFilter"
allow-clear
class="mkt-filter-status"
:style="{ width: '140px' }"
:placeholder="t('marketing.commissionDetail.statusFilter')"
@change="handleSearch"
>
<a-option :value="1">{{ t('marketing.commissionDetail.status.pending') }}</a-option>
<a-option :value="2">{{ t('marketing.commissionDetail.status.settled') }}</a-option>
<a-option :value="3">{{ t('marketing.commissionDetail.status.cancelled') }}</a-option>
</a-select>
<MarketingPageTipIcon /> <MarketingPageTipIcon />
<a-button class="app-btn-search" @click="handleSearch"> <a-button class="app-btn-search" @click="handleSearch">
{{ t('common.search') }} {{ t('common.search') }}
@ -31,9 +22,6 @@
<a-button class="app-btn-toolbar" @click="openAdjustModal"> <a-button class="app-btn-toolbar" @click="openAdjustModal">
{{ t('marketing.commissionDetail.adjust') }} {{ t('marketing.commissionDetail.adjust') }}
</a-button> </a-button>
<a-button @click="openRuleModal">
{{ t('menu.marketing.commissionRule') }}
</a-button>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<a-space :size="8"> <a-space :size="8">
@ -92,52 +80,21 @@
/> />
</div> </div>
</ModalPro> </ModalPro>
<ModalPro
v-model:visible="ruleVisible"
:title="t('menu.marketing.commissionRule')"
:confirm-loading="ruleSaving"
width="640px"
unmount-on-close
@ok="handleRuleOk"
@cancel="handleRuleCancel"
>
<div class="mkt-modal">
<div class="mkt-modal__tip">
<icon-info-circle class="mkt-modal__tip-icon" />
<p>{{ t('marketing.commissionRule.pageTip') }}</p>
</div>
<a-spin :loading="ruleLoading">
<FormPro
ref="ruleFormRef"
class="mkt-modal__form"
form-layout="vertical"
:hide-colon="true"
:item-list="ruleItemList"
:row-gutter="16"
:default-span="12"
:label-col-props="{ span: 24 }"
:wrapper-col-props="{ span: 24 }"
/>
</a-spin>
</div>
</ModalPro>
</MarketingPageShell> </MarketingPageShell>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue' import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue' import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue' import ModalPro from '@/components/ModalPro/index.vue'
import FormPro from '@/components/FormPro/index.vue' import FormPro from '@/components/FormPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight' import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { isEnableStatusEnabled, toEnableStatus } from '@/utils/enableStatus'
import { buildStatusTagHtml, type StatusTagTone } from '@/utils/statusTag' import { buildStatusTagHtml, type StatusTagTone } from '@/utils/statusTag'
import { adjustCommissionDetail, getCommissionDetailList } from '@/api/marketing/commissionDetail' import { adjustCommissionDetail, getCommissionDetailList } from '@/api/marketing/commissionDetail'
import { getCommissionRule, updateCommissionRule } from '@/api/marketing/commissionRule'
import MarketingPageShell from '../components/MarketingPageShell.vue' import MarketingPageShell from '../components/MarketingPageShell.vue'
import MarketingPageTipIcon from '../components/MarketingPageTipIcon.vue' import MarketingPageTipIcon from '../components/MarketingPageTipIcon.vue'
@ -160,9 +117,11 @@ interface DetailRow {
orderNo: string orderNo: string
basePayableAmount: number basePayableAmount: number
commissionAmount: number commissionAmount: number
rewardOrderIndex: number
status: number status: number
statusName: string statusName: string
eligibleTime: string eligibleTime: string
settleDueTime: string
settleTime: string settleTime: string
} }
@ -170,20 +129,21 @@ const tableRows = ref<DetailRow[]>([])
const totalCount = ref(0) const totalCount = ref(0)
const listLoading = ref(false) const listLoading = ref(false)
const orderNo = ref('') const orderNo = ref('')
const statusFilter = ref<number | undefined>(undefined) const statusTab = ref('all')
const currentPage = ref(1) const currentPage = ref(1)
const statusTabs = computed(() => [
{ key: 'all', label: t('common.all') },
{ key: '1', label: t('marketing.commissionDetail.status.pending') },
{ key: '2', label: t('marketing.commissionDetail.status.settled') },
{ key: '3', label: t('marketing.commissionDetail.status.cancelled') },
])
const pageSize = ref(50) const pageSize = ref(50)
const adjustVisible = ref(false) const adjustVisible = ref(false)
const adjustLoading = ref(false) const adjustLoading = ref(false)
const adjustFormRef = ref<FormExpose>() const adjustFormRef = ref<FormExpose>()
const ruleVisible = ref(false)
const ruleLoading = ref(false)
const ruleSaving = ref(false)
const ruleFormRef = ref<FormExpose>()
const originalRule = ref<Record<string, unknown> | null>(null)
const adjustItemList = computed(() => [ const adjustItemList = computed(() => [
{ {
field: 'customerCode', field: 'customerCode',
@ -241,82 +201,16 @@ const adjustItemList = computed(() => [
}, },
]) ])
const ruleItemList = computed(() => [
{
field: 'ruleName',
label: t('marketing.commissionRule.form.ruleName'),
comp: 'a-input',
required: true,
rules: [{ required: true, message: t('marketing.commissionRule.form.ruleName') }],
compProps: {
allowClear: true,
maxLength: 64,
placeholder: t('marketing.commissionRule.form.ruleName'),
},
},
{
field: 'status',
label: t('marketing.commissionRule.form.status'),
comp: 'a-radio-group',
required: true,
rules: [{ required: true, message: t('marketing.commissionRule.form.status') }],
compProps: {
type: 'button',
options: [
{ label: t('common.enabled'), value: 2 },
{ label: t('common.disabled'), value: 1 },
],
},
},
{
field: 'baseAmount',
label: t('marketing.commissionRule.form.baseAmount'),
comp: 'a-input-number',
required: true,
tooltip: t('marketing.commissionRule.form.baseAmountExtra'),
rules: [{ required: true, message: t('marketing.commissionRule.form.baseAmount') }],
compProps: { min: 1, precision: 2, style: 'width: 100%', placeholder: '1000' },
},
{
field: 'commissionAmount',
label: t('marketing.commissionRule.form.commissionAmount'),
comp: 'a-input-number',
required: true,
tooltip: t('marketing.commissionRule.form.commissionAmountExtra'),
rules: [{ required: true, message: t('marketing.commissionRule.form.commissionAmount') }],
compProps: { min: 0, precision: 2, style: 'width: 100%', placeholder: '2' },
},
{
field: 'freezeDays',
label: t('marketing.commissionRule.form.freezeDays'),
comp: 'a-input-number',
required: true,
tooltip: t('marketing.commissionRule.form.freezeDaysExtra'),
rules: [{ required: true, message: t('marketing.commissionRule.form.freezeDays') }],
compProps: { min: 0, precision: 0, style: 'width: 100%', placeholder: '7' },
},
{
field: 'monthlyCap',
label: t('marketing.commissionRule.form.monthlyCap'),
comp: 'a-input-number',
tooltip: t('marketing.commissionRule.form.monthlyCapExtra'),
compProps: {
min: 0,
precision: 2,
style: 'width: 100%',
placeholder: t('marketing.commissionRule.form.monthlyCapExtra'),
},
},
])
const hotColumns = computed(() => [ const hotColumns = computed(() => [
{ key: 'referrerCustomerCode', label: t('marketing.commissionDetail.columns.referrer'), width: 110 }, { key: 'referrerCustomerCode', label: t('marketing.commissionDetail.columns.referrer'), width: 110 },
{ key: 'refereeCustomerCode', label: t('marketing.commissionDetail.columns.referee'), width: 110 }, { key: 'refereeCustomerCode', label: t('marketing.commissionDetail.columns.referee'), width: 110 },
{ key: 'orderNo', label: t('marketing.commissionDetail.columns.orderNo'), width: 130 }, { key: 'orderNo', label: t('marketing.commissionDetail.columns.orderNo'), width: 130 },
{ key: 'basePayableAmount', label: t('marketing.commissionDetail.columns.baseAmount'), width: 100 }, { key: 'basePayableAmount', label: t('marketing.commissionDetail.columns.baseAmount'), width: 120 },
{ key: 'commissionAmount', label: t('marketing.commissionDetail.columns.commission'), width: 90 }, { key: 'commissionAmount', label: t('marketing.commissionDetail.columns.commission'), width: 90 },
{ key: 'rewardOrderIndex', label: t('marketing.commissionDetail.columns.orderIndex'), width: 80 },
{ key: 'statusName', label: t('marketing.commissionDetail.columns.status'), width: 90 }, { key: 'statusName', label: t('marketing.commissionDetail.columns.status'), width: 90 },
{ key: 'eligibleTime', label: t('marketing.commissionDetail.columns.eligibleTime'), width: 160 }, { key: 'eligibleTime', label: t('marketing.commissionDetail.columns.eligibleTime'), width: 160 },
{ key: 'settleDueTime', label: t('marketing.commissionDetail.columns.settleDue'), width: 160 },
{ key: 'settleTime', label: t('marketing.commissionDetail.columns.settleTime'), width: 160 }, { key: 'settleTime', label: t('marketing.commissionDetail.columns.settleTime'), width: 160 },
]) ])
@ -345,33 +239,21 @@ function mapRow(item: Record<string, unknown>): DetailRow {
orderNo: String(item.orderNo ?? item.OrderNo ?? ''), orderNo: String(item.orderNo ?? item.OrderNo ?? ''),
basePayableAmount: Number(item.basePayableAmount ?? item.BasePayableAmount ?? 0), basePayableAmount: Number(item.basePayableAmount ?? item.BasePayableAmount ?? 0),
commissionAmount: Number(item.commissionAmount ?? item.CommissionAmount ?? 0), commissionAmount: Number(item.commissionAmount ?? item.CommissionAmount ?? 0),
rewardOrderIndex: Number(item.rewardOrderIndex ?? item.RewardOrderIndex ?? 0),
status: Number(item.status ?? item.Status ?? 0), status: Number(item.status ?? item.Status ?? 0),
statusName: String(item.statusName ?? item.StatusName ?? ''), statusName: String(item.statusName ?? item.StatusName ?? ''),
eligibleTime: String(item.eligibleTime ?? item.EligibleTime ?? '').replace('T', ' ').slice(0, 19) || '--', eligibleTime: String(item.eligibleTime ?? item.EligibleTime ?? '').replace('T', ' ').slice(0, 19) || '--',
settleDueTime: String(item.settleDueTime ?? item.SettleDueTime ?? '').replace('T', ' ').slice(0, 19) || '--',
settleTime: String(item.settleTime ?? item.SettleTime ?? '').replace('T', ' ').slice(0, 19) || '--', settleTime: String(item.settleTime ?? item.SettleTime ?? '').replace('T', ' ').slice(0, 19) || '--',
} }
} }
function mapRule(item: Record<string, unknown>) {
const status = item.status ?? item.Status ?? 2
const cap = item.monthlyCap ?? item.MonthlyCap
return {
id: (item.id ?? item.Id) as number | undefined,
ruleName: String(item.ruleName ?? item.RuleName ?? ''),
baseAmount: Number(item.baseAmount ?? item.BaseAmount ?? 1000),
commissionAmount: Number(item.commissionAmount ?? item.CommissionAmount ?? 2),
freezeDays: Number(item.freezeDays ?? item.FreezeDays ?? 7),
monthlyCap: cap != null && cap !== '' ? Number(cap) : undefined,
status: toEnableStatus(isEnableStatusEnabled(status)),
}
}
async function loadList() { async function loadList() {
listLoading.value = true listLoading.value = true
try { try {
const { items, total } = await getCommissionDetailList({ const { items, total } = await getCommissionDetailList({
orderNo: orderNo.value.trim() || undefined, orderNo: orderNo.value.trim() || undefined,
status: statusFilter.value, status: statusTab.value === 'all' ? undefined : Number(statusTab.value),
page: currentPage.value, page: currentPage.value,
pageSize: pageSize.value, pageSize: pageSize.value,
}) })
@ -447,91 +329,6 @@ async function handleAdjustOk() {
} }
} }
async function openRuleModal() {
ruleVisible.value = true
ruleLoading.value = true
await nextTick()
try {
const data = await getCommissionRule()
await nextTick()
if (data && typeof data === 'object') {
const mapped = mapRule(data as Record<string, unknown>)
originalRule.value = { ...mapped }
ruleFormRef.value?.setValues(mapped)
} else {
const defaults = {
id: undefined,
ruleName: '',
baseAmount: 1000,
commissionAmount: 2,
freezeDays: 7,
monthlyCap: undefined,
status: 2,
}
originalRule.value = { ...defaults }
ruleFormRef.value?.setValues(defaults)
}
} catch (e: any) {
Message.error(e?.message || t('common.loadFailed'))
} finally {
ruleLoading.value = false
}
}
function handleRuleCancel() {
ruleVisible.value = false
}
async function handleRuleOk() {
try {
await ruleFormRef.value?.validate()
} catch {
return false
}
const values = ruleFormRef.value?.getValues() || {}
const ruleName = String(values.ruleName || '').trim()
if (!ruleName) {
Message.warning(t('marketing.commissionRule.form.ruleName'))
return false
}
const next = {
id: values.id,
ruleName,
baseAmount: Number(values.baseAmount ?? 1000),
commissionAmount: Number(values.commissionAmount ?? 2),
freezeDays: Number(values.freezeDays ?? 7),
monthlyCap:
values.monthlyCap != null && values.monthlyCap !== '' ? Number(values.monthlyCap) : null,
status: Number(values.status ?? 2),
}
const o = originalRule.value || {}
Modal.confirm({
title: t('marketing.commissionRule.saveConfirmTitle'),
content: t('marketing.commissionRule.saveCompare', {
oldBase: o.baseAmount ?? next.baseAmount,
newBase: next.baseAmount,
oldCommission: o.commissionAmount ?? next.commissionAmount,
newCommission: next.commissionAmount,
oldFreeze: o.freezeDays ?? next.freezeDays,
newFreeze: next.freezeDays,
}),
onOk: async () => {
ruleSaving.value = true
try {
await updateCommissionRule(next)
Message.success(t('common.saveSuccess'))
ruleVisible.value = false
} catch (e: any) {
Message.error(e?.message || t('common.saveFailed'))
throw e
} finally {
ruleSaving.value = false
}
},
})
return false
}
onMounted(() => loadList()) onMounted(() => loadList())
</script> </script>

View File

@ -6,12 +6,12 @@
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
/** 兼容旧菜单「分成规则」:跳转到分成明细(规则配置为弹窗) */ /** 兼容旧菜单「分成规则」:跳转到积分规则页(分成配置在该页下方) */
defineOptions({ name: 'MarketingCommissionRule' }) defineOptions({ name: 'MarketingCommissionRule' })
const router = useRouter() const router = useRouter()
onMounted(() => { onMounted(() => {
router.replace({ name: 'MarketingCommissionDetail' }) router.replace({ name: 'MarketingPointsRule' })
}) })
</script> </script>

View File

@ -1,5 +1,8 @@
<template> <template>
<MarketingPageShell :tip="t('marketing.commissionWithdraw.pageTip')"> <MarketingPageShell :tip="t('marketing.commissionWithdraw.pageTip')">
<div class="mkt-status-tabs">
<SlidingTabs v-model="statusTab" :items="statusTabs" @change="handleSearch" />
</div>
<div class="table-toolbar"> <div class="table-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<div class="toolbar-filters"> <div class="toolbar-filters">
@ -11,19 +14,6 @@
@press-enter="handleSearch" @press-enter="handleSearch"
@clear="handleSearch" @clear="handleSearch"
/> />
<a-select
v-model="statusFilter"
allow-clear
class="mkt-filter-status"
:style="{ width: '140px' }"
:placeholder="t('marketing.commissionWithdraw.statusFilter')"
@change="handleSearch"
>
<a-option :value="1">{{ t('marketing.commissionWithdraw.status.pending') }}</a-option>
<a-option :value="2">{{ t('marketing.commissionWithdraw.status.approved') }}</a-option>
<a-option :value="3">{{ t('marketing.commissionWithdraw.status.rejected') }}</a-option>
<a-option :value="4">{{ t('marketing.commissionWithdraw.status.paid') }}</a-option>
</a-select>
<a-select <a-select
v-model="withdrawType" v-model="withdrawType"
allow-clear allow-clear
@ -123,6 +113,7 @@ import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue' import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue' import ModalPro from '@/components/ModalPro/index.vue'
import FormPro from '@/components/FormPro/index.vue' import FormPro from '@/components/FormPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight' import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { import {
approveCommissionWithdraw, approveCommissionWithdraw,
@ -163,11 +154,19 @@ const tableRows = ref<WithdrawRow[]>([])
const totalCount = ref(0) const totalCount = ref(0)
const listLoading = ref(false) const listLoading = ref(false)
const customerCode = ref('') const customerCode = ref('')
const statusFilter = ref<number | undefined>(1) const statusTab = ref('all')
const withdrawType = ref<number | undefined>(undefined) const withdrawType = ref<number | undefined>(undefined)
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(50) const pageSize = ref(50)
const statusTabs = computed(() => [
{ key: 'all', label: t('common.all') },
{ key: '1', label: t('marketing.commissionWithdraw.status.pending') },
{ key: '2', label: t('marketing.commissionWithdraw.status.approved') },
{ key: '3', label: t('marketing.commissionWithdraw.status.rejected') },
{ key: '4', label: t('marketing.commissionWithdraw.status.paid') },
])
const rejectVisible = ref(false) const rejectVisible = ref(false)
const rejectLoading = ref(false) const rejectLoading = ref(false)
const rejectTarget = ref<WithdrawRow | null>(null) const rejectTarget = ref<WithdrawRow | null>(null)
@ -262,7 +261,7 @@ async function loadList() {
try { try {
const { items, total } = await getCommissionWithdrawList({ const { items, total } = await getCommissionWithdrawList({
customerCode: customerCode.value.trim() || undefined, customerCode: customerCode.value.trim() || undefined,
status: statusFilter.value, status: statusTab.value === 'all' ? undefined : Number(statusTab.value),
withdrawType: withdrawType.value, withdrawType: withdrawType.value,
page: currentPage.value, page: currentPage.value,
pageSize: pageSize.value, pageSize: pageSize.value,

View File

@ -1,6 +1,18 @@
<template> <template>
<div class="marketing-admin-container" :class="containerClass"> <div
<div class="marketing-admin-card" :class="{ 'is-form': variant === 'form' }"> class="marketing-admin-container"
:class="[
containerClass,
variant === 'form' || variant === 'cards' ? 'is-form-scroll' : '',
]"
>
<div
class="marketing-admin-card"
:class="{
'is-form': variant === 'form',
'is-cards': variant === 'cards',
}"
>
<slot /> <slot />
</div> </div>
</div> </div>
@ -15,7 +27,7 @@ const props = withDefaults(
tip?: string tip?: string
tipType?: 'info' | 'success' | 'warning' | 'error' tipType?: 'info' | 'success' | 'warning' | 'error'
/** list = Handsontable 列表页;form = 配置/发券表单页 */ /** list = Handsontable 列表页;form = 配置/发券表单页 */
variant?: 'list' | 'form' variant?: 'list' | 'form' | 'cards'
containerClass?: string containerClass?: string
}>(), }>(),
{ {
@ -37,6 +49,10 @@ provide(MARKETING_PAGE_TIP_KEY, toRef(props, 'tip'))
overflow-y: hidden; overflow-y: hidden;
box-sizing: border-box; box-sizing: border-box;
background: var(--app-page-bg); background: var(--app-page-bg);
&.is-form-scroll {
overflow-y: auto;
}
} }
.marketing-admin-card { .marketing-admin-card {
@ -50,12 +66,26 @@ provide(MARKETING_PAGE_TIP_KEY, toRef(props, 'tip'))
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&.is-form { &.is-form {
overflow: auto; flex: 0 0 auto;
width: 100%;
max-width: 760px;
overflow: visible;
padding: 20px 24px 24px; padding: 20px 24px 24px;
> :deep(*) { > :deep(*) {
max-width: 960px; max-width: none;
} }
} }
&.is-cards {
flex: 0 0 auto;
width: 100%;
max-width: none;
overflow: visible;
padding: 0;
background: transparent;
box-shadow: none;
border-radius: 0;
}
} }
</style> </style>

View File

@ -1,5 +1,8 @@
<template> <template>
<div class="points-flow-panel"> <div class="points-flow-panel">
<div class="mkt-status-tabs">
<SlidingTabs v-model="changeTypeTab" :items="changeTypeTabs" @change="handleSearch" />
</div>
<div class="table-toolbar"> <div class="table-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<div class="toolbar-filters"> <div class="toolbar-filters">
@ -11,19 +14,6 @@
@press-enter="handleSearch" @press-enter="handleSearch"
@clear="handleSearch" @clear="handleSearch"
/> />
<a-select
v-model="changeType"
allow-clear
class="mkt-filter-status"
:style="{ width: '140px' }"
:placeholder="t('marketing.pointsFlow.changeTypeFilter')"
@change="handleSearch"
>
<a-option :value="1">{{ t('marketing.pointsFlow.changeType.earn') }}</a-option>
<a-option :value="2">{{ t('marketing.pointsFlow.changeType.consume') }}</a-option>
<a-option :value="3">{{ t('marketing.pointsFlow.changeType.adjust') }}</a-option>
<a-option :value="4">{{ t('marketing.pointsFlow.changeType.refund') }}</a-option>
</a-select>
<MarketingPageTipIcon /> <MarketingPageTipIcon />
<a-button class="app-btn-search" @click="handleSearch"> <a-button class="app-btn-search" @click="handleSearch">
{{ t('common.search') }} {{ t('common.search') }}
@ -32,9 +22,6 @@
<a-button type="primary" @click="openAdjustModal"> <a-button type="primary" @click="openAdjustModal">
{{ t('marketing.pointsFlow.adjust') }} {{ t('marketing.pointsFlow.adjust') }}
</a-button> </a-button>
<a-button @click="openRuleModal">
{{ t('marketing.pointsRule.tab.rule') }}
</a-button>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<a-space :size="8"> <a-space :size="8">
@ -92,51 +79,20 @@
/> />
</div> </div>
</ModalPro> </ModalPro>
<ModalPro
v-model:visible="ruleVisible"
:title="t('marketing.pointsRule.tab.rule')"
:confirm-loading="ruleSaving"
width="640px"
unmount-on-close
@ok="handleRuleOk"
@cancel="handleRuleCancel"
>
<div class="flow-modal">
<div class="flow-modal__tip">
<icon-info-circle class="flow-modal__tip-icon" />
<p>{{ t('marketing.pointsRule.saveConfirmContent') }}</p>
</div>
<a-spin :loading="ruleLoading">
<FormPro
ref="ruleFormRef"
class="flow-modal__form"
form-layout="vertical"
:hide-colon="true"
:item-list="ruleItemList"
:row-gutter="16"
:default-span="12"
:label-col-props="{ span: 24 }"
:wrapper-col-props="{ span: 24 }"
/>
</a-spin>
</div>
</ModalPro>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue' import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue' import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue' import ModalPro from '@/components/ModalPro/index.vue'
import FormPro from '@/components/FormPro/index.vue' import FormPro from '@/components/FormPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight' import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { isEnableStatusEnabled, toEnableStatus } from '@/utils/enableStatus'
import { adjustPoints, getPointsFlowList } from '@/api/marketing/pointsFlow' import { adjustPoints, getPointsFlowList } from '@/api/marketing/pointsFlow'
import { getPointsRule, updatePointsRule } from '@/api/marketing/pointsRule'
import MarketingPageTipIcon from './MarketingPageTipIcon.vue' import MarketingPageTipIcon from './MarketingPageTipIcon.vue'
defineOptions({ name: 'PointsFlowPanel' }) defineOptions({ name: 'PointsFlowPanel' })
@ -167,19 +123,22 @@ const tableRows = ref<PointsFlowRow[]>([])
const totalCount = ref(0) const totalCount = ref(0)
const listLoading = ref(false) const listLoading = ref(false)
const customerCode = ref('') const customerCode = ref('')
const changeType = ref<number | undefined>(undefined) const changeTypeTab = ref('all')
const currentPage = ref(1) const currentPage = ref(1)
const changeTypeTabs = computed(() => [
{ key: 'all', label: t('common.all') },
{ key: '1', label: t('marketing.pointsFlow.changeType.earn') },
{ key: '2', label: t('marketing.pointsFlow.changeType.consume') },
{ key: '3', label: t('marketing.pointsFlow.changeType.adjust') },
{ key: '4', label: t('marketing.pointsFlow.changeType.refund') },
])
const pageSize = ref(50) const pageSize = ref(50)
const adjustVisible = ref(false) const adjustVisible = ref(false)
const adjustLoading = ref(false) const adjustLoading = ref(false)
const adjustFormRef = ref<FormExpose>() const adjustFormRef = ref<FormExpose>()
const ruleVisible = ref(false)
const ruleLoading = ref(false)
const ruleSaving = ref(false)
const ruleFormRef = ref<FormExpose>()
const adjustItemList = computed(() => [ const adjustItemList = computed(() => [
{ {
field: 'customerCode', field: 'customerCode',
@ -229,60 +188,6 @@ const adjustItemList = computed(() => [
}, },
]) ])
const ruleItemList = computed(() => [
{
field: 'ruleName',
label: t('marketing.pointsRule.form.ruleName'),
comp: 'a-input',
required: true,
rules: [{ required: true, message: t('marketing.pointsRule.form.ruleName') }],
compProps: {
placeholder: t('marketing.pointsRule.form.ruleName'),
maxLength: 64,
allowClear: true,
},
},
{
field: 'status',
label: t('marketing.pointsRule.form.status'),
comp: 'a-radio-group',
required: true,
rules: [{ required: true, message: t('marketing.pointsRule.form.status') }],
compProps: {
type: 'button',
options: [
{ label: t('common.enabled'), value: 2 },
{ label: t('common.disabled'), value: 1 },
],
},
},
{
field: 'pointsPerYuan',
label: t('marketing.pointsRule.form.pointsPerYuan'),
comp: 'a-input-number',
required: true,
tooltip: t('marketing.pointsRule.form.pointsPerYuanExtra'),
rules: [{ required: true, message: t('marketing.pointsRule.form.pointsPerYuan') }],
compProps: { min: 0, precision: 4, style: 'width: 100%', placeholder: '1' },
},
{
field: 'firstOrderMultiplier',
label: t('marketing.pointsRule.form.firstOrderMultiplier'),
comp: 'a-input-number',
required: true,
rules: [{ required: true, message: t('marketing.pointsRule.form.firstOrderMultiplier') }],
compProps: { min: 1, max: 10, step: 0.1, precision: 2, style: 'width: 100%', placeholder: '1' },
},
{
field: 'maxPointsPerOrder',
label: t('marketing.pointsRule.form.maxPointsPerOrder'),
comp: 'a-input-number',
required: true,
rules: [{ required: true, message: t('marketing.pointsRule.form.maxPointsPerOrder') }],
compProps: { min: 0, precision: 0, style: 'width: 100%', placeholder: '10000' },
},
])
const hotColumns = computed(() => [ const hotColumns = computed(() => [
{ key: 'customerCode', label: t('marketing.pointsFlow.columns.customerCode'), width: 110 }, { key: 'customerCode', label: t('marketing.pointsFlow.columns.customerCode'), width: 110 },
{ key: 'customerName', label: t('marketing.pointsFlow.columns.customerName'), width: 120 }, { key: 'customerName', label: t('marketing.pointsFlow.columns.customerName'), width: 120 },
@ -308,24 +213,12 @@ function mapRow(item: Record<string, unknown>): PointsFlowRow {
} }
} }
function mapRule(item: Record<string, unknown>) {
const status = item.status ?? item.Status ?? 2
return {
id: (item.id ?? item.Id) as number | undefined,
ruleName: String(item.ruleName ?? item.RuleName ?? ''),
pointsPerYuan: Number(item.pointsPerYuan ?? item.PointsPerYuan ?? 1),
firstOrderMultiplier: Number(item.firstOrderMultiplier ?? item.FirstOrderMultiplier ?? 1),
maxPointsPerOrder: Number(item.maxPointsPerOrder ?? item.MaxPointsPerOrder ?? 10000),
status: toEnableStatus(isEnableStatusEnabled(status)),
}
}
async function loadList() { async function loadList() {
listLoading.value = true listLoading.value = true
try { try {
const { items, total } = await getPointsFlowList({ const { items, total } = await getPointsFlowList({
customerCode: customerCode.value.trim() || undefined, customerCode: customerCode.value.trim() || undefined,
changeType: changeType.value, changeType: changeTypeTab.value === 'all' ? undefined : Number(changeTypeTab.value),
page: currentPage.value, page: currentPage.value,
pageSize: pageSize.value, pageSize: pageSize.value,
}) })
@ -395,79 +288,9 @@ async function handleAdjustOk() {
} }
} }
async function openRuleModal() {
ruleVisible.value = true
ruleLoading.value = true
await nextTick()
try {
const data = await getPointsRule()
await nextTick()
if (data && typeof data === 'object') {
ruleFormRef.value?.setValues(mapRule(data as Record<string, unknown>))
} else {
ruleFormRef.value?.setValues({
id: undefined,
ruleName: '',
pointsPerYuan: 1,
firstOrderMultiplier: 1,
maxPointsPerOrder: 10000,
status: 2,
})
}
} catch (e: any) {
Message.error(e?.message || t('common.loadFailed'))
} finally {
ruleLoading.value = false
}
}
function handleRuleCancel() {
ruleVisible.value = false
}
async function handleRuleOk() {
try {
await ruleFormRef.value?.validate()
} catch {
return false
}
const values = ruleFormRef.value?.getValues() || {}
const ruleName = String(values.ruleName || '').trim()
if (!ruleName) {
Message.warning(t('marketing.pointsRule.form.ruleName'))
return false
}
Modal.confirm({
title: t('marketing.pointsRule.saveConfirmTitle'),
content: t('marketing.pointsRule.saveConfirmContent'),
onOk: async () => {
ruleSaving.value = true
try {
await updatePointsRule({
id: values.id,
ruleName,
pointsPerYuan: Number(values.pointsPerYuan ?? 1),
firstOrderMultiplier: Number(values.firstOrderMultiplier ?? 1),
maxPointsPerOrder: Number(values.maxPointsPerOrder ?? 10000),
status: Number(values.status ?? 2),
})
Message.success(t('common.saveSuccess'))
ruleVisible.value = false
} catch (e: any) {
Message.error(e?.message || t('common.saveFailed'))
throw e
} finally {
ruleSaving.value = false
}
},
})
return false
}
onMounted(() => loadList()) onMounted(() => loadList())
defineExpose({ loadList, openAdjustModal, openRuleModal }) defineExpose({ loadList, openAdjustModal })
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@ -534,15 +357,5 @@ defineExpose({ loadList, openAdjustModal, openRuleModal })
width: 100%; width: 100%;
border-radius: 4px; border-radius: 4px;
} }
:deep(.arco-radio-group-button) {
display: flex;
width: 100%;
.arco-radio-button {
flex: 1;
text-align: center;
}
}
} }
</style> </style>

View File

@ -1,5 +1,8 @@
<template> <template>
<MarketingPageShell tip-type="warning" :tip="t('marketing.couponGrant.pageTip')"> <MarketingPageShell tip-type="warning" :tip="t('marketing.couponGrant.pageTip')">
<div class="mkt-status-tabs">
<SlidingTabs v-model="recordStatusTab" :items="recordStatusTabs" @change="handleRecordSearch" />
</div>
<div class="table-toolbar"> <div class="table-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<div class="toolbar-filters"> <div class="toolbar-filters">
@ -21,19 +24,6 @@
@press-enter="handleRecordSearch" @press-enter="handleRecordSearch"
@clear="handleRecordSearch" @clear="handleRecordSearch"
/> />
<a-select
v-model="recordStatus"
allow-clear
class="mkt-filter-status"
:style="{ width: '140px' }"
:placeholder="t('marketing.couponGrant.records.statusFilter')"
@change="handleRecordSearch"
>
<a-option :value="1">{{ t('marketing.couponGrant.instanceStatus.available') }}</a-option>
<a-option :value="2">{{ t('marketing.couponGrant.instanceStatus.locked') }}</a-option>
<a-option :value="3">{{ t('marketing.couponGrant.instanceStatus.used') }}</a-option>
<a-option :value="4">{{ t('marketing.couponGrant.instanceStatus.expired') }}</a-option>
</a-select>
<a-input <a-input
v-model="recordKeyword" v-model="recordKeyword"
allow-clear allow-clear
@ -146,6 +136,7 @@ import FormPro from '@/components/FormPro/index.vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue' import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue' import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue' import ModalPro from '@/components/ModalPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight' import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { getCouponTemplateList } from '@/api/marketing/couponTemplate' import { getCouponTemplateList } from '@/api/marketing/couponTemplate'
import { getCouponInstanceList, grantCoupons, previewCouponGrant } from '@/api/marketing/couponGrant' import { getCouponInstanceList, grantCoupons, previewCouponGrant } from '@/api/marketing/couponGrant'
@ -190,11 +181,20 @@ const recordTotal = ref(0)
const recordLoading = ref(false) const recordLoading = ref(false)
const recordTemplateId = ref<number | undefined>(undefined) const recordTemplateId = ref<number | undefined>(undefined)
const recordCustomerCode = ref('') const recordCustomerCode = ref('')
const recordStatus = ref<number | undefined>(undefined) const recordStatusTab = ref('all')
const recordKeyword = ref('') const recordKeyword = ref('')
const recordPage = ref(1) const recordPage = ref(1)
const recordPageSize = ref(30) const recordPageSize = ref(30)
const recordStatusTabs = computed(() => [
{ key: 'all', label: t('common.all') },
{ key: '5', label: t('marketing.couponGrant.instanceStatus.pendingUnlock') },
{ key: '1', label: t('marketing.couponGrant.instanceStatus.available') },
{ key: '2', label: t('marketing.couponGrant.instanceStatus.locked') },
{ key: '3', label: t('marketing.couponGrant.instanceStatus.used') },
{ key: '4', label: t('marketing.couponGrant.instanceStatus.expired') },
])
const itemList = computed(() => [ const itemList = computed(() => [
{ {
field: 'templateId', field: 'templateId',
@ -336,7 +336,7 @@ async function loadRecords() {
const { items, total } = await getCouponInstanceList({ const { items, total } = await getCouponInstanceList({
templateId: recordTemplateId.value, templateId: recordTemplateId.value,
customerCode: recordCustomerCode.value.trim() || undefined, customerCode: recordCustomerCode.value.trim() || undefined,
status: recordStatus.value, status: recordStatusTab.value === 'all' ? undefined : Number(recordStatusTab.value),
keyword: recordKeyword.value.trim() || undefined, keyword: recordKeyword.value.trim() || undefined,
page: recordPage.value, page: recordPage.value,
pageSize: recordPageSize.value, pageSize: recordPageSize.value,

View File

@ -1,5 +1,8 @@
<template> <template>
<MarketingPageShell :tip="t('marketing.couponTemplate.pageTip')"> <MarketingPageShell :tip="t('marketing.couponTemplate.pageTip')">
<div class="mkt-status-tabs">
<SlidingTabs v-model="statusTab" :items="statusTabs" @change="handleSearch" />
</div>
<div class="table-toolbar"> <div class="table-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<div class="toolbar-filters"> <div class="toolbar-filters">
@ -11,18 +14,6 @@
@press-enter="handleSearch" @press-enter="handleSearch"
@clear="handleSearch" @clear="handleSearch"
/> />
<a-select
v-model="statusFilter"
allow-clear
class="mkt-filter-status"
:style="{ width: '140px' }"
:placeholder="t('marketing.couponTemplate.statusFilter')"
@change="handleSearch"
>
<a-option :value="0">{{ t('marketing.couponTemplate.status.draft') }}</a-option>
<a-option :value="1">{{ t('marketing.couponTemplate.status.active') }}</a-option>
<a-option :value="2">{{ t('marketing.couponTemplate.status.ended') }}</a-option>
</a-select>
<MarketingPageTipIcon /> <MarketingPageTipIcon />
<a-button class="app-btn-search" @click="handleSearch"> <a-button class="app-btn-search" @click="handleSearch">
{{ t('common.search') }} {{ t('common.search') }}
@ -103,6 +94,7 @@ import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue' import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue' import ModalPro from '@/components/ModalPro/index.vue'
import FormPro from '@/components/FormPro/index.vue' import FormPro from '@/components/FormPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight' import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { import {
createCouponTemplate, createCouponTemplate,
@ -141,6 +133,8 @@ interface TemplateRow {
validStartTime: string | null validStartTime: string | null
validEndTime: string | null validEndTime: string | null
validDaysAfterReceive: number | null validDaysAfterReceive: number | null
applyScopeJson: string
applyCountryCodes: string
remark: string remark: string
status: number status: number
statusName: string statusName: string
@ -156,10 +150,17 @@ const tableRows = ref<TemplateRow[]>([])
const totalCount = ref(0) const totalCount = ref(0)
const listLoading = ref(false) const listLoading = ref(false)
const keyword = ref('') const keyword = ref('')
const statusFilter = ref<number | undefined>(undefined) const statusTab = ref('all')
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(50) const pageSize = ref(50)
const statusTabs = computed(() => [
{ key: 'all', label: t('common.all') },
{ key: '0', label: t('marketing.couponTemplate.status.draft') },
{ key: '1', label: t('marketing.couponTemplate.status.active') },
{ key: '2', label: t('marketing.couponTemplate.status.ended') },
])
const modalVisible = ref(false) const modalVisible = ref(false)
const modalLoading = ref(false) const modalLoading = ref(false)
const editingId = ref<number | undefined>(undefined) const editingId = ref<number | undefined>(undefined)
@ -176,6 +177,9 @@ const grantChannelOptions = computed(() => [
{ label: t('marketing.couponTemplate.grantChannel.manual'), value: 'manual' }, { label: t('marketing.couponTemplate.grantChannel.manual'), value: 'manual' },
{ label: t('marketing.couponTemplate.grantChannel.newcomer'), value: 'newcomer' }, { label: t('marketing.couponTemplate.grantChannel.newcomer'), value: 'newcomer' },
{ label: t('marketing.couponTemplate.grantChannel.selfReceive'), value: 'selfReceive' }, { label: t('marketing.couponTemplate.grantChannel.selfReceive'), value: 'selfReceive' },
{ label: t('marketing.couponTemplate.grantChannel.referral'), value: 'referral' },
{ label: t('marketing.couponTemplate.grantChannel.route'), value: 'route' },
{ label: t('marketing.couponTemplate.grantChannel.festival'), value: 'festival' },
]) ])
const itemList = computed(() => [ const itemList = computed(() => [
@ -287,6 +291,7 @@ const itemList = computed(() => [
options: [ options: [
{ label: t('marketing.couponTemplate.validType.fixed'), value: 1 }, { label: t('marketing.couponTemplate.validType.fixed'), value: 1 },
{ label: t('marketing.couponTemplate.validType.afterReceive'), value: 2 }, { label: t('marketing.couponTemplate.validType.afterReceive'), value: 2 },
{ label: t('marketing.couponTemplate.validType.afterUnlock'), value: 3 },
], ],
}, },
}, },
@ -319,10 +324,21 @@ const itemList = computed(() => [
label: t('marketing.couponTemplate.form.validDaysAfterReceive'), label: t('marketing.couponTemplate.form.validDaysAfterReceive'),
comp: 'a-input-number', comp: 'a-input-number',
required: true, required: true,
ifShow: (model: Record<string, unknown>) => Number(model.validType) === 2, ifShow: (model: Record<string, unknown>) => Number(model.validType) === 2 || Number(model.validType) === 3,
rules: [{ required: true, message: t('marketing.couponTemplate.form.validDaysAfterReceive') }], rules: [{ required: true, message: t('marketing.couponTemplate.form.validDaysAfterReceive') }],
compProps: { min: 1, precision: 0, style: 'width: 100%', placeholder: '30' }, compProps: { min: 1, precision: 0, style: 'width: 100%', placeholder: '30' },
}, },
{
field: 'applyCountryCodes',
label: t('marketing.couponTemplate.form.applyCountries'),
comp: 'a-input',
tooltip: t('marketing.couponTemplate.form.applyCountriesExtra'),
span: 24,
compProps: {
placeholder: 'MY,SG',
allowClear: true,
},
},
{ {
field: 'remark', field: 'remark',
label: t('marketing.couponTemplate.form.remark'), label: t('marketing.couponTemplate.form.remark'),
@ -349,6 +365,26 @@ const hotColumns = computed(() => [
{ key: 'statusName', label: t('marketing.couponTemplate.columns.status'), width: 80 }, { key: 'statusName', label: t('marketing.couponTemplate.columns.status'), width: 80 },
]) ])
function parseCountryCodes(json: unknown): string {
if (!json) return ''
try {
const parsed = typeof json === 'string' ? JSON.parse(json) : json
const codes = Array.isArray(parsed?.countryCodes) ? parsed.countryCodes : []
return codes.filter(Boolean).join(',')
} catch {
return ''
}
}
function toApplyScopeJson(raw: unknown): string | undefined {
const codes = String(raw || '')
.split(/[,,\s]+/)
.map((x) => x.trim().toUpperCase())
.filter(Boolean)
if (!codes.length) return undefined
return JSON.stringify({ countryCodes: codes })
}
function formatDateTime(value: unknown): string | null { function formatDateTime(value: unknown): string | null {
if (value == null || value === '') return null if (value == null || value === '') return null
return String(value).replace('T', ' ').slice(0, 19) return String(value).replace('T', ' ').slice(0, 19)
@ -404,6 +440,8 @@ function mapRow(item: Record<string, unknown>): TemplateRow {
item.validDaysAfterReceive != null || item.ValidDaysAfterReceive != null item.validDaysAfterReceive != null || item.ValidDaysAfterReceive != null
? Number(item.validDaysAfterReceive ?? item.ValidDaysAfterReceive) ? Number(item.validDaysAfterReceive ?? item.ValidDaysAfterReceive)
: null, : null,
applyScopeJson: String(item.applyScopeJson ?? item.ApplyScopeJson ?? ''),
applyCountryCodes: parseCountryCodes(item.applyScopeJson ?? item.ApplyScopeJson),
remark: String(item.remark ?? item.Remark ?? ''), remark: String(item.remark ?? item.Remark ?? ''),
status: Number(item.status ?? item.Status ?? 0), status: Number(item.status ?? item.Status ?? 0),
statusName: String(item.statusName ?? item.StatusName ?? ''), statusName: String(item.statusName ?? item.StatusName ?? ''),
@ -422,7 +460,7 @@ async function loadList() {
try { try {
const { items, total } = await getCouponTemplateList({ const { items, total } = await getCouponTemplateList({
keyword: keyword.value.trim() || undefined, keyword: keyword.value.trim() || undefined,
status: statusFilter.value, status: statusTab.value === 'all' ? undefined : Number(statusTab.value),
page: currentPage.value, page: currentPage.value,
pageSize: pageSize.value, pageSize: pageSize.value,
}) })
@ -462,6 +500,7 @@ async function openModal(row?: TemplateRow) {
validStartTime: row.validStartTime || undefined, validStartTime: row.validStartTime || undefined,
validEndTime: row.validEndTime || undefined, validEndTime: row.validEndTime || undefined,
validDaysAfterReceive: row.validDaysAfterReceive ?? 30, validDaysAfterReceive: row.validDaysAfterReceive ?? 30,
applyCountryCodes: row.applyCountryCodes || '',
remark: row.remark || '', remark: row.remark || '',
} }
: { : {
@ -479,6 +518,7 @@ async function openModal(row?: TemplateRow) {
validStartTime: undefined, validStartTime: undefined,
validEndTime: undefined, validEndTime: undefined,
validDaysAfterReceive: 30, validDaysAfterReceive: 30,
applyCountryCodes: '',
remark: '', remark: '',
} }
formRef.value?.setValues(values) formRef.value?.setValues(values)
@ -552,7 +592,8 @@ async function handleModalOk() {
validType, validType,
validStartTime: validType === 1 ? values.validStartTime || undefined : undefined, validStartTime: validType === 1 ? values.validStartTime || undefined : undefined,
validEndTime: validType === 1 ? values.validEndTime || undefined : undefined, validEndTime: validType === 1 ? values.validEndTime || undefined : undefined,
validDaysAfterReceive: validType === 2 ? Number(values.validDaysAfterReceive ?? 30) : undefined, validDaysAfterReceive: validType === 2 || validType === 3 ? Number(values.validDaysAfterReceive ?? 30) : undefined,
applyScopeJson: toApplyScopeJson(values.applyCountryCodes),
remark: String(values.remark || '').trim() || undefined, remark: String(values.remark || '').trim() || undefined,
grantChannel: String(values.grantChannel || 'manual').trim() || 'manual', grantChannel: String(values.grantChannel || 'manual').trim() || 'manual',
} }

View File

@ -218,7 +218,7 @@
<a-input-number <a-input-number
v-model="formState.extraDiscountRate" v-model="formState.extraDiscountRate"
:min="0" :min="0"
:max="0.99" :max="0.05"
:step="0.01" :step="0.01"
:precision="4" :precision="4"
style="width: 100%" style="width: 100%"
@ -226,6 +226,15 @@
/> />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12">
<a-form-item
:label="t('marketing.memberLevel.form.canDowngrade')"
field="canDowngrade"
:tooltip="t('marketing.memberLevel.form.canDowngradeExtra')"
>
<a-switch v-model="formState.canDowngrade" />
</a-form-item>
</a-col>
</a-row> </a-row>
</section> </section>
@ -305,6 +314,7 @@ interface MemberLevelRow {
levelName: string levelName: string
minPoints: number minPoints: number
extraDiscountRate: number extraDiscountRate: number
canDowngrade: boolean
sort: number sort: number
iconUrl: string iconUrl: string
status: boolean status: boolean
@ -318,6 +328,7 @@ interface MemberLevelFormState {
levelName: string levelName: string
minPoints: number minPoints: number
extraDiscountRate: number extraDiscountRate: number
canDowngrade: boolean
sort: number sort: number
iconUrl: string iconUrl: string
status: number status: number
@ -352,10 +363,13 @@ const previewInitial = computed(() => {
return code ? code.slice(0, 1).toUpperCase() : 'M' return code ? code.slice(0, 1).toUpperCase() : 'M'
}) })
const previewDiscountText = computed(() => { const previewDiscountText = computed(() => formatFreightOff(formState.extraDiscountRate))
const rate = Number(formState.extraDiscountRate || 0)
return `${(rate * 100).toFixed(2)}%` function formatFreightOff(rate: unknown) {
}) const n = Number(rate || 0)
if (!n) return t('marketing.memberLevel.discount.original')
return t('marketing.memberLevel.discount.fold', { fold: Math.round((1 - n) * 100) })
}
function createEmptyForm(): MemberLevelFormState { function createEmptyForm(): MemberLevelFormState {
return { return {
@ -363,6 +377,7 @@ function createEmptyForm(): MemberLevelFormState {
levelName: '', levelName: '',
minPoints: 0, minPoints: 0,
extraDiscountRate: 0, extraDiscountRate: 0,
canDowngrade: false,
sort: 0, sort: 0,
iconUrl: '', iconUrl: '',
status: 2, status: 2,
@ -387,6 +402,7 @@ function mapRow(item: any): MemberLevelRow {
levelName: item.levelName ?? item.LevelName ?? '', levelName: item.levelName ?? item.LevelName ?? '',
minPoints: Number(item.minPoints ?? item.MinPoints ?? 0), minPoints: Number(item.minPoints ?? item.MinPoints ?? 0),
extraDiscountRate: Number(item.extraDiscountRate ?? item.ExtraDiscountRate ?? 0), extraDiscountRate: Number(item.extraDiscountRate ?? item.ExtraDiscountRate ?? 0),
canDowngrade: Boolean(item.canDowngrade ?? item.CanDowngrade),
sort: Number(item.sort ?? item.Sort ?? 0), sort: Number(item.sort ?? item.Sort ?? 0),
iconUrl: String(item.iconUrl ?? item.IconUrl ?? ''), iconUrl: String(item.iconUrl ?? item.IconUrl ?? ''),
status: isEnableStatusEnabled(status), status: isEnableStatusEnabled(status),
@ -404,7 +420,13 @@ const hotColumns = computed(() => [
key: 'extraDiscountRate', key: 'extraDiscountRate',
label: t('marketing.memberLevel.form.extraDiscountRate'), label: t('marketing.memberLevel.form.extraDiscountRate'),
width: 100, width: 100,
formatter: (v: number) => `${(Number(v) * 100).toFixed(2)}%`, formatter: (v: number) => formatFreightOff(v),
},
{
key: 'canDowngrade',
label: t('marketing.memberLevel.form.canDowngrade'),
width: 90,
formatter: (v: boolean) => (v ? t('common.enabled') : t('common.disabled')),
}, },
{ key: 'sort', label: t('marketing.memberLevel.form.sort'), width: 70 }, { key: 'sort', label: t('marketing.memberLevel.form.sort'), width: 70 },
{ key: 'statusName', label: t('marketing.memberLevel.form.status'), width: 80 }, { key: 'statusName', label: t('marketing.memberLevel.form.status'), width: 80 },
@ -450,6 +472,7 @@ function openDrawer(row?: MemberLevelRow) {
levelName: row.levelName, levelName: row.levelName,
minPoints: row.minPoints, minPoints: row.minPoints,
extraDiscountRate: row.extraDiscountRate, extraDiscountRate: row.extraDiscountRate,
canDowngrade: row.canDowngrade,
sort: row.sort, sort: row.sort,
iconUrl: row.iconUrl || '', iconUrl: row.iconUrl || '',
status: toEnableStatus(row.status), status: toEnableStatus(row.status),
@ -507,6 +530,7 @@ async function handleDrawerOk() {
levelName, levelName,
minPoints: Number(formState.minPoints ?? 0), minPoints: Number(formState.minPoints ?? 0),
extraDiscountRate: Number(formState.extraDiscountRate ?? 0), extraDiscountRate: Number(formState.extraDiscountRate ?? 0),
canDowngrade: Boolean(formState.canDowngrade),
sort: Number(formState.sort ?? 0), sort: Number(formState.sort ?? 0),
iconUrl: String(formState.iconUrl || '').trim() || undefined, iconUrl: String(formState.iconUrl || '').trim() || undefined,
status: Number(formState.status ?? 2), status: Number(formState.status ?? 2),

View File

@ -1,16 +1,604 @@
<template> <template>
<MarketingPageShell :tip="t('marketing.pointsFlow.pageTip')"> <MarketingPageShell variant="cards" :tip="t('marketing.pointsRule.pageTip')">
<PointsFlowPanel /> <div class="points-rule-page">
<section class="rule-section">
<h2 class="rule-section__title">{{ t('marketing.pointsRule.sectionTitle') }}</h2>
<div class="rule-section__tip">
<icon-info-circle class="rule-section__tip-icon" />
<p>{{ t('marketing.pointsRule.saveConfirmContent') }}</p>
<MarketingPageTipIcon />
</div>
<a-spin :loading="pointsLoading">
<FormPro
ref="pointsFormRef"
class="rule-form"
form-layout="vertical"
:hide-colon="true"
:item-list="pointsItemList"
:row-gutter="20"
:default-span="12"
:label-col-props="{ span: 24 }"
:wrapper-col-props="{ span: 24 }"
/>
</a-spin>
<div class="form-actions">
<a-space :size="8">
<a-button :disabled="pointsLoading || pointsSaving" @click="handlePointsReset">
{{ t('common.reset') }}
</a-button>
<a-button
type="primary"
:loading="pointsSaving"
:disabled="pointsLoading"
@click="handlePointsSave"
>
{{ t('common.save') }}
</a-button>
</a-space>
</div>
</section>
<section v-if="canEditCommission" class="rule-section">
<h2 class="rule-section__title">{{ t('menu.marketing.commissionRule') }}</h2>
<div class="rule-section__tip">
<icon-info-circle class="rule-section__tip-icon" />
<p>{{ t('marketing.commissionRule.pageTip') }}</p>
</div>
<a-spin :loading="commissionLoading">
<FormPro
ref="commissionFormRef"
class="rule-form"
form-layout="vertical"
:hide-colon="true"
:item-list="commissionItemList"
:row-gutter="20"
:default-span="12"
:label-col-props="{ span: 24 }"
:wrapper-col-props="{ span: 24 }"
/>
</a-spin>
<div class="form-actions">
<a-space :size="8">
<a-button :disabled="commissionLoading || commissionSaving" @click="handleCommissionReset">
{{ t('common.reset') }}
</a-button>
<a-button
type="primary"
:loading="commissionSaving"
:disabled="commissionLoading"
@click="handleCommissionSave"
>
{{ t('common.save') }}
</a-button>
</a-space>
</div>
</section>
</div>
</MarketingPageShell> </MarketingPageShell>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import FormPro from '@/components/FormPro/index.vue'
import { isEnableStatusEnabled, toEnableStatus } from '@/utils/enableStatus'
import { hasPermission } from '@/utils/rbac-permission'
import { getPointsRule, updatePointsRule } from '@/api/marketing/pointsRule'
import { getCommissionRule, updateCommissionRule } from '@/api/marketing/commissionRule'
import MarketingPageShell from '../components/MarketingPageShell.vue' import MarketingPageShell from '../components/MarketingPageShell.vue'
import PointsFlowPanel from '../components/PointsFlowPanel.vue' import MarketingPageTipIcon from '../components/MarketingPageTipIcon.vue'
/** 兼容旧菜单「积分规则」:与积分流水同一页,规则配置走弹窗 */
defineOptions({ name: 'MarketingPointsRule' }) defineOptions({ name: 'MarketingPointsRule' })
const { t } = useI18n() const { t } = useI18n()
type FormExpose = {
validate: () => Promise<unknown>
getValues: () => Record<string, unknown>
setValues: (values: Record<string, unknown>) => void
}
const canEditCommission = computed(() => hasPermission('Marketing.Commission.Rule.Edit'))
const pointsLoading = ref(false)
const pointsSaving = ref(false)
const pointsFormRef = ref<FormExpose>()
const loadedPoints = ref<Record<string, unknown>>({
id: undefined,
ruleName: '',
pointsPerYuan: 1,
status: 2,
})
const commissionLoading = ref(false)
const commissionSaving = ref(false)
const commissionFormRef = ref<FormExpose>()
function emptyCommission(): Record<string, unknown> {
return {
id: undefined,
ruleName: '',
minInviteLevelCode: 'V1',
minPaidBaseFreight: 50,
firstOrderRatePercent: 5,
firstOrderCap: 50,
followOrderRatePercent: 3,
followOrderCap: 30,
followWindowDays: 90,
maxRewardOrders: 6,
settleBusinessDays: 3,
monthlyCap: undefined,
status: 2,
}
}
function toRatePercent(value: unknown, fallback: number) {
const n = Number(value)
if (!Number.isFinite(n)) return fallback
return n <= 1 ? Math.round(n * 10000) / 100 : n
}
function toRateDecimal(value: unknown, fallbackPercent: number) {
const n = Number(value)
if (!Number.isFinite(n)) return fallbackPercent / 100
return n > 1 ? n / 100 : n
}
const loadedCommission = ref<Record<string, unknown>>(emptyCommission())
const statusSwitchProps = computed(() => ({
checkedValue: 2,
uncheckedValue: 1,
checkedText: t('common.enabled'),
uncheckedText: t('common.disabled'),
}))
const pointsItemList = computed(() => [
{
field: 'ruleName',
label: t('marketing.pointsRule.form.ruleName'),
span: 24,
comp: 'a-input',
required: true,
rules: [{ required: true, message: t('marketing.pointsRule.form.ruleName') }],
compProps: {
placeholder: t('marketing.pointsRule.form.ruleName'),
maxLength: 64,
allowClear: true,
},
},
{
field: 'status',
label: t('marketing.pointsRule.form.status'),
span: 24,
comp: 'a-switch',
required: true,
rules: [{ required: true, message: t('marketing.pointsRule.form.status') }],
compProps: statusSwitchProps.value,
},
{
field: 'pointsPerYuan',
label: t('marketing.pointsRule.form.pointsPerYuan'),
span: 24,
comp: 'a-input-number',
required: true,
tooltip: t('marketing.pointsRule.form.pointsPerYuanExtra'),
rules: [{ required: true, message: t('marketing.pointsRule.form.pointsPerYuan') }],
compProps: { min: 0, precision: 4, style: 'width: 100%', placeholder: '1' },
},
])
const commissionItemList = computed(() => [
{
field: 'ruleName',
label: t('marketing.commissionRule.form.ruleName'),
span: 24,
comp: 'a-input',
required: true,
rules: [{ required: true, message: t('marketing.commissionRule.form.ruleName') }],
compProps: {
allowClear: true,
maxLength: 64,
placeholder: t('marketing.commissionRule.form.ruleName'),
},
},
{
field: 'status',
label: t('marketing.commissionRule.form.status'),
span: 24,
comp: 'a-switch',
required: true,
rules: [{ required: true, message: t('marketing.commissionRule.form.status') }],
compProps: statusSwitchProps.value,
},
{
field: 'minInviteLevelCode',
label: t('marketing.commissionRule.form.minInviteLevelCode'),
span: 12,
comp: 'a-input',
required: true,
tooltip: t('marketing.commissionRule.form.minInviteLevelCodeExtra'),
rules: [{ required: true, message: t('marketing.commissionRule.form.minInviteLevelCode') }],
compProps: { allowClear: true, maxLength: 8, placeholder: 'V1' },
},
{
field: 'minPaidBaseFreight',
label: t('marketing.commissionRule.form.minPaidBaseFreight'),
span: 12,
comp: 'a-input-number',
required: true,
tooltip: t('marketing.commissionRule.form.minPaidBaseFreightExtra'),
rules: [{ required: true, message: t('marketing.commissionRule.form.minPaidBaseFreight') }],
compProps: { min: 0, precision: 2, style: 'width: 100%', placeholder: '50' },
},
{
field: 'firstOrderRatePercent',
label: t('marketing.commissionRule.form.firstOrderRate'),
span: 12,
comp: 'a-input-number',
required: true,
tooltip: t('marketing.commissionRule.form.firstOrderRateExtra'),
rules: [{ required: true, message: t('marketing.commissionRule.form.firstOrderRate') }],
compProps: { min: 0, max: 100, precision: 2, style: 'width: 100%', placeholder: '5' },
},
{
field: 'firstOrderCap',
label: t('marketing.commissionRule.form.firstOrderCap'),
span: 12,
comp: 'a-input-number',
required: true,
rules: [{ required: true, message: t('marketing.commissionRule.form.firstOrderCap') }],
compProps: { min: 0, precision: 2, style: 'width: 100%', placeholder: '50' },
},
{
field: 'followOrderRatePercent',
label: t('marketing.commissionRule.form.followOrderRate'),
span: 12,
comp: 'a-input-number',
required: true,
tooltip: t('marketing.commissionRule.form.followOrderRateExtra'),
rules: [{ required: true, message: t('marketing.commissionRule.form.followOrderRate') }],
compProps: { min: 0, max: 100, precision: 2, style: 'width: 100%', placeholder: '3' },
},
{
field: 'followOrderCap',
label: t('marketing.commissionRule.form.followOrderCap'),
span: 12,
comp: 'a-input-number',
required: true,
rules: [{ required: true, message: t('marketing.commissionRule.form.followOrderCap') }],
compProps: { min: 0, precision: 2, style: 'width: 100%', placeholder: '30' },
},
{
field: 'followWindowDays',
label: t('marketing.commissionRule.form.followWindowDays'),
span: 12,
comp: 'a-input-number',
required: true,
rules: [{ required: true, message: t('marketing.commissionRule.form.followWindowDays') }],
compProps: { min: 1, precision: 0, style: 'width: 100%', placeholder: '90' },
},
{
field: 'maxRewardOrders',
label: t('marketing.commissionRule.form.maxRewardOrders'),
span: 12,
comp: 'a-input-number',
required: true,
rules: [{ required: true, message: t('marketing.commissionRule.form.maxRewardOrders') }],
compProps: { min: 1, precision: 0, style: 'width: 100%', placeholder: '6' },
},
{
field: 'settleBusinessDays',
label: t('marketing.commissionRule.form.settleBusinessDays'),
span: 12,
comp: 'a-input-number',
required: true,
tooltip: t('marketing.commissionRule.form.settleBusinessDaysExtra'),
rules: [{ required: true, message: t('marketing.commissionRule.form.settleBusinessDays') }],
compProps: { min: 0, precision: 0, style: 'width: 100%', placeholder: '3' },
},
{
field: 'monthlyCap',
label: t('marketing.commissionRule.form.monthlyCap'),
span: 12,
comp: 'a-input-number',
tooltip: t('marketing.commissionRule.form.monthlyCapExtra'),
compProps: {
min: 0,
precision: 2,
style: 'width: 100%',
placeholder: t('marketing.commissionRule.form.monthlyCapExtra'),
},
},
])
function mapPoints(item: Record<string, unknown>) {
const status = item.status ?? item.Status ?? 2
return {
id: (item.id ?? item.Id) as number | undefined,
ruleName: String(item.ruleName ?? item.RuleName ?? ''),
pointsPerYuan: Number(item.pointsPerYuan ?? item.PointsPerYuan ?? 1),
status: toEnableStatus(isEnableStatusEnabled(status)),
}
}
function mapCommission(item: Record<string, unknown>) {
const status = item.status ?? item.Status ?? 2
const cap = item.monthlyCap ?? item.MonthlyCap
const settle =
item.settleBusinessDays ?? item.SettleBusinessDays ?? item.freezeDays ?? item.FreezeDays ?? 3
return {
id: (item.id ?? item.Id) as number | undefined,
ruleName: String(item.ruleName ?? item.RuleName ?? ''),
minInviteLevelCode: String(item.minInviteLevelCode ?? item.MinInviteLevelCode ?? 'V1'),
minPaidBaseFreight: Number(item.minPaidBaseFreight ?? item.MinPaidBaseFreight ?? 50),
firstOrderRatePercent: toRatePercent(item.firstOrderRate ?? item.FirstOrderRate, 5),
firstOrderCap: Number(item.firstOrderCap ?? item.FirstOrderCap ?? 50),
followOrderRatePercent: toRatePercent(item.followOrderRate ?? item.FollowOrderRate, 3),
followOrderCap: Number(item.followOrderCap ?? item.FollowOrderCap ?? 30),
followWindowDays: Number(item.followWindowDays ?? item.FollowWindowDays ?? 90),
maxRewardOrders: Number(item.maxRewardOrders ?? item.MaxRewardOrders ?? 6),
settleBusinessDays: Number(settle),
monthlyCap: cap != null && cap !== '' ? Number(cap) : undefined,
status: toEnableStatus(isEnableStatusEnabled(status)),
}
}
function applyPoints(values: Record<string, unknown>) {
loadedPoints.value = values
pointsFormRef.value?.setValues({ ...values })
}
function applyCommission(values: Record<string, unknown>) {
loadedCommission.value = values
commissionFormRef.value?.setValues({ ...values })
}
async function loadPoints() {
pointsLoading.value = true
try {
const data = await getPointsRule()
await nextTick()
if (data && typeof data === 'object') {
applyPoints(mapPoints(data as Record<string, unknown>))
} else {
applyPoints({
id: undefined,
ruleName: '',
pointsPerYuan: 1,
status: 2,
})
}
} catch (e: any) {
Message.error(e?.message || t('common.loadFailed'))
} finally {
pointsLoading.value = false
}
}
async function loadCommission() {
if (!canEditCommission.value) return
commissionLoading.value = true
try {
const data = await getCommissionRule()
await nextTick()
if (data && typeof data === 'object') {
applyCommission(mapCommission(data as Record<string, unknown>))
} else {
applyCommission(emptyCommission())
}
} catch (e: any) {
Message.error(e?.message || t('common.loadFailed'))
} finally {
commissionLoading.value = false
}
}
function handlePointsReset() {
applyPoints({ ...loadedPoints.value })
}
function handleCommissionReset() {
applyCommission({ ...loadedCommission.value })
}
async function handlePointsSave() {
try {
await pointsFormRef.value?.validate()
} catch {
return
}
const values = pointsFormRef.value?.getValues() || {}
const ruleName = String(values.ruleName || '').trim()
if (!ruleName) {
Message.warning(t('marketing.pointsRule.form.ruleName'))
return
}
Modal.confirm({
title: t('marketing.pointsRule.saveConfirmTitle'),
content: t('marketing.pointsRule.saveConfirmContent'),
onOk: async () => {
pointsSaving.value = true
try {
await updatePointsRule({
id: values.id,
ruleName,
pointsPerYuan: Number(values.pointsPerYuan ?? 1),
firstOrderMultiplier: 1,
maxPointsPerOrder: 0,
status: Number(values.status ?? 2),
})
Message.success(t('common.saveSuccess'))
await loadPoints()
} catch (e: any) {
Message.error(e?.message || t('common.saveFailed'))
throw e
} finally {
pointsSaving.value = false
}
},
})
}
async function handleCommissionSave() {
try {
await commissionFormRef.value?.validate()
} catch {
return
}
const values = commissionFormRef.value?.getValues() || {}
const ruleName = String(values.ruleName || '').trim()
if (!ruleName) {
Message.warning(t('marketing.commissionRule.form.ruleName'))
return
}
const next = {
id: values.id,
ruleName,
commissionMode: 'percent_of_paid_base_freight',
minInviteLevelCode: String(values.minInviteLevelCode || 'V1').trim(),
minPaidBaseFreight: Number(values.minPaidBaseFreight ?? 50),
firstOrderRate: toRateDecimal(values.firstOrderRatePercent, 5),
firstOrderCap: Number(values.firstOrderCap ?? 50),
followOrderRate: toRateDecimal(values.followOrderRatePercent, 3),
followOrderCap: Number(values.followOrderCap ?? 30),
followWindowDays: Number(values.followWindowDays ?? 90),
maxRewardOrders: Number(values.maxRewardOrders ?? 6),
settleBusinessDays: Number(values.settleBusinessDays ?? 3),
freezeDays: Number(values.settleBusinessDays ?? 3),
monthlyCap:
values.monthlyCap != null && values.monthlyCap !== '' ? Number(values.monthlyCap) : null,
status: Number(values.status ?? 2),
}
const o = loadedCommission.value
Modal.confirm({
title: t('marketing.commissionRule.saveConfirmTitle'),
content: t('marketing.commissionRule.saveCompare', {
oldRate: o.firstOrderRatePercent,
newRate: values.firstOrderRatePercent,
oldFollow: o.followOrderRatePercent,
newFollow: values.followOrderRatePercent,
oldMin: o.minPaidBaseFreight,
newMin: next.minPaidBaseFreight,
oldSettle: o.settleBusinessDays,
newSettle: next.settleBusinessDays,
}),
onOk: async () => {
commissionSaving.value = true
try {
await updateCommissionRule(next)
Message.success(t('common.saveSuccess'))
await loadCommission()
} catch (e: any) {
Message.error(e?.message || t('common.saveFailed'))
throw e
} finally {
commissionSaving.value = false
}
},
})
}
onMounted(async () => {
await loadPoints()
await loadCommission()
})
</script> </script>
<style scoped lang="less">
@import '../styles/admin-shared.less';
.points-rule-page {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
align-items: stretch;
@media (max-width: 991px) {
grid-template-columns: 1fr;
}
}
.rule-section {
display: flex;
flex-direction: column;
min-width: 0;
padding: 20px 24px 24px;
background: var(--app-card-bg);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-sizing: border-box;
:deep(.form-actions) {
margin-top: auto;
}
}
.rule-section__title {
margin: 0 0 12px;
font-size: 16px;
font-weight: 600;
line-height: 24px;
color: var(--color-text-1);
}
.rule-section__tip {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 20px;
padding: 10px 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--app-primary-color) 8%, var(--app-card-bg));
border: 1px solid color-mix(in srgb, var(--app-primary-color) 18%, var(--color-border-2));
p {
margin: 0;
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.55;
color: var(--color-text-2, #4e5969);
}
}
.rule-section__tip-icon {
flex-shrink: 0;
margin-top: 2px;
color: var(--app-primary-color);
font-size: 16px;
}
.rule-form {
:deep(.arco-form-item) {
margin-bottom: 4px;
}
:deep(.arco-form-item-label-col) {
margin-bottom: 4px;
padding: 0 !important;
}
:deep(.arco-input-wrapper),
:deep(.arco-input-number) {
width: 100%;
border-radius: 4px;
}
:deep(.arco-radio-group-button) {
display: flex;
width: 100%;
.arco-radio-button {
flex: 1;
text-align: center;
}
}
}
</style>

View File

@ -116,6 +116,12 @@
flex: 0 0 140px; flex: 0 0 140px;
} }
.mkt-status-tabs {
padding: 10px 16px 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border-2);
}
/* 查询按钮左侧说明问号:与筛选区垂直对齐 */ /* 查询按钮左侧说明问号:与筛选区垂直对齐 */
.toolbar-filters :deep(.marketing-page-tip-icon), .toolbar-filters :deep(.marketing-page-tip-icon),
.toolbar-left :deep(.marketing-page-tip-icon) { .toolbar-left :deep(.marketing-page-tip-icon) {

View File

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { IconDown } from '@arco-design/web-vue/es/icon' import IconTabService from '@/assets/icons/h5/tab-service.svg'
defineOptions({ name: 'PriceCheckQuoteResultCard' }) defineOptions({ name: 'PriceCheckQuoteResultCard' })
@ -328,13 +328,13 @@
</div> </div>
<div class="quote-card__actions" @click.stop> <div class="quote-card__actions" @click.stop>
<button type="button" class="ops-btn ops-btn--primary" @click="emit('order')"> <a-dropdown trigger="click" position="bl">
{{ t('order.priceCheck.table.orderNow') }} <button
</button> type="button"
<a-dropdown trigger="click" position="br"> class="advisor-icon-btn"
<button type="button" class="ops-btn ops-btn--ghost"> :aria-label="t('order.priceCheck.table.askAi')"
{{ t('order.priceCheck.table.askAi') }} >
<icon-down style="margin-left: 2px" /> <IconTabService class="advisor-icon-btn__icon" />
</button> </button>
<template #content> <template #content>
<a-doption @click="emit('ask', 'canShip')"> <a-doption @click="emit('ask', 'canShip')">
@ -345,6 +345,9 @@
</a-doption> </a-doption>
</template> </template>
</a-dropdown> </a-dropdown>
<button type="button" class="ops-btn ops-btn--primary" @click="emit('order')">
{{ t('order.priceCheck.table.orderNow') }}
</button>
</div> </div>
</div> </div>
</a-spin> </a-spin>
@ -666,25 +669,18 @@
.quote-card__actions { .quote-card__actions {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: stretch; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
flex-shrink: 0; flex-shrink: 0;
min-width: 88px;
:deep(.arco-dropdown-open), :deep(.arco-dropdown-open),
:deep(.arco-trigger), :deep(.arco-trigger),
> :deep(.arco-dropdown-trigger), > :deep(.arco-dropdown-trigger),
> .arco-dropdown-open { > .arco-dropdown-open {
width: 100%; display: inline-flex;
display: block; flex-shrink: 0;
}
> :deep(span),
> :deep(div) {
width: 100%;
display: block;
} }
} }
@ -692,36 +688,54 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%;
height: 28px;
padding: 0 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer; cursor: pointer;
border: 1px solid transparent; border: 1px solid transparent;
&--ghost {
background: transparent;
border-color: var(--color-border-2);
color: var(--color-text-2);
&:hover {
color: var(--app-primary-color, #007979);
border-color: color-mix(in srgb, var(--app-primary-color, #007979) 35%, var(--color-border-2));
background: color-mix(in srgb, var(--app-primary-color, #007979) 4%, transparent);
}
}
&--primary { &--primary {
min-width: 88px;
height: 32px;
padding: 0 16px;
border-radius: 6px;
background: var(--app-primary-color, #007979); background: var(--app-primary-color, #007979);
color: #fff; color: #fff;
font-size: 13px;
font-weight: 600;
line-height: 1;
text-align: center;
box-shadow: 0 1px 2px color-mix(in srgb, var(--app-primary-color, #007979) 28%, transparent);
&:hover { &:hover {
filter: brightness(1.04); filter: brightness(1.06);
} }
} }
} }
.advisor-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: none;
border-radius: 16px;
background: transparent;
color: var(--app-primary-color, #007979);
cursor: pointer;
flex-shrink: 0;
&:hover {
background: color-mix(in srgb, var(--app-primary-color, #007979) 8%, transparent);
}
}
.advisor-icon-btn__icon {
width: 22px;
height: 22px;
display: block;
}
.quote-card__fee-pop { .quote-card__fee-pop {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -799,7 +813,7 @@
.quote-card__actions { .quote-card__actions {
width: 100%; width: 100%;
justify-content: flex-end; justify-content: center;
} }
} }
@ -810,13 +824,10 @@
} }
.quote-card__actions { .quote-card__actions {
flex-direction: row;
width: 100%; width: 100%;
min-width: 0;
} }
.ops-btn--primary { .ops-btn--primary {
flex: 1;
min-width: 120px; min-width: 120px;
} }
} }

View File

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { IconDown, IconSearch } from '@arco-design/web-vue/es/icon' import { IconClose, IconDown, IconSearch } from '@arco-design/web-vue/es/icon'
import SlidingTabs from '@/components/SlidingTabs/index.vue' import SlidingTabs from '@/components/SlidingTabs/index.vue'
import type { PriceCheckShippingGroup } from '@/api/order' import type { PriceCheckShippingGroup } from '@/api/order'
@ -132,7 +132,7 @@ function clearSelection(e: Event) {
aria-label="clear" aria-label="clear"
@click="clearSelection" @click="clearSelection"
> >
× <icon-close />
</button> </button>
<icon-down class="trigger-icon" /> <icon-down class="trigger-icon" />
</div> </div>
@ -233,20 +233,23 @@ function clearSelection(e: Event) {
} }
.trigger-clear { .trigger-clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0; flex-shrink: 0;
width: 18px; width: 16px;
height: 18px; height: 16px;
margin-left: 4px; margin-left: 4px;
padding: 0;
border: none; border: none;
border-radius: 50%; background: transparent;
background: var(--color-fill-4); color: var(--color-text-3);
color: #fff;
font-size: 12px; font-size: 12px;
line-height: 16px; line-height: 1;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: var(--color-text-3); color: var(--color-text-1);
} }
} }

View File

@ -40,7 +40,7 @@
const formData = reactive({ const formData = reactive({
destinationCountry: '', destinationCountry: '',
shippingMethod: '', shippingMethod: '',
cargoWeight: '', cargoWeight: '1',
length: '', length: '',
width: '', width: '',
height: '', height: '',
@ -92,18 +92,6 @@
const couponInstanceId = ref<number | undefined>(undefined); const couponInstanceId = ref<number | undefined>(undefined);
const availableCoupons = ref<CouponAvailableOutput[]>([]); const availableCoupons = ref<CouponAvailableOutput[]>([]);
const advancedActiveCount = computed(() => {
let n = 0;
if (formData.shippingMethod) n += 1;
if (formData.cargoType) n += 1;
if (formData.warehouseId != null) n += 1;
if (hasFullDimensions() || hasPartialDimensions()) n += 1;
if (String(formData.destinationProvince || '').trim()) n += 1;
if (String(formData.destinationCity || '').trim()) n += 1;
if (couponInstanceId.value) n += 1;
return n;
});
const needPostalHint = computed(() => { const needPostalHint = computed(() => {
if (!hasQueried.value) return false; if (!hasQueried.value) return false;
if (String(formData.postalCode || '').trim()) return false; if (String(formData.postalCode || '').trim()) return false;
@ -125,7 +113,6 @@
| 'destinationCity' | 'destinationCity'
| 'cargoType' | 'cargoType'
| 'warehouse' | 'warehouse'
| 'shipping'
| 'coupon'; | 'coupon';
value?: string | number; value?: string | number;
}; };
@ -181,17 +168,6 @@
}); });
} }
if (formData.shippingMethod) {
const flat = (shippingMethodGroups.value || []).flatMap((g) => g.products || []);
const hit = flat.find((it) => it.productCode === formData.shippingMethod);
chips.push({
key: 'shipping',
kind: 'shipping',
value: formData.shippingMethod,
label: hit?.label || formData.shippingMethod,
});
}
if (couponInstanceId.value) { if (couponInstanceId.value) {
const c = availableCoupons.value.find((x) => (x.id ?? x.Id) === couponInstanceId.value); const c = availableCoupons.value.find((x) => (x.id ?? x.Id) === couponInstanceId.value);
const name = String(c?.templateName ?? c?.TemplateName ?? '').trim(); const name = String(c?.templateName ?? c?.TemplateName ?? '').trim();
@ -229,10 +205,6 @@
formData.warehouseId = undefined; formData.warehouseId = undefined;
return; return;
} }
if (chip.kind === 'shipping') {
formData.shippingMethod = '';
return;
}
if (chip.kind === 'coupon') { if (chip.kind === 'coupon') {
couponInstanceId.value = undefined; couponInstanceId.value = undefined;
} }
@ -246,7 +218,6 @@
formData.destinationCity = ''; formData.destinationCity = '';
formData.cargoType = ''; formData.cargoType = '';
formData.warehouseId = undefined; formData.warehouseId = undefined;
formData.shippingMethod = '';
couponInstanceId.value = undefined; couponInstanceId.value = undefined;
} }
@ -621,14 +592,15 @@
availableCoupons.value = coupons ?? []; availableCoupons.value = coupons ?? [];
hintBannerDismissed.value = false; hintBannerDismissed.value = false;
queryResults.value = processQueryResults(items || []); queryResults.value = processQueryResults(items || []);
selectedGuideRecord.value = null;
resetRecommendState(); resetRecommendState();
sortMode.value = 'recommend'; sortMode.value = 'recommend';
currentPage.value = 1; currentPage.value = 1;
recalcLoadingKeys.value = {}; recalcLoadingKeys.value = {};
if (!queryResults.value.length) { if (!queryResults.value.length) {
selectedGuideRecord.value = null;
applyPriceCheckFailures(failures ?? []); applyPriceCheckFailures(failures ?? []);
} else { } else {
selectedGuideRecord.value = sortedResults.value[0] ?? queryResults.value[0];
clearPriceCheckFailures(); clearPriceCheckFailures();
Message.success(t('order.priceCheck.message.querySuccess', { count: queryResults.value.length })); Message.success(t('order.priceCheck.message.querySuccess', { count: queryResults.value.length }));
} }
@ -648,7 +620,7 @@
Object.assign(formData, { Object.assign(formData, {
destinationCountry: '', destinationCountry: '',
shippingMethod: '', shippingMethod: '',
cargoWeight: '', cargoWeight: '1',
length: '', length: '',
width: '', width: '',
height: '', height: '',
@ -1001,6 +973,17 @@
</div> </div>
<div class="form-row form-row-primary"> <div class="form-row form-row-primary">
<div class="form-item form-item-channel">
<label class="form-label">{{ $t('order.priceCheck.form.channel') }}</label>
<ShippingMethodTabSelect
v-model="formData.shippingMethod"
:groups="shippingMethodGroups"
:loading="optionsLoading"
:placeholder="$t('order.priceCheck.form.channel.placeholder')"
class="form-select"
/>
</div>
<div class="form-item form-item-country"> <div class="form-item form-item-country">
<label class="form-label required">{{ $t('order.priceCheck.form.destinationCountry') }}</label> <label class="form-label required">{{ $t('order.priceCheck.form.destinationCountry') }}</label>
<a-select <a-select
@ -1035,21 +1018,21 @@
</a-select> </a-select>
</div> </div>
<div class="form-item"> <div ref="postalInputRef" class="form-item form-item-compact">
<label class="form-label required">{{ $t('order.priceCheck.form.cargoWeight') }}</label> <label class="form-label">{{ $t('order.priceCheck.form.postalCode') }}</label>
<a-input <a-input
v-model="formData.cargoWeight" v-model="formData.postalCode"
:placeholder="$t('order.priceCheck.form.cargoWeight.placeholder')" :placeholder="$t('order.priceCheck.form.postalCode.placeholder')"
class="form-input" class="form-input"
@press-enter="handleQuery" @press-enter="handleQuery"
/> />
</div> </div>
<div ref="postalInputRef" class="form-item"> <div class="form-item form-item-compact">
<label class="form-label">{{ $t('order.priceCheck.form.postalCode') }}</label> <label class="form-label required">{{ $t('order.priceCheck.form.cargoWeight') }}</label>
<a-input <a-input
v-model="formData.postalCode" v-model="formData.cargoWeight"
:placeholder="$t('order.priceCheck.form.postalCode.placeholder')" :placeholder="$t('order.priceCheck.form.cargoWeight.placeholder')"
class="form-input" class="form-input"
@press-enter="handleQuery" @press-enter="handleQuery"
/> />
@ -1071,10 +1054,7 @@
{{ $t('order.priceCheck.actions.reset') }} {{ $t('order.priceCheck.actions.reset') }}
</a-button> </a-button>
<button type="button" class="advanced-toggle-btn" @click="showAdvanced = !showAdvanced"> <button type="button" class="advanced-toggle-btn" @click="showAdvanced = !showAdvanced">
<span> <span>{{ $t('order.priceCheck.form.moreFilters') }}</span>
{{ $t('order.priceCheck.form.moreFilters') }}
<span v-if="advancedActiveCount" class="advanced-count">{{ advancedActiveCount }}</span>
</span>
<icon-up v-if="showAdvanced" /> <icon-up v-if="showAdvanced" />
<icon-down v-else /> <icon-down v-else />
</button> </button>
@ -1110,10 +1090,10 @@
<icon-close /> <icon-close />
</span> </span>
</button> </button>
<button type="button" class="filter-summary-bar__clear" @click="clearAdvancedConditions">
{{ $t('order.priceCheck.form.clearFilters') }}
</button>
</div> </div>
<button type="button" class="filter-summary-bar__clear" @click="clearAdvancedConditions">
{{ $t('order.priceCheck.form.clearFilters') }}
</button>
</div> </div>
<div v-show="showAdvanced" class="form-row form-row-advanced"> <div v-show="showAdvanced" class="form-row form-row-advanced">
@ -1183,17 +1163,6 @@
</a-select> </a-select>
</div> </div>
<div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.channel') }}</label>
<ShippingMethodTabSelect
v-model="formData.shippingMethod"
:groups="shippingMethodGroups"
:loading="optionsLoading"
:placeholder="$t('order.priceCheck.form.channel.placeholder')"
class="form-select"
/>
</div>
<div class="form-item"> <div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.cargoType') }}</label> <label class="form-label">{{ $t('order.priceCheck.form.cargoType') }}</label>
<a-select <a-select
@ -1365,8 +1334,8 @@
background-color: var(--app-page-bg); background-color: var(--app-page-bg);
} }
.price-check-layout { display: flex; gap: 12px; flex: 1; min-height: 0; min-width: 0; } .price-check-layout { display: flex; gap: 12px; flex: 1; min-height: 0; min-width: 0; }
.price-check-main { flex: 7 1 0; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; } .price-check-main { flex: 5 1 0; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
.price-check-aside { flex: 3 1 0; min-width: 300px; min-height: 0; display: flex; flex-direction: column; > * { flex: 1; min-height: 0; } } .price-check-aside { flex: 3 1 0; min-width: 340px; min-height: 0; display: flex; flex-direction: column; > * { flex: 1; min-height: 0; } }
.liquid-panel { .liquid-panel {
position: relative; position: relative;
z-index: 0; z-index: 0;
@ -1439,8 +1408,9 @@
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
} }
.form-row { display: flex; gap: 20px; align-items: flex-start; flex-wrap: wrap; } .form-row { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.form-row-primary, .form-row-advanced { overflow-x: auto; padding-bottom: 2px; -webkit-overflow-scrolling: touch; } .form-row-primary, .form-row-advanced { overflow-x: auto; padding-bottom: 2px; -webkit-overflow-scrolling: touch; }
.form-row-primary { flex-wrap: nowrap; }
.form-row-advanced { .form-row-advanced {
margin-top: 12px; margin-top: 12px;
padding-top: 16px; padding-top: 16px;
@ -1543,23 +1513,9 @@
white-space: nowrap; white-space: nowrap;
&:hover { color: var(--app-primary-color, #007979); } &:hover { color: var(--app-primary-color, #007979); }
} }
.advanced-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
margin-left: 4px;
padding: 0 4px;
border-radius: 8px;
background: rgba(0, 121, 121, 0.1);
color: var(--app-primary-color, #007979);
font-size: 11px;
line-height: 16px;
}
.form-hint { margin: 0; color: var(--color-text-3); font-size: 12px; line-height: 1.3; } .form-hint { margin: 0; color: var(--color-text-3); font-size: 12px; line-height: 1.3; }
.form-item { .form-item {
display: flex; flex-direction: column; gap: 8px; flex: 0 0 auto; width: 168px; min-width: 140px; display: flex; flex-direction: column; gap: 8px; flex: 0 0 auto; width: 148px; min-width: 132px;
&.dimensions-group { width: 260px; min-width: 220px; } &.dimensions-group { width: 260px; min-width: 220px; }
&.button-group { width: auto; min-width: 0; flex-shrink: 0; } &.button-group { width: auto; min-width: 0; flex-shrink: 0; }
.button-group-row { .button-group-row {
@ -1570,7 +1526,9 @@
justify-content: flex-start; justify-content: flex-start;
flex-wrap: nowrap; flex-wrap: nowrap;
} }
&.form-item-country { width: 280px; min-width: 240px; flex-shrink: 0; } &.form-item-country { width: 240px; min-width: 200px; flex-shrink: 0; }
&.form-item-channel { width: 220px; min-width: 180px; flex-shrink: 0; }
&.form-item-compact { width: 112px; min-width: 100px; }
} }
.country-shortcuts { .country-shortcuts {
display: flex; display: flex;

View File

@ -24,6 +24,7 @@
<section class="portal-detail-section"> <section class="portal-detail-section">
<a-tabs v-model:active-key="activeTab" type="rounded" @change="handleTabChange"> <a-tabs v-model:active-key="activeTab" type="rounded" @change="handleTabChange">
<a-tab-pane key="available" :title="t('marketing.portal.coupons.tab.available')" /> <a-tab-pane key="available" :title="t('marketing.portal.coupons.tab.available')" />
<a-tab-pane key="locked" :title="t('marketing.portal.coupons.tab.locked')" />
<a-tab-pane key="used" :title="t('marketing.portal.coupons.tab.used')" /> <a-tab-pane key="used" :title="t('marketing.portal.coupons.tab.used')" />
<a-tab-pane key="expired" :title="t('marketing.portal.coupons.tab.expired')" /> <a-tab-pane key="expired" :title="t('marketing.portal.coupons.tab.expired')" />
</a-tabs> </a-tabs>
@ -75,10 +76,18 @@
</a-tag> </a-tag>
</div> </div>
<p class="coupon-validity"> <p class="coupon-validity">
{{ formatDate(item.validStartTime) }} ~ {{ formatDate(item.validEndTime) }} <template v-if="item.status === 5">
{{ item.unlockHint || t('marketing.portal.coupons.unlockHint') }}
</template>
<template v-else>
{{ formatDate(item.validStartTime) }} ~ {{ formatDate(item.validEndTime) }}
</template>
</p> </p>
<div class="coupon-body-foot"> <div class="coupon-body-foot">
<a-tag v-if="item.isExpiringSoon" size="small"> <a-tag v-if="item.status === 5" size="small">
{{ t('marketing.portal.coupons.tab.locked') }}
</a-tag>
<a-tag v-else-if="item.isExpiringSoon" size="small">
{{ t('marketing.portal.coupons.expiringSoon') }} {{ t('marketing.portal.coupons.expiringSoon') }}
</a-tag> </a-tag>
<span v-else-if="item.status === 1" class="hint hint--action"> <span v-else-if="item.status === 1" class="hint hint--action">
@ -132,6 +141,7 @@ interface CouponRow {
status: number status: number
statusName: string statusName: string
isExpiringSoon: boolean isExpiringSoon: boolean
unlockHint: string
} }
const { t } = useI18n() const { t } = useI18n()
@ -160,6 +170,7 @@ const howSteps = computed(() => [
const statusMap: Record<string, number> = { const statusMap: Record<string, number> = {
available: 1, available: 1,
locked: 5,
used: 3, used: 3,
expired: 4, expired: 4,
} }
@ -167,6 +178,7 @@ const statusMap: Record<string, number> = {
const emptyTitle = computed(() => { const emptyTitle = computed(() => {
if (activeTab.value === 'used') return t('marketing.portal.coupons.emptyUsedTitle') if (activeTab.value === 'used') return t('marketing.portal.coupons.emptyUsedTitle')
if (activeTab.value === 'expired') return t('marketing.portal.coupons.emptyExpiredTitle') if (activeTab.value === 'expired') return t('marketing.portal.coupons.emptyExpiredTitle')
if (activeTab.value === 'locked') return t('marketing.portal.coupons.emptyLockedTitle')
return t('marketing.portal.coupons.emptyTitle') return t('marketing.portal.coupons.emptyTitle')
}) })
@ -181,6 +193,7 @@ const availableSummary = computed(() => String(availableCount.value))
function statusFallback(status: number) { function statusFallback(status: number) {
if (status === 1) return t('marketing.portal.coupons.tab.available') if (status === 1) return t('marketing.portal.coupons.tab.available')
if (status === 5) return t('marketing.portal.coupons.tab.locked')
if (status === 3) return t('marketing.portal.coupons.tab.used') if (status === 3) return t('marketing.portal.coupons.tab.used')
if (status === 4) return t('marketing.portal.coupons.tab.expired') if (status === 4) return t('marketing.portal.coupons.tab.expired')
return '--' return '--'
@ -228,6 +241,7 @@ function mapRow(item: Record<string, unknown>): CouponRow {
status: Number(item.status ?? item.Status ?? 0), status: Number(item.status ?? item.Status ?? 0),
statusName: String(item.statusName ?? item.StatusName ?? ''), statusName: String(item.statusName ?? item.StatusName ?? ''),
isExpiringSoon: isExpiringSoon(validEndTime), isExpiringSoon: isExpiringSoon(validEndTime),
unlockHint: String(item.unlockHint ?? item.UnlockHint ?? ''),
} }
} }

View File

@ -6,19 +6,28 @@
<span class="portal-glass__blob" aria-hidden="true" /> <span class="portal-glass__blob" aria-hidden="true" />
<span class="portal-glass__shine" aria-hidden="true" /> <span class="portal-glass__shine" aria-hidden="true" />
<div class="portal-glass__body share-hero__focus"> <div class="portal-glass__body share-hero__focus">
<p class="share-hero__eyebrow">{{ t('marketing.portal.invite.yourCode') }}</p> <template v-if="canInvite">
<div class="invite-code-row"> <p class="share-hero__eyebrow">{{ t('marketing.portal.invite.yourCode') }}</p>
<span class="invite-code">{{ inviteCode }}</span> <div class="invite-code-row">
<span class="referee-pill"> <span class="invite-code">{{ inviteCode }}</span>
{{ t('marketing.portal.invite.refereeOnly', { count: refereeCount }) }} <span class="referee-pill">
</span> {{ t('marketing.portal.invite.refereeOnly', { count: refereeCount }) }}
</div> </span>
</div>
<div class="reward-row">
<span>{{ t('marketing.portal.invite.pendingReward') }} ¥{{ formatMoney(pendingReward) }}</span>
<span>{{ t('marketing.portal.invite.settledReward') }} ¥{{ formatMoney(settledReward) }}</span>
</div>
<a-button type="primary" long size="large" class="share-primary" @click="copyShareText">
{{ t('marketing.portal.invite.copySharePrimary') }}
</a-button>
</template>
<template v-else>
<p class="share-hero__eyebrow">{{ t('marketing.portal.invite.lockedTitle', { level: minInviteLevel }) }}</p>
<p class="portal-empty__desc">{{ t('marketing.portal.invite.lockedDesc') }}</p>
</template>
<a-button type="primary" long size="large" class="share-primary" @click="copyShareText"> <details v-if="canInvite" class="share-more">
{{ t('marketing.portal.invite.copySharePrimary') }}
</a-button>
<details class="share-more">
<summary>{{ t('marketing.portal.invite.secondaryActions') }}</summary> <summary>{{ t('marketing.portal.invite.secondaryActions') }}</summary>
<div class="share-more__body"> <div class="share-more__body">
<div class="link-row"> <div class="link-row">
@ -45,6 +54,7 @@
</section> </section>
<PortalHowTo <PortalHowTo
v-if="canInvite"
:title="t('marketing.portal.invite.howTitle')" :title="t('marketing.portal.invite.howTitle')"
:hint="t('marketing.portal.invite.howHint')" :hint="t('marketing.portal.invite.howHint')"
:steps="howSteps" :steps="howSteps"
@ -60,7 +70,7 @@
<div v-if="!refereeRows.length && !refereeLoading" class="portal-empty"> <div v-if="!refereeRows.length && !refereeLoading" class="portal-empty">
<p class="portal-empty__title">{{ t('marketing.portal.invite.emptyTitle') }}</p> <p class="portal-empty__title">{{ t('marketing.portal.invite.emptyTitle') }}</p>
<p class="portal-empty__desc">{{ t('marketing.portal.invite.emptyDesc') }}</p> <p class="portal-empty__desc">{{ t('marketing.portal.invite.emptyDesc') }}</p>
<a-button type="primary" @click="copyShareText"> <a-button v-if="canInvite" type="primary" @click="copyShareText">
{{ t('marketing.portal.invite.copySharePrimary') }} {{ t('marketing.portal.invite.copySharePrimary') }}
</a-button> </a-button>
</div> </div>
@ -80,6 +90,9 @@
</div> </div>
</div> </div>
<div class="portal-list-item__side referee-tags"> <div class="portal-list-item__side referee-tags">
<a-tag v-if="row.rewardOrderCount" size="small">
{{ t('marketing.portal.invite.tag.reward', { count: row.rewardOrderCount }) }}
</a-tag>
<a-tag v-if="row.hasDeliverySuccess" size="small"> <a-tag v-if="row.hasDeliverySuccess" size="small">
{{ t('marketing.portal.invite.tag.delivered') }} {{ t('marketing.portal.invite.tag.delivered') }}
</a-tag> </a-tag>
@ -136,6 +149,7 @@ interface RefereeRow {
createTime: string createTime: string
hasPaidOrder: boolean hasPaidOrder: boolean
hasDeliverySuccess: boolean hasDeliverySuccess: boolean
rewardOrderCount: number
avatarText: string avatarText: string
} }
@ -174,6 +188,27 @@ const refereeCount = computed(() =>
Number(invite.value?.refereeCount ?? invite.value?.RefereeCount ?? refereeTotal.value ?? 0), Number(invite.value?.refereeCount ?? invite.value?.RefereeCount ?? refereeTotal.value ?? 0),
) )
const canInvite = computed(() => {
const raw = invite.value?.canInvite ?? invite.value?.CanInvite
return raw === true || raw === 1 || raw === 'true'
})
const minInviteLevel = computed(() =>
String(invite.value?.minInviteLevelCode ?? invite.value?.MinInviteLevelCode ?? 'V1'),
)
const pendingReward = computed(() =>
Number(invite.value?.pendingReward ?? invite.value?.PendingReward ?? 0),
)
const settledReward = computed(() =>
Number(invite.value?.settledReward ?? invite.value?.SettledReward ?? 0),
)
function formatMoney(value: number) {
return value.toFixed(2)
}
async function copyShareText() { async function copyShareText() {
await copyText(shareText.value) await copyText(shareText.value)
} }
@ -200,6 +235,7 @@ function mapReferee(item: Record<string, unknown>): RefereeRow {
createTime: String(item.createTime ?? item.CreateTime ?? '').replace('T', ' ').slice(0, 19), createTime: String(item.createTime ?? item.CreateTime ?? '').replace('T', ' ').slice(0, 19),
hasPaidOrder: Boolean(item.hasPaidOrder ?? item.HasPaidOrder), hasPaidOrder: Boolean(item.hasPaidOrder ?? item.HasPaidOrder),
hasDeliverySuccess: Boolean(item.hasDeliverySuccessOrder ?? item.HasDeliverySuccessOrder), hasDeliverySuccess: Boolean(item.hasDeliverySuccessOrder ?? item.HasDeliverySuccessOrder),
rewardOrderCount: Number(item.rewardOrderCount ?? item.RewardOrderCount ?? 0),
avatarText: avatarSource.slice(0, 1).toUpperCase(), avatarText: avatarSource.slice(0, 1).toUpperCase(),
} }
} }
@ -313,6 +349,14 @@ onMounted(() => {
border: 1px solid color-mix(in srgb, var(--app-primary-color) 18%, var(--color-border-2)); border: 1px solid color-mix(in srgb, var(--app-primary-color) 18%, var(--color-border-2));
} }
.reward-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
color: var(--color-text-2);
}
.share-primary { .share-primary {
margin-top: 4px; margin-top: 4px;
} }

View File

@ -10,9 +10,6 @@
<p class="level-eyebrow">{{ t('marketing.portal.member.currentLevel') }}</p> <p class="level-eyebrow">{{ t('marketing.portal.member.currentLevel') }}</p>
<div class="level-name-row"> <div class="level-name-row">
<h2 class="level-name">{{ levelName }}</h2> <h2 class="level-name">{{ levelName }}</h2>
<span v-if="priceTierKey" class="tier-badge">
{{ t('marketing.portal.member.priceTier') }} · {{ priceTierKey }}
</span>
</div> </div>
</div> </div>
<a-button type="primary" size="small" class="member-cta" @click="goPlaceOrder"> <a-button type="primary" size="small" class="member-cta" @click="goPlaceOrder">
@ -20,14 +17,12 @@
</a-button> </a-button>
</div> </div>
<p class="benefit-desc">{{ levelNotice }}</p>
<div class="points-grid" :class="{ 'has-discount': !!extraDiscountText }"> <div class="points-grid" :class="{ 'has-discount': !!extraDiscountText }">
<div class="points-item"> <div class="points-item">
<span class="points-label">{{ t('marketing.portal.member.totalPoints') }}</span> <span class="points-label">{{ t('marketing.portal.member.totalPoints') }}</span>
<span class="points-value">{{ formatNumber(totalPoints) }}</span> <span class="points-value">{{ formatNumber(rollingPoints) }}</span>
</div>
<div class="points-item">
<span class="points-label">{{ t('marketing.portal.member.availablePoints') }}</span>
<span class="points-value">{{ formatNumber(availablePoints) }}</span>
</div> </div>
<div v-if="extraDiscountText" class="points-item"> <div v-if="extraDiscountText" class="points-item">
<span class="points-label">{{ t('marketing.portal.member.extraDiscount') }}</span> <span class="points-label">{{ t('marketing.portal.member.extraDiscount') }}</span>
@ -170,14 +165,14 @@ const levelName = computed(
String(overview.value?.memberLevelName ?? overview.value?.MemberLevelName ?? '') || String(overview.value?.memberLevelName ?? overview.value?.MemberLevelName ?? '') ||
t('marketing.portal.member.defaultLevel'), t('marketing.portal.member.defaultLevel'),
) )
const priceTierKey = computed(() => const rollingPoints = computed(() =>
String(overview.value?.priceTierKey ?? overview.value?.PriceTierKey ?? ''), Number(
) overview.value?.rollingPoints ??
const totalPoints = computed(() => overview.value?.RollingPoints ??
Number(overview.value?.totalPoints ?? overview.value?.TotalPoints ?? 0), overview.value?.totalPoints ??
) overview.value?.TotalPoints ??
const availablePoints = computed(() => 0,
Number(overview.value?.availablePoints ?? overview.value?.AvailablePoints ?? 0), ),
) )
const nextLevelName = computed(() => const nextLevelName = computed(() =>
String(overview.value?.nextLevelName ?? overview.value?.NextLevelName ?? ''), String(overview.value?.nextLevelName ?? overview.value?.NextLevelName ?? ''),
@ -192,14 +187,17 @@ const extraDiscountRate = computed(() =>
Number(overview.value?.extraDiscountRate ?? overview.value?.ExtraDiscountRate ?? 0), Number(overview.value?.extraDiscountRate ?? overview.value?.ExtraDiscountRate ?? 0),
) )
const extraDiscountText = computed(() => { const extraDiscountText = computed(() => {
if (!extraDiscountRate.value) return '' if (!extraDiscountRate.value) return t('marketing.memberLevel.discount.original')
return `${(extraDiscountRate.value * 100).toFixed(1)}%` return t('marketing.memberLevel.discount.fold', {
fold: Math.round((1 - extraDiscountRate.value) * 100),
})
}) })
const levelNotice = computed(() => t('marketing.portal.member.subtitle'))
const progressPercent = computed(() => { const progressPercent = computed(() => {
if (!nextLevelName.value) return 100 if (!nextLevelName.value) return 100
const need = pointsToNext.value const need = pointsToNext.value
const total = totalPoints.value const total = rollingPoints.value
const target = total + need const target = total + need
if (!target) return 100 if (!target) return 100
return Math.min(Math.round((total / target) * 100), 100) return Math.min(Math.round((total / target) * 100), 100)