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:
zzh 2026-07-28 10:21:23 +08:00
parent 8cf883c90c
commit 8a9d900628
27 changed files with 1351 additions and 239 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 KiB

1
src/components.d.ts vendored
View File

@ -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']

View 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>

View File

@ -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)',

View File

@ -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',

View File

@ -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': '好友下单并派送成功后,您可获得分成',

View File

@ -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
View 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' });
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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();

View File

@ -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,82 +42,171 @@
<a-spin :loading="optionsLoading" class="h5-po__spin">
<section v-show="step === 0" class="h5-po__card">
<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>
<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"
>
<a-option v-for="opt in senderOptions" :key="opt.id" :value="opt.id">
{{ 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 :placeholder="t('h5.placeOrder.clientNoPh')" />
<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>
<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>
<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" :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')" />
<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>
<label class="h5-po__label">{{ t('h5.placeOrder.sender') }}</label>
<a-select
:model-value="selectedSenderId"
allow-clear
:loading="senderLoading"
:placeholder="t('h5.placeOrder.senderPh')"
@change="onSenderChange"
<p
v-if="fieldError('length') || fieldError('width') || fieldError('height')"
class="h5-po__field-err"
>
<a-option v-for="opt in senderOptions" :key="opt.id" :value="opt.id">
{{ opt.label }}
</a-option>
</a-select>
{{ 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([
getSalesProductList({ page: 1, pageSize: 500, isActive: 2 }),
getDictListAll({ dictType: 'cargo_attribute' }),
getWarehouseList({ page: 1, pageSize: 500 }),
getDictListAll({ dictType: 'currency' }),
getCountryList(),
]);
const [productRes, cargoRows, warehouseRes, currencyRows, countryRes, salesProductGroupDictRows] =
await Promise.all([
getSalesProductList({ page: 1, pageSize: 500, isActive: 2 }),
getDictListAll({ dictType: 'cargo_attribute' }),
getWarehouseList({ page: 1, pageSize: 500 }),
getDictListAll({ dictType: 'currency' }),
getCountryList(),
getDictListAll({ dictType: 'sales_product_group' }),
]);
const domesticPickupGroupCodes = new Set(
(salesProductGroupDictRows ?? [])
.filter((d: any) => String(d?.dictName ?? d?.DictName ?? '').includes('国内揽收'))
.map((d: any) => String(d?.dictCode ?? d?.DictCode ?? '').trim())
.filter((code: string) => Boolean(code)),
);
productOptions.value = (productRes.items ?? [])
.filter((row: any) => {
const groupCode = String(row?.productGroup ?? row?.ProductGroup ?? '').trim();
return domesticPickupGroupCodes.size ? !domesticPickupGroupCodes.has(groupCode) : true;
})
.map((row: any) => {
const code = String(row?.productCode ?? row?.ProductCode ?? '').trim();
const name = String(row?.productName ?? row?.ProductName ?? '').trim();
@ -738,9 +1079,16 @@ async function loadSenders() {
.filter(Boolean) as typeof senderOptions.value;
const def = (senderOptions.value as any[]).find((x) => x.isDefault) || senderOptions.value[0];
if (def) onSenderChange(def.id);
if (def) {
onSenderChange(def.id);
showSenderForm.value = false;
} else {
onSenderChange(undefined);
showSenderForm.value = true;
}
} catch {
senderOptions.value = [];
showSenderForm.value = true;
} finally {
senderLoading.value = false;
}
@ -751,6 +1099,8 @@ watch(
(visible) => {
if (!visible) {
submitting.value = false;
showSenderForm.value = false;
resetSenderForm();
return;
}
resetForm();
@ -1000,6 +1350,63 @@ watch(
}
}
.h5-po__sender-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
.h5-po__label {
margin: 0;
}
}
.h5-po__link {
border: none;
background: transparent;
padding: 0;
color: var(--app-primary-color);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.h5-po__sender-empty {
margin: 0 0 10px;
padding: 10px 12px;
border-radius: 10px;
background: #fff7e8;
color: #ad6800;
font-size: 12px;
line-height: 1.45;
}
.h5-po__field-err {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.4;
color: #f53f3f;
&--block {
margin: 0 0 10px;
padding: 8px 10px;
border-radius: 8px;
background: #ffece8;
}
}
.h5-po__sender-form {
display: grid;
gap: 8px;
margin-bottom: 4px;
}
.h5-po__btn--block {
width: 100%;
}
.h5-po__dims,
.h5-po__three {
display: grid;

View File

@ -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() {

View File

@ -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',

View File

@ -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': '暂无邀请数据',

View File

@ -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();
});

View File

@ -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>

View File

@ -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 },

View File

@ -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'],

View File

@ -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) {

View File

@ -12,6 +12,7 @@ import { postOrderList } from '@/api/order'
import { createPickupOrder, getSenderAddressList } from '@/api/order/senderAddress'
import { getSalesProductList } from '@/api/channel/salesProduct'
import { getWarehouseList } from '@/api/system/warehouse'
import { getDictListAll } from '@/api/system/dict'
import { $baseMessage } from '@/utils'
import { hasPermission } from '@/utils/rbac-permission'
import CompleteContactModal from '@/components/CompleteContactModal/index.vue'
@ -114,7 +115,7 @@ async function loadReferenceOptions() {
refLoading.value = true
try {
const [senderRes, warehouseRes, productRes] = await Promise.all([
const [senderRes, warehouseRes, productRes, salesProductGroupDictRows] = await Promise.all([
getSenderAddressList(
withCustomerId({
page: 1,
@ -125,6 +126,7 @@ async function loadReferenceOptions() {
),
getWarehouseList({ page: 1, pageSize: 500 }),
getSalesProductList({ page: 1, pageSize: 500, isActive: 2 }),
getDictListAll({ dictType: 'sales_product_group' }),
])
senderOptions.value = (senderRes.items || []).map((item: any) => {
@ -144,14 +146,27 @@ async function loadReferenceOptions() {
return { value: id, label: name }
}).filter((x) => x.value > 0)
productOptions.value = (productRes.items || []).map((item: any) => {
const code = String(item?.productCode ?? item?.ProductCode ?? '').trim()
const name = String(item?.productName ?? item?.ProductName ?? '').trim()
return {
value: code,
label: name ? `${name} ${code}` : code,
}
}).filter((x) => x.value)
const domesticPickupGroupCodes = new Set(
(salesProductGroupDictRows ?? [])
.filter((d: any) => String(d?.dictName ?? d?.DictName ?? '').includes('国内揽收'))
.map((d: any) => String(d?.dictCode ?? d?.DictCode ?? '').trim())
.filter((code: string) => Boolean(code)),
)
productOptions.value = (productRes.items || [])
.filter((item: any) => {
const groupCode = String(item?.productGroup ?? item?.ProductGroup ?? '').trim()
return domesticPickupGroupCodes.size ? domesticPickupGroupCodes.has(groupCode) : true
})
.map((item: any) => {
const code = String(item?.productCode ?? item?.ProductCode ?? '').trim()
const name = String(item?.productName ?? item?.ProductName ?? '').trim()
return {
value: code,
label: name ? `${name} ${code}` : code,
}
})
.filter((x) => x.value)
} catch (e: any) {
$baseMessage(e?.message || t('order.pickupOrder.message.loadRefFailed'), 'error')
} finally {

View File

@ -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) {
Message.error(e?.message || t('order.placeOrder.single.failed'));
if (isCustomerContactRequiredError(e)) {
contactBlocked.value = true;
contactVisible.value = true;
Message.warning(t('h5.contact.requiredToast'));
} else if (
String(e?.message || '').includes('发件人姓名不能为空') ||
/sender\s*name/i.test(String(e?.message || ''))
) {
Message.warning(
senderAddressOptions.value.length
? t('order.placeOrder.single.senderRequired')
: t('order.placeOrder.single.senderEmpty'),
);
void openSenderModal();
} else {
Message.error(e?.message || t('order.placeOrder.single.failed'));
}
} finally {
singleSubmitting.value = false;
}
@ -2156,6 +2229,15 @@
@success="onContactCompleted"
@cancel="onContactCancelled"
/>
<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 {

View File

@ -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',

View File

@ -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'],

View File

@ -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) {

View File

@ -43,16 +43,13 @@
</div>
<div class="share-hero__qr">
<div class="qr-frame">
<InviteQrImage
v-if="inviteUrl"
:value="inviteUrl"
:size="200"
:alt="t('marketing.portal.invite.qrAlt')"
class="qr-image"
/>
</div>
<p class="qr-tip">{{ t('marketing.portal.invite.qrTip') }}</p>
<InvitePoster
v-if="inviteUrl"
:value="inviteUrl"
:invite-code="inviteCode"
:display-width="220"
:alt="t('marketing.portal.invite.posterAlt')"
/>
</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 {