diff --git a/src/api/order/index.ts b/src/api/order/index.ts index d738ad7..48cc190 100644 --- a/src/api/order/index.ts +++ b/src/api/order/index.ts @@ -257,6 +257,8 @@ export interface OrderDownstreamForwardResult { requestJson?: string statusCode?: number responseBody?: string + downstreamServiceNo?: string + trackNo?: string } /** POST /order/downstream-forward-create-one → 后端查订单表组装并转发 createOrderOne */ @@ -273,6 +275,8 @@ export async function postOrderDownstreamForwardCreateOne( requestJson: d.requestJson ?? d.RequestJson, statusCode: d.statusCode ?? d.StatusCode, responseBody: d.responseBody ?? d.ResponseBody, + downstreamServiceNo: d.downstreamServiceNo ?? d.DownstreamServiceNo, + trackNo: d.trackNo ?? d.TrackNo, } } @@ -518,6 +522,21 @@ export type ParsedBatchTrackUpsertPayload = { responseBody?: unknown responseBodyRaw?: string } + downstreamForward?: ParsedBatchDownstreamForwardDetail +} + +export type ParsedBatchDownstreamForwardDetail = { + supplierCode?: string + requestUrl?: string + requestBody?: unknown + requestBodyRaw?: string + responseStatusCode?: number + responseBody?: unknown + responseBodyRaw?: string + businessCode?: string + businessMessage?: string + downstreamServiceNo?: string + trackNo?: string } export function parseBatchJobTrackUpsertPayload(item: { resultJson?: string }): ParsedBatchTrackUpsertPayload | null { @@ -539,6 +558,95 @@ function stringifyJsonBlock(value: unknown): string { } } +function formatDownstreamForwardDetailLines(forward: ParsedBatchDownstreamForwardDetail): string[] { + const lines: string[] = [] + if (forward.supplierCode) lines.push(`supplierCode:${forward.supplierCode}`) + if (forward.downstreamServiceNo) lines.push(`服务商单号:${forward.downstreamServiceNo}`) + if (forward.trackNo) lines.push(`跟踪号:${forward.trackNo}`) + if (forward.requestUrl) lines.push(`请求:${forward.requestUrl}`) + const requestBody = stringifyJsonBlock(forward.requestBody) || forward.requestBodyRaw || '' + if (requestBody) lines.push(`请求体:\n${requestBody}`) + if (forward.responseStatusCode != null) lines.push(`HTTP:${forward.responseStatusCode}`) + if (forward.businessCode) lines.push(`业务码:${forward.businessCode}`) + if (forward.businessMessage) lines.push(`业务消息:${forward.businessMessage}`) + const responseBody = stringifyJsonBlock(forward.responseBody) || forward.responseBodyRaw || '' + if (responseBody) lines.push(`响应:\n${responseBody}`) + return lines +} + +/** 单条批量下游预报的 resultJson 展示文本 */ +export function formatBatchJobDownstreamForwardItemText(label: string, item: ParsedOrderBatchJobItem): string { + const parsed = parseBatchJobTrackUpsertPayload(item) + const lines = [`【${label}】`] + if (item.status === 'failed') { + lines.push(`失败:${item.failReason || parsed?.message || '未知原因'}`) + } else if (item.status === 'success') { + lines.push(parsed?.message || item.resultMessage || '成功') + } else { + lines.push(item.resultMessage || item.status || '处理中') + } + + const forward = parsed?.downstreamForward + if (forward) { + lines.push(...formatDownstreamForwardDetailLines(forward)) + return lines.join('\n') + } + + if (parsed) { + lines.push(stringifyJsonBlock(parsed)) + } else if (item.resultJson) { + lines.push(item.resultJson) + } + return lines.join('\n') +} + +/** 批量预报下游完成后的汇总展示(含请求/响应报文) */ +export function formatBatchJobDownstreamForwardResultText( + title: string, + job: { jobId: number; status: string; total: number; successCount: number; failCount: number }, + items: ParsedOrderBatchJobItem[], + rows: any[], + resolveLabel?: (row: any, item: ParsedOrderBatchJobItem) => string, +): string { + const resolve = resolveLabel ?? ((row, item) => resolveBatchJobItemRowLabel(row, item)) + const lines = [ + `【${title}】任务 #${job.jobId}`, + `状态:${job.status},合计 ${job.total},成功 ${job.successCount},失败 ${job.failCount}`, + '', + ] + for (const item of items) { + const row = rows.find((r) => { + const rowId = Number(r?.id ?? r?.orderId ?? r?.Id ?? r?.OrderId ?? 0) + return rowId === item.orderId + }) + lines.push(formatBatchJobDownstreamForwardItemText(resolve(row, item), item)) + lines.push('') + } + return lines.join('\n').trimEnd() +} + +export function getBatchJobDownstreamForwardResultSummary(item: ParsedOrderBatchJobItem): string { + const parsed = parseBatchJobTrackUpsertPayload(item) + const forward = parsed?.downstreamForward + if (!forward) { + return item.resultMessage || parsed?.message || item.failReason || '' + } + const code = forward.businessCode + ? `code=${forward.businessCode}` + : forward.responseStatusCode != null + ? `HTTP ${forward.responseStatusCode}` + : '' + const msg = forward.businessMessage || '' + const body = stringifyJsonBlock(forward.responseBody) || forward.responseBodyRaw || '' + const preview = (msg || body).replace(/\s+/g, ' ').slice(0, 120) + return [code, preview].filter(Boolean).join(' · ') || item.resultMessage || item.failReason || '' +} + +export function formatBatchJobDownstreamForwardDetailText(item: ParsedOrderBatchJobItem): string { + const label = getBatchJobItemDisplayNo(item) + return formatBatchJobDownstreamForwardItemText(label, item) +} + /** 单条批量轨迹任务的 resultJson 展示文本 */ export function formatBatchJobTrackUpsertItemText(label: string, item: ParsedOrderBatchJobItem): string { const parsed = parseBatchJobTrackUpsertPayload(item) diff --git a/src/assets/tg.jpg b/src/assets/tg.jpg new file mode 100644 index 0000000..eab7d3b Binary files /dev/null and b/src/assets/tg.jpg differ diff --git a/src/components.d.ts b/src/components.d.ts index af40923..2851f29 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -28,6 +28,7 @@ declare module 'vue' { GlobalSetting: typeof import('./components/global-setting/index.vue')['default'] HandsontablePro: typeof import('./components/HandsontablePro/index.vue')['default'] InterfaceDetail: typeof import('./components/InterfaceDetail/index.vue')['default'] + InvitePoster: typeof import('./components/InvitePoster.vue')['default'] InviteQrImage: typeof import('./components/InviteQrImage.vue')['default'] JsonEditorPro: typeof import('./components/JsonEditorPro/index.vue')['default'] MarkdownDocPanel: typeof import('./components/MarkdownDocPanel/index.vue')['default'] diff --git a/src/components/InvitePoster.vue b/src/components/InvitePoster.vue new file mode 100644 index 0000000..695d5f5 --- /dev/null +++ b/src/components/InvitePoster.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 74710fd..4844d37 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -1015,7 +1015,7 @@ export default { 'order.myOrders.actions.pay': 'Pay Now', 'order.myOrders.actions.predeclare': 'Re-predeclare', 'order.myOrders.actions.createTrackOrder': 'Create track order', - 'order.myOrders.actions.forwardDownstreamCreate': 'Get Tracking No.', + 'order.myOrders.actions.forwardDownstreamCreate': 'Forward Downstream', 'order.myOrders.modal.downstreamForwardTitle': 'Downstream forward result', 'order.myOrders.modal.payTitle': 'Payment confirmation', 'order.myOrders.modal.payOrderTotal': 'Amount due', @@ -1231,6 +1231,7 @@ export default { 'order.myOrders.table.waybillNo': 'Waybill No.', 'order.myOrders.table.customerOrderNo': 'Customer Order No.', 'order.myOrders.table.trackingNo': 'Tracking No.', + 'order.myOrders.table.downstreamServiceNo': 'Provider Service No.', 'order.myOrders.table.latestTrack': 'Latest track', 'order.myOrders.table.copyHint': 'Tracking No.', 'order.myOrders.table.customerName': 'Customer Name', @@ -1298,6 +1299,7 @@ export default { 'order.myOrders.drawer.serviceNo': 'Waybill No.', 'order.myOrders.drawer.clientNo': 'Client Order No.', 'order.myOrders.drawer.trackNo': 'Tracking No.', + 'order.myOrders.drawer.downstreamServiceNo': 'Provider Service No.', 'order.myOrders.drawer.productCode': 'Product Code', 'order.myOrders.drawer.productName': 'Sales Product', 'order.myOrders.drawer.channelCode': 'Channel Code', @@ -1672,6 +1674,8 @@ export default { 'order.placeOrder.single.senderPreviewTitle': 'Current sender', 'order.placeOrder.single.senderDefaultTag': '(default)', 'order.placeOrder.single.senderEmpty': 'No sender address yet. Add one in Address Management first.', + 'order.placeOrder.single.senderRequired': 'Please select a sender address', + 'order.placeOrder.single.senderPickHint': 'Select sender address first', 'order.placeOrder.single.senderClear': 'Clear sender', 'order.placeOrder.single.senderManage': 'Manage addresses', 'order.placeOrder.single.senderEditHint': 'Click to change sender', @@ -1684,6 +1688,9 @@ export default { 'order.placeOrder.single.tip2': 'Use “Generate sample” to try the full flow quickly.', 'order.placeOrder.single.submitHint': 'Complete required fields and accept the agreement to submit', 'order.placeOrder.single.submitReady': 'Ready to submit', + 'order.placeOrder.contact.blockTitle': 'Complete your contact info', + 'order.placeOrder.contact.blockDesc': 'Add phone and email to continue placing orders', + 'order.placeOrder.contact.blockAction': 'Complete now', 'order.placeOrder.single.viewCustomerGuide': 'View customer guide', 'order.placeOrder.single.declaredCurrencyHint': 'Default is USD; enter declared prices in that currency', 'order.placeOrder.single.exportCustomsPlaceholder': 'Optional (defaults to simplified declaration)', diff --git a/src/locale/marketing/en-US.ts b/src/locale/marketing/en-US.ts index 42eea4b..108cf86 100644 --- a/src/locale/marketing/en-US.ts +++ b/src/locale/marketing/en-US.ts @@ -280,7 +280,7 @@ export default { 'marketing.portal.invite.howTitle': '3 quick steps', 'marketing.portal.invite.howHint': 'Share in about a minute', 'marketing.portal.invite.how1Title': 'Copy your invite', - 'marketing.portal.invite.how1Desc': 'Copy the code/link, or let friends scan the QR code', + 'marketing.portal.invite.how1Desc': 'Copy the code/link, or save the poster on the right and share it', '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', @@ -296,6 +296,7 @@ export default { 'marketing.portal.invite.copySuccess': 'Copied. Ready to share!', 'marketing.portal.invite.qrAlt': 'Invite QR code', 'marketing.portal.invite.qrTip': 'Scan in WeChat to register with referral binding', + 'marketing.portal.invite.posterAlt': 'Invite poster', 'marketing.portal.invite.step1': 'Copy the link or code and send it to friends', 'marketing.portal.invite.step2': 'Friends register through your link', 'marketing.portal.invite.step3': 'After their order is delivered, you earn commission', diff --git a/src/locale/marketing/zh-CN.ts b/src/locale/marketing/zh-CN.ts index 4f5f63c..8947a89 100644 --- a/src/locale/marketing/zh-CN.ts +++ b/src/locale/marketing/zh-CN.ts @@ -280,7 +280,7 @@ export default { 'marketing.portal.invite.howTitle': '三步上手', 'marketing.portal.invite.howHint': '大约 1 分钟完成分享', 'marketing.portal.invite.how1Title': '复制邀请信息', - 'marketing.portal.invite.how1Desc': '复制邀请码、链接,或让好友扫右侧二维码', + 'marketing.portal.invite.how1Desc': '复制邀请码、链接,或保存右侧海报发给好友', 'marketing.portal.invite.how2Title': '好友完成注册', 'marketing.portal.invite.how2Desc': '好友在微信内打开链接,完成公众号授权注册后自动绑定引荐关系', 'marketing.portal.invite.how3Title': '坐等收益到账', @@ -295,6 +295,7 @@ export default { 'marketing.portal.invite.copySuccess': '已复制,快去分享吧', 'marketing.portal.invite.qrAlt': '邀请二维码', 'marketing.portal.invite.qrTip': '微信扫码注册,自动绑定引荐关系', + 'marketing.portal.invite.posterAlt': '邀请推广海报', 'marketing.portal.invite.step1': '复制链接或邀请码,发给好友', 'marketing.portal.invite.step2': '好友通过链接完成注册', 'marketing.portal.invite.step3': '好友下单并派送成功后,您可获得分成', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 89e47bf..0041eca 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -902,7 +902,7 @@ export default { 'order.myOrders.actions.pay': '立即支付', 'order.myOrders.actions.predeclare': '重新预报', 'order.myOrders.actions.createTrackOrder': '创建轨迹订单', - 'order.myOrders.actions.forwardDownstreamCreate': '获取跟踪号', + 'order.myOrders.actions.forwardDownstreamCreate': '预报下游', 'order.myOrders.modal.downstreamForwardTitle': '下游预报结果', 'order.myOrders.modal.payTitle': '支付确认', 'order.myOrders.modal.payOrderTotal': '应付合计', @@ -1119,6 +1119,7 @@ export default { 'order.myOrders.table.waybillNo': '运单号', 'order.myOrders.table.customerOrderNo': '客户单号', 'order.myOrders.table.trackingNo': '跟踪号', + 'order.myOrders.table.downstreamServiceNo': '服务商单号', 'order.myOrders.table.latestTrack': '最新轨迹', 'order.myOrders.table.copyHint': '跟踪号', 'order.myOrders.table.customerName': '客户名称', @@ -1186,6 +1187,7 @@ export default { 'order.myOrders.drawer.serviceNo': '运单号', 'order.myOrders.drawer.clientNo': '客户单号', 'order.myOrders.drawer.trackNo': '跟踪号', + 'order.myOrders.drawer.downstreamServiceNo': '服务商单号', 'order.myOrders.drawer.productCode': '产品code', 'order.myOrders.drawer.productName': '销售产品', 'order.myOrders.drawer.channelCode': '渠道code', @@ -1559,6 +1561,8 @@ export default { 'order.placeOrder.single.senderPreviewTitle': '当前发件信息', 'order.placeOrder.single.senderDefaultTag': '(默认)', 'order.placeOrder.single.senderEmpty': '暂无发件人地址,请先在地址管理中新增', + 'order.placeOrder.single.senderRequired': '请选择发件人地址', + 'order.placeOrder.single.senderPickHint': '请先选择发件地址', 'order.placeOrder.single.senderClear': '清空发件人', 'order.placeOrder.single.senderManage': '去地址管理', 'order.placeOrder.single.senderEditHint': '点击可更换发件人', @@ -1571,6 +1575,9 @@ export default { 'order.placeOrder.single.tip2': '可先点击「生成示例订单」快速体验完整流程。', 'order.placeOrder.single.submitHint': '请完善必填信息并勾选协议后提交', 'order.placeOrder.single.submitReady': '信息已完善,可提交下单', + 'order.placeOrder.contact.blockTitle': '请先完善联系方式', + 'order.placeOrder.contact.blockDesc': '补全手机号和邮箱后,即可继续下单', + 'order.placeOrder.contact.blockAction': '去完善', 'order.placeOrder.single.viewCustomerGuide': '查看对客说明', 'order.placeOrder.single.declaredCurrencyHint': '默认美元,请按币种填写申报价格', 'order.placeOrder.single.exportCustomsPlaceholder': '请选择(可不选,默认简易申报)', diff --git a/src/utils/invitePoster.ts b/src/utils/invitePoster.ts new file mode 100644 index 0000000..d289c1f --- /dev/null +++ b/src/utils/invitePoster.ts @@ -0,0 +1,137 @@ +import QRCode from 'qrcode'; +import posterBgUrl from '@/assets/tg.jpg'; + +/** 海报原图像素尺寸(tg.jpg) */ +export const INVITE_POSTER_WIDTH = 1080; +export const INVITE_POSTER_HEIGHT = 1440; + +/** + * 二维码放在下方白色空白区居中,下方留出话术空间。 + * 空白区约 y=880~1360,水平中心 540。 + */ +const QR_SIZE = 340; +const QR_CENTER_X = INVITE_POSTER_WIDTH / 2; +const QR_CENTER_Y = 1120; +const QR_PAD = 12; + +export interface InvitePosterOptions { + inviteUrl: string; + inviteCode?: string; + /** 覆盖默认话术行(居中绘制在二维码下方) */ + captionLines?: string[]; +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.decoding = 'async'; + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('海报背景加载失败')); + img.src = src; + }); +} + +function defaultCaptionLines(inviteCode?: string): string[] { + void inviteCode; + return ['微信扫码注册,双方有礼']; +} + +function drawCenteredText( + ctx: CanvasRenderingContext2D, + text: string, + centerX: number, + y: number, + options: { font: string; fillStyle: string; maxWidth: number }, +) { + ctx.font = options.font; + ctx.fillStyle = options.fillStyle; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, centerX, y, options.maxWidth); +} + +/** + * 将邀请链接二维码叠到推广海报空白处,并在下方绘制邀请话术,返回 PNG data URL。 + */ +export async function buildInvitePosterDataUrl( + inviteUrlOrOptions: string | InvitePosterOptions, +): Promise { + const options: InvitePosterOptions = + typeof inviteUrlOrOptions === 'string' + ? { inviteUrl: inviteUrlOrOptions } + : inviteUrlOrOptions; + + const inviteUrl = options.inviteUrl?.trim() ?? ''; + if (!inviteUrl) { + throw new Error('邀请链接为空'); + } + + const captionLines = + options.captionLines?.map((x) => x.trim()).filter(Boolean) ?? + defaultCaptionLines(options.inviteCode); + + const [bg, qrDataUrl] = await Promise.all([ + loadImage(posterBgUrl), + QRCode.toDataURL(inviteUrl, { + width: QR_SIZE, + margin: 1, + errorCorrectionLevel: 'M', + color: { dark: '#0a3373', light: '#ffffff' }, + }), + ]); + + const qr = await loadImage(qrDataUrl); + const canvas = document.createElement('canvas'); + canvas.width = INVITE_POSTER_WIDTH; + canvas.height = INVITE_POSTER_HEIGHT; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Canvas 不可用'); + } + + ctx.drawImage(bg, 0, 0, INVITE_POSTER_WIDTH, INVITE_POSTER_HEIGHT); + + const x = Math.round(QR_CENTER_X - QR_SIZE / 2); + const y = Math.round(QR_CENTER_Y - QR_SIZE / 2); + + // 轻微白底,避免贴合海报底色时对比不足 + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + const r = 16; + const bx = x - QR_PAD; + const by = y - QR_PAD; + const bw = QR_SIZE + QR_PAD * 2; + const bh = QR_SIZE + QR_PAD * 2; + ctx.moveTo(bx + r, by); + ctx.arcTo(bx + bw, by, bx + bw, by + bh, r); + ctx.arcTo(bx + bw, by + bh, bx, by + bh, r); + ctx.arcTo(bx, by + bh, bx, by, r); + ctx.arcTo(bx, by, bx + bw, by, r); + ctx.closePath(); + ctx.fill(); + + ctx.drawImage(qr, x, y, QR_SIZE, QR_SIZE); + + // 二维码下方邀请话术 + const textStartY = y + QR_SIZE + QR_PAD + 28; + const lineGap = 36; + const maxWidth = 720; + captionLines.forEach((line, index) => { + drawCenteredText(ctx, line, QR_CENTER_X, textStartY + index * lineGap, { + font: '500 30px "Microsoft YaHei", "PingFang SC", sans-serif', + fillStyle: '#4e5969', + maxWidth, + }); + }); + + return canvas.toDataURL('image/png'); +} + +export async function invitePosterToFile( + dataUrl: string, + filename = 'invite-poster.png', +): Promise { + const res = await fetch(dataUrl); + const blob = await res.blob(); + return new File([blob], filename, { type: blob.type || 'image/png' }); +} diff --git a/src/views/h5/customer-center/components/H5InviteSheet.vue b/src/views/h5/customer-center/components/H5InviteSheet.vue index 3eb501c..f3e756e 100644 --- a/src/views/h5/customer-center/components/H5InviteSheet.vue +++ b/src/views/h5/customer-center/components/H5InviteSheet.vue @@ -85,9 +85,14 @@ -
- -

{{ t('h5.invite.qrTip') }}

+
+
@@ -120,7 +125,8 @@ import { Message } from '@arco-design/web-vue'; import { IconClose, IconShareAlt, IconUserGroup } from '@arco-design/web-vue/es/icon'; import { getMyInvite, getReferees } from '@/api/marketing/portal'; import { buildInviteRegisterUrl, buildInviteShareText } from '@/utils/inviteLink'; -import InviteQrImage from '@/components/InviteQrImage.vue'; +import { invitePosterToFile } from '@/utils/invitePoster'; +import InvitePoster from '@/components/InvitePoster.vue'; import H5EmptyState from './H5EmptyState.vue'; defineOptions({ name: 'H5InviteSheet' }); @@ -141,6 +147,8 @@ const activeTab = ref('share'); const invite = ref | null>(null); const referees = ref([]); const refereeTotal = ref(0); +const posterRef = ref<{ getDataUrl: () => string } | null>(null); +const posterDataUrl = ref(''); const drawerStyle = { borderRadius: '20px 20px 0 0', @@ -176,9 +184,25 @@ async function copyShareText() { await copyText(shareText.value); } +function onPosterReady(url: string) { + posterDataUrl.value = url; +} + async function nativeShare() { if (!inviteUrl.value || !canNativeShare.value) return; try { + const dataUrl = posterDataUrl.value || posterRef.value?.getDataUrl?.() || ''; + if (dataUrl) { + const file = await invitePosterToFile(dataUrl); + if (navigator.canShare?.({ files: [file] })) { + await navigator.share({ + title: t('h5.invite.shareTitle'), + text: shareText.value, + files: [file], + }); + return; + } + } await navigator.share({ title: t('h5.invite.shareTitle'), text: shareText.value, @@ -461,23 +485,8 @@ watch( } } -.qr { +.poster { margin-top: 18px; - text-align: center; - - :deep(.invite-qr-image) { - margin: 0 auto; - padding: 12px; - border-radius: 16px; - background: #f7f8fa; - box-sizing: content-box; - } - - p { - margin: 10px 0 0; - font-size: 12px; - color: #86909c; - } } .referee { diff --git a/src/views/h5/customer-center/components/H5JourneyGuide.vue b/src/views/h5/customer-center/components/H5JourneyGuide.vue index b11dd86..ead6042 100644 --- a/src/views/h5/customer-center/components/H5JourneyGuide.vue +++ b/src/views/h5/customer-center/components/H5JourneyGuide.vue @@ -34,6 +34,12 @@ + @@ -51,10 +57,13 @@ import { IconRight, IconDown, } from '@arco-design/web-vue/es/icon'; +import type { CustomerProfileCompleteness } from '@/api/customer'; import { h5Navigate } from '@/utils/device'; +import { checkCustomerContactRequired } from '@/utils/customerContactGate'; import H5WarehouseAddressSheet from './H5WarehouseAddressSheet.vue'; import H5ShipGuideSheet from './H5ShipGuideSheet.vue'; import H5PlaceOrderSheet from './H5PlaceOrderSheet.vue'; +import H5CompleteContactSheet from './H5CompleteContactSheet.vue'; defineOptions({ name: 'H5JourneyGuide' }); @@ -77,6 +86,9 @@ const expanded = ref(false); const warehouseVisible = ref(false); const shipVisible = ref(false); const placeOrderVisible = ref(false); +const contactVisible = ref(false); +const contactProfile = ref(null); +let pendingPlaceOrder = false; const steps: Step[] = [ { @@ -126,6 +138,27 @@ const steps: Step[] = [ }, ]; +async function openPlaceOrder() { + const { needContact, profile } = await checkCustomerContactRequired(); + if (needContact) { + pendingPlaceOrder = true; + contactProfile.value = profile; + contactVisible.value = true; + return; + } + placeOrderVisible.value = true; +} + +function onContactSuccess() { + if (!pendingPlaceOrder) return; + pendingPlaceOrder = false; + placeOrderVisible.value = true; +} + +function onContactCancel() { + pendingPlaceOrder = false; +} + function onStep(step: Step) { if (step.action === 'warehouse') { warehouseVisible.value = true; @@ -136,7 +169,7 @@ function onStep(step: Step) { return; } if (step.action === 'placeOrder') { - placeOrderVisible.value = true; + void openPlaceOrder(); return; } if (step.path) { diff --git a/src/views/h5/customer-center/components/H5PickupOrderSheet.vue b/src/views/h5/customer-center/components/H5PickupOrderSheet.vue index 7db1b63..c74b1a8 100644 --- a/src/views/h5/customer-center/components/H5PickupOrderSheet.vue +++ b/src/views/h5/customer-center/components/H5PickupOrderSheet.vue @@ -81,6 +81,7 @@ import { Message } from '@arco-design/web-vue'; import { IconClose } from '@arco-design/web-vue/es/icon'; import { getWarehouseList } from '@/api/system/warehouse'; import { getSalesProductList } from '@/api/channel/salesProduct'; +import { getDictListAll } from '@/api/system/dict'; import { createPickupOrder, getSenderAddressList } from '@/api/order/senderAddress'; import { pickField } from '@/utils/warehouseListMapper'; import { isCustomerContactRequiredError } from '@/utils/customerContactGate'; @@ -141,10 +142,11 @@ function resetForm() { async function loadRefs() { loading.value = true; try { - const [senderRes, whRes, productRes] = await Promise.all([ + const [senderRes, whRes, productRes, salesProductGroupDictRows] = await Promise.all([ getSenderAddressList({ page: 1, pageSize: 100, addressType: 'sender' }), getWarehouseList({ page: 1, pageSize: 100 }), getSalesProductList({ page: 1, pageSize: 200, isActive: 2 }), + getDictListAll({ dictType: 'sales_product_group' }), ]); senders.value = (senderRes.items || []) @@ -170,7 +172,18 @@ async function loadRefs() { }) .filter(Boolean) as typeof warehouses.value; + const domesticPickupGroupCodes = new Set( + (salesProductGroupDictRows ?? []) + .filter((d: any) => String(d?.dictName ?? d?.DictName ?? '').includes('国内揽收')) + .map((d: any) => String(d?.dictCode ?? d?.DictCode ?? '').trim()) + .filter((code: string) => Boolean(code)), + ); + products.value = (productRes.items || []) + .filter((row: any) => { + const groupCode = String(row?.productGroup ?? row?.ProductGroup ?? '').trim(); + return domesticPickupGroupCodes.size ? domesticPickupGroupCodes.has(groupCode) : true; + }) .map((row: any) => { const code = String(row?.productCode ?? row?.ProductCode ?? '').trim(); const name = String(row?.productName ?? row?.ProductName ?? '').trim(); diff --git a/src/views/h5/customer-center/components/H5PlaceOrderSheet.vue b/src/views/h5/customer-center/components/H5PlaceOrderSheet.vue index 1b87712..4f16c9a 100644 --- a/src/views/h5/customer-center/components/H5PlaceOrderSheet.vue +++ b/src/views/h5/customer-center/components/H5PlaceOrderSheet.vue @@ -29,7 +29,7 @@ type="button" class="h5-po__step" :class="{ 'is-active': step === idx, 'is-done': idx < step }" - @click="step = idx" + @click="goToStep(idx)" > {{ idx + 1 }}.{{ s.label }} @@ -42,82 +42,171 @@
+
+ + +
+ +
+ + + + + + +
+ + + - + +

{{ fieldError('clientNo') }}

{{ opt.label }} +

{{ fieldError('productCode') }}

- + {{ opt.label }} +

{{ fieldError('cargoAttribute') }}

- + {{ opt.label }} +

{{ fieldError('warehouseId') }}

+

{{ fieldError('actualWeight') }}

- - - + + +
- - - - - {{ opt.label }} - - + {{ fieldError('length') || fieldError('width') || fieldError('height') }} +

- + +

+ {{ fieldError('receiver.recipientsName') }} +

- + +

+ {{ fieldError('receiver.phone') }} +

- + {{ opt.label }} +

+ {{ fieldError('receiver.countryCode') }} +

+

+ {{ fieldError('receiver.streetAddress1') }} +

@@ -126,19 +215,40 @@ - + +

+ {{ fieldError('receiver.zipCode') }} +

+

+ {{ declarationLiveHint }} +

- + {{ opt.label }} +

+ {{ fieldError('declaredCurrency') }} +

+

+ {{ fieldError('itemsEmpty') }} +

+
{{ t('h5.placeOrder.itemTitle', { n: idx + 1 }) }} @@ -153,7 +263,14 @@
- + +

+ {{ fieldError(`items.${idx}.enName`) }} +

@@ -161,17 +278,46 @@
- +
- +
- +
+

+ {{ + fieldError(`items.${idx}.unitPrice`) || + fieldError(`items.${idx}.unitWeight`) || + fieldError(`items.${idx}.quantity`) + }} +

{{ t('h5.placeOrder.amount') }} {{ row.amount || '-' }} · {{ t('h5.placeOrder.itemWeight') }} {{ row.weight || '-' }} @@ -224,7 +370,7 @@ import { useI18n } from 'vue-i18n'; import { Message, Modal } from '@arco-design/web-vue'; import { IconClose } from '@arco-design/web-vue/es/icon'; import { postOrderParcelCreate } from '@/api/order'; -import { getSenderAddressList } from '@/api/order/senderAddress'; +import { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress'; import { getSalesProductList } from '@/api/channel/salesProduct'; import { getDictListAll } from '@/api/system/dict'; import { getCountryList } from '@/api/system/country'; @@ -234,6 +380,17 @@ import { pickField } from '@/utils/warehouseListMapper'; import { h5Navigate } from '@/utils/device'; import { isCustomerContactRequiredError } from '@/utils/customerContactGate'; +function isSenderRequiredError(error: unknown): boolean { + const msg = String( + (error as any)?.response?.data?.message || + (error as any)?.response?.data?.Message || + (error as any)?.message || + error || + '', + ); + return msg.includes('发件人姓名不能为空') || /sender\s*name/i.test(msg); +} + defineOptions({ name: 'H5PlaceOrderSheet' }); const PRICE_CHECK_ORDER_DRAFT_KEY = 'oms_price_check_order_draft'; @@ -255,9 +412,21 @@ const router = useRouter(); const step = ref(0); const optionsLoading = ref(false); const senderLoading = ref(false); +const showSenderForm = ref(false); +const savingSender = ref(false); +const senderForm = reactive({ + senderName: '', + phone: '', + province: '', + city: '', + streetAddress1: '', + countryCode: 'CN', +}); const submitting = ref(false); const handoffTip = ref(''); const selectedSenderId = ref(); +/** 当前步骤校验失败后标红;申报步骤始终即时标红 */ +const showFieldErrors = ref(false); const drawerStyle = { borderRadius: '20px 20px 0 0', @@ -397,9 +566,117 @@ function validateDeclareItem(row: DeclareItem): string | null { return null; } +function collectFieldErrors(idx: number): Record { + const errors: Record = {}; + if (idx === 0) { + if (!trim(form.sender.senderName)) { + errors.sender = senderOptions.value.length + ? t('h5.placeOrder.err.sender') + : t('h5.placeOrder.err.senderEmpty'); + } + if (!trim(form.clientNo)) errors.clientNo = t('h5.placeOrder.err.clientNo'); + if (!trim(form.productCode)) errors.productCode = t('h5.placeOrder.err.product'); + if (!trim(form.cargoAttribute)) errors.cargoAttribute = t('h5.placeOrder.err.cargo'); + if (!trim(form.warehouseId) || parseNum(form.warehouseId) == null) { + errors.warehouseId = t('h5.placeOrder.err.warehouse'); + } + if (parseNum(form.actualWeight) == null) errors.actualWeight = t('h5.placeOrder.err.weight'); + if ((parseNum(form.length) ?? 0) <= 0) errors.length = t('h5.placeOrder.err.length'); + if ((parseNum(form.width) ?? 0) <= 0) errors.width = t('h5.placeOrder.err.width'); + if ((parseNum(form.height) ?? 0) <= 0) errors.height = t('h5.placeOrder.err.height'); + } + if (idx === 1) { + if (!trim(form.receiver.recipientsName)) { + errors['receiver.recipientsName'] = t('h5.placeOrder.err.receiverName'); + } + if (!trim(form.receiver.phone)) errors['receiver.phone'] = t('h5.placeOrder.err.receiverPhone'); + if (!trim(form.receiver.countryCode)) { + errors['receiver.countryCode'] = t('h5.placeOrder.err.country'); + } + if (!trim(form.receiver.streetAddress1)) { + errors['receiver.streetAddress1'] = t('h5.placeOrder.err.address'); + } + if (!trim(form.receiver.zipCode)) errors['receiver.zipCode'] = t('h5.placeOrder.err.zip'); + } + if (idx === 2) { + if (!trim(form.declaredCurrency)) errors.declaredCurrency = t('h5.placeOrder.err.currency'); + if (!items.value.length) errors.itemsEmpty = t('h5.placeOrder.err.itemsEmpty'); + items.value.forEach((row, i) => { + if (!trim(row.enName)) errors[`items.${i}.enName`] = t('h5.placeOrder.err.enName'); + if (parseNum(row.unitPrice) == null) { + errors[`items.${i}.unitPrice`] = t('h5.placeOrder.err.unitPrice'); + } + if (parseNum(row.unitWeight) == null) { + errors[`items.${i}.unitWeight`] = t('h5.placeOrder.err.unitWeight'); + } + if (parseNum(row.quantity) == null) errors[`items.${i}.quantity`] = t('h5.placeOrder.err.qty'); + }); + } + return errors; +} + +function isDeclareRowStarted(row: DeclareItem) { + return Boolean( + trim(row.enName) || + trim(row.cnName) || + trim(row.unitPrice) || + trim(row.unitWeight) || + trim(row.quantity), + ); +} + +const activeFieldErrors = computed(() => { + if (step.value === 2) { + const all = collectFieldErrors(2); + if (showFieldErrors.value) return all; + const filtered: Record = {}; + Object.entries(all).forEach(([key, msg]) => { + if (key === 'declaredCurrency') { + filtered[key] = msg; + return; + } + const match = /^items\.(\d+)\./.exec(key); + if (!match) return; + const row = items.value[Number(match[1])]; + if (row && isDeclareRowStarted(row)) filtered[key] = msg; + }); + return filtered; + } + if (!showFieldErrors.value) return {} as Record; + return collectFieldErrors(step.value); +}); + +const declarationLiveHint = computed(() => { + if (step.value !== 2 || showFieldErrors.value) return ''; + return firstFieldErrorMessage(2) || ''; +}); + +function fieldHasError(key: string) { + return Boolean(activeFieldErrors.value[key]); +} + +function fieldError(key: string) { + return activeFieldErrors.value[key] || ''; +} + +function firstFieldErrorMessage(idx: number): string | null { + if (idx === 2) { + if (!trim(form.declaredCurrency)) return t('h5.placeOrder.err.currency'); + if (!items.value.length) return t('h5.placeOrder.err.itemsEmpty'); + for (let i = 0; i < items.value.length; i += 1) { + const msg = validateDeclareItem(items.value[i]); + if (msg) return t('h5.placeOrder.err.itemLine', { n: i + 1, msg }); + } + return null; + } + const errors = collectFieldErrors(idx); + return Object.values(errors)[0] || null; +} + function resetForm() { step.value = 0; handoffTip.value = ''; + showFieldErrors.value = false; selectedSenderId.value = undefined; items.value = [createEmptyItem()]; form.clientNo = ''; @@ -429,47 +706,86 @@ function ensureClientNo() { } function validateStep(idx: number): string | null { - if (idx === 0) { - if (!trim(form.clientNo)) return t('h5.placeOrder.err.clientNo'); - if (!trim(form.productCode)) return t('h5.placeOrder.err.product'); - if (!trim(form.cargoAttribute)) return t('h5.placeOrder.err.cargo'); - if (!trim(form.warehouseId) || parseNum(form.warehouseId) == null) { - return t('h5.placeOrder.err.warehouse'); - } - if (parseNum(form.actualWeight) == null) return t('h5.placeOrder.err.weight'); - if ((parseNum(form.length) ?? 0) <= 0) return t('h5.placeOrder.err.length'); - if ((parseNum(form.width) ?? 0) <= 0) return t('h5.placeOrder.err.width'); - if ((parseNum(form.height) ?? 0) <= 0) return t('h5.placeOrder.err.height'); - } - if (idx === 1) { - if (!trim(form.receiver.recipientsName)) return t('h5.placeOrder.err.receiverName'); - if (!trim(form.receiver.phone)) return t('h5.placeOrder.err.receiverPhone'); - if (!trim(form.receiver.countryCode)) return t('h5.placeOrder.err.country'); - if (!trim(form.receiver.streetAddress1)) return t('h5.placeOrder.err.address'); - if (!trim(form.receiver.zipCode)) return t('h5.placeOrder.err.zip'); - } - if (idx === 2) { - if (!trim(form.declaredCurrency)) return t('h5.placeOrder.err.currency'); - if (!items.value.length) return t('h5.placeOrder.err.itemsEmpty'); - for (let i = 0; i < items.value.length; i += 1) { - const msg = validateDeclareItem(items.value[i]); - if (msg) { - return t('h5.placeOrder.err.itemLine', { n: i + 1, msg }); - } - } - } - return null; + return firstFieldErrorMessage(idx); } function goNext() { const err = validateStep(step.value); if (err) { + showFieldErrors.value = true; + if (!trim(form.sender.senderName) && !senderOptions.value.length) { + showSenderForm.value = true; + } Message.warning(err); return; } + showFieldErrors.value = false; step.value += 1; } +function goToStep(idx: number) { + if (idx === step.value || submitting.value) return; + if (idx < step.value) { + showFieldErrors.value = false; + step.value = idx; + return; + } + for (let i = step.value; i < idx; i += 1) { + const err = validateStep(i); + if (err) { + showFieldErrors.value = true; + if (i === 0 && !trim(form.sender.senderName) && !senderOptions.value.length) { + showSenderForm.value = true; + } + step.value = i; + Message.warning(err); + return; + } + } + showFieldErrors.value = false; + step.value = idx; +} + +function resetSenderForm() { + Object.assign(senderForm, { + senderName: '', + phone: '', + province: '', + city: '', + streetAddress1: '', + countryCode: 'CN', + }); +} + +async function saveSender() { + if (!trim(senderForm.senderName) || !trim(senderForm.streetAddress1)) { + Message.warning(t('h5.placeOrder.err.senderForm')); + return; + } + savingSender.value = true; + try { + await createSenderAddress({ + addressType: 'sender', + addressName: trim(senderForm.senderName), + senderName: trim(senderForm.senderName), + phone: trim(senderForm.phone), + countryCode: trim(senderForm.countryCode) || 'CN', + province: trim(senderForm.province), + city: trim(senderForm.city), + streetAddress1: trim(senderForm.streetAddress1), + isDefault: senderOptions.value.length === 0, + }); + Message.success(t('h5.placeOrder.senderSaved')); + showSenderForm.value = false; + resetSenderForm(); + await loadSenders(); + } catch (e: any) { + Message.error(e?.message || t('h5.placeOrder.senderSaveFailed')); + } finally { + savingSender.value = false; + } +} + function onSenderChange(id: number | undefined) { selectedSenderId.value = id; const hit = senderOptions.value.find((x) => x.id === id); @@ -544,6 +860,10 @@ async function submit() { const err = validateStep(i); if (err) { step.value = i; + showFieldErrors.value = true; + if (i === 0 && !trim(form.sender.senderName) && !senderOptions.value.length) { + showSenderForm.value = true; + } Message.warning(err); return; } @@ -573,6 +893,14 @@ async function submit() { } catch (e: any) { if (isCustomerContactRequiredError(e)) { Message.warning(t('h5.contact.requiredToast')); + } else if (isSenderRequiredError(e)) { + step.value = 0; + if (!senderOptions.value.length) showSenderForm.value = true; + Message.warning( + senderOptions.value.length + ? t('h5.placeOrder.err.sender') + : t('h5.placeOrder.err.senderEmpty'), + ); } else { Message.error(e?.message || t('h5.placeOrder.failed')); } @@ -641,15 +969,28 @@ function applyDraft(draft: Record | null) { async function loadOptions(draft: Record | null) { optionsLoading.value = true; try { - const [productRes, cargoRows, warehouseRes, currencyRows, countryRes] = await Promise.all([ - getSalesProductList({ page: 1, pageSize: 500, isActive: 2 }), - getDictListAll({ dictType: 'cargo_attribute' }), - getWarehouseList({ page: 1, pageSize: 500 }), - getDictListAll({ dictType: 'currency' }), - getCountryList(), - ]); + const [productRes, cargoRows, warehouseRes, currencyRows, countryRes, salesProductGroupDictRows] = + await Promise.all([ + getSalesProductList({ page: 1, pageSize: 500, isActive: 2 }), + getDictListAll({ dictType: 'cargo_attribute' }), + getWarehouseList({ page: 1, pageSize: 500 }), + getDictListAll({ dictType: 'currency' }), + getCountryList(), + getDictListAll({ dictType: 'sales_product_group' }), + ]); + + const domesticPickupGroupCodes = new Set( + (salesProductGroupDictRows ?? []) + .filter((d: any) => String(d?.dictName ?? d?.DictName ?? '').includes('国内揽收')) + .map((d: any) => String(d?.dictCode ?? d?.DictCode ?? '').trim()) + .filter((code: string) => Boolean(code)), + ); productOptions.value = (productRes.items ?? []) + .filter((row: any) => { + const groupCode = String(row?.productGroup ?? row?.ProductGroup ?? '').trim(); + return domesticPickupGroupCodes.size ? !domesticPickupGroupCodes.has(groupCode) : true; + }) .map((row: any) => { const code = String(row?.productCode ?? row?.ProductCode ?? '').trim(); const name = String(row?.productName ?? row?.ProductName ?? '').trim(); @@ -738,9 +1079,16 @@ async function loadSenders() { .filter(Boolean) as typeof senderOptions.value; const def = (senderOptions.value as any[]).find((x) => x.isDefault) || senderOptions.value[0]; - if (def) onSenderChange(def.id); + if (def) { + onSenderChange(def.id); + showSenderForm.value = false; + } else { + onSenderChange(undefined); + showSenderForm.value = true; + } } catch { senderOptions.value = []; + showSenderForm.value = true; } finally { senderLoading.value = false; } @@ -751,6 +1099,8 @@ watch( (visible) => { if (!visible) { submitting.value = false; + showSenderForm.value = false; + resetSenderForm(); return; } resetForm(); @@ -1000,6 +1350,63 @@ watch( } } +.h5-po__sender-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; + + .h5-po__label { + margin: 0; + } +} + +.h5-po__link { + border: none; + background: transparent; + padding: 0; + color: var(--app-primary-color); + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; +} + +.h5-po__sender-empty { + margin: 0 0 10px; + padding: 10px 12px; + border-radius: 10px; + background: #fff7e8; + color: #ad6800; + font-size: 12px; + line-height: 1.45; +} + +.h5-po__field-err { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.4; + color: #f53f3f; + + &--block { + margin: 0 0 10px; + padding: 8px 10px; + border-radius: 8px; + background: #ffece8; + } +} + +.h5-po__sender-form { + display: grid; + gap: 8px; + margin-bottom: 4px; +} + +.h5-po__btn--block { + width: 100%; +} + .h5-po__dims, .h5-po__three { display: grid; diff --git a/src/views/h5/customer-center/components/H5ShipGuideSheet.vue b/src/views/h5/customer-center/components/H5ShipGuideSheet.vue index 562da12..671f628 100644 --- a/src/views/h5/customer-center/components/H5ShipGuideSheet.vue +++ b/src/views/h5/customer-center/components/H5ShipGuideSheet.vue @@ -128,6 +128,7 @@ import { Message } from '@arco-design/web-vue'; import { IconClose, IconLocation } from '@arco-design/web-vue/es/icon'; import type { CustomerProfileCompleteness } from '@/api/customer'; import { getWarehouseList } from '@/api/system/warehouse'; +import { getDictListAll } from '@/api/system/dict'; import { getSalesProductList } from '@/api/channel/salesProduct'; import { createPickupOrder, @@ -205,10 +206,11 @@ function defaultPickupTime() { async function loadRefs() { loading.value = true; try { - const [senderRes, whRes, productRes] = await Promise.all([ + const [senderRes, whRes, productRes, salesProductGroupDictRows] = await Promise.all([ getSenderAddressList({ page: 1, pageSize: 100, addressType: 'sender' }), getWarehouseList({ page: 1, pageSize: 100 }), getSalesProductList({ page: 1, pageSize: 200, isActive: 2 }), + getDictListAll({ dictType: 'sales_product_group' }), ]); senders.value = (senderRes.items || []) @@ -242,7 +244,20 @@ async function loadRefs() { pickupForm.warehouseId = warehouses.value[0].id; } + const domesticPickupGroupCodes = new Set( + (salesProductGroupDictRows ?? []) + .filter((d: any) => String(d?.dictName ?? d?.DictName ?? '').includes('国内揽收')) + .map((d: any) => String(d?.dictCode ?? d?.DictCode ?? '').trim()) + .filter((code: string) => Boolean(code)), + ); + products.value = (productRes.items || []) + .filter((row: any) => { + const groupCode = String(row?.productGroup ?? row?.ProductGroup ?? '').trim(); + if (!domesticPickupGroupCodes.size) return true; + if (mode.value === 'pickup') return domesticPickupGroupCodes.has(groupCode); + return !domesticPickupGroupCodes.has(groupCode); + }) .map((row: any) => { const code = String(row?.productCode ?? row?.ProductCode ?? '').trim(); const name = String(row?.productName ?? row?.ProductName ?? '').trim(); @@ -295,6 +310,7 @@ async function saveSender() { async function togglePickupMode() { if (mode.value === 'pickup') { mode.value = 'guide'; + void loadRefs(); return; } const { needContact, profile } = await checkCustomerContactRequired(); @@ -304,10 +320,12 @@ async function togglePickupMode() { return; } mode.value = 'pickup'; + void loadRefs(); } function onContactSuccess() { mode.value = 'pickup'; + void loadRefs(); } async function submitPickup() { diff --git a/src/views/h5/customer-center/locale/en-US.ts b/src/views/h5/customer-center/locale/en-US.ts index 6ff4f22..4acfc5c 100644 --- a/src/views/h5/customer-center/locale/en-US.ts +++ b/src/views/h5/customer-center/locale/en-US.ts @@ -209,8 +209,20 @@ export default { 'h5.placeOrder.length': 'L', 'h5.placeOrder.width': 'W', 'h5.placeOrder.height': 'H', - 'h5.placeOrder.sender': 'Sender address (optional)', + 'h5.placeOrder.sender': 'Sender address', 'h5.placeOrder.senderPh': 'Select saved sender', + 'h5.placeOrder.senderEmpty': 'No sender address yet. Add one before placing an order.', + 'h5.placeOrder.addSender': 'Add sender', + 'h5.placeOrder.cancelAddSender': 'Cancel', + 'h5.placeOrder.senderNamePh': 'Sender name', + 'h5.placeOrder.senderPhonePh': 'Phone', + 'h5.placeOrder.senderProvincePh': 'Province / State', + 'h5.placeOrder.senderCityPh': 'City', + 'h5.placeOrder.senderAddressPh': 'Street address', + 'h5.placeOrder.saveSender': 'Save sender', + 'h5.placeOrder.savingSender': 'Saving…', + 'h5.placeOrder.senderSaved': 'Sender address saved', + 'h5.placeOrder.senderSaveFailed': 'Failed to save sender address', 'h5.placeOrder.default': 'Default', 'h5.placeOrder.receiverName': 'Recipient', 'h5.placeOrder.receiverPhone': 'Phone', @@ -250,6 +262,9 @@ export default { 'h5.placeOrder.err.length': 'Enter length', 'h5.placeOrder.err.width': 'Enter width', 'h5.placeOrder.err.height': 'Enter height', + 'h5.placeOrder.err.sender': 'Select a sender address', + 'h5.placeOrder.err.senderEmpty': 'Add a sender address before placing an order', + 'h5.placeOrder.err.senderForm': 'Enter sender name and street address', 'h5.placeOrder.err.receiverName': 'Enter recipient', 'h5.placeOrder.err.receiverPhone': 'Enter phone', 'h5.placeOrder.err.country': 'Select country', @@ -370,6 +385,12 @@ export default { 'h5.invite.share': 'Share', 'h5.invite.shareTitle': 'Join with my invite', 'h5.invite.qrTip': 'Friends scan or long-press in WeChat to register via the official account', + 'h5.invite.posterAlt': 'Invite poster', + 'h5.invite.posterLoading': 'Generating poster…', + 'h5.invite.posterFailed': 'Failed to generate poster. Please retry.', + 'h5.invite.savePoster': 'Save poster', + 'h5.invite.posterSaved': 'Poster saved', + 'h5.invite.posterTip': 'Long-press to recognize the QR, or save and share with friends', 'h5.invite.referees': 'Invited friends', 'h5.invite.refereesEmpty': 'No friends have registered with your invite yet', 'h5.invite.empty': 'No invite data', diff --git a/src/views/h5/customer-center/locale/zh-CN.ts b/src/views/h5/customer-center/locale/zh-CN.ts index 6703ea7..05093f4 100644 --- a/src/views/h5/customer-center/locale/zh-CN.ts +++ b/src/views/h5/customer-center/locale/zh-CN.ts @@ -208,8 +208,20 @@ export default { 'h5.placeOrder.length': '长', 'h5.placeOrder.width': '宽', 'h5.placeOrder.height': '高', - 'h5.placeOrder.sender': '发件人地址(可选)', + 'h5.placeOrder.sender': '发件人地址', 'h5.placeOrder.senderPh': '选择常用发件地址', + 'h5.placeOrder.senderEmpty': '暂无发件地址,请先新增后再下单', + 'h5.placeOrder.addSender': '新增发件地址', + 'h5.placeOrder.cancelAddSender': '取消新增', + 'h5.placeOrder.senderNamePh': '发件人姓名', + 'h5.placeOrder.senderPhonePh': '手机号', + 'h5.placeOrder.senderProvincePh': '省/州', + 'h5.placeOrder.senderCityPh': '城市', + 'h5.placeOrder.senderAddressPh': '详细地址', + 'h5.placeOrder.saveSender': '保存发件地址', + 'h5.placeOrder.savingSender': '保存中…', + 'h5.placeOrder.senderSaved': '发件地址已保存', + 'h5.placeOrder.senderSaveFailed': '保存发件地址失败', 'h5.placeOrder.default': '默认', 'h5.placeOrder.receiverName': '收件人', 'h5.placeOrder.receiverPhone': '手机号', @@ -249,6 +261,9 @@ export default { 'h5.placeOrder.err.length': '请填写长度', 'h5.placeOrder.err.width': '请填写宽度', 'h5.placeOrder.err.height': '请填写高度', + 'h5.placeOrder.err.sender': '请选择发件人地址', + 'h5.placeOrder.err.senderEmpty': '请先新增发件人地址后再下单', + 'h5.placeOrder.err.senderForm': '请填写发件人姓名与详细地址', 'h5.placeOrder.err.receiverName': '请填写收件人', 'h5.placeOrder.err.receiverPhone': '请填写手机号', 'h5.placeOrder.err.country': '请选择目的国家', @@ -368,6 +383,12 @@ export default { 'h5.invite.share': '分享给好友', 'h5.invite.shareTitle': '邀请你注册', 'h5.invite.qrTip': '好友用微信扫码或长按识别,完成公众号注册', + 'h5.invite.posterAlt': '邀请推广海报', + 'h5.invite.posterLoading': '海报生成中…', + 'h5.invite.posterFailed': '海报生成失败,请稍后重试', + 'h5.invite.savePoster': '保存海报', + 'h5.invite.posterSaved': '海报已保存', + 'h5.invite.posterTip': '长按海报识别二维码,或保存后发给好友', 'h5.invite.referees': '已邀请好友', 'h5.invite.refereesEmpty': '还没有好友通过你的邀请注册', 'h5.invite.empty': '暂无邀请数据', diff --git a/src/views/h5/customer-center/price/index.vue b/src/views/h5/customer-center/price/index.vue index 1b2c818..a374999 100644 --- a/src/views/h5/customer-center/price/index.vue +++ b/src/views/h5/customer-center/price/index.vue @@ -350,6 +350,12 @@ v-model:visible="placeOrderVisible" :draft="placeOrderDraft" /> +

@@ -359,13 +365,16 @@ 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 type { CustomerProfileCompleteness } from '@/api/customer'; import { postOrderPriceCheck } from '@/api/order'; import { getCountryList } from '@/api/system/country'; import { getWarehouseList } from '@/api/system/warehouse'; import { getDictListAll } from '@/api/system/dict'; import { getSalesProductDetail } from '@/api/channel/salesProduct'; import { h5Navigate } from '@/utils/device'; +import { checkCustomerContactRequired } from '@/utils/customerContactGate'; import H5PlaceOrderSheet from '../components/H5PlaceOrderSheet.vue'; +import H5CompleteContactSheet from '../components/H5CompleteContactSheet.vue'; defineOptions({ name: 'H5Price' }); @@ -376,6 +385,9 @@ const { t } = useI18n(); const router = useRouter(); const placeOrderVisible = ref(false); const placeOrderDraft = ref | null>(null); +const contactVisible = ref(false); +const contactProfile = ref(null); +let pendingOpenOrder = false; const form = reactive({ destinationCountry: '', @@ -741,8 +753,8 @@ function askAdvisor(item: any) { }); } -function goOrder(item: any) { - const draft = { +function buildOrderDraft(item: any) { + return { productCode: String(item?.productCode ?? '').trim(), productId: item?.productId ?? item?.id ?? null, shippingMethod: String(item?.shippingMethod ?? '').trim(), @@ -763,6 +775,9 @@ function goOrder(item: any) { height: '', totalCost: item?.totalCost ?? null, }; +} + +function openPlaceOrderWithDraft(draft: Record) { try { sessionStorage.setItem(PRICE_CHECK_ORDER_DRAFT_KEY, JSON.stringify(draft)); localStorage.setItem(PRICE_CHECK_ORDER_DRAFT_KEY, JSON.stringify(draft)); @@ -774,6 +789,30 @@ function goOrder(item: any) { placeOrderVisible.value = true; } +async function goOrder(item: any) { + const draft = buildOrderDraft(item); + const { needContact, profile } = await checkCustomerContactRequired(); + if (needContact) { + placeOrderDraft.value = draft; + pendingOpenOrder = true; + contactProfile.value = profile; + contactVisible.value = true; + return; + } + openPlaceOrderWithDraft(draft); +} + +function onContactSuccess() { + if (!pendingOpenOrder) return; + pendingOpenOrder = false; + const draft = placeOrderDraft.value || {}; + openPlaceOrderWithDraft(draft); +} + +function onContactCancel() { + pendingOpenOrder = false; +} + onMounted(() => { void loadOptions(); }); diff --git a/src/views/order/batchJobs/components/BatchJobDetailDrawer.vue b/src/views/order/batchJobs/components/BatchJobDetailDrawer.vue index d89782b..e644a06 100644 --- a/src/views/order/batchJobs/components/BatchJobDetailDrawer.vue +++ b/src/views/order/batchJobs/components/BatchJobDetailDrawer.vue @@ -11,6 +11,8 @@ import { formatBatchJobSummaryMessage, formatBatchJobTrackUpsertDetailText, getBatchJobTrackUpsertResultSummary, + formatBatchJobDownstreamForwardDetailText, + getBatchJobDownstreamForwardResultSummary, postOrderBatchJobCancel, postOrderBatchJobRetryFailed, type ParsedOrderBatchJob, @@ -50,6 +52,7 @@ const completionNotified = ref(false) const refNoColumnTitle = computed(() => t('order.batchJobs.items.clientNo')) const showRowNumber = computed(() => isBatchCreateAction(job.value?.action ?? '')) const isTrackUpsertJob = computed(() => job.value?.action === 'track-upsert-small-bag') +const isDownstreamForwardJob = computed(() => job.value?.action === 'downstream-forward-create-one') const progressPercent = computed(() => { if (!job.value?.total) return 0 @@ -201,6 +204,9 @@ function getItemResultText(record: ParsedOrderBatchJobItem) { if (isTrackUpsertJob.value && record.resultJson) { return getBatchJobTrackUpsertResultSummary(record) || record.resultMessage || t('order.batchJobs.itemStatus.success') } + if (isDownstreamForwardJob.value && record.resultJson) { + return getBatchJobDownstreamForwardResultSummary(record) || record.resultMessage || t('order.batchJobs.itemStatus.success') + } if (record.resultMessage) return record.resultMessage if (record.status === 'success') return t('order.batchJobs.itemStatus.success') if (record.status === 'pending') return t('order.batchJobs.itemStatus.pending') @@ -212,6 +218,9 @@ function getItemResultDetailText(record: ParsedOrderBatchJobItem) { if (isTrackUpsertJob.value && record.resultJson) { return formatBatchJobTrackUpsertDetailText(record) } + if (isDownstreamForwardJob.value && record.resultJson) { + return formatBatchJobDownstreamForwardDetailText(record) + } return record.resultJson || record.failReason || record.resultMessage || '-' } diff --git a/src/views/order/myOrders/columnConfig.ts b/src/views/order/myOrders/columnConfig.ts index 32c6faa..7eea219 100644 --- a/src/views/order/myOrders/columnConfig.ts +++ b/src/views/order/myOrders/columnConfig.ts @@ -30,6 +30,7 @@ export const MY_ORDERS_COLUMN_CATALOG: OrderListColumnDef[] = [ { key: 'waybillNo', labelKey: 'order.myOrders.table.waybillNo', width: 200 }, { key: 'customerOrderNo', labelKey: 'order.myOrders.table.customerOrderNo', width: 150 }, { key: 'trackingNo', labelKey: 'order.myOrders.table.trackingNo', width: 160 }, + { key: 'downstreamServiceNo', labelKey: 'order.myOrders.table.downstreamServiceNo', width: 180 }, { key: 'orderStatus', labelKey: 'order.myOrders.table.orderStatus', width: 130 }, { key: 'shippingMethod', labelKey: 'order.myOrders.table.shippingMethod', width: 120 }, { key: 'serviceChannel', labelKey: 'order.myOrders.table.serviceChannel', width: 120 }, diff --git a/src/views/order/myOrders/components/OrderDetailDrawer.vue b/src/views/order/myOrders/components/OrderDetailDrawer.vue index f76c081..b1fc9ed 100644 --- a/src/views/order/myOrders/components/OrderDetailDrawer.vue +++ b/src/views/order/myOrders/components/OrderDetailDrawer.vue @@ -62,6 +62,10 @@ {{ $t('order.myOrders.drawer.trackNo') }} {{ getOrderValue('trackNo') }} + + {{ $t('order.myOrders.drawer.downstreamServiceNo') }} + {{ getOrderValue('downstreamServiceNo') }} + {{ $t('order.myOrders.drawer.consigneeCountryName') }} {{ getOrderValue('consigneeCountryName') }} @@ -795,6 +799,7 @@ const orderBasicFields = [ { key: 'serviceNo', labelKey: 'order.myOrders.drawer.serviceNo' }, { key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' }, { key: 'trackNo', labelKey: 'order.myOrders.drawer.trackNo' }, + { key: 'downstreamServiceNo', labelKey: 'order.myOrders.drawer.downstreamServiceNo' }, { key: 'productCode', labelKey: 'order.myOrders.drawer.productCode' }, { key: 'productName', labelKey: 'order.myOrders.drawer.productName' }, { key: 'consigneeCountry', labelKey: 'order.myOrders.drawer.consigneeCountry' }, @@ -824,7 +829,7 @@ const orderBasicFieldGroups = [ { key: 'identity', labelKey: 'order.myOrders.drawer.basic.group.identity', - fields: ['serviceNo', 'clientNo', 'trackNo'], + fields: ['serviceNo', 'clientNo', 'trackNo', 'downstreamServiceNo'], }, { key: 'product', @@ -1075,6 +1080,7 @@ function getOrderValue(key: string) { serviceNo: ['serviceNo', 'waybillNo'], clientNo: ['clientNo', 'customerOrderNo'], trackNo: ['trackNo', 'trackingNo'], + downstreamServiceNo: ['downstreamServiceNo', 'DownstreamServiceNo'], productCode: ['productCode'], productName: ['productName', 'shippingMethod'], channelCode: ['channelCode'], diff --git a/src/views/order/myOrders/index.vue b/src/views/order/myOrders/index.vue index 2347669..800c1a4 100644 --- a/src/views/order/myOrders/index.vue +++ b/src/views/order/myOrders/index.vue @@ -23,6 +23,7 @@ postOrderBatchJob, waitOrderBatchJob, formatBatchJobTrackUpsertResultText, + formatBatchJobDownstreamForwardResultText, isOrderBatchQueueDisabledError, formatBatchJobFailureMessage, formatBatchJobSummaryMessage, @@ -101,6 +102,7 @@ const serviceNo = pick(raw, 'serviceNo', 'ServiceNo', 'waybillNo', 'WaybillNo'); const clientNo = pick(raw, 'clientNo', 'ClientNo', 'customerOrderNo', 'CustomerOrderNo'); const trackNo = pick(raw, 'trackNo', 'TrackNo', 'trackingNo', 'TrackingNo'); + const downstreamServiceNo = pick(raw, 'downstreamServiceNo', 'DownstreamServiceNo'); const productCode = pick(raw, 'productCode', 'ProductCode'); const productName = pick(raw, 'productName', 'ProductName'); const channelCode = pick(raw, 'channelCode', 'ChannelCode'); @@ -112,6 +114,7 @@ waybillNo: serviceNo ?? '', customerOrderNo: clientNo ?? '', trackingNo: trackNo ?? '', + downstreamServiceNo: downstreamServiceNo ?? '', productCode: productCode ?? '', productName: productName ?? productCode ?? '', channelCode: channelCode ?? '', @@ -415,31 +418,6 @@ return lines.join('\n'); } - function formatBatchJobResultText( - title: string, - job: { jobId: number; status: string; total: number; successCount: number; failCount: number }, - items: Array<{ orderId: number; serviceNo?: string; clientNo?: string; status: string; failReason?: string }>, - rows: any[] - ): string { - const lines = [ - `【${title}】任务 #${job.jobId}`, - `状态:${job.status},合计 ${job.total},成功 ${job.successCount},失败 ${job.failCount}`, - '', - ]; - for (const item of items) { - const row = rows.find((r) => Number(getOrderRowId(r)) === item.orderId); - const label = resolveBatchJobItemRowLabel(row, item); - if (item.status === 'success') { - lines.push(`【${label}】成功`); - } else if (item.status === 'failed') { - lines.push(`【${label}】失败:${item.failReason || '未知原因'}`); - } else { - lines.push(`【${label}】${item.status || '处理中'}`); - } - } - return lines.join('\n'); - } - async function runSelectedOrderBatch(options: { action: string; rows: any[]; @@ -536,19 +514,25 @@ ): string { const lines = [ `【${waybillNo || '-'}】`, + result.success ? '成功' : '失败', `standardCode:createOrderOne`, ]; if (result.supplierCode) { lines.push(`supplierCode:${result.supplierCode}`); } - lines.push(`URL:${result.url || '-'}`); - lines.push(`HTTP:${result.statusCode ?? '-'}`); - lines.push(`成功:${result.success ? '是' : '否'}`); - if (result.responseBody) { - lines.push(`响应:${result.responseBody}`); + if (result.downstreamServiceNo) { + lines.push(`服务商单号:${result.downstreamServiceNo}`); } + if (result.trackNo) { + lines.push(`跟踪号:${result.trackNo}`); + } + lines.push(`请求:${result.url || '-'}`); if (result.requestJson) { - lines.push(`请求体:${result.requestJson}`); + lines.push(`请求体:\n${result.requestJson}`); + } + lines.push(`HTTP:${result.statusCode ?? '-'}`); + if (result.responseBody) { + lines.push(`响应:\n${result.responseBody}`); } return lines.join('\n'); } @@ -559,12 +543,12 @@ const job = await postOrderBatchJob({ action: 'downstream-forward-create-one', orderIds }); const result = await waitOrderBatchJob(job.jobId); if (result.timedOut) { - downstreamForwardResultText.value = `${formatBatchJobSummaryMessage(result.job, { timedOut: true, jobId: job.jobId })}\n\n${formatBatchJobResultText('预报下游', result.job, result.items, rows)}`; + downstreamForwardResultText.value = `${formatBatchJobSummaryMessage(result.job, { timedOut: true, jobId: job.jobId })}\n\n${formatBatchJobDownstreamForwardResultText('预报下游', result.job, result.items, rows)}`; downstreamForwardModalVisible.value = true; return; } const { job: finishedJob, items } = result; - downstreamForwardResultText.value = formatBatchJobResultText('预报下游', finishedJob, items, rows); + downstreamForwardResultText.value = formatBatchJobDownstreamForwardResultText('预报下游', finishedJob, items, rows); downstreamForwardModalVisible.value = true; return; } catch (e: any) { diff --git a/src/views/order/pickupOrder/index.vue b/src/views/order/pickupOrder/index.vue index 1427145..21e3845 100644 --- a/src/views/order/pickupOrder/index.vue +++ b/src/views/order/pickupOrder/index.vue @@ -12,6 +12,7 @@ import { postOrderList } from '@/api/order' import { createPickupOrder, getSenderAddressList } from '@/api/order/senderAddress' import { getSalesProductList } from '@/api/channel/salesProduct' import { getWarehouseList } from '@/api/system/warehouse' +import { getDictListAll } from '@/api/system/dict' import { $baseMessage } from '@/utils' import { hasPermission } from '@/utils/rbac-permission' import CompleteContactModal from '@/components/CompleteContactModal/index.vue' @@ -114,7 +115,7 @@ async function loadReferenceOptions() { refLoading.value = true try { - const [senderRes, warehouseRes, productRes] = await Promise.all([ + const [senderRes, warehouseRes, productRes, salesProductGroupDictRows] = await Promise.all([ getSenderAddressList( withCustomerId({ page: 1, @@ -125,6 +126,7 @@ async function loadReferenceOptions() { ), getWarehouseList({ page: 1, pageSize: 500 }), getSalesProductList({ page: 1, pageSize: 500, isActive: 2 }), + getDictListAll({ dictType: 'sales_product_group' }), ]) senderOptions.value = (senderRes.items || []).map((item: any) => { @@ -144,14 +146,27 @@ async function loadReferenceOptions() { return { value: id, label: name } }).filter((x) => x.value > 0) - productOptions.value = (productRes.items || []).map((item: any) => { - const code = String(item?.productCode ?? item?.ProductCode ?? '').trim() - const name = String(item?.productName ?? item?.ProductName ?? '').trim() - return { - value: code, - label: name ? `${name} ${code}` : code, - } - }).filter((x) => x.value) + const domesticPickupGroupCodes = new Set( + (salesProductGroupDictRows ?? []) + .filter((d: any) => String(d?.dictName ?? d?.DictName ?? '').includes('国内揽收')) + .map((d: any) => String(d?.dictCode ?? d?.DictCode ?? '').trim()) + .filter((code: string) => Boolean(code)), + ) + + productOptions.value = (productRes.items || []) + .filter((item: any) => { + const groupCode = String(item?.productGroup ?? item?.ProductGroup ?? '').trim() + return domesticPickupGroupCodes.size ? domesticPickupGroupCodes.has(groupCode) : true + }) + .map((item: any) => { + const code = String(item?.productCode ?? item?.ProductCode ?? '').trim() + const name = String(item?.productName ?? item?.ProductName ?? '').trim() + return { + value: code, + label: name ? `${name} ${code}` : code, + } + }) + .filter((x) => x.value) } catch (e: any) { $baseMessage(e?.message || t('order.pickupOrder.message.loadRefFailed'), 'error') } finally { diff --git a/src/views/order/placeOrder/index.vue b/src/views/order/placeOrder/index.vue index 6b961f3..987af1b 100644 --- a/src/views/order/placeOrder/index.vue +++ b/src/views/order/placeOrder/index.vue @@ -871,27 +871,18 @@ ) { return false; } + const length = parseNumberValue(singleForm.length); + const width = parseNumberValue(singleForm.width); + const height = parseNumberValue(singleForm.height); + if (length == null || length <= 0) return false; + if (width == null || width <= 0) return false; + if (height == null || height <= 0) return false; if (!formTrim(String(singleForm.quantity ?? ''))) return false; return true; } function isSingleSenderFilled() { - const sender = singleForm.sender; - return Boolean( - formTrim(sender.senderName) || - formTrim(sender.company) || - formTrim(sender.phone) || - formTrim(sender.telePhone) || - formTrim(sender.email) || - formTrim(sender.countryCode) || - formTrim(sender.province) || - formTrim(sender.city) || - formTrim(sender.zipCode) || - formTrim(sender.streetAddress1) || - formTrim(sender.streetAddress2) || - formTrim(sender.streetAddress3) || - formTrim(sender.houseNumber) - ); + return Boolean(formTrim(singleForm.sender?.senderName)); } function isSingleRecipientComplete() { @@ -918,7 +909,7 @@ const singleProgressStep = computed(() => { if (!isSingleBasicComplete()) return 1; - // 发件人选填:基本信息完成后进入收件人 + if (!isSingleSenderFilled()) return 2; if (!isSingleRecipientComplete()) return 3; if (!isSingleDeclarationComplete() || !formTrim(singleForm.declaredCurrency)) return 4; return 4; @@ -929,6 +920,7 @@ singleAgreementChecked.value && isSingleDeclarationComplete() && isSingleRecipientComplete() && + isSingleSenderFilled() && isSingleBasicComplete() && !!formTrim(singleForm.declaredCurrency), ); @@ -1099,7 +1091,7 @@ const singleFlowSteps = computed(() => [ { key: 'basic', label: t('order.placeOrder.single.stepBasic') }, - { key: 'sender', label: t('order.placeOrder.single.stepSenderOptional') }, + { key: 'sender', label: t('order.placeOrder.single.stepSender') }, { key: 'recipient', label: t('order.placeOrder.single.stepRecipient') }, { key: 'declaration', label: t('order.placeOrder.single.stepDeclaration') }, ]); @@ -1204,7 +1196,8 @@ .filter((x): x is SenderAddressOption => !!x); if (applyDefault && !isSingleSenderFilled()) { - const defaultAddr = senderAddressOptions.value.find((x) => x.isDefault); + const defaultAddr = + senderAddressOptions.value.find((x) => x.isDefault) || senderAddressOptions.value[0]; if (defaultAddr) { applySenderAddressById(defaultAddr.id); } @@ -1456,16 +1449,36 @@ async function loadSingleFormReferenceOptions() { singleReferenceLoading.value = true; try { - const [productRes, cargoDictRows, exportCustomsDictRows, warehouseRes, currencyDictRows, countryRes] = - await Promise.all([ + const [ + productRes, + cargoDictRows, + exportCustomsDictRows, + warehouseRes, + currencyDictRows, + countryRes, + salesProductGroupDictRows, + ] = await Promise.all([ getSalesProductList({ page: 1, pageSize: 500, isActive: 2 }), getDictListAll({ dictType: 'cargo_attribute' }), getDictListAll({ dictType: 'export_customs_mode' }), getWarehouseList({ page: 1, pageSize: 500 }), getDictListAll({ dictType: 'currency' }), getCountryList(), + getDictListAll({ dictType: 'sales_product_group' }), ]); + + const domesticPickupGroupCodes = new Set( + (salesProductGroupDictRows ?? []) + .filter((d: any) => String(d?.dictName ?? d?.DictName ?? '').includes('国内揽收')) + .map((d: any) => String(d?.dictCode ?? d?.DictCode ?? '').trim()) + .filter((code: string) => Boolean(code)), + ); + singleSalesProductOptions.value = (productRes.items ?? []) + .filter((row: any) => { + const groupCode = String(row?.productGroup ?? row?.ProductGroup ?? '').trim(); + return domesticPickupGroupCodes.size ? !domesticPickupGroupCodes.has(groupCode) : true; + }) .map(normalizeProductRow) .filter((p) => p.code) .map((p) => ({ @@ -1787,6 +1800,16 @@ errors.push({ message: '请填写包裹数量', section: 'basic', field: 'quantity' }); } + if (!formTrim(singleForm.sender?.senderName)) { + errors.push({ + message: senderAddressOptions.value.length + ? t('order.placeOrder.single.senderRequired') + : t('order.placeOrder.single.senderEmpty'), + section: 'sender', + field: 'sender.senderName', + }); + } + const receiver = singleForm.receiver; if (!formTrim(receiver.recipientsName)) { errors.push({ message: '请填写收件人名称', section: 'recipient', field: 'receiver.recipientsName' }); @@ -1848,15 +1871,49 @@ return collectSingleFieldErrors()[0] ?? null; } + function isDeclarationRowStarted(row: any) { + return Boolean( + formTrim(row?.enName) || + formTrim(row?.cnName) || + formTrim(String(row?.unitPrice ?? '')) || + formTrim(String(row?.unitWeight ?? '')) || + formTrim(String(row?.quantity ?? '')), + ); + } + const singleFieldErrorMap = computed(() => { - if (!singleShowFieldErrors.value) return {} as Record; const map: Record = {}; + const liveDeclaration = + isSingleBasicComplete() && isSingleSenderFilled() && isSingleRecipientComplete(); collectSingleFieldErrors().forEach((err) => { + if (singleShowFieldErrors.value) { + if (!map[err.field]) map[err.field] = err.message; + return; + } + if (!(liveDeclaration && err.section === 'declaration')) return; + // 币种始终即时提示;明细行仅在开始填写后标出缺项 + if (err.field === 'declaredCurrency') { + if (!map[err.field]) map[err.field] = err.message; + return; + } + const match = /^items\.(\d+)\./.exec(err.field); + if (!match) return; + const row = singleForm.items?.[Number(match[1])]; + if (!isDeclarationRowStarted(row)) return; if (!map[err.field]) map[err.field] = err.message; }); return map; }); + const declarationLiveHint = computed(() => { + if (!(isSingleBasicComplete() && isSingleSenderFilled() && isSingleRecipientComplete())) { + return ''; + } + if (isSingleDeclarationComplete() && formTrim(singleForm.declaredCurrency)) return ''; + const first = collectSingleFieldErrors().find((err) => err.section === 'declaration'); + return first?.message || ''; + }); + function fieldValidateStatus(field: string) { return singleFieldErrorMap.value[field] ? 'error' : undefined; } @@ -2119,7 +2176,23 @@ }, }); } catch (e: any) { - Message.error(e?.message || t('order.placeOrder.single.failed')); + if (isCustomerContactRequiredError(e)) { + contactBlocked.value = true; + contactVisible.value = true; + Message.warning(t('h5.contact.requiredToast')); + } else if ( + String(e?.message || '').includes('发件人姓名不能为空') || + /sender\s*name/i.test(String(e?.message || '')) + ) { + Message.warning( + senderAddressOptions.value.length + ? t('order.placeOrder.single.senderRequired') + : t('order.placeOrder.single.senderEmpty'), + ); + void openSenderModal(); + } else { + Message.error(e?.message || t('order.placeOrder.single.failed')); + } } finally { singleSubmitting.value = false; } @@ -2156,6 +2229,15 @@ @success="onContactCompleted" @cancel="onContactCancelled" /> +
+
+ {{ t('order.placeOrder.contact.blockTitle') }} + {{ t('order.placeOrder.contact.blockDesc') }} +
+ + {{ t('order.placeOrder.contact.blockAction') }} + +
@@ -2208,14 +2290,20 @@
{{ t('order.placeOrder.single.tip1') }} @@ -2564,12 +2652,18 @@ size="small" :class="{ 'declaration-cell-error': isDeclarationCellError(index, col.key) }" :status="isDeclarationCellError(index, col.key) ? 'error' : undefined" + :placeholder=" + isDeclarationCellError(index, col.key) + ? singleFieldErrorMap[`items.${index}.${col.key}`] + : undefined + " />
+

{{ declarationLiveHint }}

{{ $t('order.myOrders.drawer.trackNo') }} {{ getOrderValue('trackNo') }} + + {{ $t('order.myOrders.drawer.downstreamServiceNo') }} + {{ getOrderValue('downstreamServiceNo') }} + {{ $t('order.myOrders.drawer.channelName') }} {{ getOrderValue('channelName') }} @@ -1011,6 +1015,7 @@ const orderBasicFields = [ { key: 'serviceNo', labelKey: 'order.myOrders.drawer.serviceNo' }, { key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' }, { key: 'trackNo', labelKey: 'order.myOrders.drawer.trackNo' }, + { key: 'downstreamServiceNo', labelKey: 'order.myOrders.drawer.downstreamServiceNo' }, { key: 'productCode', labelKey: 'order.myOrders.drawer.productCode' }, { key: 'productName', labelKey: 'order.myOrders.drawer.productName' }, { key: 'channelCode', labelKey: 'order.myOrders.drawer.channelCode' }, @@ -1042,7 +1047,7 @@ const orderBasicFieldGroups = [ { key: 'identity', labelKey: 'order.myOrders.drawer.basic.group.identity', - fields: ['serviceNo', 'clientNo', 'trackNo'], + fields: ['serviceNo', 'clientNo', 'trackNo', 'downstreamServiceNo'], }, { key: 'product', @@ -1295,6 +1300,7 @@ function getOrderValue(key: string) { serviceNo: ['serviceNo', 'waybillNo'], clientNo: ['clientNo', 'customerOrderNo'], trackNo: ['trackNo', 'trackingNo'], + downstreamServiceNo: ['downstreamServiceNo', 'DownstreamServiceNo'], productCode: ['productCode'], productName: ['productName', 'shippingMethod'], channelCode: ['channelCode'], diff --git a/src/views/order/waybillManage/index.vue b/src/views/order/waybillManage/index.vue index 08a3caf..7b88884 100644 --- a/src/views/order/waybillManage/index.vue +++ b/src/views/order/waybillManage/index.vue @@ -23,6 +23,7 @@ postOrderBatchJob, waitOrderBatchJob, formatBatchJobTrackUpsertResultText, + formatBatchJobDownstreamForwardResultText, isOrderBatchQueueDisabledError, formatBatchJobFailureMessage, formatBatchJobSummaryMessage, @@ -111,6 +112,7 @@ const serviceNo = pick(raw, 'serviceNo', 'ServiceNo', 'waybillNo', 'WaybillNo'); const clientNo = pick(raw, 'clientNo', 'ClientNo', 'customerOrderNo', 'CustomerOrderNo'); const trackNo = pick(raw, 'trackNo', 'TrackNo', 'trackingNo', 'TrackingNo'); + const downstreamServiceNo = pick(raw, 'downstreamServiceNo', 'DownstreamServiceNo'); const productCode = pick(raw, 'productCode', 'ProductCode'); const productName = pick(raw, 'productName', 'ProductName'); const channelCode = pick(raw, 'channelCode', 'ChannelCode'); @@ -163,6 +165,7 @@ waybillNo: serviceNo ?? '', customerOrderNo: clientNo ?? '', trackingNo: trackNo ?? '', + downstreamServiceNo: downstreamServiceNo ?? '', productCode: productCode ?? '', productName: productName ?? productCode ?? '', channelCode: channelCode ?? '', @@ -475,31 +478,6 @@ return lines.join('\n'); } - function formatBatchJobResultText( - title: string, - job: { jobId: number; status: string; total: number; successCount: number; failCount: number }, - items: Array<{ orderId: number; serviceNo?: string; clientNo?: string; status: string; failReason?: string }>, - rows: any[] - ): string { - const lines = [ - `【${title}】任务 #${job.jobId}`, - `状态:${job.status},合计 ${job.total},成功 ${job.successCount},失败 ${job.failCount}`, - '', - ]; - for (const item of items) { - const row = rows.find((r) => Number(getOrderRowId(r)) === item.orderId); - const label = resolveBatchJobItemRowLabel(row, item); - if (item.status === 'success') { - lines.push(`【${label}】成功`); - } else if (item.status === 'failed') { - lines.push(`【${label}】失败:${item.failReason || '未知原因'}`); - } else { - lines.push(`【${label}】${item.status || '处理中'}`); - } - } - return lines.join('\n'); - } - async function runSelectedOrderBatch(options: { action: string; rows: any[]; @@ -596,19 +574,25 @@ ): string { const lines = [ `【${waybillNo || '-'}】`, + result.success ? '成功' : '失败', `standardCode:createOrderOne`, ]; if (result.supplierCode) { lines.push(`supplierCode:${result.supplierCode}`); } - lines.push(`URL:${result.url || '-'}`); - lines.push(`HTTP:${result.statusCode ?? '-'}`); - lines.push(`成功:${result.success ? '是' : '否'}`); - if (result.responseBody) { - lines.push(`响应:${result.responseBody}`); + if (result.downstreamServiceNo) { + lines.push(`服务商单号:${result.downstreamServiceNo}`); } + if (result.trackNo) { + lines.push(`跟踪号:${result.trackNo}`); + } + lines.push(`请求:${result.url || '-'}`); if (result.requestJson) { - lines.push(`请求体:${result.requestJson}`); + lines.push(`请求体:\n${result.requestJson}`); + } + lines.push(`HTTP:${result.statusCode ?? '-'}`); + if (result.responseBody) { + lines.push(`响应:\n${result.responseBody}`); } return lines.join('\n'); } @@ -619,12 +603,12 @@ const job = await postOrderBatchJob({ action: 'downstream-forward-create-one', orderIds }); const result = await waitOrderBatchJob(job.jobId); if (result.timedOut) { - downstreamForwardResultText.value = `${formatBatchJobSummaryMessage(result.job, { timedOut: true, jobId: job.jobId })}\n\n${formatBatchJobResultText('预报下游', result.job, result.items, rows)}`; + downstreamForwardResultText.value = `${formatBatchJobSummaryMessage(result.job, { timedOut: true, jobId: job.jobId })}\n\n${formatBatchJobDownstreamForwardResultText('预报下游', result.job, result.items, rows)}`; downstreamForwardModalVisible.value = true; return; } const { job: finishedJob, items } = result; - downstreamForwardResultText.value = formatBatchJobResultText('预报下游', finishedJob, items, rows); + downstreamForwardResultText.value = formatBatchJobDownstreamForwardResultText('预报下游', finishedJob, items, rows); downstreamForwardModalVisible.value = true; return; } catch (e: any) { diff --git a/src/views/user/marketing/invite/index.vue b/src/views/user/marketing/invite/index.vue index 6cf4067..573a012 100644 --- a/src/views/user/marketing/invite/index.vue +++ b/src/views/user/marketing/invite/index.vue @@ -43,16 +43,13 @@
@@ -161,7 +158,7 @@ import { IconCopy } from '@arco-design/web-vue/es/icon' import PaginationPro from '@/components/PaginationPro/index.vue' import { getMyInvite, getReferees } from '@/api/marketing/portal' import { buildInviteRegisterUrl, buildInviteShareText } from '@/utils/inviteLink' -import InviteQrImage from '@/components/InviteQrImage.vue' +import InvitePoster from '@/components/InvitePoster.vue' import PortalPageHeader from '../components/PortalPageHeader.vue' import PortalHowTo from '../components/PortalHowTo.vue' @@ -307,9 +304,9 @@ onMounted(() => loadAll()) .share-hero__grid { display: grid; - grid-template-columns: minmax(0, 1fr) 200px; + grid-template-columns: minmax(0, 1fr) 240px; gap: 28px; - align-items: center; + align-items: start; } .share-hero__eyebrow { @@ -356,31 +353,7 @@ onMounted(() => loadAll()) display: flex; flex-direction: column; align-items: center; - justify-content: center; -} - -.qr-frame { - width: 168px; - height: 168px; - padding: 10px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.85); - background: rgba(255, 255, 255, 0.9); - box-shadow: 0 8px 24px rgba(0, 121, 121, 0.08); - box-sizing: border-box; -} - -.qr-image { - width: 100%; - height: 100%; - display: block; -} - -.qr-tip { - margin: 10px 0 0; - font-size: 12px; - color: #86909c; - text-align: center; + justify-content: flex-start; } .stats-row {