feat: 官网纸面对话与登录翻页过渡,波浪更稳,并同步面单打印优化
Some checks failed
Build and Deploy Front / deploy (push) Has been cancelled

欢迎区/侧栏/聊天槽统一暖纸拟物;跟进改仓储代发;波浪降扰动关视差减少眩晕。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-24 13:33:00 +08:00
parent 95918100cf
commit 07b71ca565
18 changed files with 1869 additions and 662 deletions

View File

@ -1,21 +1,216 @@
<template>
<a-config-provider :locale="locale">
<router-view />
<div
class="app-portal-stage"
:class="{ 'app-portal-stage--flip': isPageFlip }"
>
<router-view v-slot="{ Component, route }">
<Transition
:css="false"
@leave="onPortalLeave"
@enter="onPortalEnter"
@after-leave="clearPortalTransition"
>
<component
:is="Component"
v-if="Component"
:key="portalViewKey(route)"
/>
</Transition>
</router-view>
</div>
<GlobalSetting v-if="showGlobalSetting" />
</a-config-provider>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { computed, ref } from 'vue';
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import enUS from '@arco-design/web-vue/es/locale/lang/en-us';
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
import useLocale from '@/hooks/locale';
import { isLoginRouteName } from '@/utils/login-portal';
import { consumePageFlip } from '@/utils/page-transition-flag';
import GlobalSetting from '@/components/global-setting/index.vue';
const route = useRoute();
const router = useRouter();
/** '' | 'oms-page-flip' */
const portalTransition = ref('');
const isPageFlip = computed(() => portalTransition.value === 'oms-page-flip');
function clearPortalTransition() {
portalTransition.value = '';
}
/** 用顶层路由名作 key,避免业务页切路由时整页 remount */
function portalViewKey(r: RouteLocationNormalizedLoaded) {
const top = r.matched[0];
if (top?.name) return String(top.name);
return top?.path || r.path;
}
function prepareSheet(node: HTMLElement, z: number) {
node.style.position = 'absolute';
node.style.inset = '0';
node.style.width = '100%';
node.style.minHeight = '100%';
node.style.zIndex = String(z);
node.style.transformStyle = 'preserve-3d';
node.style.backfaceVisibility = 'hidden';
node.style.willChange = 'transform, opacity, filter';
}
function clearSheetStyles(node: HTMLElement) {
node.style.position = '';
node.style.inset = '';
node.style.width = '';
node.style.minHeight = '';
node.style.zIndex = '';
node.style.transformStyle = '';
node.style.backfaceVisibility = '';
node.style.willChange = '';
node.style.transform = '';
node.style.transformOrigin = '';
node.style.filter = '';
node.style.opacity = '';
node.style.boxShadow = '';
}
/**
* 真翻书:书脊在左固定,右上角往左掀。
* 多段缓动;新页在底下同步亮起。
*/
function playBookFlipLeave(node: HTMLElement, done: () => void) {
prepareSheet(node, 4);
node.style.transformOrigin = '0% 22%';
node.style.boxShadow = '-8px 12px 28px rgba(8, 18, 28, 0.16)';
const shade = document.createElement('div');
shade.className = 'oms-flip-shade';
node.appendChild(shade);
const anim = node.animate(
[
{
transform: 'rotateY(0deg)',
filter: 'brightness(1)',
opacity: 1,
boxShadow: '-6px 10px 24px rgba(8, 18, 28, 0.12)',
offset: 0,
},
{
transform: 'rotateY(-8deg)',
filter: 'brightness(0.99)',
opacity: 1,
boxShadow: '-10px 12px 28px rgba(8, 18, 28, 0.16)',
offset: 0.18,
},
{
transform: 'rotateY(-28deg)',
filter: 'brightness(0.97)',
opacity: 1,
boxShadow: '-16px 14px 34px rgba(8, 18, 28, 0.2)',
offset: 0.36,
},
{
transform: 'rotateY(-58deg)',
filter: 'brightness(0.94)',
opacity: 1,
boxShadow: '-22px 12px 40px rgba(8, 18, 28, 0.24)',
offset: 0.55,
},
{
transform: 'rotateY(-98deg)',
filter: 'brightness(0.9)',
opacity: 0.92,
boxShadow: '-26px 10px 44px rgba(8, 18, 28, 0.26)',
offset: 0.74,
},
{
transform: 'rotateY(-138deg)',
filter: 'brightness(0.84)',
opacity: 0.7,
boxShadow: '-18px 8px 32px rgba(8, 18, 28, 0.18)',
offset: 0.9,
},
{
transform: 'rotateY(-162deg)',
filter: 'brightness(0.78)',
opacity: 0.28,
boxShadow: '-6px 6px 18px rgba(8, 18, 28, 0.1)',
offset: 1,
},
],
{
duration: 1480,
easing: 'cubic-bezier(0.33, 0.0, 0.2, 1)',
fill: 'forwards',
},
);
const shadeAnim = shade.animate(
[
{ opacity: 0, offset: 0 },
{ opacity: 0.25, offset: 0.18 },
{ opacity: 0.55, offset: 0.45 },
{ opacity: 0.7, offset: 0.7 },
{ opacity: 0.2, offset: 1 },
],
{
duration: 1480,
easing: 'cubic-bezier(0.33, 0.0, 0.2, 1)',
fill: 'forwards',
},
);
Promise.all([anim.finished, shadeAnim.finished])
.catch(() => undefined)
.finally(() => {
shade.remove();
done();
});
}
function playBookFlipEnter(node: HTMLElement, done: () => void) {
prepareSheet(node, 1);
const anim = node.animate(
[
{ opacity: 0.88, filter: 'brightness(0.9)', offset: 0 },
{ opacity: 0.94, filter: 'brightness(0.94)', offset: 0.45 },
{ opacity: 1, filter: 'brightness(1)', offset: 1 },
],
{
duration: 1480,
easing: 'cubic-bezier(0.33, 0.0, 0.2, 1)',
fill: 'forwards',
},
);
anim.finished
.catch(() => undefined)
.finally(() => {
clearSheetStyles(node);
done();
});
}
function onPortalLeave(el: Element, done: () => void) {
if (portalTransition.value !== 'oms-page-flip') {
done();
return;
}
playBookFlipLeave(el as HTMLElement, done);
}
function onPortalEnter(el: Element, done: () => void) {
if (portalTransition.value !== 'oms-page-flip') {
done();
return;
}
playBookFlipEnter(el as HTMLElement, done);
}
function isStandalonePortalPath(path: string) {
return (
@ -40,6 +235,29 @@
return true;
});
function isPcLoginRouteName(name: unknown) {
return name === 'login' || name === 'adminLogin';
}
router.beforeEach((to, from) => {
const armed = consumePageFlip();
const siteToLogin =
from.name === 'siteRoot' &&
(to.name === 'login' || to.name === 'H5Login') &&
to.query.from === 'official-site';
const loginPortalSwitch =
isPcLoginRouteName(from.name) &&
isPcLoginRouteName(to.name) &&
from.name !== to.name;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if ((armed || siteToLogin || loginPortalSwitch) && !reduceMotion) {
portalTransition.value = 'oms-page-flip';
} else {
portalTransition.value = '';
}
});
const { currentLocale } = useLocale();
const locale = computed(() => {
switch (currentLocale.value) {
@ -59,7 +277,6 @@
background: var(--app-sidebar-bg) !important;
}
/* 针对Vue scoped样式的强制覆盖 */
[data-v-eb552580].main-menu {
background: var(--app-sidebar-bg) !important;
}
@ -72,18 +289,15 @@ div.main-menu[data-v-eb552580] {
background: var(--app-sidebar-bg) !important;
}
/* 使用最高优先级的选择器 */
html body .main-menu {
background: var(--app-sidebar-bg) !important;
}
/* 主菜单文字样式覆盖 */
.main-menu .menu-text {
font-size: 12px !important;
font-weight: bold !important;
}
/* 针对Vue scoped样式的文字覆盖 */
[data-v-eb552580] .menu-text {
font-size: 12px !important;
font-weight: bold !important;
@ -94,9 +308,45 @@ html body .main-menu {
font-weight: bold !important;
}
/* 最高优先级选择器 */
html body .main-menu .menu-text {
font-size: 12px !important;
font-weight: bold !important;
}
.app-portal-stage {
min-height: 100%;
}
.app-portal-stage--flip {
position: relative;
perspective: 2600px;
perspective-origin: 78% 16%;
overflow: hidden;
min-height: 100vh;
min-height: 100dvh;
background: #0b1620;
}
.oms-flip-shade {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
background:
linear-gradient(
90deg,
rgba(0, 0, 0, 0.28) 0%,
rgba(0, 0, 0, 0.1) 14%,
rgba(0, 0, 0, 0.02) 28%,
transparent 46%
),
linear-gradient(
215deg,
rgba(255, 255, 255, 0.22) 0%,
rgba(255, 255, 255, 0.05) 18%,
transparent 42%
);
mix-blend-mode: multiply;
opacity: 0;
}
</style>

View File

@ -110,6 +110,8 @@
onSelectionChange?: (selectedRows: any[]) => void;
// 自定义单元格渲染器
customCellRenderer?: (row: number, col: number, value: any, rowData: any) => string | null;
/** 行 class(如状态底色),避免依赖 :has() 在虚拟滚动时闪烁 */
resolveRowClassName?: (rowData: any, rowIndex: number) => string | string[] | null | undefined;
// 高度
height?: number;
// 是否只读
@ -120,6 +122,11 @@
onOpenMultiValueModal?: (field: string, label: string) => void;
// 列是否拉伸铺满容器宽度
stretchColumns?: boolean;
/**
* 是否对 props.data 做 deep watch。
* 只读列表整表替换时建议 false,避免 shallow + deep 双刷 loadData。
*/
deepWatchData?: boolean;
// 固定行高(像素),设置后禁用 autoRowSize
rowHeight?: number;
// 是否启用“序号表头-设置排序”入口
@ -167,6 +174,7 @@
multiValueFields: () => [],
height: undefined,
stretchColumns: false,
deepWatchData: true,
rowHeight: undefined,
enableColumnOrderConfig: false,
enableColumnReorder: false,
@ -319,6 +327,13 @@
checkbox: CHECKBOX_COL_WIDTH,
};
let remasuringActionWidth = false;
/** 操作列宽按按钮方案缓存,避免 afterRender 反复量 DOM */
let actionWidthSchemeKey = '';
let actionWidthCalibrated = false;
/** 滚动中跳过 afterRender 重活,停滚后再补一次,减轻闪白 */
let tableScrolling = false;
let scrollIdleTimer: ReturnType<typeof setTimeout> | null = null;
const ROW_STATUS_CLASS_RE = /\brow-status-\S+/g;
/**
* 固定列序:操作(可选) → 序号 → 复选框(可选) → 数据列
@ -414,14 +429,30 @@
return padX + buttons * btnSize + Math.max(0, buttons - 1) * gap;
}
function getActionWidthSchemeKey(): string {
if (!props.actionRenderer) return 'none';
const html = props.data.length
? props.actionRenderer(props.data[0], 0) || ''
: props.actionRenderer({}, 0) || '';
const actions = [...html.matchAll(/data-action=["']([^"']+)["']/g)].map((m) => m[1]);
if (actions.length) return actions.join('|');
return `btn:${countActionButtonsInHtml(html)}`;
}
/** 按操作按钮内容自适应操作列宽 */
function resolveActionColumnWidth(): number {
let maxWidth = 48;
if (!props.actionRenderer) {
layoutFixedWidths.action = maxWidth;
actionWidthSchemeKey = 'none';
actionWidthCalibrated = true;
return maxWidth;
}
const sampleLen = Math.min(Math.max(props.data.length, 1), 40);
const scheme = getActionWidthSchemeKey();
if (scheme === actionWidthSchemeKey && layoutFixedWidths.action >= 48) {
return layoutFixedWidths.action;
}
const sampleLen = Math.min(Math.max(props.data.length, 1), 8);
const samples: string[] = [];
if (!props.data.length) {
samples.push(props.actionRenderer({}, 0) || '');
@ -438,6 +469,8 @@
});
maxWidth = Math.min(Math.max(48, Math.ceil(maxWidth)), 420);
layoutFixedWidths.action = maxWidth;
actionWidthSchemeKey = scheme;
actionWidthCalibrated = false;
return maxWidth;
}
@ -450,9 +483,11 @@
return stretchedWidth;
}
/** 渲染后用真实单元格再校准操作列宽(覆盖页面自定义 CSS) */
/** 渲染后用真实单元格再校准操作列宽(覆盖页面自定义 CSS);同操作方案只校准一次 */
function remasureActionColumnFromDom() {
if (!hotInstance || !props.showActionColumn || remasuringActionWidth) return;
const scheme = getActionWidthSchemeKey();
if (actionWidthCalibrated && scheme === actionWidthSchemeKey) return;
const { actionColIndex } = getLayoutIndices();
if (actionColIndex < 0 || !hotContainer.value) return;
const cells = hotContainer.value.querySelectorAll('td.ht-action-cell');
@ -484,6 +519,8 @@
});
if (maxContent <= 0) return;
const next = Math.min(Math.max(48, maxContent + 16), 420);
actionWidthSchemeKey = scheme;
actionWidthCalibrated = true;
if (Math.abs(next - layoutFixedWidths.action) < 4) return;
remasuringActionWidth = true;
layoutFixedWidths.action = next;
@ -1497,8 +1534,9 @@
automaticLayout: true,
observeDOMVisibility: true,
renderAllRows: false,
viewportColumnRenderingOffset: 'auto',
viewportRowRenderingOffset: 'auto',
// 加大视口外预渲染,滚动时少出现「空一下再出字」
viewportColumnRenderingOffset: 8,
viewportRowRenderingOffset: 40,
// 强制启用滚动条
scrollable: true,
// 确保滚动条可见
@ -1750,6 +1788,18 @@
td.classList.remove('ht-select-cell', 'ht-select-cell--empty', 'ht-editable-cell');
const layout = getLayoutIndices();
const applyRowClass = (rowIndex: number) => {
if (!props.resolveRowClassName || rowIndex < 0) return;
const tr = td.parentElement as HTMLTableRowElement | null;
if (!tr) return;
const rowData = rowAt(rowIndex);
const next = props.resolveRowClassName(rowData, rowIndex);
const classes = (Array.isArray(next) ? next : [next])
.map((x) => String(x || '').trim())
.filter(Boolean);
const cleaned = (tr.className || '').replace(ROW_STATUS_CLASS_RE, '').replace(/\s+/g, ' ').trim();
tr.className = classes.length ? `${cleaned} ${classes.join(' ')}`.trim() : cleaned;
};
// 操作列(永远第一列,居中)
if (col === layout.actionColIndex && row >= 0 && row < props.data.length) {
@ -1766,6 +1816,7 @@
</svg>
</div>`;
}
applyRowClass(row);
return;
}
@ -1776,6 +1827,8 @@
td.innerHTML = value || '';
td.style.color = '#8c8c8c';
td.style.fontWeight = '500';
// 无操作列时由序号列挂行 class
if (layout.actionColIndex < 0) applyRowClass(row);
return;
}
@ -2036,34 +2089,16 @@
}
}
},
afterScrollVertically: markTableScrolling,
afterScrollHorizontally: markTableScrolling,
afterRender: function(isForced: boolean) {
if (!hotInstance) return;
ensureHorizontalScrollbarVisible();
// 移除操作列和序号列的筛选图标(这些列不需要筛选功能)
const removeFilterIconFromColumns = () => {
const headerRows = hotContainer.value?.querySelectorAll('.ht_clone_top thead tr, .ht_master thead tr');
if (!headerRows?.length) return;
const { actionColIndex, serialColIndex, checkboxColIndex } = getLayoutIndices();
const noFilterCols = new Set(
[checkboxColIndex, actionColIndex, serialColIndex].filter((i) => i >= 0)
);
headerRows.forEach((row: Element) => {
const ths = row.querySelectorAll('th');
ths.forEach((th: Element, index: number) => {
if (noFilterCols.has(index)) {
const btn = th.querySelector('.changeType');
if (btn?.parentElement) btn.parentElement.removeChild(btn);
}
});
});
};
removeFilterIconFromColumns();
syncHybridHeaders();
applySelectedRowStyles();
remasureActionColumnFromDom();
// remasure/updateSettings 后 HOT 会按原生滚动条再次缩短 clone,需再对齐一次
scheduleCloneOverlaySync();
// 滚动过程中的增量 render:只保选中态,避免反复量 DOM / 同步表头造成闪白
if (!isForced && tableScrolling) {
applySelectedRowStyles();
return;
}
runAfterRenderMaintenance();
}
};
@ -2077,6 +2112,44 @@
}
};
function runAfterRenderMaintenance() {
if (!hotInstance) return;
ensureHorizontalScrollbarVisible();
// 移除操作列和序号列的筛选图标(这些列不需要筛选功能)
const headerRows = hotContainer.value?.querySelectorAll('.ht_clone_top thead tr, .ht_master thead tr');
if (headerRows?.length) {
const { actionColIndex, serialColIndex, checkboxColIndex } = getLayoutIndices();
const noFilterCols = new Set(
[checkboxColIndex, actionColIndex, serialColIndex].filter((i) => i >= 0),
);
headerRows.forEach((row: Element) => {
const ths = row.querySelectorAll('th');
ths.forEach((th: Element, index: number) => {
if (noFilterCols.has(index)) {
const btn = th.querySelector('.changeType');
if (btn?.parentElement) btn.parentElement.removeChild(btn);
}
});
});
}
syncHybridHeaders();
applySelectedRowStyles();
remasureActionColumnFromDom();
// remasure/updateSettings 后 HOT 会按原生滚动条再次缩短 clone,需再对齐一次
scheduleCloneOverlaySync();
}
function markTableScrolling() {
tableScrolling = true;
if (scrollIdleTimer) clearTimeout(scrollIdleTimer);
scrollIdleTimer = setTimeout(() => {
tableScrolling = false;
scrollIdleTimer = null;
runAfterRenderMaintenance();
}, 140);
}
const ensureHorizontalScrollbarVisible = () => {
if (!hotContainer.value) return;
const holder = hotContainer.value.querySelector('.ht_master .wtHolder') as HTMLElement | null;
@ -2541,7 +2614,7 @@
* 数据刷新策略:
* - 可编辑表:afterChange 会写回行对象;若 deep watch,上千行一改一格就全表 loadData → 卡死。
* 因此只在数组引用/长度变化时刷新。
* - 只读表:仍 deep watch,兼容外部原地改字段后的展示刷新。
* - 只读表:默认 deep watch;列表页可传 deepWatchData=false,整表替换时避免双刷。
*/
watch(
() => [props.data, props.data?.length] as const,
@ -2554,6 +2627,7 @@
() => props.data,
() => {
if (props.readOnly === false) return;
if (props.deepWatchData === false) return;
refreshHandsontableData();
},
{ deep: true },
@ -2713,6 +2787,11 @@
onUnmounted(() => {
pauseHeaderSearchEmit();
if (scrollIdleTimer) {
clearTimeout(scrollIdleTimer);
scrollIdleTimer = null;
}
tableScrolling = false;
if (resizeObserver && containerRef.value) {
try {
resizeObserver.unobserve(containerRef.value);

View File

@ -10,8 +10,24 @@
>
<div class="print-modal-body">
<div :class="['print-status-bar', `print-status-bar--${printerStatusTone}`]">
<span class="print-status-dot" />
<span class="print-status-text">{{ printerStatusText }}</span>
<div class="print-status-bar__left">
<span class="print-status-dot" />
<span class="print-status-text">{{ printerStatusText }}</span>
</div>
<div class="print-status-bar__right">
<a-select
v-model="selectedPrinter"
class="print-printer-select"
size="mini"
allow-search
:loading="printerListLoading"
:disabled="!canSelectPrinter"
:options="printerSelectOptions"
placeholder="默认打印机"
@change="onPrinterChange"
@popup-visible-change="onPrinterPopupVisible"
/>
</div>
</div>
<div class="print-options-card">
@ -51,6 +67,27 @@
>
查看面单
</a-button>
<span
v-if="getItemPrintResult(item.id) === 'success'"
class="batch-list-item-result is-success"
title="打印成功"
>
<icon-check :size="16" />
</span>
<span
v-else-if="getItemPrintResult(item.id) === 'fail'"
class="batch-list-item-result is-fail"
title="打印失败"
>
<icon-close :size="16" />
</span>
<span
v-else-if="getItemPrintResult(item.id) === 'printing'"
class="batch-list-item-result is-printing"
title="打印中"
>
<icon-loading :size="14" />
</span>
</div>
<a-empty v-if="!listItems.length" description="暂无可打印内容" />
</div>
@ -74,7 +111,7 @@
:loading="printingAll"
@click="printAll"
>
{{ printingAll ? '发送中…' : '全部打印' }}
{{ printingAll ? '打印中…' : '全部打印' }}
</a-button>
<a-button
type="primary"
@ -90,13 +127,16 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { Message } from '@arco-design/web-vue';
import { IconCheck, IconClose, IconLoading } from '@arco-design/web-vue/es/icon';
import {
fetchHtmlTextForHiprint,
fetchPdfBytesForHiprint,
hiprintRefreshPrinterList,
hiprintSendBlobPdf,
hiprintSendHtmlPrint,
type HiprintPrinterInfo,
} from '@/utils/hiprint/hiprintElectronSocket';
import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState';
import { resolveOmsAssetUrl } from '@/api/oms/common';
@ -106,7 +146,9 @@ import {
buildHiprintBlobPrintExtras,
HIPRINT_PAPER_PRESET_OPTIONS,
readHiprintPrintFormFromStorage,
readHiprintSelectedPrinterFromStorage,
writeHiprintPrintFormToStorage,
writeHiprintSelectedPrinterToStorage,
} from '@/utils/hiprint/hiprintPrintOptions';
interface PrintItem {
@ -115,6 +157,8 @@ interface PrintItem {
url: string;
}
type ItemPrintResult = 'idle' | 'printing' | 'success' | 'fail';
const props = withDefaults(
defineProps<{
visible: boolean;
@ -142,7 +186,31 @@ const advancedOpen = ref(false);
const printingActive = ref(false);
const printingAll = ref(false);
const printJustSucceeded = ref(false);
const printerList = ref<HiprintPrinterInfo[]>([]);
const printerListLoading = ref(false);
const selectedPrinter = ref(readHiprintSelectedPrinterFromStorage());
/** 批量列表逐票打印结果:idle | printing | success | fail */
const itemPrintResultMap = reactive<Record<string, ItemPrintResult>>({});
let printSuccessTimer: ReturnType<typeof setTimeout> | null = null;
let printerListReqId = 0;
function itemResultKey(id: number | string) {
return String(id);
}
function getItemPrintResult(id: number | string): ItemPrintResult {
return itemPrintResultMap[itemResultKey(id)] || 'idle';
}
function setItemPrintResult(id: number | string, result: ItemPrintResult) {
itemPrintResultMap[itemResultKey(id)] = result;
}
function resetItemPrintResults() {
Object.keys(itemPrintResultMap).forEach((key) => {
delete itemPrintResultMap[key];
});
}
const listItems = computed(() => props.items || []);
const printableItems = computed(() => listItems.value.filter((x) => !!String(x?.url || '').trim()));
@ -170,7 +238,7 @@ const printerStatusTone = computed(() => {
});
const printerStatusText = computed(() => {
if (printingActive.value || printingAll.value) return '正在发送打印任务…';
if (printingActive.value || printingAll.value) return '正在打印,等待客户端回执…';
if (props.preparing) return '正在获取面单文件…';
if (hiprintClientState.connecting) return '正在连接本机打印程序…';
if (hiprintClientState.connected) return '本机打印已就绪,可一键自动打印';
@ -180,6 +248,31 @@ const printerStatusText = computed(() => {
const footerBusy = computed(() => printingActive.value || printingAll.value);
const canSelectPrinter = computed(
() => hiprintClientState.enabled && hiprintClientState.connected && !footerBusy.value,
);
const printerSelectOptions = computed(() =>
printerList.value.map((p) => ({
value: p.name,
label: p.isDefault ? `${p.displayName || p.name}(默认)` : p.displayName || p.name,
})),
);
function pickDefaultPrinterName(list: HiprintPrinterInfo[]) {
return list.find((p) => p.isDefault)?.name || list[0]?.name || '';
}
function applyPrinterSelection(list: HiprintPrinterInfo[]) {
const current = selectedPrinter.value;
const next =
current && list.some((p) => p.name === current) ? current : pickDefaultPrinterName(list);
if (next !== current) {
selectedPrinter.value = next;
}
if (next) writeHiprintSelectedPrinterToStorage(next);
}
const canViewLabel = computed(() => !!activeItem.value?.url && !props.preparing);
const canPrint = computed(
@ -191,8 +284,8 @@ const canPrint = computed(
);
const printActiveLabel = computed(() => {
if (printJustSucceeded.value) return '已发送';
if (printingActive.value) return '发送中…';
if (printJustSucceeded.value) return '已打印';
if (printingActive.value) return '打印中…';
return props.mode === 'batch' ? '打印当前' : '打印';
});
@ -209,17 +302,59 @@ function flashPrintSuccess() {
}, 1600);
}
async function loadPrinterList(force = false) {
if (!hiprintClientState.enabled || !hiprintClientState.connected) {
printerList.value = [];
return;
}
if (printerListLoading.value && !force) return;
const reqId = ++printerListReqId;
printerListLoading.value = true;
try {
const list = await hiprintRefreshPrinterList();
if (reqId !== printerListReqId) return;
printerList.value = list;
applyPrinterSelection(list);
} catch {
if (reqId !== printerListReqId) return;
printerList.value = [];
} finally {
if (reqId === printerListReqId) printerListLoading.value = false;
}
}
function onPrinterChange(value: string | undefined) {
writeHiprintSelectedPrinterToStorage(String(value || ''));
}
function onPrinterPopupVisible(visible: boolean) {
if (visible) void loadPrinterList(true);
}
watch(
() => props.visible,
(visible) => {
if (!visible) {
advancedOpen.value = false;
printJustSucceeded.value = false;
resetItemPrintResults();
if (printSuccessTimer) clearTimeout(printSuccessTimer);
printSuccessTimer = null;
return;
}
activeId.value = printableItems.value[0]?.id ?? null;
selectedPrinter.value = readHiprintSelectedPrinterFromStorage();
resetItemPrintResults();
void loadPrinterList(true);
},
);
watch(
() => hiprintClientState.connected,
(connected) => {
if (!props.visible) return;
if (connected) void loadPrinterList(true);
else printerList.value = [];
},
);
@ -282,15 +417,16 @@ function openActiveInNative() {
async function silentPrintByUrl(url: string) {
const extras = buildHiprintBlobPrintExtras(hiprintForm.value);
const printer = String(selectedPrinter.value || '').trim() || undefined;
const isPdf = /\.pdf(\?|#|$)/i.test(url);
try {
const bytes = await fetchPrintAssetBytes(url);
if (isPdf) {
await hiprintSendBlobPdf({ pdf_blob: bytes, printExtras: extras });
await hiprintSendBlobPdf({ pdf_blob: bytes, printer, printExtras: extras });
return;
}
const html = new TextDecoder('utf-8').decode(bytes);
await hiprintSendHtmlPrint({ html, printExtras: extras });
await hiprintSendHtmlPrint({ html, printer, printExtras: extras });
return;
} catch (primaryError) {
const absolute = toAbsoluteUrl(url);
@ -304,11 +440,23 @@ async function silentPrintByUrl(url: string) {
}
if (isPdf) {
const pdf_blob = await fetchPdfBytesForHiprint(toAbsoluteUrl(url));
await hiprintSendBlobPdf({ pdf_blob, printExtras: extras });
await hiprintSendBlobPdf({ pdf_blob, printer, printExtras: extras });
return;
}
const html = await fetchHtmlTextForHiprint(toAbsoluteUrl(url));
await hiprintSendHtmlPrint({ html, printExtras: extras });
await hiprintSendHtmlPrint({ html, printer, printExtras: extras });
}
async function printOneItem(item: PrintItem) {
setItemPrintResult(item.id, 'printing');
try {
await silentPrintByUrl(item.url);
setItemPrintResult(item.id, 'success');
return true;
} catch (e: unknown) {
setItemPrintResult(item.id, 'fail');
throw e;
}
}
async function printActive() {
@ -322,11 +470,11 @@ async function printActive() {
}
printingActive.value = true;
try {
await silentPrintByUrl(activeItem.value.url);
await printOneItem(activeItem.value);
flashPrintSuccess();
Message.success('已发送到本机打印');
Message.success('打印成功');
} catch (e: any) {
Message.warning(e?.message || '自动打印失败,将尝试浏览器打开面单');
Message.warning(e?.message || '打印失败,将尝试浏览器打开面单');
await openItemInNative(activeItem.value);
} finally {
printingActive.value = false;
@ -344,18 +492,22 @@ async function printAll() {
}
printingAll.value = true;
try {
const tasks = printableItems.value.map((item) =>
silentPrintByUrl(item.url).catch(async () => {
await openItemInNative(item);
}),
);
const results = await Promise.allSettled(tasks);
const failed = results.filter((x) => x.status === 'rejected').length;
const tasks = printableItems.value.map(async (item) => {
try {
await printOneItem(item);
return true;
} catch {
return false;
}
});
const results = await Promise.all(tasks);
const ok = results.filter(Boolean).length;
const failed = results.length - ok;
if (failed === 0) {
flashPrintSuccess();
Message.success(`批量打印完成(${results.length} 单)`);
} else {
Message.warning(`批量打印完成:成功 ${results.length - failed},失败 ${failed}`);
Message.warning(`批量打印完成:成功 ${ok},失败 ${failed}`);
}
} finally {
printingAll.value = false;
@ -373,7 +525,8 @@ async function printAll() {
.print-status-bar {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
@ -381,6 +534,30 @@ async function printAll() {
background: var(--color-fill-2);
}
.print-status-bar__left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.print-status-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.print-status-bar__right {
flex-shrink: 0;
width: 180px;
}
.print-printer-select {
width: 100%;
}
.print-status-bar--success {
background: rgba(0, 180, 42, 0.08);
border-color: rgba(0, 180, 42, 0.25);
@ -478,6 +655,27 @@ async function printAll() {
color: var(--color-text-3);
}
.batch-list-item-result {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
&.is-success {
color: rgb(0, 180, 42);
}
&.is-fail {
color: rgb(245, 63, 63);
}
&.is-printing {
color: var(--color-text-3);
}
}
.single-label-row {
display: flex;
align-items: center;

View File

@ -19,6 +19,7 @@ import {
} from '@/utils/login-portal';
import { preferH5Portal } from '@/utils/device';
import { applyRbacUserToUserStore } from '@/utils/enter-system';
import { peekPageFlip } from '@/utils/page-transition-flag';
function buildLoginRedirect(to: {
name?: string | symbol | null;
@ -63,7 +64,9 @@ export default function setupUserLoginInfoGuard(router: Router) {
!isLoginRouteName(toName) &&
!isLoginRouteName(fromName) &&
tabBarStore.getCacheList.includes(toName);
// 门户翻页:跳过顶栏横条,等待感交给翻页动画
if (
!peekPageFlip() &&
!isCachedTabSwitch &&
!String(to.path || '').startsWith('/h5') &&
to.path !== '/'

View File

@ -10,6 +10,7 @@ import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState';
import {
buildHiprintBlobPrintExtras,
readHiprintPrintFormFromStorage,
readHiprintSelectedPrinterFromStorage,
} from '@/utils/hiprint/hiprintPrintOptions';
function toAbsoluteUrl(url: string) {
@ -22,15 +23,20 @@ function toAbsoluteUrl(url: string) {
return `${window.location.origin}/${resolved}`;
}
function resolveSelectedPrinter() {
return readHiprintSelectedPrinterFromStorage() || undefined;
}
async function sendBytesToHiprint(rawUrl: string, bytes: Uint8Array) {
const extras = buildHiprintBlobPrintExtras(readHiprintPrintFormFromStorage());
const printer = resolveSelectedPrinter();
const isPdf = /\.pdf(\?|#|$)/i.test(rawUrl);
if (isPdf) {
await hiprintSendBlobPdf({ pdf_blob: bytes, printExtras: extras });
await hiprintSendBlobPdf({ pdf_blob: bytes, printer, printExtras: extras });
return;
}
const html = new TextDecoder('utf-8').decode(bytes);
await hiprintSendHtmlPrint({ html, printExtras: extras });
await hiprintSendHtmlPrint({ html, printer, printExtras: extras });
}
/** 本机 hiprint 静默打印,不打开新标签页 */
@ -60,14 +66,15 @@ export async function dispatchPrintUrl(rawUrl: string) {
}
const absolute = toAbsoluteUrl(url);
const printer = resolveSelectedPrinter();
if (/\.pdf(\?|#|$)/i.test(url)) {
const pdf_blob = await fetchPdfBytesForHiprint(absolute);
const extras = buildHiprintBlobPrintExtras(readHiprintPrintFormFromStorage());
await hiprintSendBlobPdf({ pdf_blob, printExtras: extras });
await hiprintSendBlobPdf({ pdf_blob, printer, printExtras: extras });
return;
}
const html = await fetchHtmlTextForHiprint(absolute);
const extras = buildHiprintBlobPrintExtras(readHiprintPrintFormFromStorage());
await hiprintSendHtmlPrint({ html, printExtras: extras });
await hiprintSendHtmlPrint({ html, printer, printExtras: extras });
}

View File

@ -91,15 +91,123 @@ export async function ensureHiprintSocketConnected(timeoutMs = 15000) {
return socket;
}
export async function hiprintSendUrlPdf(payload: { pdf_path: string; printer?: string; templateId?: string }) {
if (!payload.pdf_path) throw new Error('pdf 地址为空');
export type HiprintPrinterInfo = {
name: string;
displayName?: string;
isDefault?: boolean;
description?: string;
status?: number;
};
/** 向本机打印客户端请求打印机列表(emit refreshPrinterList → on printerList) */
export async function hiprintRefreshPrinterList(timeoutMs = 8000): Promise<HiprintPrinterInfo[]> {
const socket = await ensureHiprintSocketConnected();
return new Promise<HiprintPrinterInfo[]>((resolve, reject) => {
const timer = setTimeout(() => {
socket.off('printerList', onList);
reject(new Error('获取打印机列表超时'));
}, timeoutMs);
const onList = (list: unknown) => {
clearTimeout(timer);
socket.off('printerList', onList);
const arr = Array.isArray(list) ? list : [];
resolve(
arr
.map((item) => {
const row = item as Partial<HiprintPrinterInfo>;
const name = String(row?.name || row?.displayName || '').trim();
if (!name) return null;
return {
name,
displayName: String(row?.displayName || name).trim() || name,
isDefault: !!row?.isDefault,
description: row?.description,
status: row?.status,
} as HiprintPrinterInfo;
})
.filter((x): x is HiprintPrinterInfo => !!x),
);
};
socket.once('printerList', onList);
socket.emit('refreshPrinterList');
});
}
function createHiprintTemplateId(prefix = 'oms') {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function matchHiprintTemplateId(payload: unknown, templateId: string) {
if (payload == null) return false;
if (typeof payload === 'string') return payload === templateId;
if (typeof payload === 'object') {
const row = payload as Record<string, unknown>;
const id = row.templateId ?? row.id;
return String(id ?? '') === templateId;
}
return false;
}
function readHiprintErrorMessage(payload: unknown) {
if (typeof payload === 'string' && payload.trim()) return payload.trim();
if (payload && typeof payload === 'object') {
const row = payload as Record<string, unknown>;
const msg = row.msg ?? row.message ?? row.error ?? row.errMsg;
if (msg != null && String(msg).trim()) return String(msg).trim();
}
return '打印失败';
}
/** 等待本机客户端按 templateId 回传 success / error */
function waitHiprintPrintResult(socket: Socket, templateId: string, timeoutMs = 90000) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error('等待打印结果超时'));
}, timeoutMs);
const onSuccess = (payload: unknown) => {
if (!matchHiprintTemplateId(payload, templateId)) return;
cleanup();
resolve();
};
const onError = (payload: unknown) => {
if (!matchHiprintTemplateId(payload, templateId)) return;
cleanup();
reject(new Error(readHiprintErrorMessage(payload)));
};
const cleanup = () => {
clearTimeout(timer);
socket.off('success', onSuccess);
socket.off('error', onError);
};
socket.on('success', onSuccess);
socket.on('error', onError);
});
}
export async function hiprintSendUrlPdf(payload: {
pdf_path: string;
printer?: string;
templateId?: string;
/** 是否等待客户端 success/error,默认 true */
waitResult?: boolean;
}) {
if (!payload.pdf_path) throw new Error('pdf 地址为空');
const templateId = payload.templateId || createHiprintTemplateId();
const socket = await ensureHiprintSocketConnected();
const wait =
payload.waitResult === false ? Promise.resolve() : waitHiprintPrintResult(socket, templateId);
socket.emit('news', {
type: 'url_pdf',
templateId: payload.templateId || `oms-${Date.now()}`,
templateId,
pdf_path: payload.pdf_path,
printer: payload.printer || undefined,
});
await wait;
}
function resolveBrowserFetchUrl(pathOrUrl: string) {
@ -166,18 +274,24 @@ export async function hiprintSendBlobPdf(payload: {
printer?: string;
templateId?: string;
printExtras?: Record<string, unknown>;
/** 是否等待客户端 success/error,默认 true */
waitResult?: boolean;
}) {
if (!(payload.pdf_blob instanceof Uint8Array) || payload.pdf_blob.byteLength === 0) {
throw new Error('PDF 数据为空');
}
const templateId = payload.templateId || createHiprintTemplateId();
const socket = await ensureHiprintSocketConnected();
const wait =
payload.waitResult === false ? Promise.resolve() : waitHiprintPrintResult(socket, templateId);
socket.emit('news', {
type: 'blob_pdf',
templateId: payload.templateId || `oms-${Date.now()}`,
templateId,
pdf_blob: payload.pdf_blob,
printer: payload.printer || undefined,
...(payload.printExtras || {}),
});
await wait;
}
export async function hiprintSendHtmlPrint(payload: {
@ -185,15 +299,21 @@ export async function hiprintSendHtmlPrint(payload: {
printer?: string;
templateId?: string;
printExtras?: Record<string, unknown>;
/** 是否等待客户端 success/error,默认 true */
waitResult?: boolean;
}) {
if (!payload.html || !String(payload.html).trim()) throw new Error('HTML 内容为空');
const templateId = payload.templateId || createHiprintTemplateId();
const socket = await ensureHiprintSocketConnected();
const wait =
payload.waitResult === false ? Promise.resolve() : waitHiprintPrintResult(socket, templateId);
socket.emit('news', {
type: 'html',
html: payload.html,
templateId: payload.templateId || `oms-${Date.now()}`,
templateId,
printer: payload.printer || undefined,
...(payload.printExtras || {}),
});
await wait;
}

View File

@ -1,4 +1,5 @@
const STORAGE_KEY = 'oms_hiprint_print_options_v1';
const PRINTER_STORAGE_KEY = 'oms_hiprint_selected_printer_v1';
export interface HiprintPrintForm {
paperPreset: string;
@ -9,6 +10,27 @@ export interface HiprintPrintForm {
scaleFactor: number;
}
export function readHiprintSelectedPrinterFromStorage(): string {
try {
return String(window.localStorage.getItem(PRINTER_STORAGE_KEY) || '').trim();
} catch {
return '';
}
}
export function writeHiprintSelectedPrinterToStorage(printer: string) {
try {
const name = String(printer || '').trim();
if (!name) {
window.localStorage.removeItem(PRINTER_STORAGE_KEY);
return;
}
window.localStorage.setItem(PRINTER_STORAGE_KEY, name);
} catch {
// ignore
}
}
/** 物流面单常用尺寸(mm),走 pageSize 而非系统纸张名 */
export const HIPRINT_LABEL_PAPER_MM: Record<string, { widthMm: number; heightMm: number }> = {
label_100x150: { widthMm: 100, heightMm: 150 },

View File

@ -1,6 +1,9 @@
/** 登录进系统后首屏跳过 page-fade */
let skipOnce = false;
/** 门户翻页过渡(官网→登录 / 客户↔管理登录,单次消费) */
let pageFlipOnce = false;
export function skipNextPageTransition() {
skipOnce = true;
}
@ -10,3 +13,34 @@ export function consumeSkipPageTransition(): boolean {
skipOnce = false;
return true;
}
/** 武装一次书页翻页过渡 */
export function armPageFlip() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
pageFlipOnce = true;
}
/** @deprecated 同 armPageFlip */
export function armOfficialLoginFlip() {
armPageFlip();
}
export function consumePageFlip(): boolean {
if (!pageFlipOnce) return false;
pageFlipOnce = false;
return true;
}
/** @deprecated 同 consumePageFlip */
export function consumeOfficialLoginFlip(): boolean {
return consumePageFlip();
}
export function peekPageFlip(): boolean {
return pageFlipOnce;
}
/** @deprecated 同 peekPageFlip */
export function peekOfficialLoginFlip(): boolean {
return peekPageFlip();
}

View File

@ -5,6 +5,7 @@
:to="to"
replace
:aria-label="label"
@click="onSwitchClick"
>
<icon-swap :size="16" />
</router-link>
@ -16,6 +17,7 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { IconSwap } from '@arco-design/web-vue/es/icon';
import { ADMIN_LOGIN_PATH, CUSTOMER_LOGIN_PATH } from '@/utils/login-portal';
import { armPageFlip } from '@/utils/page-transition-flag';
const props = defineProps<{
/** 当前页面对侧:admin=去管理登录;customer=去客户登录 */
@ -33,6 +35,19 @@ const label = computed(() =>
? t('login.switchToAdmin')
: t('login.switchToCustomer'),
);
function onSwitchClick(e: MouseEvent) {
if (
e.button !== 0 ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey
) {
return;
}
armPageFlip();
}
</script>
<style lang="less" scoped>

View File

@ -144,7 +144,12 @@ const showCharacters = ref(false);
const activeKey = ref<'pwd' | 'wechat'>('pwd');
const wechatMounted = ref(false);
const wechatLoginRef = ref<{ prefetch?: () => void } | null>(null);
const shellReady = ref(false);
/** 翻页进入时立刻出壳,避免过渡中空屏 */
const shellReady = ref(
route.query.from === 'official-site' ||
route.name === 'login' ||
route.name === 'adminLogin',
);
const isExiting = ref(false);
const loginCharactersApi = shallowRef<LoginCharactersApi | null>(null);
const containerRef = ref<HTMLElement | null>(null);

View File

@ -186,8 +186,25 @@
return undefined;
}
/** 列表行与表格列对齐:接口用 serviceNo/clientNo/trackNo,表格用运单号/客户单号/跟踪号 */
function normalizeOrderRow(raw: any) {
function escapeHtml(text: string) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function buildCustomerLabelMap() {
const map = new Map<number, string>();
for (const opt of customerOptions.value || []) {
const id = Number(opt.value);
if (Number.isFinite(id)) map.set(id, String(opt.label || ''));
}
return map;
}
/** 列表行与表格列对齐:只保留标量/展平字段,不保留嵌套树(Items/FeeList 等) */
function normalizeOrderRow(raw: any, customerLabelMap?: Map<number, string>) {
const r = raw?.receiver || raw?.Receiver || {};
const s = raw?.sender || raw?.Sender || {};
const wl = raw?.weightList || raw?.WeightList;
@ -204,15 +221,68 @@
const channelName = pick(raw, 'channelName', 'ChannelName');
const isPaidRaw = pick(raw, 'isPaid', 'IsPaid');
const customerId = pick(raw, 'customerId', 'CustomerId') ?? raw?.customerId;
const customerFromOptions = customerOptions.value.find(
(opt) => Number(opt.value) === Number(customerId),
);
const customerLabel =
(customerId != null && customerLabelMap?.get(Number(customerId))) ||
customerOptions.value.find((opt) => Number(opt.value) === Number(customerId))?.label ||
'';
const boolish = (v: any) => v === true || v === 1 || v === '1';
const hasInsuranceRaw = pick(raw, 'hasInsurance', 'HasInsurance');
let hasInsurance: boolean | undefined;
if (hasInsuranceRaw === true || hasInsuranceRaw === 1 || hasInsuranceRaw === '1') hasInsurance = true;
else if (hasInsuranceRaw === false || hasInsuranceRaw === 0 || hasInsuranceRaw === '0') {
hasInsurance = false;
}
const statusCode =
resolveOrderStatusCode(raw) || pick(raw, 'status', 'Status') || raw?.status || '';
const orderStatusText = pick(raw, 'orderStatus', 'OrderStatus') ?? raw?.orderStatus;
const handleStatus = pick(raw, 'handleStatus', 'HandleStatus') ?? raw?.handleStatus;
const handleStatusName = pick(raw, 'handleStatusName', 'HandleStatusName') ?? raw?.handleStatusName;
const predeclareFailReason = pick(raw, 'predeclareFailReason', 'PredeclareFailReason') ?? '';
const allocationPendingReason =
pick(raw, 'allocationPendingReason', 'AllocationPendingReason') ?? '';
const isPaid = boolish(isPaidRaw);
const isCod = boolish(pick(raw, 'isCod', 'IsCod'));
const isIntercept = boolish(pick(raw, 'isIntercept', 'IsIntercept'));
const isRemote = boolish(pick(raw, 'isRemote', 'IsRemote'));
const cargoAttribute = pick(raw, 'cargoAttribute', 'CargoAttribute') ?? raw?.cargoAttribute;
const fulfillmentType = pick(raw, 'fulfillmentType', 'FulfillmentType') ?? 'direct';
const stockBindStatus = pick(raw, 'stockBindStatus', 'StockBindStatus') ?? 'none';
const fulfillProgress = pick(raw, 'fulfillProgress', 'FulfillProgress') ?? '';
const domesticTrackingNos = pick(raw, 'domesticTrackingNos', 'DomesticTrackingNos') ?? '';
const primaryBinCode = pick(raw, 'primaryBinCode', 'PrimaryBinCode') ?? '';
const waybillNo = serviceNo ?? '';
const consigneeCountry =
pick(raw, 'consigneeCountry', 'ConsigneeCountry') ?? pick(r, 'country', 'Country') ?? '';
const consigneeCountryName = pick(raw, 'consigneeCountryName', 'ConsigneeCountryName') ?? '';
const statusMeta = getOrderStatusMeta(String(statusCode || ''));
const statusDisplay = statusMeta
? t(statusMeta.labelKey)
: orderStatusText || statusCode || '--';
const handleCode = resolveOrderHandleStatusCode({ handleStatus, status: statusCode });
const handleMeta = getOrderHandleStatusMeta(handleCode);
const handleText =
handleStatusName || (handleMeta ? t(handleMeta.labelKey) : '');
const showHandleTag = isOrderHandleSubTabParent(String(statusCode || '')) && handleText;
const orderStatusHtml = `<span style="display:inline-flex;align-items:center;flex-wrap:wrap;gap:6px;max-width:100%;">${buildOrderStatusTagHtml(String(statusDisplay), statusMeta)}${
showHandleTag ? buildOrderStatusSubTagHtml(String(handleText), handleMeta) : ''
}</span>`;
let predeclareFailReasonHtml: string | null = null;
if (predeclareFailReason) {
const guide = buildFailGuideFromReason(String(predeclareFailReason));
const display = guide.reason || String(predeclareFailReason);
predeclareFailReasonHtml = `<span title="${escapeHtml(guide.rawReason || String(predeclareFailReason))}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8a5c58;font-size:12px;">${escapeHtml(display)}</span>`;
}
const allocationPendingReasonHtml = allocationPendingReason
? `<span title="${escapeHtml(String(allocationPendingReason))}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#7a6a52;font-size:12px;">${escapeHtml(String(allocationPendingReason))}</span>`
: null;
return {
...raw,
// 展平寄件人(便于列配置直接选接口字段)
id: pick(raw, 'id', 'Id') ?? raw?.id,
// 展平寄件人
senderName: pick(s, 'senderName', 'SenderName') ?? '',
senderCompany: pick(s, 'company', 'Company') ?? '',
senderPhone: pick(s, 'phone', 'Phone') ?? '',
@ -226,7 +296,7 @@
senderStreetAddress2: pick(s, 'streetAddress2', 'StreetAddress2') ?? '',
senderStreetAddress3: pick(s, 'streetAddress3', 'StreetAddress3') ?? '',
senderHouseNumber: pick(s, 'houseNumber', 'HouseNumber') ?? '',
// 展平收件人原始字段
// 展平收件人
receiverRecipientsName: pick(r, 'recipientsName', 'RecipientsName') ?? '',
receiverCompanyName: pick(r, 'companyName', 'CompanyName') ?? '',
receiverPhone: pick(r, 'phone', 'Phone') ?? '',
@ -247,7 +317,10 @@
receiverEori: pick(r, 'eori', 'Eori') ?? '',
receiverVat: pick(r, 'vat', 'Vat') ?? '',
waybillNo: serviceNo ?? '',
serviceNo: serviceNo ?? '',
clientNo: clientNo ?? '',
trackNo: trackNo ?? '',
waybillNo,
customerOrderNo: clientNo ?? '',
trackingNo: trackNo ?? '',
downstreamServiceNo: downstreamServiceNo ?? '',
@ -255,22 +328,33 @@
productName: productName ?? productCode ?? '',
channelCode: channelCode ?? '',
channelName: channelName ?? channelCode ?? '',
downstreamSupplierName:
pick(raw, 'downstreamSupplierName', 'DownstreamSupplierName') ?? '',
downstreamSupplierCode:
pick(raw, 'downstreamSupplierCode', 'DownstreamSupplierCode') ?? '',
isPaid: boolish(isPaidRaw),
isCod: boolish(pick(raw, 'isCod', 'IsCod')),
isIntercept: boolish(pick(raw, 'isIntercept', 'IsIntercept')),
isRemote: boolish(pick(raw, 'isRemote', 'IsRemote')),
consignee: pick(raw, 'consignee', 'Consignee') ?? pick(r, 'recipientsName', 'RecipientsName') ?? '',
consigneePhone: pick(raw, 'consigneePhone', 'ConsigneePhone') ?? pick(r, 'phone', 'Phone') ?? '',
consigneeAddress: pick(raw, 'consigneeAddress', 'ConsigneeAddress') ?? pick(r, 'streetAddress1', 'StreetAddress1') ?? '',
consigneePostcode: pick(raw, 'consigneePostcode', 'ConsigneePostcode') ?? pick(r, 'zipCode', 'ZipCode') ?? '',
consigneeState: pick(raw, 'consigneeState', 'ConsigneeState') ?? pick(r, 'province', 'Province') ?? '',
downstreamSupplierName: pick(raw, 'downstreamSupplierName', 'DownstreamSupplierName') ?? '',
downstreamSupplierCode: pick(raw, 'downstreamSupplierCode', 'DownstreamSupplierCode') ?? '',
downstreamAppKey: pick(raw, 'downstreamAppKey', 'DownstreamAppKey') ?? '',
downstreamAppId: pick(raw, 'downstreamAppId', 'DownstreamAppId') ?? '',
channelForecastCode: pick(raw, 'channelForecastCode', 'ChannelForecastCode') ?? '',
maxTimeliness: pick(raw, 'maxTimeliness', 'MaxTimeliness') ?? null,
salesProductRulesVersion: pick(raw, 'salesProductRulesVersion', 'SalesProductRulesVersion') ?? '',
salesProductPriceVersion: pick(raw, 'salesProductPriceVersion', 'SalesProductPriceVersion') ?? '',
isPaid,
isCod,
isIntercept,
isRemote,
consignee:
pick(raw, 'consignee', 'Consignee') ?? pick(r, 'recipientsName', 'RecipientsName') ?? '',
consigneePhone:
pick(raw, 'consigneePhone', 'ConsigneePhone') ?? pick(r, 'phone', 'Phone') ?? '',
consigneeAddress:
pick(raw, 'consigneeAddress', 'ConsigneeAddress') ??
pick(r, 'streetAddress1', 'StreetAddress1') ??
'',
consigneePostcode:
pick(raw, 'consigneePostcode', 'ConsigneePostcode') ?? pick(r, 'zipCode', 'ZipCode') ?? '',
consigneeState:
pick(raw, 'consigneeState', 'ConsigneeState') ?? pick(r, 'province', 'Province') ?? '',
consigneeCity: pick(raw, 'consigneeCity', 'ConsigneeCity') ?? pick(r, 'city', 'City') ?? '',
consigneeCountry: pick(raw, 'consigneeCountry', 'ConsigneeCountry') ?? pick(r, 'country', 'Country') ?? '',
consigneeCountryName: pick(raw, 'consigneeCountryName', 'ConsigneeCountryName') ?? '',
consigneeCountry,
consigneeCountryName,
ioss: pick(raw, 'ioss', 'Ioss', 'IOSS') ?? '',
eori: pick(raw, 'eori', 'Eori', 'EORI') ?? pick(r, 'eori', 'Eori') ?? '',
shippingMethod: productName ?? productCode ?? '',
@ -282,48 +366,100 @@
receivableAmount: pick(raw, 'receivableAmount', 'ReceivableAmount') ?? '',
declaredSum: pick(raw, 'declaredSum', 'DeclaredSum') ?? raw?.declaredSum ?? null,
declaredCurrency: pick(raw, 'declaredCurrency', 'DeclaredCurrency') ?? raw?.declaredCurrency ?? '',
quantity: pick(raw, 'quantity', 'Quantity') ?? null,
codCurrency: pick(raw, 'codCurrency', 'CodCurrency') ?? '',
codSum: pick(raw, 'codSum', 'CodSum') ?? null,
additionalServices: pick(raw, 'additionalServices', 'AdditionalServices') ?? raw?.additionalServices,
hasInsurance: (() => {
const hi = pick(raw, 'hasInsurance', 'HasInsurance');
if (hi === true || hi === 1 || hi === '1') return true;
if (hi === false || hi === 0 || hi === '0') return false;
return undefined;
})(),
hasInsurance,
hasAllocationTrace: boolish(pick(raw, 'hasAllocationTrace', 'HasAllocationTrace')),
allocationScheme: pick(raw, 'allocationScheme', 'AllocationScheme') ?? '',
forecastReceivableAmount:
pick(raw, 'forecastReceivableAmount', 'ForecastReceivableAmount') ?? null,
forecastPayableAmount: pick(raw, 'forecastPayableAmount', 'ForecastPayableAmount') ?? null,
orderStatus: pick(raw, 'orderStatus', 'OrderStatus') ?? raw?.orderStatus,
predeclareFailReason: pick(raw, 'predeclareFailReason', 'PredeclareFailReason') ?? '',
allocationPendingReason: pick(raw, 'allocationPendingReason', 'AllocationPendingReason') ?? '',
status: resolveOrderStatusCode(raw) || pick(raw, 'status', 'Status') || raw?.status,
handleStatus: pick(raw, 'handleStatus', 'HandleStatus') ?? raw?.handleStatus,
handleStatusName: pick(raw, 'handleStatusName', 'HandleStatusName') ?? raw?.handleStatusName,
cargoAttribute: pick(raw, 'cargoAttribute', 'CargoAttribute') ?? raw?.cargoAttribute,
orderStatus: orderStatusText,
predeclareFailReason,
allocationPendingReason,
status: statusCode,
handleStatus,
handleStatusName,
issueType: pick(raw, 'issueType', 'IssueType') ?? '',
issueTypeName: pick(raw, 'issueTypeName', 'IssueTypeName') ?? '',
issueTitle: pick(raw, 'issueTitle', 'IssueTitle') ?? '',
issueContent: pick(raw, 'issueContent', 'IssueContent') ?? '',
issueTemplateId: pick(raw, 'issueTemplateId', 'IssueTemplateId') ?? null,
issueRemark: pick(raw, 'issueRemark', 'IssueRemark') ?? '',
issueTime: pick(raw, 'issueTime', 'IssueTime') ?? null,
claimType: pick(raw, 'claimType', 'ClaimType') ?? '',
claimTypeName: pick(raw, 'claimTypeName', 'ClaimTypeName') ?? '',
claimAmount: pick(raw, 'claimAmount', 'ClaimAmount') ?? null,
claimCurrency: pick(raw, 'claimCurrency', 'ClaimCurrency') ?? '',
claimReason: pick(raw, 'claimReason', 'ClaimReason') ?? '',
claimTime: pick(raw, 'claimTime', 'ClaimTime') ?? null,
previousOrderStatus: pick(raw, 'previousOrderStatus', 'PreviousOrderStatus') ?? '',
cargoAttribute,
createTime: pick(raw, 'createTime', 'CreateTime') ?? raw?.createTime,
createBy: pick(raw, 'createBy', 'CreateBy') ?? '',
updateTime: pick(raw, 'updateTime', 'UpdateTime') ?? null,
updateBy: pick(raw, 'updateBy', 'UpdateBy') ?? '',
receiveTime: pick(raw, 'receiveTime', 'ReceiveTime') ?? raw?.receiveTime,
shipTime: pick(raw, 'shipTime', 'ShipTime') ?? raw?.shipTime,
payTime: pick(raw, 'payTime', 'PayTime') ?? raw?.payTime,
signTime: pick(raw, 'signTime', 'SignTime') ?? raw?.signTime,
warehouseId: pick(raw, 'warehouseId', 'WarehouseId') ?? null,
warehouse: pick(raw, 'warehouse', 'Warehouse') ?? raw?.warehouse,
customerId,
customerName:
pick(raw, 'customerName', 'CustomerName') ||
customerFromOptions?.label?.replace(/\s*\([^)]*\)\s*$/, '') ||
String(customerLabel).replace(/\s*\([^)]*\)\s*$/, '') ||
'',
customerCode:
pick(raw, 'customerCode', 'CustomerCode') ||
(customerFromOptions?.label?.match(/\(([^)]+)\)\s*$/)?.[1] ?? '') ||
(String(customerLabel).match(/\(([^)]+)\)\s*$/)?.[1] ?? '') ||
'',
orderSource: pick(raw, 'orderSource', 'OrderSource') ?? '',
pickupTime: pick(raw, 'pickupTime', 'PickupTime') ?? null,
pickupOrderId: pick(raw, 'pickupOrderId', 'PickupOrderId') ?? null,
carrierWaybillNo: pick(raw, 'carrierWaybillNo', 'CarrierWaybillNo') ?? '',
pickupNo: pick(raw, 'pickupNo', 'PickupNo') ?? '',
inboundMethod: pick(raw, 'inboundMethod', 'InboundMethod') ?? '',
fulfillmentType: pick(raw, 'fulfillmentType', 'FulfillmentType') ?? 'direct',
fulfillmentType,
fulfillmentSource: pick(raw, 'fulfillmentSource', 'FulfillmentSource') ?? '',
stockBindStatus: pick(raw, 'stockBindStatus', 'StockBindStatus') ?? 'none',
stockBindStatus,
stockBindLineCount: pick(raw, 'stockBindLineCount', 'StockBindLineCount') ?? 0,
stockBindQtyTotal: pick(raw, 'stockBindQtyTotal', 'StockBindQtyTotal') ?? 0,
domesticTrackingNos: pick(raw, 'domesticTrackingNos', 'DomesticTrackingNos') ?? '',
fulfillProgress: pick(raw, 'fulfillProgress', 'FulfillProgress') ?? '',
primaryBinCode: pick(raw, 'primaryBinCode', 'PrimaryBinCode') ?? '',
domesticTrackingNos,
fulfillProgress,
primaryBinCode,
primarySkuCodes: pick(raw, 'primarySkuCodes', 'PrimarySkuCodes') ?? '',
customerSenderAddressId: pick(raw, 'customerSenderAddressId', 'CustomerSenderAddressId') ?? null,
requestId: pick(raw, 'requestId', 'RequestId') ?? '',
exportCustomsMode: pick(raw, 'exportCustomsMode', 'ExportCustomsMode') ?? '',
exportQualificationId: pick(raw, 'exportQualificationId', 'ExportQualificationId') ?? null,
// 预计算 HTML,减轻 Handsontable 每格重算
_orderStatusHtml: orderStatusHtml,
_predeclareFailReasonHtml: predeclareFailReasonHtml,
_allocationPendingReasonHtml: allocationPendingReasonHtml,
_isPaidHtml: buildBooleanTagHtml(
isPaid ? t('order.myOrders.drawer.yes') : t('order.myOrders.drawer.no'),
isPaid,
),
_isCodHtml: buildBooleanTagHtml(
isCod ? t('order.myOrders.drawer.yes') : t('order.myOrders.drawer.no'),
isCod,
),
_isInterceptHtml: buildBooleanTagHtml(
isIntercept ? t('order.myOrders.drawer.yes') : t('order.myOrders.drawer.no'),
isIntercept,
),
_isRemoteHtml: buildBooleanTagHtml(
isRemote ? t('order.myOrders.drawer.yes') : t('order.myOrders.drawer.no'),
isRemote,
),
_waybillNoHtml: `<span data-row-status="${escapeHtml(String(statusCode || ''))}">${escapeHtml(String(waybillNo || '--'))}</span>`,
_consigneeCountryText:
consigneeCountry && consigneeCountryName
? `${consigneeCountry} ${consigneeCountryName}`
: consigneeCountryName || consigneeCountry || null,
};
}
@ -336,7 +472,7 @@
if (id != null && id !== '') {
try {
const detail = await getOrderDetail(id);
nextRow = normalizeOrderRow({ ...row, ...detail });
nextRow = normalizeOrderRow({ ...row, ...detail }, buildCustomerLabelMap());
} catch {
/* 保留列表行数据 */
}
@ -351,10 +487,14 @@
}
function handleOrderDetailUpdated(row: any) {
currentOrder.value = normalizeOrderRow(row);
const customerLabelMap = buildCustomerLabelMap();
currentOrder.value = normalizeOrderRow(row, customerLabelMap);
const idx = orderList.value.findIndex((it) => Number(it.id) === Number(row?.id));
if (idx >= 0) {
orderList.value[idx] = normalizeOrderRow({ ...orderList.value[idx], ...row });
// 替换数组引用,配合 deepWatchData=false 触发 Handsontable 刷新
const next = orderList.value.slice();
next[idx] = normalizeOrderRow({ ...orderList.value[idx], ...row }, customerLabelMap);
orderList.value = next;
}
}
@ -715,14 +855,6 @@
const listLoading = ref(false);
let listRequestSeq = 0;
function escapeHtml(text: string) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const selectedRowCount = ref(0);
/** 表格选中行(用于批量操作) */
const selectedOrderRows = ref<any[]>([]);
@ -1076,7 +1208,8 @@
if (requestSeq !== listRequestSeq) {
return;
}
const mapped = (items || []).map((row) => normalizeOrderRow(row));
const customerLabelMap = buildCustomerLabelMap();
const mapped = (items || []).map((row) => normalizeOrderRow(row, customerLabelMap));
orderList.value = mapped;
totalCount.value = total;
tabCounts.value[status] = total;
@ -1748,7 +1881,15 @@
const tableColumnsKey = computed(() => activeColumnKeys.value.join('|'));
function syncColumnCatalogFromRows(rows: Record<string, any>[]) {
columnCatalog.value = mergeDiscoveredColumns(getWaybillBaseCatalog(), rows);
const next = mergeDiscoveredColumns(getWaybillBaseCatalog(), rows);
const prev = columnCatalog.value;
if (
prev.length === next.length &&
prev.every((col, idx) => col.key === next[idx]?.key)
) {
return;
}
columnCatalog.value = next;
}
async function openColumnConfig() {
@ -1788,48 +1929,30 @@
return wrapWarehouseActions(parts, rowIndex);
};
/** 行底色用 class,避免 :has() 在虚拟滚动复用行时闪一下 */
function resolveWaybillRowClassName(rowData: any) {
const code = resolveOrderStatusCode(rowData);
if (code === 'problematic' || code === 'pendingPayment' || code === 'pendingAllocation') {
return `row-status-${code}`;
}
return null;
}
const customCellRenderer = (row: number, col: number, value: any, rowData: any) => {
const colOffset = 2; // action + serialNo
const dataColIndex = col - colOffset;
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null;
const colKey = hotColumns.value[dataColIndex].key;
if (colKey === 'orderStatus') {
const code = resolveOrderStatusCode(rowData);
const meta = getOrderStatusMeta(code);
const statusText = meta ? t(meta.labelKey) : (rowData?.orderStatus || code || '--');
const handleCode = resolveOrderHandleStatusCode(rowData);
const handleMeta = getOrderHandleStatusMeta(handleCode);
const handleText =
rowData?.handleStatusName ||
(handleMeta ? t(handleMeta.labelKey) : '');
const showHandleTag = isOrderHandleSubTabParent(code) && handleText;
const handleTagHtml = showHandleTag
? buildOrderStatusSubTagHtml(String(handleText), handleMeta)
: '';
return `<span style="display:inline-flex;align-items:center;flex-wrap:wrap;gap:6px;max-width:100%;">${buildOrderStatusTagHtml(String(statusText), meta)}${handleTagHtml}</span>`;
}
if (colKey === 'predeclareFailReason') {
const reason = rowData?.predeclareFailReason || value || '';
if (!reason) return null;
const guide = buildFailGuideFromReason(String(reason));
const display = guide.reason || String(reason);
const escaped = escapeHtml(display);
const title = escapeHtml(guide.rawReason || String(reason));
return `<span title="${title}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8a5c58;font-size:12px;">${escaped}</span>`;
}
if (colKey === 'allocationPendingReason') {
const reason = rowData?.allocationPendingReason || value || '';
if (!reason) return null;
const escaped = escapeHtml(String(reason));
return `<span title="${escaped}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#7a6a52;font-size:12px;">${escaped}</span>`;
}
if (['isPaid', 'isCod', 'isIntercept', 'isRemote'].includes(colKey)) {
const rawVal = rowData?.[colKey] ?? value;
const yes = rawVal === true || rawVal === 1 || rawVal === '1';
const text = yes ? t('order.myOrders.drawer.yes') : t('order.myOrders.drawer.no');
return buildBooleanTagHtml(text, yes);
}
if (colKey === 'orderStatus') return rowData?._orderStatusHtml || null;
if (colKey === 'predeclareFailReason') return rowData?._predeclareFailReasonHtml || null;
if (colKey === 'allocationPendingReason') return rowData?._allocationPendingReasonHtml || null;
if (colKey === 'isPaid') return rowData?._isPaidHtml || null;
if (colKey === 'isCod') return rowData?._isCodHtml || null;
if (colKey === 'isIntercept') return rowData?._isInterceptHtml || null;
if (colKey === 'isRemote') return rowData?._isRemoteHtml || null;
if (colKey === 'waybillNo') return rowData?._waybillNoHtml || null;
if (colKey === 'consigneeCountry') return rowData?._consigneeCountryText || null;
if (colKey === 'cargoAttribute') {
const raw = String(rowData?.cargoAttribute || value || '').trim().toLowerCase();
if (!raw) return null;
@ -1844,12 +1967,6 @@
const tone = raw === 'sensitive' ? 'wait' : raw === 'general' ? 'mute' : 'neutral';
return buildStatusTagHtml(text, tone);
}
if (colKey === 'consigneeCountry') {
const code = rowData?.consigneeCountry || value || '';
const name = rowData?.consigneeCountryName || '';
if (code && name) return `${code} ${name}`;
return name || code || null;
}
if (['chargeableWeight', 'receivableAmount', 'declaredSum', 'codSum', 'claimAmount'].includes(colKey)) {
return value != null && value !== '' ? Number(value).toLocaleString() : null;
}
@ -1901,12 +2018,6 @@
const code = String(rowData?.primaryBinCode || value || '').trim();
return code || '--';
}
// 首列添加 data-row-status 用于行背景样式
if (colKey === 'waybillNo') {
const statusCode = resolveOrderStatusCode(rowData);
const escaped = escapeHtml(String(value || '--'));
return `<span data-row-status="${statusCode}">${escaped}</span>`;
}
return null;
};
@ -2252,7 +2363,7 @@
<div ref="tableContainerRef" class="list-wrap">
<a-spin :loading="listLoading" class="table-spin">
<HandsontablePro
:key="`${activeTab}-${tableColumnsKey}`"
:key="tableColumnsKey"
:columns="hotColumns"
:data="processedTableData"
:total="totalCount"
@ -2261,7 +2372,9 @@
:show-action-column="true"
:action-renderer="actionRenderer"
:custom-cell-renderer="customCellRenderer"
:stretch-columns="true"
:resolve-row-class-name="resolveWaybillRowClassName"
:stretch-columns="false"
:deep-watch-data="false"
:height="tableHeight"
:search-params="headerSearchParams"
sort-mode="remote"
@ -2847,14 +2960,14 @@
border-left: none !important;
}
// 根据订单状态设置行背景
tbody tr:has([data-row-status="problematic"]) td {
// 根据订单状态设置行背景(class 由 resolveRowClassName 写入)
tbody tr.row-status-problematic td {
background-color: #fff1f0 !important;
}
tbody tr:has([data-row-status="pendingPayment"]) td {
tbody tr.row-status-pendingPayment td {
background-color: #ffece8 !important;
}
tbody tr:has([data-row-status="pendingAllocation"]) td {
tbody tr.row-status-pendingAllocation td {
background-color: #fff7e6 !important;
}

View File

@ -3,26 +3,26 @@
<div class="ai-shell-bg" aria-hidden="true">
<GradientWaves
class="ai-shell-bg__waves"
horizon-color="#023e8a"
wave-color="#0077b6"
crest-color="#caf0f8"
:speed="0.34"
:amplitude="3.3"
:wave-scale="0.72"
:wave-ratio="0.82"
:swell="42"
:turbulence="26"
:tilt="1.08"
horizon-color="#0369a1"
wave-color="#0ea5c9"
crest-color="#e0f7fc"
:speed="waveMotion ? 0.2 : 0"
:amplitude="waveMotion ? 2.7 : 0"
:wave-scale="0.64"
:wave-ratio="0.85"
:swell="30"
:turbulence="waveMotion ? 10 : 0"
:tilt="1.06"
:zoom="0.92"
:height="4.8"
:fog-depth="22"
detail="low"
:brightness="1.08"
:opacity="1.0"
:mouse-interaction="true"
:parallax-strength="0.4"
:brightness="1.02"
:opacity="0.82"
:mouse-interaction="false"
:parallax-strength="0"
:grain="false"
:fps="30"
:fps="waveMotion ? 28 : 12"
:render-scale="0.72"
:max-dpr="1.25"
/>
@ -60,42 +60,42 @@
IDE<span class="ai-welcome-title__dot">.</span>
</span>
<TextType
v-if="titleAnimate"
as="span"
class-name="ai-welcome-title__type"
:text="shortTitle ? copy.titleRestShort : copy.titleRest"
:typing-speed="75"
:pause-duration="1500"
:show-cursor="true"
:typing-speed="36"
:pause-duration="900"
:show-cursor="false"
:loop="false"
cursor-character="|"
cursor-class-name="ai-welcome-title__cursor"
/>
<span v-else class="ai-welcome-title__type">{{
shortTitle ? copy.titleRestShort : copy.titleRest
}}</span>
</h2>
<p class="ai-welcome-sub">{{ copy.sub }}</p>
</div>
<div class="ai-welcome__foot">
<div class="ai-welcome-stats-block">
<ul class="ai-welcome-stats" :aria-label="copy.statsAria">
<li
v-for="stat in HERO_STATS"
:key="stat.label.zh"
class="ai-welcome-stats__item"
>
<strong class="ai-welcome-stats__value">{{ isZh ? stat.value.zh : stat.value.en }}</strong>
<span class="ai-welcome-stats__label">{{ isZh ? stat.label.zh : stat.label.en }}</span>
</li>
</ul>
</div>
<div class="ai-welcome__actions">
<a class="ai-welcome-cta" :href="loginUrl" @pointerenter="$emit('warm-login')" @focus="$emit('warm-login')">{{
copy.heroCta
}}</a>
</div>
<div class="ai-welcome__meta">
<ul class="ai-welcome-stats ai-welcome-stats--compact" :aria-label="copy.statsAria">
<li
v-for="stat in HERO_STATS"
:key="stat.label.zh"
class="ai-welcome-stats__item"
>
<strong class="ai-welcome-stats__value">{{ isZh ? stat.value.zh : stat.value.en }}</strong>
<span class="ai-welcome-stats__label">{{ isZh ? stat.label.zh : stat.label.en }}</span>
</li>
</ul>
</div>
</div>
<div class="ai-welcome__command">
<div class="ai-welcome-compose">
<p class="ai-welcome-compose__lead" :hidden="chatting">{{ copy.composeLead }}</p>
<div class="ai-welcome-compose-panel">
<a
class="ai-welcome-cta-link"
:href="loginUrl"
@pointerenter="$emit('warm-login')"
@focus="$emit('warm-login')"
>{{ copy.heroCta }}</a>
<p class="ai-welcome-compose__lead">{{ copy.composeLead }}</p>
<div class="ai-home-dests" role="group" :aria-label="copy.destAria">
<button
v-for="dest in homeDests"
@ -119,23 +119,49 @@
/>
</div>
</div>
<div class="ai-intent-grid">
<button
v-for="door in HOME_DOORS"
:key="door.id"
type="button"
class="ai-intent-door"
:disabled="busy"
@click="onHomeDoor(door)"
>
<span class="ai-intent-door__icon">
<SiteIllusIcon :name="door.icon" />
</span>
<span class="ai-intent-door__copy">
<span class="ai-intent-door__title">{{ isZh ? door.zh.title : door.en.title }}</span>
<span class="ai-intent-door__hint">{{ isZh ? door.zh.hint : door.en.hint }}</span>
</span>
</button>
<div class="ai-intent-section">
<div class="ai-intent-group">
<p class="ai-intent-group__label">{{ copy.groupFulfill }}</p>
<div class="ai-intent-grid ai-intent-grid--primary">
<button
v-for="door in primaryDoors"
:key="door.id"
type="button"
class="ai-intent-door is-primary"
:disabled="busy"
@click="onHomeDoor(door)"
>
<span class="ai-intent-door__icon">
<SiteIllusIcon :name="door.icon" />
</span>
<span class="ai-intent-door__copy">
<span class="ai-intent-door__title">{{ isZh ? door.zh.title : door.en.title }}</span>
<span class="ai-intent-door__hint">{{ isZh ? door.zh.hint : door.en.hint }}</span>
</span>
</button>
</div>
</div>
<div class="ai-intent-group ai-intent-group--growth">
<p class="ai-intent-group__label">{{ copy.groupGrowth }}</p>
<div class="ai-intent-grid ai-intent-grid--growth">
<button
v-for="door in growthDoors"
:key="door.id"
type="button"
class="ai-intent-door"
:disabled="busy"
@click="onHomeDoor(door)"
>
<span class="ai-intent-door__icon">
<SiteIllusIcon :name="door.icon" />
</span>
<span class="ai-intent-door__copy">
<span class="ai-intent-door__title">{{ isZh ? door.zh.title : door.en.title }}</span>
<span class="ai-intent-door__hint">{{ isZh ? door.zh.hint : door.en.hint }}</span>
</span>
</button>
</div>
</div>
</div>
</div>
</div>
@ -309,7 +335,11 @@
const copy = computed(() => (props.isZh ? WELCOME_COPY.zh : WELCOME_COPY.en));
const homeDests = ROUTE_DEST_PRESETS.filter((item) => item.id !== 'all');
const primaryDoors = HOME_DOORS.filter((d) => d.primary);
const growthDoors = HOME_DOORS.filter((d) => !d.primary);
const shortTitle = ref(false);
const titleAnimate = ref(true);
const waveMotion = ref(true);
const draft = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
@ -346,18 +376,25 @@
}
);
let mq: MediaQueryList | null = null;
function syncTitle() {
shortTitle.value = !!mq?.matches;
let mqNarrow: MediaQueryList | null = null;
let mqReduce: MediaQueryList | null = null;
function syncMediaPrefs() {
shortTitle.value = !!mqNarrow?.matches;
waveMotion.value = !mqReduce?.matches;
titleAnimate.value = !mqReduce?.matches;
}
onMounted(() => {
mq = window.matchMedia('(max-width: 640px)');
syncTitle();
mq.addEventListener('change', syncTitle);
mqNarrow = window.matchMedia('(max-width: 640px)');
mqReduce = window.matchMedia('(prefers-reduced-motion: reduce)');
syncMediaPrefs();
mqNarrow.addEventListener('change', syncMediaPrefs);
mqReduce.addEventListener('change', syncMediaPrefs);
});
onBeforeUnmount(() => {
mq?.removeEventListener('change', syncTitle);
mqNarrow?.removeEventListener('change', syncMediaPrefs);
mqReduce?.removeEventListener('change', syncMediaPrefs);
});
</script>

View File

@ -41,6 +41,7 @@
:href="loginUrl"
@pointerenter="$emit('warm-login')"
@focus="$emit('warm-login')"
@click="onLoginClick"
>{{ isZh ? '登录' : 'Log in' }}</a>
<a
v-else
@ -69,6 +70,7 @@
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import {
@ -78,7 +80,7 @@
import { applyRbacUserToUserStore } from '@/utils/enter-system';
import { DEFAULT_AVATAR_URL, isValidAvatarUrl, resolveAvatarUrl } from '@/utils/avatar';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { officialAppHomeUrl } from './lib/site-urls';
import { goOfficialLogin, officialAppHomeUrl } from './lib/site-urls';
const props = defineProps<{
nav: string;
@ -95,10 +97,15 @@
'warm-login': [];
}>();
const router = useRouter();
const userStore = useUserStore();
const rbacUserStore = useRbacUserStore();
const signedIn = computed(() => rbacUserStore.isLoggedIn);
const homeUrl = officialAppHomeUrl();
function onLoginClick(e: MouseEvent) {
goOfficialLogin(router, undefined, e);
}
const avatarUrl = ref(DEFAULT_AVATAR_URL);
const displayName = computed(
() =>
@ -196,12 +203,10 @@
opacity: '0',
});
const sliderPos = { x: 0, w: 0 };
let settleTimer: ReturnType<typeof setTimeout> | null = null;
let sliderAnimating = false;
let resizeObserver: ResizeObserver | null = null;
let sliderRaf = 0;
const SLIDER_INSET = 4;
const LIQUID_STRETCH_MS = 120;
function applySlider(x: number, w: number, opacity = '1') {
sliderPos.x = x;
@ -213,74 +218,47 @@
};
}
function readLiveSlider() {
const slider = dockRef.value?.querySelector<HTMLElement>('.site-nav-dock__slider');
if (!slider) return { ...sliderPos };
const cs = getComputedStyle(slider);
return {
x: Number.parseFloat(cs.left) || sliderPos.x,
w: Number.parseFloat(cs.width) || sliderPos.w,
};
}
function clearSettleTimer() {
if (settleTimer) {
clearTimeout(settleTimer);
settleTimer = null;
}
}
function updateSlider(animate: boolean) {
const dock = dockRef.value;
const active = dock?.querySelector<HTMLElement>('.site-nav-dock__item.is-active');
if (!dock || !active || window.matchMedia('(max-width: 1279px)').matches) {
sliderStyle.value = { ...sliderStyle.value, opacity: '0' };
sliderAnimating = false;
return;
}
const x = active.offsetLeft + SLIDER_INSET;
const w = Math.max(active.offsetWidth - SLIDER_INSET * 2, 0);
clearSettleTimer();
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 首帧或关动画:先贴到位再开 transition,避免初次跳动
if (!animate || !sliderReady.value || sliderPos.w <= 0 || reduceMotion) {
sliderReady.value = false;
applySlider(x, w);
requestAnimationFrame(() => {
sliderReady.value = true;
});
sliderAnimating = false;
return;
}
const from = readLiveSlider();
if (Math.abs(from.x - x) < 1 && Math.abs(from.w - w) < 1) {
if (Math.abs(sliderPos.x - x) < 0.5 && Math.abs(sliderPos.w - w) < 0.5) {
applySlider(x, w);
sliderAnimating = false;
return;
}
const coverLeft = Math.min(from.x, x);
const coverWidth = Math.max(from.x + from.w, x + w) - coverLeft;
sliderAnimating = true;
applySlider(coverLeft, coverWidth);
settleTimer = setTimeout(() => {
applySlider(x, w);
sliderAnimating = false;
settleTimer = null;
}, LIQUID_STRETCH_MS);
// 单段滑移:left/width 一次到位,交给 CSS 缓动
applySlider(x, w);
}
async function syncSlider(animate: boolean) {
await nextTick();
updateSlider(animate);
if (sliderRaf) cancelAnimationFrame(sliderRaf);
sliderRaf = requestAnimationFrame(() => {
sliderRaf = 0;
updateSlider(animate);
});
}
function onViewportResize() {
if (sliderAnimating) return;
updateSlider(false);
}
@ -295,7 +273,7 @@
});
onBeforeUnmount(() => {
clearSettleTimer();
if (sliderRaf) cancelAnimationFrame(sliderRaf);
resizeObserver?.disconnect();
window.removeEventListener('resize', onViewportResize);
});

View File

@ -72,10 +72,35 @@
</label>
<div class="ai-routes-toolbar">
<p v-if="metaText" class="ai-routes-meta">{{ metaText }}</p>
<nav
v-if="loadState === 'ready' && filtered.length > 0 && pageCount > 1"
class="ai-routes-pager"
:aria-label="isZh ? '线路分页' : 'Route pagination'"
>
<button
type="button"
class="ai-routes-pager__btn"
:disabled="currentPage <= 1"
:aria-label="isZh ? '上一页' : 'Previous page'"
@click="goPage(currentPage - 1)"
>
‹
</button>
<span class="ai-routes-pager__label">{{ currentPage }} / {{ pageCount }}</span>
<button
type="button"
class="ai-routes-pager__btn"
:disabled="currentPage >= pageCount"
:aria-label="isZh ? '下一页' : 'Next page'"
@click="goPage(currentPage + 1)"
>
›
</button>
</nav>
<button type="button" class="ai-routes-sticky" @click="askDirect">{{ stickyLabel }}</button>
</div>
</div>
<div class="ai-routes-panel__body">
<div ref="bodyRef" class="ai-routes-panel__body">
<div v-if="loadState === 'loading'" class="ai-routes-state">
<div class="ai-routes-skeleton"></div>
<div class="ai-routes-skeleton"></div>
@ -101,7 +126,7 @@
</div>
</div>
<template v-else>
<article v-for="item in visibleItems" :key="String(item.id)" class="ai-route-card">
<article v-for="item in pageItems" :key="String(item.id)" class="ai-route-card">
<div class="ai-route-card__head">
<div class="ai-route-card__titles">
<span
@ -120,7 +145,6 @@
<span v-if="item.tradeTerms === 'DDP'"> · {{ copy.ddpHint }}</span>
<span v-else-if="item.tradeTerms === 'DDU'"> · {{ copy.dduHint }}</span>
</p>
<p class="ai-route-card__cargo">{{ routeCargoHint(item, catalog, lang) }}</p>
<p
v-if="item.productDescription"
class="ai-route-card__desc"
@ -170,18 +194,13 @@
</button>
</div>
</article>
<div v-if="visibleItems.length < filtered.length" class="ai-routes-more">
<button type="button" class="ai-routes-more__btn" @click="visibleCount += ROUTE_PAGE_SIZE">
{{ copy.loadMore }}({{ filtered.length - visibleItems.length }})
</button>
</div>
</template>
</div>
</aside>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { officialLoginUrl } from './lib/site-urls';
import {
ROUTE_DEST_PRESETS,
@ -193,7 +212,6 @@
fetchRoutesCatalog,
filterRouteItems,
formatRouteTimeliness,
routeCargoHint,
routeCoverageLabel,
type RoutesCatalog,
type RouteItem,
@ -217,7 +235,8 @@
const groupFilter = ref('all');
const termsFilter = ref('all');
const search = ref('');
const visibleCount = ref(ROUTE_PAGE_SIZE);
const currentPage = ref(1);
const bodyRef = ref<HTMLElement | null>(null);
const expanded = ref<Record<string, boolean>>({});
const groupChips = computed(() => {
@ -241,7 +260,14 @@
}),
);
const visibleItems = computed(() => filtered.value.slice(0, visibleCount.value));
const pageCount = computed(() =>
Math.max(1, Math.ceil(filtered.value.length / ROUTE_PAGE_SIZE) || 1),
);
const pageItems = computed(() => {
const start = (currentPage.value - 1) * ROUTE_PAGE_SIZE;
return filtered.value.slice(start, start + ROUTE_PAGE_SIZE);
});
const hasFilters = computed(
() =>
@ -253,12 +279,7 @@
const metaText = computed(() => {
if (loadState.value !== 'ready') return '';
const total = filtered.value.length;
const shown = visibleItems.value.length;
if (shown < total) {
return copy.value.matchShown.replace('{shown}', String(shown)).replace('{total}', String(total));
}
return copy.value.matchCount.replace('{n}', String(total));
return copy.value.matchCount.replace('{n}', String(filtered.value.length));
});
const stickyLabel = computed(() => {
@ -268,7 +289,16 @@
});
function resetPage() {
visibleCount.value = ROUTE_PAGE_SIZE;
currentPage.value = 1;
}
function goPage(page: number) {
const next = Math.min(pageCount.value, Math.max(1, page));
if (next === currentPage.value) return;
currentPage.value = next;
void nextTick(() => {
if (bodyRef.value) bodyRef.value.scrollTop = 0;
});
}
function setDest(id: string) {
@ -324,6 +354,10 @@
watch(search, () => resetPage());
watch(pageCount, (count) => {
if (currentPage.value > count) currentPage.value = count;
});
onMounted(() => {
void reload();
});

View File

@ -6,6 +6,7 @@
'is-services-open': panel === 'services',
'is-topic-open': panel === 'pricing' || panel === 'about' || panel === 'contact',
}"
@click.capture="onOfficialLoginCapture"
>
<SiteHeader
:nav="nav"
@ -57,7 +58,7 @@
@click="closePanel"
></button>
</Transition>
<Transition name="site-sheet" mode="out-in">
<Transition name="site-sheet">
<SiteRoutesPanel
v-if="panel === 'routes'"
key="routes"
@ -91,13 +92,18 @@
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import SiteHeader from './SiteHeader.vue';
import SiteAdvisorShell from './SiteAdvisorShell.vue';
import SiteRoutesPanel from './SiteRoutesPanel.vue';
import SiteServicesPanel from './SiteServicesPanel.vue';
import SiteTopicPanel from './SiteTopicPanel.vue';
import SiteWecomModal from './SiteWecomModal.vue';
import { officialLoginUrl, warmupOfficialLogin } from './lib/site-urls';
import {
goOfficialLoginFromHref,
officialLoginUrl,
warmupOfficialLogin,
} from './lib/site-urls';
import { shouldOpenWecomDirect, WECOM_KF_URL } from './lib/site-wecom';
import { useSiteAdvisor } from './useSiteAdvisor';
import './styles/vendor.css';
@ -112,6 +118,7 @@
const DESCRIPTION =
'IDE 面向中小卖家的跨境物流。全球数字化一站式跨境物流服务商。注册登记后即可询价,一口价不砍,专属顾问跟到签收。';
const router = useRouter();
const lang = ref<'zh' | 'en'>('zh');
const nav = ref('home');
const panel = ref<PanelId>(null);
@ -122,6 +129,16 @@
const isZh = computed(() => lang.value === 'zh');
const loginUrl = computed(() => officialLoginUrl());
/** 顾问卡片等其它「去登录」链接统一走 SPA 翻页 */
function onOfficialLoginCapture(e: MouseEvent) {
const target = e.target as Element | null;
const anchor = target?.closest?.('a[href]') as HTMLAnchorElement | null;
if (!anchor || anchor.classList.contains('site-nav-login')) return;
const href = anchor.getAttribute('href') || '';
if (!href.includes('from=official-site')) return;
goOfficialLoginFromHref(router, href, e);
}
function focusComposer() {
const input = document.querySelector('.ai-composer-input') as HTMLTextAreaElement | null;
input?.focus();
@ -375,7 +392,7 @@
.site-fade-enter-active,
.site-fade-leave-active {
transition: opacity 0.22s cubic-bezier(0.22, 1, 0.36, 1);
transition: opacity 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
.site-fade-enter-from,
@ -386,20 +403,45 @@
.site-sheet-enter-active,
.site-sheet-leave-active {
transition:
opacity 0.24s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
opacity 0.34s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1),
filter 0.34s cubic-bezier(0.22, 1, 0.36, 1);
}
.site-sheet-leave-active {
pointer-events: none;
}
.site-sheet-enter-from {
opacity: 0;
filter: blur(2px);
transform: translateX(-50%) translateY(-10px);
}
.site-sheet-enter-from,
.site-sheet-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-12px) scale(0.985);
filter: blur(1px);
transform: translateX(-50%) translateY(-6px);
}
@media (max-width: 780px) {
.site-sheet-enter-from,
.site-sheet-enter-from {
filter: none;
transform: translateY(16px);
}
.site-sheet-leave-to {
transform: translateY(18px) scale(1);
filter: none;
transform: translateY(10px);
}
}
@media (prefers-reduced-motion: reduce) {
.site-fade-enter-active,
.site-fade-leave-active,
.site-sheet-enter-active,
.site-sheet-leave-active {
transition-duration: 0.01ms;
}
}
</style>

View File

@ -1,18 +1,93 @@
export function officialAppHomeUrl() {
import type { Router } from 'vue-router';
import { armOfficialLoginFlip } from '@/utils/page-transition-flag';
function preferMobilePortal() {
const ua = navigator.userAgent || '';
const mobile = /MicroMessenger|Android|iPhone|iPod|iPad|Mobile/i.test(ua);
return new URL(mobile ? '/h5' : '/home/workplace', window.location.origin).toString();
return /MicroMessenger|Android|iPhone|iPod|iPad|Mobile/i.test(ua);
}
export function officialAppHomeUrl() {
return new URL(
preferMobilePortal() ? '/h5' : '/home/workplace',
window.location.origin,
).toString();
}
export function officialLoginLocation(dest?: string) {
const path = preferMobilePortal() ? '/h5/login' : '/login';
const query: Record<string, string> = { from: 'official-site' };
if (dest) query.dest = dest;
return { path, query };
}
export function officialLoginUrl(dest?: string) {
const ua = navigator.userAgent || '';
const mobile = /MicroMessenger|Android|iPhone|iPod|iPad|Mobile/i.test(ua);
const url = new URL(mobile ? '/h5/login' : '/login', window.location.origin);
url.searchParams.set('from', 'official-site');
if (dest) url.searchParams.set('dest', dest);
const { path, query } = officialLoginLocation(dest);
const url = new URL(path, window.location.origin);
Object.entries(query).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
return url.toString();
}
/** 同域 SPA 进登录;普通左键走翻页,修饰键仍允许新标签打开 */
export function goOfficialLogin(
router: Router,
dest?: string,
event?: MouseEvent,
) {
if (event) {
if (event.defaultPrevented) return false;
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return false;
}
event.preventDefault();
}
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduceMotion) {
armOfficialLoginFlip();
}
// 先确保登录分包就绪,再开翻页,避免顶栏进度条干等
void (async () => {
try {
if (preferMobilePortal()) {
await import('@/views/h5/customer-center/login/index.vue');
} else {
await import('@/views/login/index.vue');
}
} catch {
/* ignore:仍继续跳转 */
}
await router.push(officialLoginLocation(dest));
})();
return true;
}
/** 从已有 login URL 解析 dest 后 SPA 跳转 */
export function goOfficialLoginFromHref(
router: Router,
href: string,
event?: MouseEvent,
) {
try {
const url = new URL(href, window.location.origin);
if (url.origin !== window.location.origin) return false;
if (url.pathname !== '/login' && url.pathname !== '/h5/login') return false;
if (url.searchParams.get('from') !== 'official-site') return false;
const dest = url.searchParams.get('dest') || undefined;
return goOfficialLogin(router, dest, event);
} catch {
return false;
}
}
export function warmupOfficialLogin() {
const warmed = document.documentElement.dataset.omsLoginWarm;
if (warmed) return;
@ -28,16 +103,22 @@ export function warmupOfficialLogin() {
prefetch.rel = 'prefetch';
prefetch.href = url;
document.head.appendChild(prefetch);
// 预拉取登录分包,翻页进入时少等 chunk
void import('@/views/login/index.vue');
} catch {
/* ignore */
}
}
export function officialTrackLoginUrl(number?: string) {
const ua = navigator.userAgent || '';
const mobile = /MicroMessenger|Android|iPhone|iPod|iPad|Mobile/i.test(ua);
const url = new URL(mobile ? '/h5/login' : '/login', window.location.origin);
url.searchParams.set('redirect', mobile ? '/h5/orders' : '/home/workplace');
const url = new URL(
preferMobilePortal() ? '/h5/login' : '/login',
window.location.origin,
);
url.searchParams.set(
'redirect',
preferMobilePortal() ? '/h5/orders' : '/home/workplace',
);
if (number) url.searchParams.set('track', number);
return url.toString();
}

View File

@ -4,7 +4,6 @@ export const WELCOME_COPY = {
titleShort: 'IDE. Logistics advisor',
titleRest: ' Your cross-border logistics advisor',
titleRestShort: ' Logistics advisor',
sub: 'US, EU and Southeast Asia parcels. List rates after you register. One advisor through to delivery.',
placeholder: 'e.g. 5 kg general cargo to the US, how many days?',
sendLabel: 'Ask',
human: 'Talk to an advisor',
@ -18,6 +17,8 @@ export const WELCOME_COPY = {
statsAria: 'Service highlights',
composeLead: 'Where are you shipping? Pick a destination, or ask below.',
destAria: 'Pick a destination',
groupFulfill: 'Shipping',
groupGrowth: 'Benefits',
partnerTag: 'Partners',
partnerAria: 'Connected platforms and carriers',
trustIcp: '粤ICP备2025463970号-2',
@ -29,7 +30,6 @@ export const WELCOME_COPY = {
titleShort: 'IDE.跨境物流顾问',
titleRest: '您身边的跨境物流顾问',
titleRestShort: '跨境物流顾问',
sub: '中小卖家也能按线路看一口价。不砍价、不拉大群,一个人从报价跟到签收。',
placeholder: '例如:发美国普货,5kg,要几天',
sendLabel: '提问',
human: '联系顾问',
@ -43,6 +43,8 @@ export const WELCOME_COPY = {
statsAria: '服务数据',
composeLead: '发到哪?点目的地看线路,或直接提问。',
destAria: '选择目的地',
groupFulfill: '履约',
groupGrowth: '权益',
partnerTag: '合作伙伴',
partnerAria: '对接平台与服务商',
trustIcp: '粤ICP备2025463970号-2',
@ -97,6 +99,7 @@ export const SITE_INTENTS = [
{
id: 'track',
icon: 'track',
followup: false,
en: {
title: 'Where is my parcel?',
hint: 'Sign in to see live scans from pickup to delivery',
@ -108,6 +111,20 @@ export const SITE_INTENTS = [
label: '这票货从揽收到派送卡在哪',
},
},
{
id: 'warehouse',
icon: 'track',
en: {
title: 'Warehouse fulfillment',
hint: 'Inbound, pick & pack — ship when orders land',
label: 'I need warehouse dropshipping',
},
zh: {
title: '仓储代发',
hint: '入仓拣货打包,订单来了就发出去',
label: '我要仓储一件代发',
},
},
{
id: 'hard',
icon: 'hard',
@ -207,43 +224,49 @@ export const HOME_DOORS = [
id: 'hard',
icon: 'hard',
intentId: 'hard' as const,
zh: { title: '带电、别人不接也能问', hint: '先帮你看出得去的线路,再谈税。' },
en: { title: 'Battery or declined cargo', hint: 'We find a lane that can leave, then the tax path.' },
primary: true,
zh: { title: '敏感难发', hint: '先帮你看出得去的线路,再谈税。' },
en: { title: 'Sensitive cargo', hint: 'We find a lane that can leave, then the tax path.' },
},
{
id: 'track',
id: 'warehouse',
icon: 'track',
intentId: 'track' as const,
zh: { title: '货到哪了', hint: '登录后看揽收到派送的真实节点。' },
en: { title: 'Where is my parcel?', hint: 'Sign in to see scans from pickup to delivery.' },
intentId: 'warehouse' as const,
primary: true,
zh: { title: '仓储代发', hint: '入仓拣货打包,订单来了就发出去。' },
en: { title: 'Warehouse fulfillment', hint: 'Inbound, pick & pack — ship when orders land.' },
},
{
id: 'clearance',
icon: 'clearance',
intentId: 'compliance' as const,
zh: { title: '出口报关 / 退税', hint: '正式报关、退税,或没有专票怎么出。' },
en: { title: 'Export paperwork', hint: 'Declaration, VAT refund, or shipping without invoices.' },
primary: true,
zh: { title: '报关退税', hint: '正式报关、退税,或没有专票怎么出。' },
en: { title: 'Customs & VAT', hint: 'Declaration, refund, or shipping without invoices.' },
},
{
id: 'member',
icon: 'growth',
intentId: 'member' as const,
zh: { title: '会员成长体系', hint: '近 12 个月发货积分定级,基础运费最高 95 折。' },
en: { title: 'Member growth', hint: 'Rolling 12-month shipping points — up to 95% on base freight.' },
primary: false,
zh: { title: '会员成长', hint: '近 12 个月发货积分定级,基础运费最高 95 折。' },
en: { title: 'Member tiers', hint: '12-month shipping points — up to 95% on base freight.' },
},
{
id: 'invite',
icon: 'coin',
intentId: 'invite' as const,
primary: false,
zh: { title: '邀新分成', hint: '好友派送成功,你拿运费余额 + 20 元券。' },
en: { title: 'Invite & earn', hint: 'When friends deliver, you get freight credit + a ¥20 coupon.' },
en: { title: 'Invite & earn', hint: 'Friends deliver → freight credit + ¥20 coupon.' },
},
{
id: 'newcomer',
icon: 'coupon',
intentId: 'newcomer' as const,
zh: { title: '新人注册福利', hint: '注册送券:首单、备货、第二单都有。' },
en: { title: 'New-seller coupons', hint: 'Sign up for first-ship, stock-up, and second-ship coupons.' },
primary: false,
zh: { title: '新人福利', hint: '注册送券:首单、备货、第二单都有。' },
en: { title: 'New-seller deals', hint: 'Coupons for first ship, stock-up, and second ship.' },
},
] as const;

File diff suppressed because it is too large Load Diff