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');
}
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) {
// 官网「注册询价」走客户登录页微信扫码(注册/登录同一入口)
const url = new URL(loginPortalUrl());
@ -2654,7 +2678,13 @@
if (heroCta) {
heroCta.textContent = t('heroCta');
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');
if (welcomeHuman) welcomeHuman.textContent = t('human') + ' →';
renderWelcomeStats();

View File

@ -66,6 +66,14 @@
: 'IDE客户中心'
: 'IDE I Faster & Simpler';
} 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>
<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 = {
successCount: number
failCount: number
skipCount: number
failures: Array<{ rowNumber: number; reason: string }>
}
@ -261,6 +262,7 @@ export async function importKpiAreaInfo(
return {
successCount: Number(d.successCount ?? d.SuccessCount ?? 0),
failCount: Number(d.failCount ?? d.FailCount ?? 0),
skipCount: Number(d.skipCount ?? d.SkipCount ?? 0),
failures,
}
}

View File

@ -62,6 +62,11 @@ export async function updateSalesProduct(body: Record<string, any> & { id: numbe
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) {
const res = await axiosInstance.post(omsUrl('/channel/sales-product/delete'), { id })
assertBusinessOk(res.data)

View File

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

View File

@ -66,10 +66,10 @@ export default {
'menu.marketing': 'Marketing',
'menu.marketing.memberLevel': 'Member Levels',
'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.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.edit': 'Edit level',
'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.levelName': 'Level name',
'marketing.memberLevel.form.levelNamePlaceholder': 'e.g. Standard, Silver',
'marketing.memberLevel.form.minPoints': 'Min points',
'marketing.memberLevel.form.minPointsExtra': 'Total points required to reach this level',
'marketing.memberLevel.form.extraDiscountRate': 'Extra discount rate',
'marketing.memberLevel.form.extraDiscountRateExtra': 'e.g. 0.01 = extra 1% off after tier price',
'marketing.memberLevel.form.minPoints': 'Rolling points min',
'marketing.memberLevel.form.minPointsExtra': 'Rolling 12-month paid base-freight points to reach this level',
'marketing.memberLevel.form.extraDiscountRate': 'Base-freight off rate',
'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.sortExtra': 'Smaller number ranks first',
'marketing.memberLevel.form.status': 'Status',
@ -94,8 +98,8 @@ export default {
'marketing.memberLevel.form.requiredHint': 'Level code and name are required',
'marketing.memberLevel.section.basic': 'Basic info',
'marketing.memberLevel.section.basicHint': 'Code cannot change after create; name and status can',
'marketing.memberLevel.section.pricing': 'Points & discount',
'marketing.memberLevel.section.pricingHint': 'Upgrade threshold and extra discount',
'marketing.memberLevel.section.pricing': 'Threshold & freight discount',
'marketing.memberLevel.section.pricingHint': 'Rolling 12-month points; discount on base freight only',
'marketing.memberLevel.section.display': 'Display & benefits',
'marketing.memberLevel.section.displayHint': 'Portal display only; does not affect pricing',
'marketing.memberLevel.preview.nameFallback': 'Untitled level',
@ -2368,7 +2372,7 @@ export default {
'order.priceCheck.form.destinationCountry.placeholder': 'Please select',
'order.priceCheck.form.shippingMethod': 'Shipping method',
'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.cargoWeight': 'Cargo weight',
'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.tip': 'more accurate',
'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.clearFilters': 'Clear',
'order.priceCheck.form.removeFilter': 'Remove filter',
@ -2465,9 +2469,9 @@ export default {
'order.priceCheck.table.viewDetail': 'Details',
'order.priceCheck.table.viewGuide': 'Channel guide',
'order.priceCheck.table.viewGuideHint': 'Click to view route guide',
'order.priceCheck.table.customsOrderNow': 'Place order',
'order.priceCheck.table.orderNow': 'Place order',
'order.priceCheck.table.askAi': 'Ask AI',
'order.priceCheck.table.customsOrderNow': 'Go to order',
'order.priceCheck.table.orderNow': 'Go to order',
'order.priceCheck.table.askAi': 'Ask advisor',
'order.priceCheck.table.askAiCanShip': 'Can this ship?',
'order.priceCheck.table.askAiCheaper': 'Anything cheaper?',
'order.priceCheck.table.empty': 'No matching quotes',
@ -4670,7 +4674,7 @@ export default {
'user.customer.tip.accountBalance': 'Available balance — adjust carefully',
'user.customer.tip.totalQuota': 'Credit limit',
'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.modal.addTitle': 'Add Customer',
'user.customer.modal.editTitle': 'Edit Customer',
@ -5171,7 +5175,13 @@ export default {
'channel.salesProducts.placeholder.productDescription': 'Customer-facing description (optional)',
'channel.salesProducts.placeholder.preStorageScript': 'Script before order persistence (optional)',
'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':
'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',
@ -5558,7 +5568,7 @@ export default {
'channel.area.message.deleteFailed': 'Delete failed. Please try again later.',
'channel.area.import.modalTitle': 'Import area details',
'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.submit': 'Import',
'channel.area.import.downloadSuccess': 'Template downloaded',
@ -5569,7 +5579,7 @@ export default {
'channel.area.import.failed': 'Import failed',
'channel.area.import.partialSuccess': 'Some rows failed. See details below.',
'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.export.success': 'Exported {count} row(s)',
'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.',
'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).',
'marketing.pointsRule.tab.rule': 'Rules',
'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.sectionTitle': 'Points rules',
'marketing.pointsRule.tab.flow': 'Ledger',
'marketing.pointsRule.form.ruleName': 'Rule name',
'marketing.pointsRule.form.pointsPerYuan': 'Points per yuan',
'marketing.pointsRule.form.pointsPerYuanExtra': 'Based on payable amount',
'marketing.pointsRule.form.firstOrderMultiplier': 'First-order multiplier',
'marketing.pointsRule.form.maxPointsPerOrder': 'Max points per order',
'marketing.pointsRule.form.pointsPerYuan': 'Points per base-freight yuan',
'marketing.pointsRule.form.pointsPerYuanExtra': 'Paid base freight only; surcharges and coupons excluded',
'marketing.pointsRule.form.status': 'Status',
'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':
'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.customerCodePlaceholder': 'Customer code',
'marketing.pointsFlow.changeTypeFilter': 'Change type',
@ -69,7 +68,7 @@ export default {
'marketing.pointsFlow.adjustModal.remarkMin': 'Reason must be at least 10 characters',
'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.statusFilter': 'Status',
'marketing.couponTemplate.status.draft': 'Draft',
@ -77,13 +76,13 @@ export default {
'marketing.couponTemplate.status.ended': 'Ended',
'marketing.couponTemplate.modal.add': 'Add 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.templateName': 'Name',
'marketing.couponTemplate.form.couponType': 'Type',
'marketing.couponTemplate.form.grantChannel': 'Issue type',
'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.discountRate': 'Discount rate',
'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.maxDiscountAmountPlaceholder': 'Optional',
'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.totalQuantityExtra': '0 = unlimited',
'marketing.couponTemplate.form.perCustomerLimit': 'Per customer limit',
'marketing.couponTemplate.form.validType': 'Validity type',
'marketing.couponTemplate.form.validStartTime': 'Start 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.requiredHint': 'Template code, name and type are required',
'marketing.couponTemplate.columns.issued': 'Issued',
@ -111,8 +112,12 @@ export default {
'marketing.couponTemplate.grantChannel.manual': 'Manual grant',
'marketing.couponTemplate.grantChannel.newcomer': 'Newcomer',
'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.afterReceive': 'N days after claim',
'marketing.couponTemplate.validType.afterUnlock': 'N days after unlock',
'marketing.couponTemplate.actions.publish': 'Publish',
'marketing.couponTemplate.actions.end': 'End',
'marketing.couponTemplate.publishConfirmTitle': 'Publish template',
@ -121,7 +126,7 @@ export default {
'marketing.couponTemplate.endConfirmContent': 'No new claims; existing coupons remain valid.',
'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.records': 'Records',
'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.useTime': 'Used at',
'marketing.couponGrant.instanceStatus.available': 'Available',
'marketing.couponGrant.instanceStatus.pendingUnlock': 'Locked (unlock)',
'marketing.couponGrant.instanceStatus.locked': 'Locked',
'marketing.couponGrant.instanceStatus.used': 'Used',
'marketing.couponGrant.instanceStatus.expired': 'Expired',
'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.baseAmount': 'Base amount',
'marketing.commissionRule.form.baseAmountExtra': 'Floor segment size',
'marketing.commissionRule.form.commissionAmount': 'Commission per segment',
'marketing.commissionRule.form.commissionAmountExtra': 'e.g. 1000→2, 999 payable = 0',
'marketing.commissionRule.form.freezeDays': 'Freeze days',
'marketing.commissionRule.form.freezeDaysExtra': 'Days after deliverySuccess before settle',
'marketing.commissionRule.form.minInviteLevelCode': 'Min invite level',
'marketing.commissionRule.form.minInviteLevelCodeExtra': 'Default V1; V0 cannot invite',
'marketing.commissionRule.form.minPaidBaseFreight': 'Min paid base freight',
'marketing.commissionRule.form.minPaidBaseFreightExtra': 'Paid base freight only, excluding surcharges',
'marketing.commissionRule.form.firstOrderRate': '1st-order rate (%)',
'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.monthlyCapExtra': 'Empty = unlimited',
'marketing.commissionRule.form.status': 'Status',
'marketing.commissionRule.saveConfirmTitle': 'Save commission rule',
'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':
'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.statusFilter': 'Status',
'marketing.commissionDetail.status.pending': 'Pending',
@ -190,8 +204,10 @@ export default {
'marketing.commissionDetail.columns.referrer': 'Referrer',
'marketing.commissionDetail.columns.referee': 'Referee',
'marketing.commissionDetail.columns.orderNo': 'Waybill',
'marketing.commissionDetail.columns.baseAmount': 'Base amount',
'marketing.commissionDetail.columns.commission': 'Commission',
'marketing.commissionDetail.columns.baseAmount': 'Paid base freight',
'marketing.commissionDetail.columns.commission': 'Reward',
'marketing.commissionDetail.columns.orderIndex': 'Order #',
'marketing.commissionDetail.columns.settleDue': 'Settle due',
'marketing.commissionDetail.columns.status': 'Status',
'marketing.commissionDetail.columns.eligibleTime': 'Eligible time',
'marketing.commissionDetail.columns.settleTime': 'Settle time',
@ -276,7 +292,8 @@ export default {
'marketing.portal.common.eyebrow': 'Marketing',
'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.howHint': 'Share in about a minute',
'marketing.portal.invite.how1Title': 'Copy your invite',
@ -284,8 +301,14 @@ export default {
'marketing.portal.invite.how2Title': 'Friends register',
'marketing.portal.invite.how2Desc':
'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.how3Desc': 'When their order is delivered, commission goes to Referral Earnings',
'marketing.portal.invite.how3Title': 'Wait for delivered orders',
'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.code': 'Invite code',
'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.refereeCount': 'Invited friends',
'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.levelDesc': 'Earn points from paid orders to upgrade membership',
'marketing.portal.invite.refereeTitle': 'My referees',
@ -314,28 +337,29 @@ export default {
'marketing.portal.invite.tag.delivered': 'Delivered',
'marketing.portal.member.title': 'My Level',
'marketing.portal.member.subtitle': 'Points accumulate after successful payment. Higher levels unlock better extra discounts.',
'marketing.portal.member.howTitle': 'How to level up',
'marketing.portal.member.howHint': 'More orders, more points',
'marketing.portal.member.how1Title': 'Place and pay',
'marketing.portal.member.how1Desc': 'Points are granted automatically after successful payment',
'marketing.portal.member.how2Title': 'Accumulate to upgrade',
'marketing.portal.member.how2Desc': 'Reach the next threshold to upgrade your membership level',
'marketing.portal.member.how3Title': 'Enjoy better rates',
'marketing.portal.member.how3Desc': 'Higher levels unlock better extra discounts',
'marketing.portal.member.subtitle':
'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.howTitle': 'How levels work',
'marketing.portal.member.howHint': 'Rolling 12-month base-freight points',
'marketing.portal.member.how1Title': 'Pay base freight',
'marketing.portal.member.how1Desc': '1 yuan of paid base freight = 1 point; surcharges and coupons excluded',
'marketing.portal.member.how2Title': 'Rolling 12-month level',
'marketing.portal.member.how2Desc': 'Daily rebuild; upgrades apply immediately',
'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.defaultLevel': 'Standard',
'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.extraDiscount': 'Extra discount',
'marketing.portal.member.extraDiscount': 'Base-freight discount',
'marketing.portal.member.nextLevel': 'Next: {name}',
'marketing.portal.member.needPoints': '{points} more points to upgrade',
'marketing.portal.member.topLevelTip': 'You are at the top level. Keep ordering to earn points.',
'marketing.portal.member.needPoints': '{points} more points to the next level',
'marketing.portal.member.topLevelTip': 'Top level. V4/V5 can step down if volume drops.',
'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.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.flow.changeType': 'Type',
'marketing.portal.member.flow.pointsDelta': 'Delta',
@ -346,23 +370,26 @@ export default {
'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.howTitle': 'How to use coupons',
'marketing.portal.coupons.howHint': 'One per order at checkout',
'marketing.portal.coupons.how1Title': 'Check available',
'marketing.portal.coupons.how1Desc': 'Confirm face value, min order, and validity in Available',
'marketing.portal.coupons.howHint': 'One per order; base freight only; mutex with member discount',
'marketing.portal.coupons.how1Title': 'Check coupons',
'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.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.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.locked': 'Locked',
'marketing.portal.coupons.tab.used': 'Used',
'marketing.portal.coupons.tab.expired': 'Expired',
'marketing.portal.coupons.empty': 'No 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.emptyLockedTitle': 'No locked coupons',
'marketing.portal.coupons.emptyUsedTitle': 'No usage history',
'marketing.portal.coupons.emptyExpiredTitle': 'No expired coupons',
'marketing.portal.coupons.emptyOtherDesc': 'Switch to Available to see your current pack',
'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.discountUnit': ' off',
'marketing.portal.coupons.unnamed': 'Coupon',
@ -425,10 +452,10 @@ export default {
'marketing.priceCheck.couponUnavailable': 'Unavailable',
'marketing.priceCheck.couponChanged': 'Price recalculated — confirm payable amount',
'marketing.priceCheck.breakdownTitle': 'Price breakdown',
'marketing.priceCheck.breakdown.receivableSubtotal': 'Subtotal after tier',
'marketing.priceCheck.breakdown.levelDiscount': 'Level extra discount',
'marketing.priceCheck.breakdown.amountAfterLevel': 'Before coupon',
'marketing.priceCheck.breakdown.couponDiscount': 'Coupon discount',
'marketing.priceCheck.breakdown.receivableSubtotal': 'Fee subtotal',
'marketing.priceCheck.breakdown.levelDiscount': 'Member base-freight discount',
'marketing.priceCheck.breakdown.amountAfterLevel': 'After member discount',
'marketing.priceCheck.breakdown.couponDiscount': 'Coupon (best of member vs coupon)',
'marketing.priceCheck.breakdown.payable': 'Payable',
'marketing.register.title': 'Register',
@ -463,7 +490,7 @@ export default {
'profile.marketing.coupons': 'My coupons',
'profile.marketing.commission': 'Referral earnings',
'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.commissionDesc': 'Commission, transfer, withdraw',
'profile.marketing.navigateFailed': 'Failed to open page, please refresh and try again',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@
saveSalesProductCustomerGuide,
toggleSalesProductPriceStatus,
updateSalesProduct,
updateSalesProductCode,
updateSalesProductPrice,
} from '@/api/channel/salesProduct';
import { getLabelTemplateList } from '@/api/channel/labelTemplate';
@ -154,6 +155,9 @@
const customerGuideSaving = ref(false);
const customerGuidePanelRef = ref<InstanceType<typeof MarkdownDocPanel> | null>(null);
const savingConfig = ref(false);
const productCodeModalVisible = ref(false);
const productCodeDraft = ref('');
const savingProductCode = ref(false);
function parseAdditionalServiceCodes(raw: unknown): string[] {
if (Array.isArray(raw)) {
@ -252,6 +256,42 @@
.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 pid = selectedProduct.value;
if (pid == null) return null;
@ -4014,10 +4054,18 @@
</template>
<icon-question-circle class="tip-icon" />
</a-tooltip>
<button
type="button"
class="product-code-edit-icon"
:aria-label="t('channel.salesProducts.form.editProductCode')"
@click.stop="openProductCodeModal"
>
<icon-edit />
</button>
</label>
<a-input
v-model="formData.productCode"
:placeholder="t('channel.salesProducts.form.placeholder.input')"
:placeholder="t('channel.salesProducts.placeholder.productCode')"
class="form-input"
disabled
/>
@ -5487,6 +5535,25 @@
</div>
</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" />
</div>
</template>
@ -5691,6 +5758,31 @@
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-select,
.form-textarea {

View File

@ -55,13 +55,14 @@
'is-expiring': item.isExpiringSoon,
'is-used': item.status === 3,
'is-expired': item.status === 4,
'is-locked': item.status === 5,
}"
@click="item.status === 1 && goOrder()"
>
<span
v-if="item.status === 3 || item.status === 4"
v-if="item.status === 3 || item.status === 4 || item.status === 5"
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"
>
{{ statusLabel(item.status) }}
@ -99,7 +100,10 @@
<div class="meta-row meta-row--validity">
<span class="meta-label">{{ t('h5.coupons.validLabel') }}</span>
<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>
</div>
@ -140,12 +144,12 @@ const emit = defineEmits<{
}>();
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 loading = ref(false);
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 = {
borderRadius: '20px 20px 0 0',
@ -173,6 +177,7 @@ function formatDate(value: unknown) {
function statusLabel(status: number) {
if (status === 3) return t('h5.coupons.tab.used');
if (status === 4) return t('h5.coupons.tab.expired');
if (status === 5) return t('h5.coupons.tab.locked');
return '';
}
@ -180,7 +185,12 @@ function usageTip(item: {
couponType?: string;
minOrderAmount?: number;
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 min = formatAmount(item.minOrderAmount ?? 0);
if (type === 'percent_off') {
@ -249,6 +259,7 @@ async function load() {
grantChannel,
isNewcomer: grantChannel === 'newcomer',
isExpiringSoon: isExpiringSoon(validEndTime, status),
unlockHint: raw.unlockHint ?? raw.UnlockHint ?? '',
};
});
} catch {
@ -475,6 +486,10 @@ watch(
&.is-expired {
color: #c9cdd4;
}
&.is-locked {
color: var(--app-primary-color, #1d7281);
}
}
.coupon-ticket__face {

View File

@ -53,7 +53,8 @@
/>
<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>
<div class="focus-block">
@ -94,6 +95,8 @@
@ready="onPosterReady"
/>
</div>
</template>
<p v-else class="invite-hero__lead">{{ t('h5.invite.locked') }}</p>
</section>
<section v-else-if="invite && activeTab === 'referees'" class="card">
@ -159,6 +162,11 @@ const drawerStyle = {
const inviteCode = computed(() =>
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 后仍能带到注册提交 */
const inviteUrl = computed(() => buildInviteRegisterUrl(inviteCode.value));
const shareText = computed(() => buildInviteShareText(inviteCode.value, inviteUrl.value));

View File

@ -833,6 +833,7 @@ export default {
'h5.coupons.empty': 'No coupons',
'h5.coupons.unnamed': 'Coupon',
'h5.coupons.tab.available': 'Available',
'h5.coupons.tab.locked': 'Locked',
'h5.coupons.tab.used': 'Used',
'h5.coupons.tab.expired': 'Expired',
'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.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.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.tab.share': 'Share invite',
'h5.invite.tab.referees': 'Invited',
'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.link': 'Invite link (open in WeChat)',
'h5.invite.copy': 'Copy',
@ -903,7 +906,7 @@ export default {
'h5.pay.later': 'Later',
'h5.me.balance': 'Balance',
'h5.me.recharge': 'Recharge',
'h5.me.points': 'Points',
'h5.me.points': '12-month points',
'h5.me.pointsToNext': '{points} to {name}',
'h5.me.pointsTopLevel': 'Top level reached',
'h5.me.lowBalance': 'Low balance — recharge soon',

View File

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

View File

@ -418,7 +418,13 @@ const memberLevelName = computed(() =>
const levelBadge = computed(() => memberLevelName.value || customerGroupLabel.value);
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(() =>
String(memberOverview.value?.nextLevelName ?? memberOverview.value?.NextLevelName ?? ''),

View File

@ -26,8 +26,6 @@
<template v-else>
<div class="login-hero__admin-stack">
<p class="login-hero__brief">{{ t('login.hero.admin.brief') }}</p>
<ol
class="login-hero__principles"
@mouseenter="hovered = true"
@ -38,12 +36,12 @@
<li
v-for="(pillar, index) in adminPillars"
:key="pillar.key"
class="login-hero__principle"
class="login-hero__principle login-hero__spine-item login-hero__spine-item--left"
:class="{
'is-active': activePrinciple === index,
'is-dim': activePrinciple >= 0 && activePrinciple !== index,
}"
:style="{ '--i': index }"
:style="{ '--i': index, '--t': pillar.t }"
tabindex="0"
@mouseenter="activePrinciple = index"
@focus="activePrinciple = index"
@ -61,27 +59,23 @@
</li>
</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">
<icon-robot :size="14" />
AI
</span>
<p class="login-hero__ai-text">{{ t('login.hero.admin.ai') }}</p>
</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>
</template>
</div>
<div class="login-hero__visual">
<picture>
<picture v-if="isCustomer">
<source srcset="/login-assets/hero-plane.webp" type="image/webp" />
<img
class="login-hero__plane"
@ -90,6 +84,13 @@
draggable="false"
/>
</picture>
<img
v-else
class="login-hero__truck"
src="/login-assets/hero-truck.png"
alt=""
draggable="false"
/>
</div>
</div>
</div>
@ -116,8 +117,37 @@ interface AdminPillar {
icon: Component;
titleKey: 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(
defineProps<{
light?: boolean;
@ -138,27 +168,6 @@ let cycleTimer: ReturnType<typeof window.setInterval> | null = null;
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() {
stopCycle();
if (isCustomer.value) return;
@ -258,25 +267,75 @@ onBeforeUnmount(() => {
max-width: 520px;
}
.login-hero--admin .login-hero__inner {
justify-content: flex-start;
align-items: center;
.login-hero--admin {
width: 100%;
height: 100%;
}
.login-hero--admin .login-hero__content {
flex: 1 1 auto;
width: 100%;
max-width: 520px;
.login-hero--admin .login-hero__inner {
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 {
width: 100%;
max-width: 460px;
max-width: 440px;
}
.login-hero__brief {
margin: 0 0 22px;
margin: 0 0 18px;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.08em;
@ -299,8 +358,8 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 36px;
align-items: center;
gap: 14px;
padding: 16px 12px 16px 4px;
gap: 12px;
padding: 12px 8px 12px 2px;
border-radius: 10px;
cursor: default;
outline: none;
@ -371,8 +430,8 @@ onBeforeUnmount(() => {
}
.login-hero__principle-title {
margin: 0 0 6px;
font-size: 22px;
margin: 0 0 4px;
font-size: 20px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.04em;
@ -409,7 +468,7 @@ onBeforeUnmount(() => {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 22px 0 0;
margin: 16px 0 0;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
@ -443,29 +502,20 @@ onBeforeUnmount(() => {
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;
flex-direction: column;
align-items: center;
gap: 10px;
margin: 20px 0 0;
padding-top: 16px;
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)
);
}
transform: translate(-50%, -50%) scale(1.3);
justify-content: center;
align-self: auto;
}
@keyframes login-hero-principle-in {
@ -542,6 +592,17 @@ onBeforeUnmount(() => {
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 {
color: rgba(26, 45, 74, 0.88);
}
@ -607,30 +668,26 @@ onBeforeUnmount(() => {
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) {
.login-hero__visual {
display: none;
}
.login-hero--admin .login-hero__visual {
display: flex;
}
.login-hero__content {
flex: 1 1 100%;
}
}
@media screen and (max-width: 900px) {
.login-hero--admin .login-hero__visual {
display: none;
}
}
@media screen and (max-width: 768px) {
.login-hero__title {
font-size: 2rem;
@ -653,10 +710,6 @@ onBeforeUnmount(() => {
.login-hero__principle-title {
font-size: 18px;
}
.login-hero__flow {
letter-spacing: 0.06em;
}
}
@media (prefers-reduced-motion: reduce) {

View File

@ -24,19 +24,36 @@
:class="{ 'login-brand-corner--light': isLightMode }"
>
<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>
<aside :class="['login-hero-panel', { 'login-hero-panel--admin': !isCustomerLogin }]">
<aside v-if="isCustomerLogin" class="login-hero-panel">
<LoginHero
:light="isLightMode"
:portal="isCustomerLogin ? 'customer' : 'admin'"
portal="customer"
/>
</aside>
<div class="login-characters-panel">
<div v-if="isCustomerLogin" class="login-characters-panel">
<LoginCharacters v-if="showCharacters" @register="registerLoginCharacters" />
</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 ref="loginFormRef" :class="['login-form', { 'login-form--ready': shellReady }]">
<div class="login-form-toolbar">
@ -72,10 +89,6 @@
</div>
</div>
</div>
<footer v-if="shellReady" class="login-footer">
{{ t('login.footer.copyright') }}
</footer>
</div>
</template>
@ -96,7 +109,6 @@ import { getLoginTypeFromRouteName } from '@/utils/login-portal';
import { IconSunFill, IconMoonFill } from '@arco-design/web-vue/es/icon';
import LoginForm from './components/pwd-login/index.vue';
import LoginHero from './components/LoginHero.vue';
import LoginCareBanner from './components/LoginCareBanner.vue';
import LoginLangToggle from './components/LoginLangToggle.vue';
import {
LOGIN_CHARACTERS_KEY,
@ -112,6 +124,9 @@ const WechatLogin = defineAsyncComponent(
const LoginCharacters = defineAsyncComponent(
() => import('./components/LoginCharacters.vue'),
);
const LoginCareBanner = defineAsyncComponent(
() => import('./components/LoginCareBanner.vue'),
);
const LOGIN_APPEARANCE_KEY = 'login-appearance';
const LOGIN_PAGE_SCROLL_LOCK_CLASS = 'login-page-no-scroll';
@ -271,14 +286,18 @@ function prefetchWechatQr() {
onMounted(() => {
document.documentElement.classList.add(LOGIN_PAGE_SCROLL_LOCK_CLASS);
shellReady.value = true;
// 首屏先出表单,下一帧再挂角色动画分包
requestAnimationFrame(() => {
const idle = window.requestIdleCallback
? (cb: () => void, timeout = 1200) => window.requestIdleCallback(cb, { timeout })
: (cb: () => void, timeout = 1200) => window.setTimeout(cb, Math.min(timeout, 400));
// 表单先出,空闲后再挂角色,避免和首屏 JS/图抢带宽
idle(() => {
showCharacters.value = true;
});
// 后台预拉完整语言包,进系统时几乎不等
void import('@/locale').then(({ ensureFullLocaleMessages }) => {
void ensureFullLocaleMessages();
});
}, 1600);
idle(() => {
void import('@/locale').then(({ ensureFullLocaleMessages }) => {
void ensureFullLocaleMessages();
});
}, 2500);
});
onBeforeUnmount(() => {
@ -318,24 +337,6 @@ onBeforeUnmount(() => {
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 {
position: absolute;
inset: 0;
@ -435,10 +436,22 @@ onBeforeUnmount(() => {
}
.login-hero-panel--admin {
display: flex;
align-items: center;
justify-content: center;
width: min(800px, calc((var(--login-split-top) + var(--login-split-bottom)) / 2 - 16px));
inset: 0;
top: 0;
left: 0;
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 {
@ -451,6 +464,22 @@ onBeforeUnmount(() => {
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 {
position: absolute;
top: 14px;

View File

@ -46,9 +46,6 @@
'login.hero.admin.pillar.reach.desc': 'Pickup to delivery · Clear flow · Fewer detours',
'login.hero.admin.ai':
'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.afternoon': 'Good afternoon — steady wins',
'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.desc': '揽收到签收 · 链路清晰 · 少绕少错',
'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.afternoon': '下午好,稳稳推进就好',
'login.care.period.evening': '傍晚好,今天也辛苦了',

View File

@ -1,5 +1,8 @@
<template>
<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="toolbar-left">
<div class="toolbar-filters">
@ -11,18 +14,6 @@
@press-enter="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 />
<a-button class="app-btn-search" @click="handleSearch">
{{ t('common.search') }}
@ -31,9 +22,6 @@
<a-button class="app-btn-toolbar" @click="openAdjustModal">
{{ t('marketing.commissionDetail.adjust') }}
</a-button>
<a-button @click="openRuleModal">
{{ t('menu.marketing.commissionRule') }}
</a-button>
</div>
<div class="toolbar-right">
<a-space :size="8">
@ -92,52 +80,21 @@
/>
</div>
</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>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
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 PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import FormPro from '@/components/FormPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { isEnableStatusEnabled, toEnableStatus } from '@/utils/enableStatus'
import { buildStatusTagHtml, type StatusTagTone } from '@/utils/statusTag'
import { adjustCommissionDetail, getCommissionDetailList } from '@/api/marketing/commissionDetail'
import { getCommissionRule, updateCommissionRule } from '@/api/marketing/commissionRule'
import MarketingPageShell from '../components/MarketingPageShell.vue'
import MarketingPageTipIcon from '../components/MarketingPageTipIcon.vue'
@ -160,9 +117,11 @@ interface DetailRow {
orderNo: string
basePayableAmount: number
commissionAmount: number
rewardOrderIndex: number
status: number
statusName: string
eligibleTime: string
settleDueTime: string
settleTime: string
}
@ -170,20 +129,21 @@ const tableRows = ref<DetailRow[]>([])
const totalCount = ref(0)
const listLoading = ref(false)
const orderNo = ref('')
const statusFilter = ref<number | undefined>(undefined)
const statusTab = ref('all')
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 adjustVisible = ref(false)
const adjustLoading = ref(false)
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(() => [
{
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(() => [
{ key: 'referrerCustomerCode', label: t('marketing.commissionDetail.columns.referrer'), width: 110 },
{ key: 'refereeCustomerCode', label: t('marketing.commissionDetail.columns.referee'), width: 110 },
{ 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: 'rewardOrderIndex', label: t('marketing.commissionDetail.columns.orderIndex'), width: 80 },
{ key: 'statusName', label: t('marketing.commissionDetail.columns.status'), width: 90 },
{ 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 },
])
@ -345,33 +239,21 @@ function mapRow(item: Record<string, unknown>): DetailRow {
orderNo: String(item.orderNo ?? item.OrderNo ?? ''),
basePayableAmount: Number(item.basePayableAmount ?? item.BasePayableAmount ?? 0),
commissionAmount: Number(item.commissionAmount ?? item.CommissionAmount ?? 0),
rewardOrderIndex: Number(item.rewardOrderIndex ?? item.RewardOrderIndex ?? 0),
status: Number(item.status ?? item.Status ?? 0),
statusName: String(item.statusName ?? item.StatusName ?? ''),
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) || '--',
}
}
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() {
listLoading.value = true
try {
const { items, total } = await getCommissionDetailList({
orderNo: orderNo.value.trim() || undefined,
status: statusFilter.value,
status: statusTab.value === 'all' ? undefined : Number(statusTab.value),
page: currentPage.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())
</script>

View File

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

View File

@ -1,5 +1,8 @@
<template>
<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="toolbar-left">
<div class="toolbar-filters">
@ -11,19 +14,6 @@
@press-enter="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
v-model="withdrawType"
allow-clear
@ -123,6 +113,7 @@ import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import FormPro from '@/components/FormPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import {
approveCommissionWithdraw,
@ -163,11 +154,19 @@ const tableRows = ref<WithdrawRow[]>([])
const totalCount = ref(0)
const listLoading = ref(false)
const customerCode = ref('')
const statusFilter = ref<number | undefined>(1)
const statusTab = ref('all')
const withdrawType = ref<number | undefined>(undefined)
const currentPage = ref(1)
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 rejectLoading = ref(false)
const rejectTarget = ref<WithdrawRow | null>(null)
@ -262,7 +261,7 @@ async function loadList() {
try {
const { items, total } = await getCommissionWithdrawList({
customerCode: customerCode.value.trim() || undefined,
status: statusFilter.value,
status: statusTab.value === 'all' ? undefined : Number(statusTab.value),
withdrawType: withdrawType.value,
page: currentPage.value,
pageSize: pageSize.value,

View File

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

View File

@ -1,5 +1,8 @@
<template>
<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="toolbar-left">
<div class="toolbar-filters">
@ -11,19 +14,6 @@
@press-enter="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 />
<a-button class="app-btn-search" @click="handleSearch">
{{ t('common.search') }}
@ -32,9 +22,6 @@
<a-button type="primary" @click="openAdjustModal">
{{ t('marketing.pointsFlow.adjust') }}
</a-button>
<a-button @click="openRuleModal">
{{ t('marketing.pointsRule.tab.rule') }}
</a-button>
</div>
<div class="toolbar-right">
<a-space :size="8">
@ -92,51 +79,20 @@
/>
</div>
</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>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
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 PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import FormPro from '@/components/FormPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { isEnableStatusEnabled, toEnableStatus } from '@/utils/enableStatus'
import { adjustPoints, getPointsFlowList } from '@/api/marketing/pointsFlow'
import { getPointsRule, updatePointsRule } from '@/api/marketing/pointsRule'
import MarketingPageTipIcon from './MarketingPageTipIcon.vue'
defineOptions({ name: 'PointsFlowPanel' })
@ -167,19 +123,22 @@ const tableRows = ref<PointsFlowRow[]>([])
const totalCount = ref(0)
const listLoading = ref(false)
const customerCode = ref('')
const changeType = ref<number | undefined>(undefined)
const changeTypeTab = ref('all')
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 adjustVisible = ref(false)
const adjustLoading = ref(false)
const adjustFormRef = ref<FormExpose>()
const ruleVisible = ref(false)
const ruleLoading = ref(false)
const ruleSaving = ref(false)
const ruleFormRef = ref<FormExpose>()
const adjustItemList = computed(() => [
{
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(() => [
{ key: 'customerCode', label: t('marketing.pointsFlow.columns.customerCode'), width: 110 },
{ 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() {
listLoading.value = true
try {
const { items, total } = await getPointsFlowList({
customerCode: customerCode.value.trim() || undefined,
changeType: changeType.value,
changeType: changeTypeTab.value === 'all' ? undefined : Number(changeTypeTab.value),
page: currentPage.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())
defineExpose({ loadList, openAdjustModal, openRuleModal })
defineExpose({ loadList, openAdjustModal })
</script>
<style scoped lang="less">
@ -534,15 +357,5 @@ defineExpose({ loadList, openAdjustModal, openRuleModal })
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

@ -1,5 +1,8 @@
<template>
<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="toolbar-left">
<div class="toolbar-filters">
@ -21,19 +24,6 @@
@press-enter="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
v-model="recordKeyword"
allow-clear
@ -146,6 +136,7 @@ import FormPro from '@/components/FormPro/index.vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { getCouponTemplateList } from '@/api/marketing/couponTemplate'
import { getCouponInstanceList, grantCoupons, previewCouponGrant } from '@/api/marketing/couponGrant'
@ -190,11 +181,20 @@ const recordTotal = ref(0)
const recordLoading = ref(false)
const recordTemplateId = ref<number | undefined>(undefined)
const recordCustomerCode = ref('')
const recordStatus = ref<number | undefined>(undefined)
const recordStatusTab = ref('all')
const recordKeyword = ref('')
const recordPage = ref(1)
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(() => [
{
field: 'templateId',
@ -336,7 +336,7 @@ async function loadRecords() {
const { items, total } = await getCouponInstanceList({
templateId: recordTemplateId.value,
customerCode: recordCustomerCode.value.trim() || undefined,
status: recordStatus.value,
status: recordStatusTab.value === 'all' ? undefined : Number(recordStatusTab.value),
keyword: recordKeyword.value.trim() || undefined,
page: recordPage.value,
pageSize: recordPageSize.value,

View File

@ -1,5 +1,8 @@
<template>
<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="toolbar-left">
<div class="toolbar-filters">
@ -11,18 +14,6 @@
@press-enter="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 />
<a-button class="app-btn-search" @click="handleSearch">
{{ t('common.search') }}
@ -103,6 +94,7 @@ import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import FormPro from '@/components/FormPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import {
createCouponTemplate,
@ -141,6 +133,8 @@ interface TemplateRow {
validStartTime: string | null
validEndTime: string | null
validDaysAfterReceive: number | null
applyScopeJson: string
applyCountryCodes: string
remark: string
status: number
statusName: string
@ -156,10 +150,17 @@ const tableRows = ref<TemplateRow[]>([])
const totalCount = ref(0)
const listLoading = ref(false)
const keyword = ref('')
const statusFilter = ref<number | undefined>(undefined)
const statusTab = ref('all')
const currentPage = ref(1)
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 modalLoading = ref(false)
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.newcomer'), value: 'newcomer' },
{ 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(() => [
@ -287,6 +291,7 @@ const itemList = computed(() => [
options: [
{ label: t('marketing.couponTemplate.validType.fixed'), value: 1 },
{ 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'),
comp: 'a-input-number',
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') }],
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',
label: t('marketing.couponTemplate.form.remark'),
@ -349,6 +365,26 @@ const hotColumns = computed(() => [
{ 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 {
if (value == null || value === '') return null
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
? Number(item.validDaysAfterReceive ?? item.ValidDaysAfterReceive)
: null,
applyScopeJson: String(item.applyScopeJson ?? item.ApplyScopeJson ?? ''),
applyCountryCodes: parseCountryCodes(item.applyScopeJson ?? item.ApplyScopeJson),
remark: String(item.remark ?? item.Remark ?? ''),
status: Number(item.status ?? item.Status ?? 0),
statusName: String(item.statusName ?? item.StatusName ?? ''),
@ -422,7 +460,7 @@ async function loadList() {
try {
const { items, total } = await getCouponTemplateList({
keyword: keyword.value.trim() || undefined,
status: statusFilter.value,
status: statusTab.value === 'all' ? undefined : Number(statusTab.value),
page: currentPage.value,
pageSize: pageSize.value,
})
@ -462,6 +500,7 @@ async function openModal(row?: TemplateRow) {
validStartTime: row.validStartTime || undefined,
validEndTime: row.validEndTime || undefined,
validDaysAfterReceive: row.validDaysAfterReceive ?? 30,
applyCountryCodes: row.applyCountryCodes || '',
remark: row.remark || '',
}
: {
@ -479,6 +518,7 @@ async function openModal(row?: TemplateRow) {
validStartTime: undefined,
validEndTime: undefined,
validDaysAfterReceive: 30,
applyCountryCodes: '',
remark: '',
}
formRef.value?.setValues(values)
@ -552,7 +592,8 @@ async function handleModalOk() {
validType,
validStartTime: validType === 1 ? values.validStartTime || 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,
grantChannel: String(values.grantChannel || 'manual').trim() || 'manual',
}

View File

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

View File

@ -1,16 +1,604 @@
<template>
<MarketingPageShell :tip="t('marketing.pointsFlow.pageTip')">
<PointsFlowPanel />
<MarketingPageShell variant="cards" :tip="t('marketing.pointsRule.pageTip')">
<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>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
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 PointsFlowPanel from '../components/PointsFlowPanel.vue'
import MarketingPageTipIcon from '../components/MarketingPageTipIcon.vue'
/** 兼容旧菜单「积分规则」:与积分流水同一页,规则配置走弹窗 */
defineOptions({ name: 'MarketingPointsRule' })
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>
<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;
}
.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-left :deep(.marketing-page-tip-icon) {

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed } from 'vue'
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' })
@ -328,13 +328,13 @@
</div>
<div class="quote-card__actions" @click.stop>
<button type="button" class="ops-btn ops-btn--primary" @click="emit('order')">
{{ t('order.priceCheck.table.orderNow') }}
</button>
<a-dropdown trigger="click" position="br">
<button type="button" class="ops-btn ops-btn--ghost">
{{ t('order.priceCheck.table.askAi') }}
<icon-down style="margin-left: 2px" />
<a-dropdown trigger="click" position="bl">
<button
type="button"
class="advisor-icon-btn"
:aria-label="t('order.priceCheck.table.askAi')"
>
<IconTabService class="advisor-icon-btn__icon" />
</button>
<template #content>
<a-doption @click="emit('ask', 'canShip')">
@ -345,6 +345,9 @@
</a-doption>
</template>
</a-dropdown>
<button type="button" class="ops-btn ops-btn--primary" @click="emit('order')">
{{ t('order.priceCheck.table.orderNow') }}
</button>
</div>
</div>
</a-spin>
@ -666,25 +669,18 @@
.quote-card__actions {
display: flex;
flex-direction: column;
align-items: stretch;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
flex-shrink: 0;
min-width: 88px;
:deep(.arco-dropdown-open),
:deep(.arco-trigger),
> :deep(.arco-dropdown-trigger),
> .arco-dropdown-open {
width: 100%;
display: block;
}
> :deep(span),
> :deep(div) {
width: 100%;
display: block;
display: inline-flex;
flex-shrink: 0;
}
}
@ -692,36 +688,54 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 28px;
padding: 0 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
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 {
min-width: 88px;
height: 32px;
padding: 0 16px;
border-radius: 6px;
background: var(--app-primary-color, #007979);
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 {
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 {
display: flex;
flex-direction: column;
@ -799,7 +813,7 @@
.quote-card__actions {
width: 100%;
justify-content: flex-end;
justify-content: center;
}
}
@ -810,13 +824,10 @@
}
.quote-card__actions {
flex-direction: row;
width: 100%;
min-width: 0;
}
.ops-btn--primary {
flex: 1;
min-width: 120px;
}
}

View File

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

View File

@ -40,7 +40,7 @@
const formData = reactive({
destinationCountry: '',
shippingMethod: '',
cargoWeight: '',
cargoWeight: '1',
length: '',
width: '',
height: '',
@ -92,18 +92,6 @@
const couponInstanceId = ref<number | undefined>(undefined);
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(() => {
if (!hasQueried.value) return false;
if (String(formData.postalCode || '').trim()) return false;
@ -125,7 +113,6 @@
| 'destinationCity'
| 'cargoType'
| 'warehouse'
| 'shipping'
| 'coupon';
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) {
const c = availableCoupons.value.find((x) => (x.id ?? x.Id) === couponInstanceId.value);
const name = String(c?.templateName ?? c?.TemplateName ?? '').trim();
@ -229,10 +205,6 @@
formData.warehouseId = undefined;
return;
}
if (chip.kind === 'shipping') {
formData.shippingMethod = '';
return;
}
if (chip.kind === 'coupon') {
couponInstanceId.value = undefined;
}
@ -246,7 +218,6 @@
formData.destinationCity = '';
formData.cargoType = '';
formData.warehouseId = undefined;
formData.shippingMethod = '';
couponInstanceId.value = undefined;
}
@ -621,14 +592,15 @@
availableCoupons.value = coupons ?? [];
hintBannerDismissed.value = false;
queryResults.value = processQueryResults(items || []);
selectedGuideRecord.value = null;
resetRecommendState();
sortMode.value = 'recommend';
currentPage.value = 1;
recalcLoadingKeys.value = {};
if (!queryResults.value.length) {
selectedGuideRecord.value = null;
applyPriceCheckFailures(failures ?? []);
} else {
selectedGuideRecord.value = sortedResults.value[0] ?? queryResults.value[0];
clearPriceCheckFailures();
Message.success(t('order.priceCheck.message.querySuccess', { count: queryResults.value.length }));
}
@ -648,7 +620,7 @@
Object.assign(formData, {
destinationCountry: '',
shippingMethod: '',
cargoWeight: '',
cargoWeight: '1',
length: '',
width: '',
height: '',
@ -1001,6 +973,17 @@
</div>
<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">
<label class="form-label required">{{ $t('order.priceCheck.form.destinationCountry') }}</label>
<a-select
@ -1035,21 +1018,21 @@
</a-select>
</div>
<div class="form-item">
<label class="form-label required">{{ $t('order.priceCheck.form.cargoWeight') }}</label>
<div ref="postalInputRef" class="form-item form-item-compact">
<label class="form-label">{{ $t('order.priceCheck.form.postalCode') }}</label>
<a-input
v-model="formData.cargoWeight"
:placeholder="$t('order.priceCheck.form.cargoWeight.placeholder')"
v-model="formData.postalCode"
:placeholder="$t('order.priceCheck.form.postalCode.placeholder')"
class="form-input"
@press-enter="handleQuery"
/>
</div>
<div ref="postalInputRef" class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.postalCode') }}</label>
<div class="form-item form-item-compact">
<label class="form-label required">{{ $t('order.priceCheck.form.cargoWeight') }}</label>
<a-input
v-model="formData.postalCode"
:placeholder="$t('order.priceCheck.form.postalCode.placeholder')"
v-model="formData.cargoWeight"
:placeholder="$t('order.priceCheck.form.cargoWeight.placeholder')"
class="form-input"
@press-enter="handleQuery"
/>
@ -1071,10 +1054,7 @@
{{ $t('order.priceCheck.actions.reset') }}
</a-button>
<button type="button" class="advanced-toggle-btn" @click="showAdvanced = !showAdvanced">
<span>
{{ $t('order.priceCheck.form.moreFilters') }}
<span v-if="advancedActiveCount" class="advanced-count">{{ advancedActiveCount }}</span>
</span>
<span>{{ $t('order.priceCheck.form.moreFilters') }}</span>
<icon-up v-if="showAdvanced" />
<icon-down v-else />
</button>
@ -1110,10 +1090,10 @@
<icon-close />
</span>
</button>
<button type="button" class="filter-summary-bar__clear" @click="clearAdvancedConditions">
{{ $t('order.priceCheck.form.clearFilters') }}
</button>
</div>
<button type="button" class="filter-summary-bar__clear" @click="clearAdvancedConditions">
{{ $t('order.priceCheck.form.clearFilters') }}
</button>
</div>
<div v-show="showAdvanced" class="form-row form-row-advanced">
@ -1183,17 +1163,6 @@
</a-select>
</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">
<label class="form-label">{{ $t('order.priceCheck.form.cargoType') }}</label>
<a-select
@ -1365,8 +1334,8 @@
background-color: var(--app-page-bg);
}
.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-aside { flex: 3 1 0; min-width: 300px; min-height: 0; display: flex; flex-direction: column; > * { flex: 1; min-height: 0; } }
.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: 340px; min-height: 0; display: flex; flex-direction: column; > * { flex: 1; min-height: 0; } }
.liquid-panel {
position: relative;
z-index: 0;
@ -1439,8 +1408,9 @@
font-size: 12px;
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 { flex-wrap: nowrap; }
.form-row-advanced {
margin-top: 12px;
padding-top: 16px;
@ -1543,23 +1513,9 @@
white-space: nowrap;
&: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-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; }
&.button-group { width: auto; min-width: 0; flex-shrink: 0; }
.button-group-row {
@ -1570,7 +1526,9 @@
justify-content: flex-start;
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 {
display: flex;

View File

@ -24,6 +24,7 @@
<section class="portal-detail-section">
<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="locked" :title="t('marketing.portal.coupons.tab.locked')" />
<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-tabs>
@ -75,10 +76,18 @@
</a-tag>
</div>
<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>
<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') }}
</a-tag>
<span v-else-if="item.status === 1" class="hint hint--action">
@ -132,6 +141,7 @@ interface CouponRow {
status: number
statusName: string
isExpiringSoon: boolean
unlockHint: string
}
const { t } = useI18n()
@ -160,6 +170,7 @@ const howSteps = computed(() => [
const statusMap: Record<string, number> = {
available: 1,
locked: 5,
used: 3,
expired: 4,
}
@ -167,6 +178,7 @@ const statusMap: Record<string, number> = {
const emptyTitle = computed(() => {
if (activeTab.value === 'used') return t('marketing.portal.coupons.emptyUsedTitle')
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')
})
@ -181,6 +193,7 @@ const availableSummary = computed(() => String(availableCount.value))
function statusFallback(status: number) {
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 === 4) return t('marketing.portal.coupons.tab.expired')
return '--'
@ -228,6 +241,7 @@ function mapRow(item: Record<string, unknown>): CouponRow {
status: Number(item.status ?? item.Status ?? 0),
statusName: String(item.statusName ?? item.StatusName ?? ''),
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__shine" aria-hidden="true" />
<div class="portal-glass__body share-hero__focus">
<p class="share-hero__eyebrow">{{ t('marketing.portal.invite.yourCode') }}</p>
<div class="invite-code-row">
<span class="invite-code">{{ inviteCode }}</span>
<span class="referee-pill">
{{ t('marketing.portal.invite.refereeOnly', { count: refereeCount }) }}
</span>
</div>
<template v-if="canInvite">
<p class="share-hero__eyebrow">{{ t('marketing.portal.invite.yourCode') }}</p>
<div class="invite-code-row">
<span class="invite-code">{{ inviteCode }}</span>
<span class="referee-pill">
{{ t('marketing.portal.invite.refereeOnly', { count: refereeCount }) }}
</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">
{{ t('marketing.portal.invite.copySharePrimary') }}
</a-button>
<details class="share-more">
<details v-if="canInvite" class="share-more">
<summary>{{ t('marketing.portal.invite.secondaryActions') }}</summary>
<div class="share-more__body">
<div class="link-row">
@ -45,6 +54,7 @@
</section>
<PortalHowTo
v-if="canInvite"
:title="t('marketing.portal.invite.howTitle')"
:hint="t('marketing.portal.invite.howHint')"
:steps="howSteps"
@ -60,7 +70,7 @@
<div v-if="!refereeRows.length && !refereeLoading" class="portal-empty">
<p class="portal-empty__title">{{ t('marketing.portal.invite.emptyTitle') }}</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') }}
</a-button>
</div>
@ -80,6 +90,9 @@
</div>
</div>
<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">
{{ t('marketing.portal.invite.tag.delivered') }}
</a-tag>
@ -136,6 +149,7 @@ interface RefereeRow {
createTime: string
hasPaidOrder: boolean
hasDeliverySuccess: boolean
rewardOrderCount: number
avatarText: string
}
@ -174,6 +188,27 @@ const refereeCount = computed(() =>
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() {
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),
hasPaidOrder: Boolean(item.hasPaidOrder ?? item.HasPaidOrder),
hasDeliverySuccess: Boolean(item.hasDeliverySuccessOrder ?? item.HasDeliverySuccessOrder),
rewardOrderCount: Number(item.rewardOrderCount ?? item.RewardOrderCount ?? 0),
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));
}
.reward-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
color: var(--color-text-2);
}
.share-primary {
margin-top: 4px;
}

View File

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