Show full downstream forecast payloads and provider service no.
Improve batch/sync forecast result UI, invite poster composition, and expose DownstreamServiceNo on order lists. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8cf883c90c
commit
8a9d900628
@ -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)
|
||||
|
||||
BIN
src/assets/tg.jpg
Normal file
BIN
src/assets/tg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 865 KiB |
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@ -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']
|
||||
|
||||
151
src/components/InvitePoster.vue
Normal file
151
src/components/InvitePoster.vue
Normal file
@ -0,0 +1,151 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { buildInvitePosterDataUrl } from '@/utils/invitePoster';
|
||||
|
||||
defineOptions({ name: 'InvitePoster' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string;
|
||||
inviteCode?: string;
|
||||
/** 展示宽度(高度按海报比例自适应) */
|
||||
displayWidth?: number | string;
|
||||
showDownload?: boolean;
|
||||
alt?: string;
|
||||
}>(),
|
||||
{
|
||||
inviteCode: '',
|
||||
displayWidth: '100%',
|
||||
showDownload: true,
|
||||
alt: 'invite-poster',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: [dataUrl: string];
|
||||
fail: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const dataUrl = ref('');
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
|
||||
async function render() {
|
||||
failed.value = false;
|
||||
dataUrl.value = '';
|
||||
if (!props.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const url = await buildInvitePosterDataUrl({
|
||||
inviteUrl: props.value,
|
||||
inviteCode: props.inviteCode,
|
||||
});
|
||||
dataUrl.value = url;
|
||||
emit('ready', url);
|
||||
} catch {
|
||||
failed.value = true;
|
||||
emit('fail');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPoster() {
|
||||
if (!dataUrl.value) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl.value;
|
||||
a.download = 'invite-poster.png';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
Message.success(t('h5.invite.posterSaved'));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void render();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.value, props.inviteCode] as const,
|
||||
() => {
|
||||
void render();
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
getDataUrl: () => dataUrl.value,
|
||||
refresh: render,
|
||||
download: downloadPoster,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="invite-poster">
|
||||
<div v-if="loading" class="invite-poster__loading">{{ t('h5.invite.posterLoading') }}</div>
|
||||
<template v-else-if="dataUrl">
|
||||
<img
|
||||
:src="dataUrl"
|
||||
:alt="alt"
|
||||
class="invite-poster__img"
|
||||
:style="{ width: typeof displayWidth === 'number' ? `${displayWidth}px` : displayWidth }"
|
||||
/>
|
||||
<button
|
||||
v-if="showDownload"
|
||||
type="button"
|
||||
class="invite-poster__save"
|
||||
@click="downloadPoster"
|
||||
>
|
||||
{{ t('h5.invite.savePoster') }}
|
||||
</button>
|
||||
<p class="invite-poster__tip">{{ t('h5.invite.posterTip') }}</p>
|
||||
</template>
|
||||
<div v-else-if="failed" class="invite-poster__fail">{{ t('h5.invite.posterFailed') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.invite-poster {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.invite-poster__img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(8, 52, 115, 0.14);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.invite-poster__loading,
|
||||
.invite-poster__fail {
|
||||
padding: 28px 12px;
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.invite-poster__save {
|
||||
margin-top: 12px;
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--app-primary-color);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.invite-poster__tip {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@ -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)',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '好友下单并派送成功后,您可获得分成',
|
||||
|
||||
@ -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': '请选择(可不选,默认简易申报)',
|
||||
|
||||
137
src/utils/invitePoster.ts
Normal file
137
src/utils/invitePoster.ts
Normal file
@ -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<HTMLImageElement> {
|
||||
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<string> {
|
||||
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<File> {
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
return new File([blob], filename, { type: blob.type || 'image/png' });
|
||||
}
|
||||
@ -85,9 +85,14 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="inviteUrl" class="qr">
|
||||
<InviteQrImage :value="inviteUrl" :size="160" />
|
||||
<p>{{ t('h5.invite.qrTip') }}</p>
|
||||
<div v-if="inviteUrl" class="poster">
|
||||
<InvitePoster
|
||||
ref="posterRef"
|
||||
:value="inviteUrl"
|
||||
:invite-code="inviteCode"
|
||||
:alt="t('h5.invite.posterAlt')"
|
||||
@ready="onPosterReady"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -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<InviteTab>('share');
|
||||
const invite = ref<Record<string, any> | null>(null);
|
||||
const referees = ref<any[]>([]);
|
||||
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 {
|
||||
|
||||
@ -34,6 +34,12 @@
|
||||
<H5WarehouseAddressSheet v-model:visible="warehouseVisible" />
|
||||
<H5ShipGuideSheet v-model:visible="shipVisible" />
|
||||
<H5PlaceOrderSheet v-model:visible="placeOrderVisible" />
|
||||
<H5CompleteContactSheet
|
||||
v-model:visible="contactVisible"
|
||||
:profile="contactProfile"
|
||||
@success="onContactSuccess"
|
||||
@cancel="onContactCancel"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@ -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<CustomerProfileCompleteness | null>(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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 }}
|
||||
</button>
|
||||
@ -42,54 +42,42 @@
|
||||
|
||||
<a-spin :loading="optionsLoading" class="h5-po__spin">
|
||||
<section v-show="step === 0" class="h5-po__card">
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.clientNo') }}</label>
|
||||
<a-input v-model="form.clientNo" allow-clear :placeholder="t('h5.placeOrder.clientNoPh')" />
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.product') }}</label>
|
||||
<a-select
|
||||
v-model="form.productCode"
|
||||
allow-search
|
||||
allow-clear
|
||||
:placeholder="t('h5.placeOrder.productPh')"
|
||||
>
|
||||
<a-option v-for="opt in productOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.cargo') }}</label>
|
||||
<a-select v-model="form.cargoAttribute" allow-clear>
|
||||
<a-option v-for="opt in cargoOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.warehouse') }}</label>
|
||||
<a-select v-model="form.warehouseId" allow-clear>
|
||||
<a-option v-for="opt in warehouseOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.weight') }}</label>
|
||||
<a-input
|
||||
v-model="form.actualWeight"
|
||||
allow-clear
|
||||
:placeholder="t('h5.placeOrder.weightPh')"
|
||||
/>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.dimensions') }}</label>
|
||||
<div class="h5-po__dims">
|
||||
<a-input v-model="form.length" :placeholder="t('h5.placeOrder.length')" />
|
||||
<a-input v-model="form.width" :placeholder="t('h5.placeOrder.width')" />
|
||||
<a-input v-model="form.height" :placeholder="t('h5.placeOrder.height')" />
|
||||
<div class="h5-po__sender-head">
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.sender') }}</label>
|
||||
<button type="button" class="h5-po__link" @click="showSenderForm = !showSenderForm">
|
||||
{{ showSenderForm ? t('h5.placeOrder.cancelAddSender') : t('h5.placeOrder.addSender') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.sender') }}</label>
|
||||
<div v-if="showSenderForm" class="h5-po__sender-form">
|
||||
<a-input v-model="senderForm.senderName" :placeholder="t('h5.placeOrder.senderNamePh')" />
|
||||
<a-input v-model="senderForm.phone" :placeholder="t('h5.placeOrder.senderPhonePh')" />
|
||||
<a-input v-model="senderForm.province" :placeholder="t('h5.placeOrder.senderProvincePh')" />
|
||||
<a-input v-model="senderForm.city" :placeholder="t('h5.placeOrder.senderCityPh')" />
|
||||
<a-textarea
|
||||
v-model="senderForm.streetAddress1"
|
||||
:placeholder="t('h5.placeOrder.senderAddressPh')"
|
||||
:auto-size="{ minRows: 2, maxRows: 3 }"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="h5-po__btn is-primary h5-po__btn--block"
|
||||
:disabled="savingSender"
|
||||
@click="saveSender"
|
||||
>
|
||||
{{ savingSender ? t('h5.placeOrder.savingSender') : t('h5.placeOrder.saveSender') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="!senderLoading && !senderOptions.length" class="h5-po__sender-empty">
|
||||
{{ t('h5.placeOrder.senderEmpty') }}
|
||||
</p>
|
||||
<a-select
|
||||
:model-value="selectedSenderId"
|
||||
allow-clear
|
||||
:loading="senderLoading"
|
||||
:error="fieldHasError('sender')"
|
||||
:placeholder="t('h5.placeOrder.senderPh')"
|
||||
@change="onSenderChange"
|
||||
>
|
||||
@ -97,27 +85,128 @@
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<p v-if="fieldError('sender')" class="h5-po__field-err">{{ fieldError('sender') }}</p>
|
||||
</template>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.clientNo') }}</label>
|
||||
<a-input
|
||||
v-model="form.clientNo"
|
||||
allow-clear
|
||||
:error="fieldHasError('clientNo')"
|
||||
:placeholder="t('h5.placeOrder.clientNoPh')"
|
||||
/>
|
||||
<p v-if="fieldError('clientNo')" class="h5-po__field-err">{{ fieldError('clientNo') }}</p>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.product') }}</label>
|
||||
<a-select
|
||||
v-model="form.productCode"
|
||||
allow-search
|
||||
allow-clear
|
||||
:error="fieldHasError('productCode')"
|
||||
:placeholder="t('h5.placeOrder.productPh')"
|
||||
>
|
||||
<a-option v-for="opt in productOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<p v-if="fieldError('productCode')" class="h5-po__field-err">{{ fieldError('productCode') }}</p>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.cargo') }}</label>
|
||||
<a-select v-model="form.cargoAttribute" allow-clear :error="fieldHasError('cargoAttribute')">
|
||||
<a-option v-for="opt in cargoOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<p v-if="fieldError('cargoAttribute')" class="h5-po__field-err">{{ fieldError('cargoAttribute') }}</p>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.warehouse') }}</label>
|
||||
<a-select v-model="form.warehouseId" allow-clear :error="fieldHasError('warehouseId')">
|
||||
<a-option v-for="opt in warehouseOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<p v-if="fieldError('warehouseId')" class="h5-po__field-err">{{ fieldError('warehouseId') }}</p>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.weight') }}</label>
|
||||
<a-input
|
||||
v-model="form.actualWeight"
|
||||
allow-clear
|
||||
:error="fieldHasError('actualWeight')"
|
||||
:placeholder="t('h5.placeOrder.weightPh')"
|
||||
/>
|
||||
<p v-if="fieldError('actualWeight')" class="h5-po__field-err">{{ fieldError('actualWeight') }}</p>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.dimensions') }}</label>
|
||||
<div class="h5-po__dims">
|
||||
<a-input
|
||||
v-model="form.length"
|
||||
:error="fieldHasError('length')"
|
||||
:placeholder="t('h5.placeOrder.length')"
|
||||
/>
|
||||
<a-input
|
||||
v-model="form.width"
|
||||
:error="fieldHasError('width')"
|
||||
:placeholder="t('h5.placeOrder.width')"
|
||||
/>
|
||||
<a-input
|
||||
v-model="form.height"
|
||||
:error="fieldHasError('height')"
|
||||
:placeholder="t('h5.placeOrder.height')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('length') || fieldError('width') || fieldError('height')"
|
||||
class="h5-po__field-err"
|
||||
>
|
||||
{{ fieldError('length') || fieldError('width') || fieldError('height') }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-show="step === 1" class="h5-po__card">
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.receiverName') }}</label>
|
||||
<a-input v-model="form.receiver.recipientsName" allow-clear />
|
||||
<a-input
|
||||
v-model="form.receiver.recipientsName"
|
||||
allow-clear
|
||||
:error="fieldHasError('receiver.recipientsName')"
|
||||
/>
|
||||
<p v-if="fieldError('receiver.recipientsName')" class="h5-po__field-err">
|
||||
{{ fieldError('receiver.recipientsName') }}
|
||||
</p>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.receiverPhone') }}</label>
|
||||
<a-input v-model="form.receiver.phone" allow-clear />
|
||||
<a-input
|
||||
v-model="form.receiver.phone"
|
||||
allow-clear
|
||||
:error="fieldHasError('receiver.phone')"
|
||||
/>
|
||||
<p v-if="fieldError('receiver.phone')" class="h5-po__field-err">
|
||||
{{ fieldError('receiver.phone') }}
|
||||
</p>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.country') }}</label>
|
||||
<a-select v-model="form.receiver.countryCode" allow-search allow-clear>
|
||||
<a-select
|
||||
v-model="form.receiver.countryCode"
|
||||
allow-search
|
||||
allow-clear
|
||||
:error="fieldHasError('receiver.countryCode')"
|
||||
>
|
||||
<a-option v-for="opt in countryOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<p v-if="fieldError('receiver.countryCode')" class="h5-po__field-err">
|
||||
{{ fieldError('receiver.countryCode') }}
|
||||
</p>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.address') }}</label>
|
||||
<a-textarea
|
||||
v-model="form.receiver.streetAddress1"
|
||||
:error="fieldHasError('receiver.streetAddress1')"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
/>
|
||||
<p v-if="fieldError('receiver.streetAddress1')" class="h5-po__field-err">
|
||||
{{ fieldError('receiver.streetAddress1') }}
|
||||
</p>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.city') }}</label>
|
||||
<a-input v-model="form.receiver.city" allow-clear />
|
||||
@ -126,19 +215,40 @@
|
||||
<a-input v-model="form.receiver.province" allow-clear />
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.zip') }}</label>
|
||||
<a-input v-model="form.receiver.zipCode" allow-clear />
|
||||
<a-input
|
||||
v-model="form.receiver.zipCode"
|
||||
allow-clear
|
||||
:error="fieldHasError('receiver.zipCode')"
|
||||
/>
|
||||
<p v-if="fieldError('receiver.zipCode')" class="h5-po__field-err">
|
||||
{{ fieldError('receiver.zipCode') }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-show="step === 2" class="h5-po__declare">
|
||||
<p v-if="declarationLiveHint" class="h5-po__field-err h5-po__field-err--block">
|
||||
{{ declarationLiveHint }}
|
||||
</p>
|
||||
<div class="h5-po__card h5-po__card--currency">
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.currency') }}</label>
|
||||
<a-select v-model="form.declaredCurrency" allow-clear>
|
||||
<a-select
|
||||
v-model="form.declaredCurrency"
|
||||
allow-clear
|
||||
:error="fieldHasError('declaredCurrency')"
|
||||
>
|
||||
<a-option v-for="opt in currencyOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<p v-if="fieldError('declaredCurrency')" class="h5-po__field-err">
|
||||
{{ fieldError('declaredCurrency') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="fieldError('itemsEmpty')" class="h5-po__field-err h5-po__field-err--block">
|
||||
{{ fieldError('itemsEmpty') }}
|
||||
</p>
|
||||
|
||||
<div v-for="(row, idx) in items" :key="row._key" class="h5-po__card h5-po__item">
|
||||
<div class="h5-po__item-head">
|
||||
<strong>{{ t('h5.placeOrder.itemTitle', { n: idx + 1 }) }}</strong>
|
||||
@ -153,7 +263,14 @@
|
||||
</div>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.enName') }}</label>
|
||||
<a-input v-model="row.enName" allow-clear />
|
||||
<a-input
|
||||
v-model="row.enName"
|
||||
allow-clear
|
||||
:error="fieldHasError(`items.${idx}.enName`)"
|
||||
/>
|
||||
<p v-if="fieldError(`items.${idx}.enName`)" class="h5-po__field-err">
|
||||
{{ fieldError(`items.${idx}.enName`) }}
|
||||
</p>
|
||||
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.cnName') }}</label>
|
||||
<a-input v-model="row.cnName" allow-clear />
|
||||
@ -161,17 +278,46 @@
|
||||
<div class="h5-po__three">
|
||||
<div>
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.unitPrice') }}</label>
|
||||
<a-input v-model="row.unitPrice" allow-clear @input="syncItemDerived(row)" />
|
||||
<a-input
|
||||
v-model="row.unitPrice"
|
||||
allow-clear
|
||||
:error="fieldHasError(`items.${idx}.unitPrice`)"
|
||||
@input="syncItemDerived(row)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.unitWeight') }}</label>
|
||||
<a-input v-model="row.unitWeight" allow-clear @input="syncItemDerived(row)" />
|
||||
<a-input
|
||||
v-model="row.unitWeight"
|
||||
allow-clear
|
||||
:error="fieldHasError(`items.${idx}.unitWeight`)"
|
||||
@input="syncItemDerived(row)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="h5-po__label">{{ t('h5.placeOrder.qty') }}</label>
|
||||
<a-input v-model="row.quantity" allow-clear @input="syncItemDerived(row)" />
|
||||
<a-input
|
||||
v-model="row.quantity"
|
||||
allow-clear
|
||||
:error="fieldHasError(`items.${idx}.quantity`)"
|
||||
@input="syncItemDerived(row)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="
|
||||
fieldError(`items.${idx}.unitPrice`) ||
|
||||
fieldError(`items.${idx}.unitWeight`) ||
|
||||
fieldError(`items.${idx}.quantity`)
|
||||
"
|
||||
class="h5-po__field-err"
|
||||
>
|
||||
{{
|
||||
fieldError(`items.${idx}.unitPrice`) ||
|
||||
fieldError(`items.${idx}.unitWeight`) ||
|
||||
fieldError(`items.${idx}.quantity`)
|
||||
}}
|
||||
</p>
|
||||
<p class="h5-po__derived">
|
||||
{{ 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<number | undefined>();
|
||||
/** 当前步骤校验失败后标红;申报步骤始终即时标红 */
|
||||
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<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<string, string>;
|
||||
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<string, any> | null) {
|
||||
async function loadOptions(draft: Record<string, any> | null) {
|
||||
optionsLoading.value = true;
|
||||
try {
|
||||
const [productRes, cargoRows, warehouseRes, currencyRows, countryRes] = await Promise.all([
|
||||
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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '暂无邀请数据',
|
||||
|
||||
@ -350,6 +350,12 @@
|
||||
v-model:visible="placeOrderVisible"
|
||||
:draft="placeOrderDraft"
|
||||
/>
|
||||
<H5CompleteContactSheet
|
||||
v-model:visible="contactVisible"
|
||||
:profile="contactProfile"
|
||||
@success="onContactSuccess"
|
||||
@cancel="onContactCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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<Record<string, any> | null>(null);
|
||||
const contactVisible = ref(false);
|
||||
const contactProfile = ref<CustomerProfileCompleteness | null>(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<string, any>) {
|
||||
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();
|
||||
});
|
||||
|
||||
@ -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 || '-'
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -62,6 +62,10 @@
|
||||
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.trackNo') }}</span>
|
||||
<span class="basic-chip-value">{{ getOrderValue('trackNo') }}</span>
|
||||
</span>
|
||||
<span class="basic-chip">
|
||||
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.downstreamServiceNo') }}</span>
|
||||
<span class="basic-chip-value">{{ getOrderValue('downstreamServiceNo') }}</span>
|
||||
</span>
|
||||
<span class="basic-chip">
|
||||
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.consigneeCountryName') }}</span>
|
||||
<span class="basic-chip-value">{{ getOrderValue('consigneeCountryName') }}</span>
|
||||
@ -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'],
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 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)
|
||||
})
|
||||
.filter((x) => x.value)
|
||||
} catch (e: any) {
|
||||
$baseMessage(e?.message || t('order.pickupOrder.message.loadRefFailed'), 'error')
|
||||
} finally {
|
||||
|
||||
@ -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<string, string>;
|
||||
const map: Record<string, string> = {};
|
||||
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) {
|
||||
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"
|
||||
/>
|
||||
<div v-if="contactBlocked" class="contact-block-banner">
|
||||
<div class="contact-block-banner__text">
|
||||
<strong>{{ t('order.placeOrder.contact.blockTitle') }}</strong>
|
||||
<span>{{ t('order.placeOrder.contact.blockDesc') }}</span>
|
||||
</div>
|
||||
<a-button type="primary" size="small" @click="contactVisible = true">
|
||||
{{ t('order.placeOrder.contact.blockAction') }}
|
||||
</a-button>
|
||||
</div>
|
||||
<a-tabs v-model:active-key="activeOrderMode" type="line" class="place-order-tabs">
|
||||
<a-tab-pane key="single" :title="t('order.placeOrder.singleOrder')">
|
||||
<div class="single-tab">
|
||||
@ -2208,14 +2290,20 @@
|
||||
</div>
|
||||
<div class="toolbar-right single-toolbar-right">
|
||||
<button
|
||||
v-if="selectedSenderSummary"
|
||||
type="button"
|
||||
class="sender-summary-chip"
|
||||
:title="t('order.placeOrder.single.senderEditHint')"
|
||||
:class="{ 'is-empty': !selectedSenderSummary }"
|
||||
:title="
|
||||
selectedSenderSummary
|
||||
? t('order.placeOrder.single.senderEditHint')
|
||||
: t('order.placeOrder.single.senderEmpty')
|
||||
"
|
||||
@click="openSenderModal"
|
||||
>
|
||||
<span class="sender-summary-label">{{ t('order.placeOrder.single.stepSender') }}</span>
|
||||
<span class="sender-summary-text">{{ selectedSenderSummary }}</span>
|
||||
<span class="sender-summary-text">
|
||||
{{ selectedSenderSummary || t('order.placeOrder.single.senderPickHint') }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="single-tips">
|
||||
<span class="single-tip-text">{{ t('order.placeOrder.single.tip1') }}</span>
|
||||
@ -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
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="declarationLiveHint" class="declaration-live-hint">{{ declarationLiveHint }}</p>
|
||||
<div
|
||||
class="declaration-resize-handle"
|
||||
title="拖拽调节高度"
|
||||
@ -3006,6 +3100,59 @@
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
&.is-contact-blocked {
|
||||
.place-order-tabs {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
opacity: 0.42;
|
||||
filter: grayscale(0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-block-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ffcf8b;
|
||||
background: #fff7e8;
|
||||
flex-shrink: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.contact-block-banner__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
color: #ad6800;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #86909c;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.declaration-live-hint {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fff7e8;
|
||||
color: #ad6800;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.liquid-card {
|
||||
@ -4223,6 +4370,16 @@
|
||||
border-color: var(--app-primary-color);
|
||||
background: rgba(45, 140, 140, 0.1);
|
||||
}
|
||||
|
||||
&.is-empty {
|
||||
border-color: #ffcf8b;
|
||||
background: #fff7e8;
|
||||
|
||||
.sender-summary-label,
|
||||
.sender-summary-text {
|
||||
color: #ad6800;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sender-summary-label {
|
||||
|
||||
@ -18,6 +18,7 @@ export const WAYBILL_MANAGE_COLUMN_CATALOG: OrderListColumnDef[] = [
|
||||
{ key: 'waybillNo', labelKey: M('waybillNo'), width: 200 },
|
||||
{ key: 'customerOrderNo', labelKey: M('customerOrderNo'), width: 150 },
|
||||
{ key: 'trackingNo', labelKey: M('trackingNo'), width: 160 },
|
||||
{ key: 'downstreamServiceNo', labelKey: M('downstreamServiceNo'), width: 180 },
|
||||
{ key: 'shippingMethod', labelKey: M('shippingMethod'), width: 120 },
|
||||
{ key: 'serviceChannel', labelKey: M('serviceChannel'), width: 120 },
|
||||
{ key: 'consignee', labelKey: M('consignee'), width: 90 },
|
||||
@ -34,6 +35,7 @@ export const WAYBILL_MANAGE_COLUMN_CATALOG: OrderListColumnDef[] = [
|
||||
{ key: 'clientNo', labelKey: M('customerOrderNo'), width: 150 },
|
||||
{ key: 'trackNo', labelKey: M('trackingNo'), width: 160 },
|
||||
{ key: 'serviceNo', labelKey: M('waybillNo'), width: 200 },
|
||||
{ key: 'downstreamServiceNo', labelKey: M('downstreamServiceNo'), width: 180 },
|
||||
{ key: 'productCode', labelKey: L('productCode'), width: 110 },
|
||||
{ key: 'productName', labelKey: M('shippingMethod'), width: 120 },
|
||||
{ key: 'channelCode', labelKey: L('channelCode'), width: 110 },
|
||||
@ -139,6 +141,7 @@ export const WAYBILL_MANAGE_DEFAULT_KEYS: string[] = [
|
||||
'waybillNo',
|
||||
'customerOrderNo',
|
||||
'trackingNo',
|
||||
'downstreamServiceNo',
|
||||
'orderStatus',
|
||||
'shippingMethod',
|
||||
'serviceChannel',
|
||||
|
||||
@ -92,6 +92,10 @@
|
||||
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.trackNo') }}</span>
|
||||
<span class="basic-chip-value">{{ getOrderValue('trackNo') }}</span>
|
||||
</span>
|
||||
<span class="basic-chip">
|
||||
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.downstreamServiceNo') }}</span>
|
||||
<span class="basic-chip-value">{{ getOrderValue('downstreamServiceNo') }}</span>
|
||||
</span>
|
||||
<span class="basic-chip">
|
||||
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.channelName') }}</span>
|
||||
<span class="basic-chip-value">{{ getOrderValue('channelName') }}</span>
|
||||
@ -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'],
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -43,17 +43,14 @@
|
||||
</div>
|
||||
|
||||
<div class="share-hero__qr">
|
||||
<div class="qr-frame">
|
||||
<InviteQrImage
|
||||
<InvitePoster
|
||||
v-if="inviteUrl"
|
||||
:value="inviteUrl"
|
||||
:size="200"
|
||||
:alt="t('marketing.portal.invite.qrAlt')"
|
||||
class="qr-image"
|
||||
:invite-code="inviteCode"
|
||||
:display-width="220"
|
||||
:alt="t('marketing.portal.invite.posterAlt')"
|
||||
/>
|
||||
</div>
|
||||
<p class="qr-tip">{{ t('marketing.portal.invite.qrTip') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user