feat: 查价按统一地理分区规则匹配,并补全州/市入参
Some checks failed
Build and Deploy Front / deploy (push) Failing after 1m33s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-05 16:12:05 +08:00
parent 07f241ab64
commit c8a52cf87e
6 changed files with 424 additions and 130 deletions

View File

@ -1267,6 +1267,7 @@ export interface PriceCheckRequest {
/** 仓库主键(多选;为空则遍历全部仓库) */
warehouseIds?: number[]
destinationProvince?: string
destinationCity?: string
postalCode?: string
/** 选用的优惠券实例 Id */
couponInstanceId?: number

View File

@ -312,6 +312,8 @@ export default {
'warehouse.parcel.drawer.frozenQty': 'Frozen',
'warehouse.parcel.drawer.itemCount': '{n} line(s)',
'warehouse.parcel.tip': 'Available = ready to bind; Frozen = reserved by orders. Use status tabs to filter.',
'warehouse.parcel.pluginHint': 'Don’t want to fill parcel details? Use the one-click add-parcel plugin.',
'warehouse.parcel.pluginHintDownload': 'Download it from the top-right icon',
'warehouse.parcel.drawer.edit': 'Edit parcel',
'warehouse.parcel.drawer.empty': 'No items',
'warehouse.parcel.table.arrivalTime': 'Arrival Time',
@ -1439,43 +1441,49 @@ export default {
'nanaAssistant.tabAi': 'AI Nana',
'nanaAssistant.tabAdvisor': 'Exclusive Advisor',
'nanaAssistant.badge': 'Logistics Advisor',
'nanaAssistant.welcomeTitle': 'What do you need help shipping?',
'nanaAssistant.welcomeSub': 'Price, cargo eligibility, tracking — pick a capability below.',
'nanaAssistant.welcomeTitle': 'What do you want to check?',
'nanaAssistant.welcomeHello': '{greet}, Xiao Ai at your service',
'nanaAssistant.welcomeSub': 'What do you want to check? Tap below, or just type.',
'nanaAssistant.greet.morning': 'Good morning',
'nanaAssistant.greet.noon': 'Good afternoon',
'nanaAssistant.greet.afternoon': 'Good afternoon',
'nanaAssistant.greet.evening': 'Good evening',
'nanaAssistant.greet.night': 'Still up',
'nanaAssistant.quickLabel': 'Common questions',
'nanaAssistant.welcomeMsg': "Hi — I'm your IDE logistics advisor. Pick a question below or type your own.",
'nanaAssistant.welcomeMsg': "Hi — I'm your IDE logistics advisor. Tap below or type your own question.",
'nanaAssistant.historyTitle': 'Chat history',
'nanaAssistant.newChat': 'New chat',
'nanaAssistant.emptyHistory': 'No conversations yet',
'nanaAssistant.searchHistory': 'Search chats or waybill no.',
'nanaAssistant.noSearchResults': 'No matching conversations',
'nanaAssistant.deleteThread': 'Delete conversation',
'nanaAssistant.hint': 'Type a question, or pick a capability above.',
'nanaAssistant.hintLive': 'Smart advisor is live — price, channel guide, tracking.',
'nanaAssistant.hint': 'Type a question, or tap an option above.',
'nanaAssistant.hintLive': 'Ask about rates, whether it can ship, or where a parcel is.',
'nanaAssistant.inputPlaceholder': 'Ask a question…',
'nanaAssistant.inputPlaceholderLive': 'Ask about price, channels, or tracking…',
'nanaAssistant.inputPlaceholderTrack': 'Enter waybill / tracking no.…',
'nanaAssistant.mcp.sectionLabel': 'Capabilities',
'nanaAssistant.mcp.exampleHint': 'e.g. {sample}',
'nanaAssistant.mcp.required': 'Required',
'nanaAssistant.mcp.optional': 'Optional',
'nanaAssistant.mcp.missingAsk': 'Please also provide: {fields}. Then I can look it up.',
'nanaAssistant.mcp.priceTitle': 'Price check',
'nanaAssistant.mcp.priceDesc': 'Estimate freight by destCountry & weight',
'nanaAssistant.mcp.priceTitle': 'Check rates',
'nanaAssistant.mcp.priceDesc': 'Estimate freight by country and weight',
'nanaAssistant.mcp.priceCoach':
'To quote I need:\n1) Destination destCountry (e.g. US)\n2) Actual weight in kg\n\nOptional: postal code, L×W×H (cm), cargo type.\nExample: US 0.5kg zip 10001',
'nanaAssistant.mcp.priceExample': 'e.g. US 0.5kg zip 10001',
'To quote I need destination country and actual weight (kg). Postal code and size help accuracy.\nExample: US 0.5kg',
'nanaAssistant.mcp.priceExample': 'Check US 0.5kg rates for me',
'nanaAssistant.mcp.priceDraft':
'Country:\nWeight (kg):\nPostal code (optional):\nDims LWH cm (optional):',
'nanaAssistant.mcp.guideTitle': 'Can this cargo ship?',
'nanaAssistant.mcp.guideDesc': 'Check against the channel customer guide',
'nanaAssistant.mcp.guideDesc': 'See if this item can go on this service',
'nanaAssistant.mcp.guideCoach':
'I need:\n1) Sales product code or name\n2) Cargo / product name\n\nExample: product EX_ASP_HKDHL, cargo lipstick — can it ship?',
'nanaAssistant.mcp.guideExample': 'e.g. product XX, cargo lipstick OK?',
'I need the sales product (service) and the cargo name.\nExample: product XX, cargo lipstick — can it ship?',
'nanaAssistant.mcp.guideExample': 'Can lipstick ship?',
'nanaAssistant.mcp.guideDraft': 'Sales product code/name:\nCargo name:',
'nanaAssistant.mcp.trackTitle': 'Track shipment',
'nanaAssistant.mcp.trackDesc': 'Timeline by waybill number',
'nanaAssistant.mcp.trackTitle': 'Where is my parcel?',
'nanaAssistant.mcp.trackDesc': 'See where the parcel is now',
'nanaAssistant.mcp.trackCoach':
'Please provide a waybill / tracking / customer order number.\nIf a waybill is already loaded, tap “Track”.',
'nanaAssistant.mcp.trackExample': 'e.g. Track waybill NFXXXX',
'Send a waybill number. If one is already loaded, tap “Track this shipment”.',
'nanaAssistant.mcp.trackExample': 'Where is waybill NFXXXX',
'nanaAssistant.mcp.trackDraft': 'Waybill No.:',
'nanaAssistant.mcp.field.destCountry': 'Destination destCountry',
'nanaAssistant.mcp.field.weight': 'Weight (kg)',
@ -1528,6 +1536,10 @@ export default {
'nanaAssistant.viewWaybill': 'View waybill →',
'nanaAssistant.queryTrack': 'Track shipment',
'nanaAssistant.endWaybillConsult': 'End waybill consult',
'nanaAssistant.waybillWelcomeTitle': 'How can I help with this shipment?',
'nanaAssistant.waybillWelcomeSub': 'Waybill {no} is loaded — track it or ask about fees.',
'nanaAssistant.waybillAskTrack': 'Track this shipment',
'nanaAssistant.waybillAskFee': 'Ask about fees',
'nanaAssistant.waybillAutoAsk': 'Please track waybill {no} for me',
'nanaAssistant.trackWaybillDemo': 'Got your request for waybill {no}. When the smart service is configured, we prefer the live API; this is a reference reply: milestones usually include pickup, export, line-haul, customs, and delivery. Your advisor will follow up on exceptions.',
'nanaAssistant.copyWechat': 'Copy WeChat ID',
@ -2255,7 +2267,9 @@ export default {
'order.priceCheck.form.warehouse': 'Warehouse',
'order.priceCheck.form.warehouse.placeholder': 'All',
'order.priceCheck.form.destinationProvince': 'State / Province',
'order.priceCheck.form.destinationProvince.placeholder': 'Please select',
'order.priceCheck.form.destinationProvince.placeholder': 'Used when postal code is empty',
'order.priceCheck.form.destinationCity': 'City',
'order.priceCheck.form.destinationCity.placeholder': 'Used when postal code is empty',
'order.priceCheck.form.postalCode': 'Postal code',
'order.priceCheck.form.postalCode.tip': 'more accurate',
'order.priceCheck.form.postalCode.placeholder': 'Optional — improves zoning',
@ -2568,7 +2582,7 @@ export default {
'order.placeOrder.single.exportQualificationManage': 'Manage',
'order.placeOrder.single.exportQualificationLoadFailed': 'Failed to load export qualifications',
'order.placeOrder.single.platformCode': 'E-commerce platform',
'order.placeOrder.single.platformCodePlaceholder': 'e.g. Amazon / eBay / Shopify',
'order.placeOrder.single.platformCodePlaceholder': 'Select ecommerce platform',
'order.placeOrder.single.showExtraCols': 'More fields',
'order.placeOrder.single.hideExtraCols': 'Less',
'order.placeOrder.single.showRecipientOptional': 'More fields',
@ -5330,6 +5344,15 @@ export default {
'channel.area.form.city': 'City',
'channel.area.form.leftPostCode': 'Start postcode',
'channel.area.form.rightPostCode': 'End postcode',
'channel.area.form.matchDim': 'Match dimension',
'channel.area.form.matchMode': 'Match mode',
'channel.area.matchDim.country': 'Country only',
'channel.area.matchDim.zip': 'Postcode',
'channel.area.matchDim.state': 'State/Province',
'channel.area.matchDim.city': 'City',
'channel.area.matchMode.prefix': 'Prefix',
'channel.area.matchMode.exact': 'Exact',
'channel.area.matchMode.range': 'Range',
'channel.area.form.placeholder.keyword': 'Enter keyword',
'channel.area.form.placeholder.areaName': 'Enter area name',
'channel.area.form.placeholder.input': 'Please enter',
@ -5337,17 +5360,28 @@ export default {
'channel.area.form.rules.countryRequired': 'Please select country/region',
'channel.area.form.rules.destCountryRequired': 'Please select country/region',
'channel.area.section.match': 'Match scope',
'channel.area.section.postcode': 'Postcode range',
'channel.area.tip.country': 'Required; country only matches all postcodes in that country',
'channel.area.tip.province': 'Optional; blank means any state/province',
'channel.area.tip.city': 'Optional; blank means any city',
'channel.area.tip.leftPostCode': 'Pair with end postcode; if one side is filled the other is required',
'channel.area.tip.rightPostCode': 'Must be greater than or equal to start postcode',
'channel.area.section.postcode': 'Postcode match',
'channel.area.tip.country': 'Required; Country-only is the lowest-priority fallback for that country',
'channel.area.tip.province': 'Required for state or city dimension',
'channel.area.tip.city': 'Required for city dimension; state is also required',
'channel.area.tip.leftPostCode': 'Prefix: leading digits; Exact: full postcode; Range: start postcode',
'channel.area.tip.rightPostCode': 'Range end postcode, closed interval (inclusive)',
'channel.area.tip.matchDim': 'One row, one dimension. Postcode beats state/city; country-only is last fallback',
'channel.area.tip.matchMode': 'Prefix matches leading chars; exact is equality; range is inclusive',
'channel.area.form.rules.maxLength128': 'Cannot exceed 128 characters',
'channel.area.form.rules.maxLength30': 'Cannot exceed 30 characters',
'channel.area.form.rules.noChinese': 'Cannot contain Chinese characters',
'channel.area.form.rules.noSpacesOnly': 'Start postcode cannot be spaces only',
'channel.area.form.rules.noSpacesOnlyEnd': 'End postcode cannot be spaces only',
'channel.area.form.rules.matchDimRequired': 'Select a match dimension',
'channel.area.form.rules.matchModeRequired': 'Select a match mode',
'channel.area.form.rules.provinceRequired': 'Enter state/province',
'channel.area.form.rules.cityRequired': 'Enter city',
'channel.area.form.rules.cityRequiresState': 'City match requires state/province',
'channel.area.form.rules.postcodeRequired': 'Enter postcode',
'channel.area.form.rules.postcodeRangeRequired': 'Range match requires end postcode',
'channel.area.form.placeholder.provinceRequired': 'Enter state/province',
'channel.area.form.placeholder.cityRequired': 'Enter city',
'channel.area.modal.addArea': 'Add area',
'channel.area.modal.editArea': 'Edit area',
'channel.area.modal.addDetail': 'Add area detail',
@ -5376,12 +5410,12 @@ export default {
'channel.config.detailCount': '{count} detail record(s)',
'channel.area.tip.addArea': 'Areas group detail records. Use a clear name such as "US West Coast" or "EU Remote".',
'channel.area.tip.editArea': 'Renaming an area does not change existing details. Avoid duplicate names.',
'channel.area.tip.addDetail': 'Country/region is required; state/province and city are optional. When using postcodes, start must not exceed end. Country only matches all postcodes in that destCountry.',
'channel.area.tip.editDetail': 'Changes apply immediately to channel matching. Verify destCountry and postcode range.',
'channel.area.tip.addDetail': 'One row, one dimension. Postcode (prefix/exact/closed range) wins; then state; then city (needs state); country-only is last fallback.',
'channel.area.tip.editDetail': 'Changes apply immediately to quote/billing matching. Verify dimension and postcode rules.',
'channel.area.form.placeholder.provinceOptional': 'Optional; leave blank for any state/province',
'channel.area.form.placeholder.cityOptional': 'Optional; leave blank for any city',
'channel.area.form.placeholder.leftPostCode': 'Optional; pair with end postcode',
'channel.area.form.placeholder.rightPostCode': 'Optional; must be greater than or equal to start',
'channel.area.form.placeholder.leftPostCode': 'Prefix, exact postcode, or range start',
'channel.area.form.placeholder.rightPostCode': 'Range end (inclusive)',
'channel.area.form.rules.maxLength50': 'Cannot exceed 50 characters',
'channel.area.form.rules.postcodeRangeInvalid': 'Start postcode cannot be greater than end postcode',
'channel.area.modal.deleteConfirmWithName': 'Delete area "{name}"? All details under it will be removed.',

View File

@ -313,6 +313,8 @@ export default {
'warehouse.parcel.drawer.frozenQty': '冻结',
'warehouse.parcel.drawer.itemCount': '共 {n} 项',
'warehouse.parcel.tip': '可用=可绑定发货;冻结=已被订单占用。点状态 Tab 快速筛选在途/到仓/已上架。',
'warehouse.parcel.pluginHint': '懒得填写包裹信息?那就使用一键添加包裹插件吧。',
'warehouse.parcel.pluginHintDownload': '去右上角下载插件',
'warehouse.parcel.drawer.edit': '编辑包裹',
'warehouse.parcel.drawer.empty': '暂无商品明细',
'warehouse.parcel.table.arrivalTime': '到仓时间',
@ -1222,42 +1224,48 @@ export default {
'nanaAssistant.tabAi': 'AI Nana',
'nanaAssistant.tabAdvisor': '专属顾问',
'nanaAssistant.badge': '物流顾问',
'nanaAssistant.welcomeTitle': '这次发货,需要什么帮助?',
'nanaAssistant.welcomeSub': '查价格、品名能否发、查轨迹 — 点选下方能力后补充信息即可。',
'nanaAssistant.welcomeTitle': '今天想查点什么?',
'nanaAssistant.welcomeHello': '{greet},小艾为您服务',
'nanaAssistant.welcomeSub': '今天想查点什么?点下面,或自己打字问我。',
'nanaAssistant.greet.morning': '早上好',
'nanaAssistant.greet.noon': '中午好',
'nanaAssistant.greet.afternoon': '下午好',
'nanaAssistant.greet.evening': '晚上好',
'nanaAssistant.greet.night': '夜深了',
'nanaAssistant.quickLabel': '常见问题',
'nanaAssistant.welcomeMsg': '你好,我是 IDE 物流顾问。可以点选下方问题,或直接输入。',
'nanaAssistant.welcomeMsg': '你好,我是 IDE 物流顾问。可以直接问,也可以点下面的入口。',
'nanaAssistant.historyTitle': '历史记录',
'nanaAssistant.newChat': '新会话',
'nanaAssistant.emptyHistory': '暂无会话',
'nanaAssistant.searchHistory': '搜索会话或运单号',
'nanaAssistant.noSearchResults': '未找到匹配的会话',
'nanaAssistant.deleteThread': '删除会话',
'nanaAssistant.hint': '直接提问,或点选上方能力。',
'nanaAssistant.hintLive': '已接入智能顾问,可查价格、对客说明、轨迹。',
'nanaAssistant.hint': '直接提问,或点上面的入口。',
'nanaAssistant.hintLive': '可以问运费、能不能寄、包裹到哪了。',
'nanaAssistant.inputPlaceholder': '输入您的问题…',
'nanaAssistant.inputPlaceholderLive': '',
'nanaAssistant.inputPlaceholderTrack': '',
'nanaAssistant.mcp.sectionLabel': '可查能力',
'nanaAssistant.mcp.exampleHint': '例:{sample}',
'nanaAssistant.mcp.required': '必填',
'nanaAssistant.mcp.optional': '选填',
'nanaAssistant.mcp.missingAsk': '还需要补充:{fields}。补全后我再帮你查。',
'nanaAssistant.mcp.priceTitle': '查价格',
'nanaAssistant.mcp.priceDesc': '按目的国与重量试算运费',
'nanaAssistant.mcp.priceTitle': '查运费',
'nanaAssistant.mcp.priceDesc': '按目的国和重量估算运费',
'nanaAssistant.mcp.priceCoach':
'查价格需要你提供:\n① 目的国家(如美国 / US)\n② 实重 kg\n\n选填:邮编、长宽高(cm)、货物属性(普货/敏感)。\n补全后发送即可,例如:美国 0.5kg 邮编10001',
'nanaAssistant.mcp.priceExample': '例:美国 0.5kg 邮编10001',
'查运费需要:目的国家和实重(kg)。有邮编、尺寸会更准。\n补全后发送即可,例如:美国 0.5kg',
'nanaAssistant.mcp.priceExample': '帮我查下美国0.5kg价格',
'nanaAssistant.mcp.priceDraft': '目的国:\n实重(kg):\n邮编(选填):\n尺寸长宽高cm(选填):',
'nanaAssistant.mcp.guideTitle': '品名能否发',
'nanaAssistant.mcp.guideDesc': '对照渠道对客说明判断可否承运',
'nanaAssistant.mcp.guideTitle': '这个货能不能寄',
'nanaAssistant.mcp.guideDesc': '看这个品名能不能走这条渠道',
'nanaAssistant.mcp.guideCoach':
'判断品名能否承运需要:\n① 销售产品代码或名称(运输方式)\n② 客户品名(英文/中文均可)\n\n例如:产品 EX_ASP_HKDHL,品名 lipstick,能不能发?',
'nanaAssistant.mcp.guideExample': '例:产品XX,品名 lipstick 能发吗',
'判断能不能寄需要:销售产品(运输方式)和品名。\n例如:产品 XX,品名 lipstick,能发吗?',
'nanaAssistant.mcp.guideExample': 'lipstick能寄吗',
'nanaAssistant.mcp.guideDraft': '销售产品代码/名称:\n客户品名:',
'nanaAssistant.mcp.trackTitle': '查轨迹',
'nanaAssistant.mcp.trackDesc': '按运单号查看物流节点',
'nanaAssistant.mcp.trackTitle': '包裹到哪了',
'nanaAssistant.mcp.trackDesc': '看包裹现在到哪了',
'nanaAssistant.mcp.trackCoach':
'查轨迹请提供运单号 / 跟踪号 / 客户单号(任选其一)。\n若已从订单带入运单,可直接点「查轨迹」。',
'nanaAssistant.mcp.trackExample': '例:请查运单 NFXXXX 的轨迹',
'查包裹位置请发运单号。如果上面已经带入运单,直接点「查这票轨迹」即可。',
'nanaAssistant.mcp.trackExample': '帮我查下运单NFXXXX到哪了',
'nanaAssistant.mcp.trackDraft': '运单号:',
'nanaAssistant.mcp.field.destCountry': '目的国家',
'nanaAssistant.mcp.field.weight': '实重(kg)',
@ -1310,6 +1318,10 @@ export default {
'nanaAssistant.viewWaybill': '查看运单 →',
'nanaAssistant.queryTrack': '查轨迹',
'nanaAssistant.endWaybillConsult': '结束运单咨询',
'nanaAssistant.waybillWelcomeTitle': '这票需要什么帮助?',
'nanaAssistant.waybillWelcomeSub': '已带入运单 {no},可以直接查轨迹或问费用。',
'nanaAssistant.waybillAskTrack': '查这票轨迹',
'nanaAssistant.waybillAskFee': '问这票费用',
'nanaAssistant.waybillAutoAsk': '请帮我查询运单 {no} 的物流轨迹',
'nanaAssistant.trackWaybillDemo': '已收到运单 {no} 的查询。若已配置智能服务,将优先走真实接口;当前为参考回复:一般可在揽收、出口、干线、清关、妥投等节点看到更新。如有异常,专属顾问会主动跟进。',
'nanaAssistant.copyWechat': '复制微信号',
@ -2141,7 +2153,9 @@ export default {
'order.priceCheck.form.warehouse': '仓库',
'order.priceCheck.form.warehouse.placeholder': '全部',
'order.priceCheck.form.destinationProvince': '目的州省',
'order.priceCheck.form.destinationProvince.placeholder': '请选择',
'order.priceCheck.form.destinationProvince.placeholder': '无邮编时按州匹配',
'order.priceCheck.form.destinationCity': '目的城市',
'order.priceCheck.form.destinationCity.placeholder': '无邮编时按城市匹配',
'order.priceCheck.form.postalCode': '邮编',
'order.priceCheck.form.postalCode.tip': '更准',
'order.priceCheck.form.postalCode.placeholder': '选填,补全更准确',
@ -2451,7 +2465,7 @@ export default {
'order.placeOrder.single.exportQualificationManage': '管理备案',
'order.placeOrder.single.exportQualificationLoadFailed': '加载出口资质备案失败',
'order.placeOrder.single.platformCode': '电商平台',
'order.placeOrder.single.platformCodePlaceholder': '如 Amazon / eBay / Shopify',
'order.placeOrder.single.platformCodePlaceholder': '请选择电商平台',
'order.placeOrder.single.showExtraCols': '更多字段',
'order.placeOrder.single.hideExtraCols': '收起',
'order.placeOrder.single.declarationPickSku': '从 SKU 选择',
@ -5367,6 +5381,15 @@ export default {
'channel.area.form.city': '城市',
'channel.area.form.leftPostCode': '起始邮编',
'channel.area.form.rightPostCode': '截止邮编',
'channel.area.form.matchDim': '匹配维度',
'channel.area.form.matchMode': '匹配方式',
'channel.area.matchDim.country': '仅国家',
'channel.area.matchDim.zip': '邮编',
'channel.area.matchDim.state': '州/省',
'channel.area.matchDim.city': '城市',
'channel.area.matchMode.prefix': '前缀',
'channel.area.matchMode.exact': '精确',
'channel.area.matchMode.range': '区间',
'channel.area.form.placeholder.keyword': '请输入关键字',
'channel.area.form.placeholder.areaName': '请输入分区名称',
'channel.area.form.placeholder.input': '请输入',
@ -5374,17 +5397,28 @@ export default {
'channel.area.form.rules.countryRequired': '请选择国家/地区',
'channel.area.form.rules.destCountryRequired': '请选择国家/地区',
'channel.area.section.match': '匹配范围',
'channel.area.section.postcode': '邮编区间',
'channel.area.tip.country': '必填;仅填国家表示匹配该国全部邮编',
'channel.area.tip.province': '选填,留空表示不限州/省',
'channel.area.tip.city': '选填,留空表示不限城市',
'channel.area.tip.leftPostCode': '与截止邮编成对填写;可只填一侧时另一侧必填',
'channel.area.tip.rightPostCode': '须大于等于起始邮编',
'channel.area.section.postcode': '邮编匹配',
'channel.area.tip.country': '必填;选择「仅国家」表示该国最低优先级兜底',
'channel.area.tip.province': '州/省或城市维度时必填',
'channel.area.tip.city': '城市维度时必填,且必须同时填写州/省',
'channel.area.tip.leftPostCode': '前缀填邮编前几位;精确填完整邮编;区间填起始邮编',
'channel.area.tip.rightPostCode': '区间截止邮编,闭区间(含起止)',
'channel.area.tip.matchDim': '一行只表达一个维度:邮编优先于州/省、城市;仅国家为最低兜底',
'channel.area.tip.matchMode': '前缀按左起位数匹配;精确全等;区间为闭区间',
'channel.area.form.rules.maxLength128': '限制不可超过128个字符',
'channel.area.form.rules.maxLength30': '限制不可超过30个字符',
'channel.area.form.rules.noChinese': '不能存在中文字符',
'channel.area.form.rules.noSpacesOnly': '起始邮编不可只输入空格',
'channel.area.form.rules.noSpacesOnlyEnd': '截止邮编不可只输入空格',
'channel.area.form.rules.matchDimRequired': '请选择匹配维度',
'channel.area.form.rules.matchModeRequired': '请选择匹配方式',
'channel.area.form.rules.provinceRequired': '请填写州/省',
'channel.area.form.rules.cityRequired': '请填写城市',
'channel.area.form.rules.cityRequiresState': '城市匹配必须同时填写州/省',
'channel.area.form.rules.postcodeRequired': '请填写邮编',
'channel.area.form.rules.postcodeRangeRequired': '区间匹配请填写截止邮编',
'channel.area.form.placeholder.provinceRequired': '请输入州/省',
'channel.area.form.placeholder.cityRequired': '请输入城市',
'channel.area.modal.addArea': '新增分区',
'channel.area.modal.editArea': '修改分区',
'channel.area.modal.addDetail': '新增分区明细',
@ -5405,7 +5439,7 @@ export default {
'channel.area.search.button.query': '查询',
'channel.area.search.button.reset': '重置',
'channel.area.import.modalTitle': '导入分区明细',
'channel.area.import.tip': '请先下载模板,按列填写国家/邮编等信息后上传 Excel(.xlsx/.xls)。国家代码必填;仅填国家代码时将自动匹配国家名称。',
'channel.area.import.tip': '请先下载模板。国家代码必填。一行一个维度:填邮编则为邮编规则(只填起始=前缀;起止相同=精确;起止不同=闭区间);不填邮编只填州=州匹配;州+城市=城市匹配;都空=仅国家兜底。',
'channel.area.import.downloadTemplate': '下载模板',
'channel.area.import.submit': '开始导入',
'channel.area.import.downloadSuccess': '模板下载成功',
@ -5428,12 +5462,12 @@ export default {
'channel.config.detailCount': '共 {count} 条明细',
'channel.area.tip.addArea': '分区用于归类管理明细。名称建议简洁明确,例如「美国西海岸」或「欧盟偏远」。',
'channel.area.tip.editArea': '修改分区名称不会影响已有明细,但请避免与现有分区重名造成混淆。',
'channel.area.tip.addDetail': '国家/地区必填;州/省、城市可选。填写邮编区间时,起始邮编不能大于截止邮编;仅填国家表示匹配该国全部邮编。',
'channel.area.tip.editDetail': '修改后将立即影响渠道匹配逻辑,请确认国家与邮编区间准确无误。',
'channel.area.tip.addDetail': '一行只选一个维度。有邮编时只按邮编匹配(前缀/精确/闭区间);无邮编才按州、再按城市;仅国家为最低兜底。',
'channel.area.tip.editDetail': '修改后立即影响查价/计费匹配,请确认维度与邮编规则。',
'channel.area.form.placeholder.provinceOptional': '选填,留空表示不限州/省',
'channel.area.form.placeholder.cityOptional': '选填,留空表示不限城市',
'channel.area.form.placeholder.leftPostCode': '选填,与截止邮编成对填写',
'channel.area.form.placeholder.rightPostCode': '选填,需大于等于起始邮编',
'channel.area.form.placeholder.leftPostCode': '前缀/精确邮编,或区间起始',
'channel.area.form.placeholder.rightPostCode': '区间截止(含)',
'channel.area.form.rules.maxLength50': '限制不可超过50个字符',
'channel.area.form.rules.postcodeRangeInvalid': '起始邮编不能大于截止邮编',
'channel.area.modal.deleteConfirmWithName': '确定删除分区「{name}」吗?其下明细将一并删除。',

View File

@ -43,6 +43,74 @@ function trimStr(v: unknown): string {
return String(v ?? '').trim();
}
function inferGeoMatch(data: Record<string, any>) {
const dim = String(data.matchDim || '').trim();
const mode = String(data.matchMode || '').trim();
if (dim) {
return {
matchDim: dim,
matchMode: mode || (dim === 'zip' ? 'range' : dim === 'country' ? 'all' : 'exact'),
};
}
const left = trimStr(data.leftPostCode || data.leftPostcode);
const right = trimStr(data.rightPostCode || data.rightPostcode);
if (left || right) {
let matchMode = 'range';
if (left && !right) matchMode = 'prefix';
else if (left && right && left === right) matchMode = 'exact';
return { matchDim: 'zip', matchMode };
}
if (trimStr(data.city)) return { matchDim: 'city', matchMode: 'exact' };
if (trimStr(data.province)) return { matchDim: 'state', matchMode: 'exact' };
return { matchDim: 'country', matchMode: 'all' };
}
function applyMatchDimension(data: Record<string, any>) {
const rest = normalizePostcodeFields({ ...data });
const { matchDim, matchMode } = inferGeoMatch(rest);
rest.matchDim = matchDim;
rest.matchMode = matchMode;
if (matchDim === 'country') {
rest.province = undefined;
rest.city = undefined;
rest.leftPostCode = undefined;
rest.rightPostCode = undefined;
rest.leftPostcode = undefined;
rest.rightPostcode = undefined;
} else if (matchDim === 'state') {
rest.city = undefined;
rest.leftPostCode = undefined;
rest.rightPostCode = undefined;
rest.leftPostcode = undefined;
rest.rightPostcode = undefined;
} else if (matchDim === 'city') {
rest.leftPostCode = undefined;
rest.rightPostCode = undefined;
rest.leftPostcode = undefined;
rest.rightPostcode = undefined;
} else if (matchDim === 'zip') {
rest.province = undefined;
rest.city = undefined;
const left = trimStr(rest.leftPostCode);
if (matchMode === 'prefix') {
rest.rightPostCode = undefined;
rest.rightPostcode = undefined;
rest.leftPostCode = left || undefined;
rest.leftPostcode = left || undefined;
} else if (matchMode === 'exact') {
rest.leftPostCode = left || undefined;
rest.leftPostcode = left || undefined;
rest.rightPostCode = left || undefined;
rest.rightPostcode = left || undefined;
} else {
rest.leftPostcode = rest.leftPostCode;
rest.rightPostcode = rest.rightPostCode;
}
}
return rest;
}
function normalizePostcodeFields(data: Record<string, any>) {
const leftPostCode = trimStr(data.leftPostCode);
const rightPostCode = trimStr(data.rightPostCode);
@ -91,20 +159,48 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
}
function beforeSubmitRightAreaInfo(data: Record<string, any>) {
const rest = normalizePostcodeFields({ ...data });
const rest = applyMatchDimension({ ...data });
delete rest._countryQuickTags;
delete rest.sectionMatch;
delete rest.sectionPostcode;
const left = rest.leftPostCode;
const right = rest.rightPostCode;
if (isPostcodeRangeInvalid(left, right)) {
$baseMessage(t('channel.area.form.rules.postcodeRangeInvalid'), 'warning');
throw new Error('postcode-range-invalid');
const { matchDim, matchMode } = inferGeoMatch(rest);
if (matchDim === 'state' && !trimStr(rest.province)) {
$baseMessage(t('channel.area.form.rules.provinceRequired'), 'warning');
throw new Error('province-required');
}
if (matchDim === 'city') {
if (!trimStr(rest.province) || !trimStr(rest.city)) {
$baseMessage(t('channel.area.form.rules.cityRequiresState'), 'warning');
throw new Error('city-requires-state');
}
}
if (matchDim === 'zip') {
const left = trimStr(rest.leftPostCode);
const right = trimStr(rest.rightPostCode);
if (!left) {
$baseMessage(t('channel.area.form.rules.postcodeRequired'), 'warning');
throw new Error('postcode-required');
}
if (matchMode === 'range') {
if (!right) {
$baseMessage(t('channel.area.form.rules.postcodeRangeRequired'), 'warning');
throw new Error('postcode-range-required');
}
if (isPostcodeRangeInvalid(left, right)) {
$baseMessage(t('channel.area.form.rules.postcodeRangeInvalid'), 'warning');
throw new Error('postcode-range-invalid');
}
}
}
const code = rest?.countryCode;
const hit = countryRows.value.find((r) => r.countryCode === code);
return {
...rest,
matchDim,
matchMode,
prefixLen: matchDim === 'zip' && matchMode === 'prefix' ? trimStr(rest.leftPostCode).length : rest.prefixLen,
countryName: hit?.chineseName ?? rest?.countryName ?? '',
};
}
@ -219,17 +315,60 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
label: '',
span: 24,
},
{
label: t('channel.area.form.matchDim'),
field: 'matchDim',
comp: 'a-select',
tooltip: t('channel.area.tip.matchDim'),
compProps: {
options: [
{ label: t('channel.area.matchDim.country'), value: 'country' },
{ label: t('channel.area.matchDim.zip'), value: 'zip' },
{ label: t('channel.area.matchDim.state'), value: 'state' },
{ label: t('channel.area.matchDim.city'), value: 'city' },
],
placeholder: t('common.selectPlaceholder'),
allowClear: false,
},
rules: [{ required: true, message: t('channel.area.form.rules.matchDimRequired') }],
span: 12,
},
{
label: t('channel.area.form.matchMode'),
field: 'matchMode',
comp: 'a-select',
tooltip: t('channel.area.tip.matchMode'),
ifShow: (model: Record<string, any>) => model.matchDim === 'zip',
compProps: {
options: [
{ label: t('channel.area.matchMode.prefix'), value: 'prefix' },
{ label: t('channel.area.matchMode.exact'), value: 'exact' },
{ label: t('channel.area.matchMode.range'), value: 'range' },
],
placeholder: t('common.selectPlaceholder'),
allowClear: false,
},
rules: [{ required: true, message: t('channel.area.form.rules.matchModeRequired') }],
span: 12,
},
{
label: t('channel.area.form.province'),
field: 'province',
comp: 'a-input',
tooltip: t('channel.area.tip.province'),
ifShow: (model: Record<string, any>) =>
model.matchDim === 'state' || model.matchDim === 'city',
compProps: {
placeholder: t('channel.area.form.placeholder.provinceOptional'),
placeholder: t('channel.area.form.placeholder.provinceRequired'),
clearable: true,
allowClear: true,
},
rules: [
{
required: true,
message: t('channel.area.form.rules.provinceRequired'),
trigger: 'change',
},
{
max: 128,
message: t('channel.area.form.rules.maxLength128'),
@ -243,12 +382,18 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
field: 'city',
comp: 'a-input',
tooltip: t('channel.area.tip.city'),
ifShow: (model: Record<string, any>) => model.matchDim === 'city',
compProps: {
placeholder: t('channel.area.form.placeholder.cityOptional'),
placeholder: t('channel.area.form.placeholder.cityRequired'),
clearable: true,
allowClear: true,
},
rules: [
{
required: true,
message: t('channel.area.form.rules.cityRequired'),
trigger: 'change',
},
{
max: 128,
message: t('channel.area.form.rules.maxLength128'),
@ -264,12 +409,14 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
hideColon: true,
comp: 'slot',
span: 24,
ifShow: (model: Record<string, any>) => model.matchDim === 'zip',
},
{
label: t('channel.area.form.leftPostCode'),
field: 'leftPostCode',
comp: 'a-input',
tooltip: t('channel.area.tip.leftPostCode'),
ifShow: (model: Record<string, any>) => model.matchDim === 'zip',
compProps: {
placeholder: t('channel.area.form.placeholder.leftPostCode'),
clearable: true,
@ -277,21 +424,11 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
},
dynamicRules: ({ model }: { model: Record<string, any> }) => [
{
required: !!model.rightPostCode,
required: model.matchDim === 'zip',
pattern: /^((?![\u4E00-\u9FA5]).)*$/,
message: t('channel.area.form.rules.noChinese'),
trigger: 'change',
},
{
validator: (_rule: any, value: any, callback: any) => {
if (value && !String(value).trim()) {
callback(new Error(t('channel.area.form.rules.noSpacesOnly')));
} else {
callback();
}
},
trigger: 'change',
},
{
max: 30,
message: t('channel.area.form.rules.maxLength30'),
@ -305,6 +442,8 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
field: 'rightPostCode',
comp: 'a-input',
tooltip: t('channel.area.tip.rightPostCode'),
ifShow: (model: Record<string, any>) =>
model.matchDim === 'zip' && model.matchMode === 'range',
compProps: {
placeholder: t('channel.area.form.placeholder.rightPostCode'),
clearable: true,
@ -312,16 +451,15 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
},
dynamicRules: ({ model }: { model: Record<string, any> }) => [
{
required: !!model.leftPostCode,
required: model.matchDim === 'zip' && model.matchMode === 'range',
pattern: /^((?![\u4E00-\u9FA5]).)*$/,
message: t('channel.area.form.rules.noChinese'),
trigger: 'change',
},
{
validator: (_rule: any, value: any, callback: any) => {
if (value && !String(value).trim()) {
callback(new Error(t('channel.area.form.rules.noSpacesOnlyEnd')));
} else if (
if (
model.matchMode === 'range' &&
model.leftPostCode &&
value &&
isPostcodeRangeInvalid(String(model.leftPostCode).trim(), String(value).trim())
@ -415,6 +553,8 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
const hotColumns = computed<ChannelHotColumn[]>(() => [
{ key: 'countryName', label: t('channel.area.form.country'), width: 120 },
{ key: 'matchDim', label: t('channel.area.form.matchDim'), width: 100 },
{ key: 'matchMode', label: t('channel.area.form.matchMode'), width: 100 },
{ key: 'province', label: t('channel.area.form.province'), width: 120 },
{ key: 'city', label: t('channel.area.form.city'), width: 120 },
{ key: 'leftPostcode', label: t('channel.area.table.leftPostcode'), width: 120 },
@ -505,6 +645,11 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
(r) => r.chineseName === row.countryName || r.countryCode === row.countryName
)?.countryCode ||
'',
...inferGeoMatch({
...row,
leftPostCode: row.leftPostcode ?? row.leftPostCode,
rightPostCode: row.rightPostcode ?? row.rightPostCode,
}),
});
return;
}
@ -513,6 +658,8 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
title: titleObj[type],
formData: {
areaId: currentId.value?.id,
matchDim: 'zip',
matchMode: 'range',
},
});
}

View File

@ -23,7 +23,11 @@
<template v-else>
<div class="route-row">
<button type="button" class="route-cell" @click="openPicker('warehouse')">
<button
type="button"
class="route-cell route-cell--origin"
@click="openPicker('warehouse')"
>
<span class="route-cell__label">{{ t('h5.price.origin') }}</span>
<span class="route-cell__value">
<span>{{ warehouseLabel || t('h5.price.originAny') }}</span>
@ -32,28 +36,14 @@
</button>
<div class="route-mid" aria-hidden="true">
<span class="route-mid__icon">
<svg
class="route-mid__arrow"
viewBox="0 0 40 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 10h24"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-dasharray="2.5 3.5"
opacity="0.55"
/>
<path
d="M26 4.2 36 10l-10 5.8V4.2Z"
fill="currentColor"
/>
</svg>
<IconRoutePackage class="route-mid__package" />
</span>
</div>
<button type="button" class="route-cell" @click="openPicker('country')">
<button
type="button"
class="route-cell route-cell--dest"
@click="openPicker('country')"
>
<span class="route-cell__label">{{ t('h5.price.destination') }}</span>
<span class="route-cell__value" :class="{ 'is-placeholder': !destinationCode }">
<span class="route-cell__dest">
@ -79,6 +69,24 @@
autocomplete="postal-code"
/>
</div>
<div class="line-field">
<span class="line-field__label">{{ t('order.priceCheck.form.destinationProvince') }}</span>
<input
v-model="form.destinationProvince"
class="line-field__input"
type="text"
:placeholder="t('order.priceCheck.form.destinationProvince.placeholder')"
/>
</div>
<div class="line-field">
<span class="line-field__label">{{ t('order.priceCheck.form.destinationCity') }}</span>
<input
v-model="form.destinationCity"
class="line-field__input"
type="text"
:placeholder="t('order.priceCheck.form.destinationCity.placeholder')"
/>
</div>
<div class="pkg-row">
<label class="pkg-cell">
@ -518,6 +526,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconDown } from '@arco-design/web-vue/es/icon';
import IconRoutePackage from '@/assets/icons/h5/route-package.svg';
import type { CustomerProfileCompleteness } from '@/api/customer';
import { postOrderPriceCheck, type PriceCheckQueryHint } from '@/api/order';
import { getCountryList } from '@/api/system/country';
@ -556,6 +565,8 @@ const form = reactive({
destinationCountry: '',
cargoWeight: '1',
postalCode: '',
destinationProvince: '',
destinationCity: '',
cargoType: '',
warehouseId: undefined as number | undefined,
length: '',
@ -1007,7 +1018,8 @@ async function handleQuery() {
...(length != null && width != null && height != null
? { length: String(length), width: String(width), height: String(height) }
: {}),
destinationProvince: '',
destinationProvince: form.destinationProvince?.trim() || '',
destinationCity: form.destinationCity?.trim() || '',
postalCode: form.postalCode.trim() || '',
cargoTypes: form.cargoType ? [form.cargoType] : undefined,
warehouseIds: form.warehouseId ? [form.warehouseId] : undefined,
@ -1316,10 +1328,10 @@ onMounted(() => {
}
.route-row {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 56px minmax(0, 1fr);
grid-template-columns: 1fr 1fr;
align-items: center;
column-gap: 2px;
padding-bottom: 12px;
border-bottom: 1px solid #eef0f3;
}
@ -1327,41 +1339,77 @@ onMounted(() => {
.route-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
gap: 5px;
min-width: 0;
padding: 2px 4px;
border: none;
background: transparent;
text-align: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:active .route-cell__value {
opacity: 0.72;
}
/* 发货仓:贴左边 */
&--origin {
grid-column: 1;
grid-row: 1;
align-items: flex-start;
text-align: left;
padding: 6px 56px 6px 0;
.route-cell__label {
text-align: left;
font-size: 11px;
}
.route-cell__value {
justify-content: flex-start;
font-size: 15px;
font-weight: 600;
color: #4e5969;
min-height: 24px;
}
}
/* 目的地:贴右边,略加重 */
&--dest {
grid-column: 2;
grid-row: 1;
align-items: flex-end;
text-align: right;
padding: 6px 0 6px 56px;
.route-cell__label {
text-align: right;
font-size: 12px;
font-weight: 500;
color: #4e5969;
}
.route-cell__value {
justify-content: flex-end;
font-size: 18px;
font-weight: 700;
color: #1d2129;
min-height: 26px;
}
}
}
.route-cell__label {
font-size: 12px;
color: #86909c;
line-height: 1.2;
width: 100%;
text-align: center;
line-height: 1.2;
color: #86909c;
}
.route-cell__value {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
max-width: 100%;
font-size: 18px;
font-weight: 700;
color: #1d2129;
line-height: 1.25;
min-height: 26px;
> span {
min-width: 0;
@ -1371,7 +1419,7 @@ onMounted(() => {
}
&.is-placeholder > span {
color: #c9cdd4;
color: color-mix(in srgb, var(--app-primary-color) 55%, #86909c);
font-weight: 600;
font-size: 16px;
}
@ -1386,7 +1434,7 @@ onMounted(() => {
.route-cell__dest {
display: inline-flex;
align-items: baseline;
justify-content: center;
justify-content: flex-end;
gap: 5px;
min-width: 0;
max-width: 100%;
@ -1415,23 +1463,27 @@ onMounted(() => {
}
.route-mid {
grid-column: 1 / -1;
grid-row: 1;
justify-self: center;
align-self: center;
display: flex;
align-items: center;
justify-content: center;
align-self: center;
min-width: 56px;
pointer-events: none;
z-index: 1;
}
.route-mid__icon {
display: inline-flex;
display: flex;
align-items: center;
justify-content: center;
color: var(--app-primary-color);
}
.route-mid__arrow {
width: 40px;
height: 20px;
.route-mid__package {
width: 48px;
height: 36px;
display: block;
}

View File

@ -44,6 +44,8 @@
/** 仓库(单选;空表示全部) */
warehouseId: undefined as number | undefined,
postalCode: '',
destinationProvince: '',
destinationCity: '',
});
/** 是否已发起过查询(用于空态 / 结果区切换) */
@ -576,7 +578,8 @@
: {}),
cargoTypes: formData.cargoType ? [formData.cargoType] : undefined,
warehouseIds: formData.warehouseId != null ? [formData.warehouseId] : undefined,
destinationProvince: '',
destinationProvince: formData.destinationProvince?.trim() || '',
destinationCity: formData.destinationCity?.trim() || '',
postalCode: formData.postalCode?.trim() || '',
couponInstanceId: couponInstanceId.value,
});
@ -617,6 +620,8 @@
cargoType: '',
warehouseId: undefined as number | undefined,
postalCode: '',
destinationProvince: '',
destinationCity: '',
});
applyDefaultFilters();
hasQueried.value = false;
@ -668,7 +673,8 @@
: {}),
cargoTypes: [payload.cargoType],
warehouseIds: [payload.warehouseId],
destinationProvince: '',
destinationProvince: formData.destinationProvince?.trim() || '',
destinationCity: formData.destinationCity?.trim() || '',
postalCode: formData.postalCode?.trim() || '',
couponInstanceId: couponInstanceId.value,
});
@ -1014,6 +1020,26 @@
/>
</div>
<div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.destinationProvince') }}</label>
<a-input
v-model="formData.destinationProvince"
:placeholder="$t('order.priceCheck.form.destinationProvince.placeholder')"
class="form-input"
@press-enter="handleQuery"
/>
</div>
<div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.destinationCity') }}</label>
<a-input
v-model="formData.destinationCity"
:placeholder="$t('order.priceCheck.form.destinationCity.placeholder')"
class="form-input"
@press-enter="handleQuery"
/>
</div>
<div class="form-item button-group">
<label class="form-label form-label-spacer" aria-hidden="true">&nbsp;</label>
<div class="button-group-row">