feat: H5 底栏 Liquid Glass 与揽收预报下游
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m23s

H5 客户中心底栏改为 iOS 风格玻璃胶囊滑块;揽收单支持预报下游,并完善附件预览与发布快照对比。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-17 10:18:30 +08:00
parent 2b3797d548
commit 5d47aa9d90
15 changed files with 587 additions and 72 deletions

View File

@ -3709,11 +3709,11 @@
/> />
<a-button <a-button
size="mini" size="mini"
class="content-type-copy-btn" class="content-type-copy-btn section-icon-btn"
title="复制当前参数为带描述的 JSON" title="复制当前参数为带描述的 JSON"
@click="copyBodyParamsAsCommentedJson" @click="copyBodyParamsAsCommentedJson"
> >
复制 <IconCopy />
</a-button> </a-button>
</div> </div>
<!-- Query tab 内容 --> <!-- Query tab 内容 -->
@ -4489,11 +4489,11 @@
/> />
<a-button <a-button
size="mini" size="mini"
class="content-type-copy-btn" class="content-type-copy-btn section-icon-btn"
title="复制当前参数为带描述的 JSON" title="复制当前参数为带描述的 JSON"
@click="copyResponseParamsAsCommentedJson" @click="copyResponseParamsAsCommentedJson"
> >
复制 <IconCopy />
</a-button> </a-button>
</div> </div>
<!-- Response body tab 内容 --> <!-- Response body tab 内容 -->
@ -5230,8 +5230,9 @@
.content-type-copy-btn { .content-type-copy-btn {
margin-left: auto; margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
width: 24px;
height: 24px; height: 24px;
padding: 0 10px; padding: 0;
border-radius: 4px; border-radius: 4px;
} }
} }

View File

@ -596,6 +596,8 @@ const CORE_GROUPS: PageButtonGroup[] = [
{ code: 'Query', name: '查询' }, { code: 'Query', name: '查询' },
{ code: 'Search', name: '搜索' }, { code: 'Search', name: '搜索' },
{ code: 'Create', name: '创建揽收订单' }, { code: 'Create', name: '创建揽收订单' },
{ code: 'ForwardDownstream', name: '预报下游订单' },
{ code: 'DownstreamForecastResult', name: '下游预报结果' },
]), ]),
B('Order.Customs9610', [ B('Order.Customs9610', [
{ code: 'Query', name: '查询' }, { code: 'Query', name: '查询' },

View File

@ -829,6 +829,9 @@ export default {
'order.pickupOrder.message.batchScheduleNoCandidates': 'No draft/scheduled sheets selected for forecast', 'order.pickupOrder.message.batchScheduleNoCandidates': 'No draft/scheduled sheets selected for forecast',
'order.pickupOrder.message.batchScheduleHint': 'Draft-forecast {count} pickup sheet(s)', 'order.pickupOrder.message.batchScheduleHint': 'Draft-forecast {count} pickup sheet(s)',
'order.pickupOrder.message.batchScheduleResult': 'Batch schedule: {success} succeeded, {fail} failed', 'order.pickupOrder.message.batchScheduleResult': 'Batch schedule: {success} succeeded, {fail} failed',
'order.pickupOrder.message.downstreamNeedSelect': 'Select pickup sheets to forward downstream',
'order.pickupOrder.message.downstreamNoBoundOrders': 'Selected pickup sheets have no bound orders to forward',
'order.pickupOrder.message.downstreamPartialEmpty': 'Skipped pickup sheets with no bound orders: {pickups}',
'order.platformStoreOrder.status.all': 'All', 'order.platformStoreOrder.status.all': 'All',
'order.platformStoreOrder.status.pending': 'Pending', 'order.platformStoreOrder.status.pending': 'Pending',
'order.platformStoreOrder.status.toShip': 'To Ship', 'order.platformStoreOrder.status.toShip': 'To Ship',
@ -1878,6 +1881,8 @@ export default {
'order.myOrders.drawer.issueHandling.processFailed': 'Failed to mark as processed', 'order.myOrders.drawer.issueHandling.processFailed': 'Failed to mark as processed',
'order.myOrders.drawer.attachments.upload': 'Upload', 'order.myOrders.drawer.attachments.upload': 'Upload',
'order.myOrders.drawer.attachments.preview': 'Preview', 'order.myOrders.drawer.attachments.preview': 'Preview',
'order.myOrders.drawer.attachments.previewFailed': 'Preview failed',
'order.myOrders.drawer.attachments.previewBlocked': 'Please allow pop-ups to preview the attachment',
'order.myOrders.drawer.attachments.delete': 'Delete', 'order.myOrders.drawer.attachments.delete': 'Delete',
'order.myOrders.drawer.attachments.empty': 'No attachments', 'order.myOrders.drawer.attachments.empty': 'No attachments',
'order.myOrders.drawer.attachments.loadFailed': 'Failed to load attachments', 'order.myOrders.drawer.attachments.loadFailed': 'Failed to load attachments',

View File

@ -825,6 +825,9 @@ export default {
'order.pickupOrder.message.batchScheduleNoCandidates': '所选揽收单无草稿/已预约可预报项', 'order.pickupOrder.message.batchScheduleNoCandidates': '所选揽收单无草稿/已预约可预报项',
'order.pickupOrder.message.batchScheduleHint': '将对已选 {count} 张揽收单统一草稿预报', 'order.pickupOrder.message.batchScheduleHint': '将对已选 {count} 张揽收单统一草稿预报',
'order.pickupOrder.message.batchScheduleResult': '批量预报:成功 {success},失败 {fail}', 'order.pickupOrder.message.batchScheduleResult': '批量预报:成功 {success},失败 {fail}',
'order.pickupOrder.message.downstreamNeedSelect': '请先选中要预报下游的揽收单',
'order.pickupOrder.message.downstreamNoBoundOrders': '所选揽收单没有可预报的绑定运单',
'order.pickupOrder.message.downstreamPartialEmpty': '以下揽收单无绑定运单或拉取失败,已跳过:{pickups}',
'order.platformStoreOrder.status.all': '全部', 'order.platformStoreOrder.status.all': '全部',
'order.platformStoreOrder.status.pending': '待处理', 'order.platformStoreOrder.status.pending': '待处理',
'order.platformStoreOrder.status.toShip': '待发货', 'order.platformStoreOrder.status.toShip': '待发货',
@ -1771,6 +1774,8 @@ export default {
'order.myOrders.drawer.issueHandling.processFailed': '标记已处理失败', 'order.myOrders.drawer.issueHandling.processFailed': '标记已处理失败',
'order.myOrders.drawer.attachments.upload': '上传', 'order.myOrders.drawer.attachments.upload': '上传',
'order.myOrders.drawer.attachments.preview': '预览', 'order.myOrders.drawer.attachments.preview': '预览',
'order.myOrders.drawer.attachments.previewFailed': '预览失败',
'order.myOrders.drawer.attachments.previewBlocked': '请允许弹出窗口以预览附件',
'order.myOrders.drawer.attachments.delete': '删除', 'order.myOrders.drawer.attachments.delete': '删除',
'order.myOrders.drawer.attachments.empty': '暂无附件', 'order.myOrders.drawer.attachments.empty': '暂无附件',
'order.myOrders.drawer.attachments.loadFailed': '加载附件失败', 'order.myOrders.drawer.attachments.loadFailed': '加载附件失败',

View File

@ -1,4 +1,4 @@
/** 从订单详情组装 createOrderOne 标准请求体(与 parcel/create 一致) */ /** 从订单详情组装 createOrderOne 标准请求体(ForwardWithSubscribe) */
function pick(raw: Record<string, any> | undefined, ...keys: string[]) { function pick(raw: Record<string, any> | undefined, ...keys: string[]) {
if (!raw) return undefined if (!raw) return undefined
@ -62,9 +62,20 @@ export function buildCountryIdToCodeMap(countries: any[]): Map<number, string> {
return map return map
} }
export type BuildCreateOrderOneBodyOptions = {
/** 服务渠道「预报渠道 code」,写入 productCode;不传则不带 productCode */
forecastCode?: string | null
}
/**
* 组装 createOrderOne 请求体。
* - productCode:取渠道「预报渠道 code」(forecastCode),不是销售产品 code
* - 不传 channelCode / requestId / exportCustomsMode 等 OMS 扩展字段
*/
export function buildCreateOrderOneBody( export function buildCreateOrderOneBody(
order: Record<string, any>, order: Record<string, any>,
countryIdToCode: Map<number, string> countryIdToCode: Map<number, string>,
options?: BuildCreateOrderOneBodyOptions
): Record<string, unknown> { ): Record<string, unknown> {
const weightList = (order.weightList ?? order.WeightList ?? []) as any[] const weightList = (order.weightList ?? order.WeightList ?? []) as any[]
const forecast = const forecast =
@ -88,21 +99,22 @@ export function buildCreateOrderOneBody(
countryIdToCode countryIdToCode
) )
return { const forecastCode = String(options?.forecastCode ?? '').trim()
requestId: pick(order, 'requestId', 'RequestId'),
const body: Record<string, unknown> = {
clientNo: pick(order, 'clientNo', 'ClientNo'), clientNo: pick(order, 'clientNo', 'ClientNo'),
trackNo: pick(order, 'trackNo', 'TrackNo'), trackNo: pick(order, 'trackNo', 'TrackNo'),
serviceNo: pick(order, 'serviceNo', 'ServiceNo'), serviceNo: pick(order, 'serviceNo', 'ServiceNo'),
productCode: pick(order, 'productCode', 'ProductCode'),
channelCode: pick(order, 'channelCode', 'ChannelCode'),
cargoAttribute: pick(order, 'cargoAttribute', 'CargoAttribute'), cargoAttribute: pick(order, 'cargoAttribute', 'CargoAttribute'),
exportCustomsMode: pick(order, 'exportCustomsMode', 'ExportCustomsMode'),
warehouseId: pick(order, 'warehouseId', 'WarehouseId'), warehouseId: pick(order, 'warehouseId', 'WarehouseId'),
customerId: pick(order, 'customerId', 'CustomerId'), customerId: pick(order, 'customerId', 'CustomerId'),
length: pick(forecast, 'length', 'Length'), length: pick(forecast, 'length', 'Length'),
width: pick(forecast, 'width', 'Width'), width: pick(forecast, 'width', 'Width'),
height: pick(forecast, 'height', 'Height'), height: pick(forecast, 'height', 'Height'),
actualWeight: pick(forecast, 'actualWeight', 'ActualWeight'), actualWeight: pick(forecast, 'actualWeight', 'ActualWeight'),
volumetricWeight: pick(forecast, 'volumetricWeight', 'VolumetricWeight'),
chargeableWeight: pick(forecast, 'chargeableWeight', 'ChargeableWeight'),
cubic: pick(forecast, 'cubic', 'Cubic'),
isCod: order.isCod === true || order.IsCod === true || order.isCod === 1 || order.IsCod === 1, isCod: order.isCod === true || order.IsCod === true || order.isCod === 1 || order.IsCod === 1,
codSum: pick(order, 'codSum', 'CodSum') != null ? String(pick(order, 'codSum', 'CodSum')) : undefined, codSum: pick(order, 'codSum', 'CodSum') != null ? String(pick(order, 'codSum', 'CodSum')) : undefined,
codCurrency: pick(order, 'codCurrency', 'CodCurrency'), codCurrency: pick(order, 'codCurrency', 'CodCurrency'),
@ -153,4 +165,10 @@ export function buildCreateOrderOneBody(
}, },
items: itemsRaw.map((row) => mapItem(row)), items: itemsRaw.map((row) => mapItem(row)),
} }
if (forecastCode) {
body.productCode = forecastCode
}
return body
} }

View File

@ -236,8 +236,11 @@ export function computeSnapshotDiff(
modifiedRaw: unknown modifiedRaw: unknown
): { length: number; rows: SnapshotDiffRow[]; equal: boolean } { ): { length: number; rows: SnapshotDiffRow[]; equal: boolean } {
try { try {
const original = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw)) const originalNorm = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw))
const modified = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw)) const modifiedNorm = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw))
// 空快照规范化后为 undefined;对比时按 {} 处理,避免 JSON.stringify(undefined) 异常及首次发布误判无差异
const original = originalNorm === undefined ? {} : originalNorm
const modified = modifiedNorm === undefined ? {} : modifiedNorm
const originalText = JSON.stringify(original) const originalText = JSON.stringify(original)
const modifiedText = JSON.stringify(modified) const modifiedText = JSON.stringify(modified)
if (originalText === modifiedText) { if (originalText === modifiedText) {

View File

@ -297,7 +297,9 @@
> >
<div class="fixed-parameter-body"> <div class="fixed-parameter-body">
<a-alert type="info" :show-icon="false" class="modal-form-tip"> <a-alert type="info" :show-icon="false" class="modal-form-tip">
配置当前服务商的公共参数,供其下接口共用。路径:修改 → 保存 → 发布映射缓存后才对线上生效。 配置当前服务商的公共参数,供其下接口共用。路径:修改 → 保存 → 发布映射缓存后才对线上生效。若赋值到
Body,需填写完整参数路径,例如
<code>[*].autoWaybillNo</code>。
</a-alert> </a-alert>
<div class="table-toolbar"> <div class="table-toolbar">
@ -375,6 +377,16 @@
.fixed-parameter-body { .fixed-parameter-body {
.modal-form-tip { .modal-form-tip {
margin-bottom: 16px; margin-bottom: 16px;
code {
margin: 0 2px;
padding: 0 4px;
font-family: Consolas, Monaco, monospace;
font-size: 12px;
color: var(--color-text-1);
background: var(--color-fill-2);
border-radius: 2px;
}
} }
.table-toolbar { .table-toolbar {

View File

@ -367,7 +367,7 @@
} }
function handlerSendMap() { function handlerSendMap() {
if (!diffLength.value) { if (publishDiffStatus.value === 'synced') {
$baseMessage( $baseMessage(
t('system.interface.mapping.message.noContentToPublish'), t('system.interface.mapping.message.noContentToPublish'),
'warning', 'warning',
@ -514,11 +514,11 @@
<template #header-actions> <template #header-actions>
<a-button <a-button
type="primary" type="primary"
:disabled="diffLength === 0" :disabled="publishDiffStatus === 'synced'"
@click="handlerSendMap" @click="handlerSendMap"
> >
{{ {{
diffLength === 0 publishDiffStatus === 'synced'
? t('system.interface.mapping.modal.noNeedPublish') ? t('system.interface.mapping.modal.noNeedPublish')
: t('system.interface.mapping.modal.confirmPublish') : t('system.interface.mapping.modal.confirmPublish')
}} }}

View File

@ -328,7 +328,7 @@
} }
function handlerSendMap() { function handlerSendMap() {
if (!diffLength.value) { if (publishDiffStatus.value === 'synced') {
return $baseMessage( return $baseMessage(
t('system.interface.mapping.message.noContentToPublish'), t('system.interface.mapping.message.noContentToPublish'),
'warning', 'warning',
@ -454,11 +454,11 @@
<template #header-actions> <template #header-actions>
<a-button <a-button
type="primary" type="primary"
:disabled="diffLength === 0" :disabled="publishDiffStatus === 'synced'"
@click="handlerSendMap" @click="handlerSendMap"
> >
{{ {{
diffLength === 0 publishDiffStatus === 'synced'
? t('system.interface.mapping.modal.noNeedPublish') ? t('system.interface.mapping.modal.noNeedPublish')
: t('system.interface.mapping.modal.confirmPublish') : t('system.interface.mapping.modal.confirmPublish')
}} }}

View File

@ -1807,7 +1807,7 @@
const getModifiedModel = computed(() => formatSnapshotJson(modifiedModel.value || {})); const getModifiedModel = computed(() => formatSnapshotJson(modifiedModel.value || {}));
const handlePublishConfirm = () => { const handlePublishConfirm = () => {
if (!publishDiffLength.value) { if (publishDiffStatus.value === 'synced') {
Message.info(t('channel.salesProducts.publish.status.synced')); Message.info(t('channel.salesProducts.publish.status.synced'));
return; return;
} }
@ -4945,7 +4945,7 @@
<a-button <a-button
type="primary" type="primary"
long long
:disabled="publishDiffLength === 0" :disabled="publishDiffStatus === 'synced'"
@click="openPricePublishAiAssist" @click="openPricePublishAiAssist"
> >
{{ {{
@ -5076,11 +5076,11 @@
<a-button <a-button
type="primary" type="primary"
size="small" size="small"
:disabled="publishDiffLength === 0" :disabled="publishDiffStatus === 'synced'"
@click="handlePublishConfirm" @click="handlePublishConfirm"
> >
{{ {{
publishDiffLength === 0 publishDiffStatus === 'synced'
? t('channel.salesProducts.publish.noNeed') ? t('channel.salesProducts.publish.noNeed')
: t('channel.salesProducts.publish.confirm') : t('channel.salesProducts.publish.confirm')
}} }}

View File

@ -927,11 +927,11 @@
<a-button <a-button
type="primary" type="primary"
size="small" size="small"
:disabled="publishDiffLength === 0" :disabled="publishDiffStatus === 'synced'"
@click="handlePublishConfirm" @click="handlePublishConfirm"
> >
{{ {{
publishDiffLength === 0 publishDiffStatus === 'synced'
? t('channel.serviceChannels.publish.noNeed') ? t('channel.serviceChannels.publish.noNeed')
: t('channel.serviceChannels.publish.confirm') : t('channel.serviceChannels.publish.confirm')
}} }}
@ -4435,7 +4435,7 @@ const disabledTime = () => {
// 发布确认 // 发布确认
const handlePublishConfirm = () => { const handlePublishConfirm = () => {
if (!publishDiffLength.value) { if (publishDiffStatus.value === 'synced') {
Message.info(t('channel.serviceChannels.publish.status.synced')) Message.info(t('channel.serviceChannels.publish.status.synced'))
return return
} }

View File

@ -1,6 +1,14 @@
<template> <template>
<div class="h5-dock-shell"> <div class="h5-dock-shell">
<nav class="h5-dock" aria-label="Customer center tabs"> <nav ref="dockRef" class="h5-dock" aria-label="Customer center tabs">
<div class="h5-dock__glass" aria-hidden="true" />
<div
class="h5-dock__slider"
:class="{ 'is-ready': sliderReady }"
:style="sliderStyle"
aria-hidden="true"
/>
<router-link <router-link
v-for="item in leftTabs" v-for="item in leftTabs"
:key="item.path" :key="item.path"
@ -49,7 +57,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { IconRobot, IconUser, IconPlus } from '@arco-design/web-vue/es/icon'; import { IconRobot, IconUser, IconPlus } from '@arco-design/web-vue/es/icon';
@ -70,6 +78,16 @@ const emit = defineEmits<{
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const dockRef = ref<HTMLElement | null>(null);
const sliderReady = ref(false);
const sliderStyle = ref<Record<string, string>>({
left: '0px',
width: '0px',
opacity: '0',
});
const sliderPos = { x: 0, w: 0 };
let settleTimer: ReturnType<typeof setTimeout> | null = null;
let sliderAnimating = false;
const leftTabs = computed(() => [ const leftTabs = computed(() => [
{ {
@ -101,6 +119,116 @@ const rightTabs = computed(() => [
function isActive(path: string) { function isActive(path: string) {
return route.path === path || route.path.startsWith(`${path}/`); return route.path === path || route.path.startsWith(`${path}/`);
} }
const SLIDER_INSET = 4;
const LIQUID_STRETCH_MS = 120;
function applySlider(x: number, w: number, opacity = '1') {
sliderPos.x = x;
sliderPos.w = w;
sliderStyle.value = {
left: `${x}px`,
width: `${w}px`,
opacity,
};
}
function readLiveSlider() {
const slider = dockRef.value?.querySelector<HTMLElement>('.h5-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>('.h5-dock__item.is-active');
if (!dock || !active) {
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 =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
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) {
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);
}
async function syncSlider(animate: boolean) {
await nextTick();
updateSlider(animate);
}
let resizeObserver: ResizeObserver | null = null;
function onViewportResize() {
if (sliderAnimating) return;
updateSlider(false);
}
onMounted(() => {
syncSlider(false);
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(onViewportResize);
if (dockRef.value) resizeObserver.observe(dockRef.value);
}
window.addEventListener('resize', onViewportResize);
});
onBeforeUnmount(() => {
clearSettleTimer();
resizeObserver?.disconnect();
window.removeEventListener('resize', onViewportResize);
});
watch(
() => [route.path, props.hideCreate] as const,
() => {
syncSlider(true);
},
);
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@ -112,40 +240,132 @@ function isActive(path: string) {
z-index: 100; z-index: 100;
display: flex; display: flex;
justify-content: center; justify-content: center;
/* 下沉贴近底部圆角 / Home Indicator,略侵入 safe-area */ /* 16px 侧边 + 半高圆角 ≈ iPhone 屏幕连续圆角 */
padding: 0 14px var(--h5-dock-bottom-inset, max(4px, calc(env(safe-area-inset-bottom, 0px) - 16px))); padding: 0 var(--h5-dock-side-inset, 16px)
var(--h5-dock-bottom-inset, max(8px, calc(env(safe-area-inset-bottom, 0px) - 12px)));
pointer-events: none; pointer-events: none;
} }
.h5-dock { .h5-dock {
position: relative;
pointer-events: auto; pointer-events: auto;
display: flex; display: flex;
align-items: center; align-items: stretch;
justify-content: center; justify-content: center;
gap: 2px; gap: 0;
width: min(100%, 400px); width: 100%;
min-height: 48px; height: var(--h5-dock-height, 68px);
padding: 4px 10px; min-height: var(--h5-dock-height, 68px);
border-radius: 9999px; padding: 5px;
background: color-mix(in srgb, #fff 72%, transparent); border-radius: var(--h5-dock-radius, 34px);
backdrop-filter: blur(22px) saturate(185%); corner-shape: squircle;
-webkit-backdrop-filter: blur(22px) saturate(185%); overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.78); isolation: isolate;
background: transparent;
border: none;
box-shadow: box-shadow:
0 10px 36px rgba(15, 23, 42, 0.14), 0 12px 40px rgba(15, 23, 42, 0.14),
0 2px 8px rgba(15, 23, 42, 0.06), 0 2px 8px rgba(15, 23, 42, 0.05);
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 0 rgba(15, 23, 42, 0.04);
box-sizing: border-box; box-sizing: border-box;
} }
.h5-dock__glass {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
corner-shape: inherit;
overflow: hidden;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.38) 0%,
rgba(255, 255, 255, 0.16) 100%
);
backdrop-filter: blur(32px) saturate(190%) brightness(1.08);
-webkit-backdrop-filter: blur(32px) saturate(190%) brightness(1.08);
/* 顶部入射高光 */
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
165deg,
rgba(255, 255, 255, 0.62) 0%,
rgba(255, 255, 255, 0.12) 32%,
rgba(255, 255, 255, 0) 58%
);
}
/* Liquid Glass 边缘:上沿更亮、下沿更淡 */
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.92) 0%,
rgba(255, 255, 255, 0.28) 42%,
rgba(255, 255, 255, 0.4) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
}
.h5-dock__slider {
position: absolute;
top: 5px;
bottom: 5px;
border-radius: calc(var(--h5-dock-radius, 34px) - 5px);
corner-shape: squircle;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.72) 0%,
rgba(255, 255, 255, 0.42) 100%
);
backdrop-filter: blur(10px) saturate(140%) brightness(1.12);
-webkit-backdrop-filter: blur(10px) saturate(140%) brightness(1.12);
box-shadow:
0 4px 16px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.95),
inset 0 -1px 0 rgba(255, 255, 255, 0.2);
pointer-events: none;
z-index: 1;
opacity: 0;
&:not(.is-ready) {
transition: none;
}
/* 先拉长覆盖目标 Tab,再弹簧收回,接近 iOS 26 液态滑块 */
&.is-ready {
transition:
left 0.42s cubic-bezier(0.32, 0.72, 0, 1),
width 0.42s cubic-bezier(0.32, 0.72, 0, 1),
opacity 0.2s ease;
}
}
.h5-dock__item { .h5-dock__item {
position: relative;
z-index: 2;
flex: 1 1 0; flex: 1 1 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 2px; gap: 3px;
min-width: 0; min-width: 0;
padding: 0 2px; padding: 0 2px;
color: #86909c; color: #86909c;
@ -155,21 +375,24 @@ function isActive(path: string) {
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
transition: color 0.2s ease; touch-action: manipulation;
transition: color 0.22s ease;
&.is-active { &.is-active {
color: var(--app-primary-color); color: var(--app-primary-color);
.h5-dock__icon-wrap { .h5-dock__icon {
background: color-mix(in srgb, var(--app-primary-color) 14%, #fff); animation: h5-dock-icon-pop 0.42s cubic-bezier(0.22, 1.35, 0.36, 1);
color: var(--app-primary-color);
border-radius: 9999px;
} }
.h5-dock__label { .h5-dock__label {
font-weight: 600; font-weight: 600;
} }
} }
&:active:not(.h5-dock__item--create) {
transform: scale(0.96);
}
} }
.h5-dock__item--create { .h5-dock__item--create {
@ -185,6 +408,7 @@ function isActive(path: string) {
&:active .h5-dock__icon-wrap--create { &:active .h5-dock__icon-wrap--create {
filter: brightness(0.96); filter: brightness(0.96);
transform: scale(0.94);
} }
.h5-dock__label { .h5-dock__label {
@ -198,23 +422,21 @@ function isActive(path: string) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 30px; width: 36px;
height: 24px; height: 32px;
border-radius: 0; border-radius: 0;
line-height: 1; line-height: 1;
transition:
background 0.2s ease,
color 0.2s ease;
} }
.h5-dock__icon-wrap--create { .h5-dock__icon-wrap--create {
width: 32px; width: 34px;
height: 32px; height: 34px;
border-radius: 50%; border-radius: 50%;
transition: transform 0.18s ease, filter 0.18s ease;
} }
.h5-dock__icon { .h5-dock__icon {
font-size: 18px; font-size: 24px;
width: 1em; width: 1em;
height: 1em; height: 1em;
} }
@ -243,11 +465,33 @@ function isActive(path: string) {
.h5-dock__label { .h5-dock__label {
max-width: 100%; max-width: 100%;
font-size: 10px; font-size: 11px;
line-height: 1.15; line-height: 1.15;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-align: center; text-align: center;
} }
@keyframes h5-dock-icon-pop {
0% {
transform: scale(0.88);
}
70% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.h5-dock__slider.is-ready {
transition: none;
}
.h5-dock__item.is-active .h5-dock__icon {
animation: none;
}
}
</style> </style>

View File

@ -23,9 +23,12 @@
--h5-primary-soft: color-mix(in srgb, var(--app-primary-color) 12%, #fff); --h5-primary-soft: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
--h5-primary-softer: color-mix(in srgb, var(--app-primary-color) 8%, #fff); --h5-primary-softer: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
--h5-safe-bottom: env(safe-area-inset-bottom, 0px); --h5-safe-bottom: env(safe-area-inset-bottom, 0px);
/* 底部 dock 下沉贴近圆角;有 Home Indicator 时略侵入 safe-area */ /* 底部 dock:侧边 inset + 胶囊半径与 iPhone 连续圆角同心 */
--h5-dock-bottom-inset: max(4px, calc(var(--h5-safe-bottom) - 16px)); --h5-dock-bottom-inset: max(8px, calc(var(--h5-safe-bottom) - 12px));
--h5-dock-offset: calc(56px + var(--h5-dock-bottom-inset)); --h5-dock-side-inset: 16px;
--h5-dock-height: 68px;
--h5-dock-radius: calc(var(--h5-dock-height) / 2);
--h5-dock-offset: calc(var(--h5-dock-height) + var(--h5-dock-bottom-inset));
background: var(--h5-bg); background: var(--h5-bg);
color: var(--h5-text); color: var(--h5-text);

View File

@ -40,9 +40,8 @@
> >
<a <a
class="attach-file-name" class="attach-file-name"
:href="resolveUrl(file.fileUrl)" href="javascript:;"
target="_blank" @click.prevent="openFile(file)"
rel="noopener noreferrer"
> >
{{ file.fileName || file.fileUrl }} {{ file.fileName || file.fileUrl }}
</a> </a>
@ -54,7 +53,8 @@
<a-button <a-button
type="text" type="text"
size="mini" size="mini"
@click="openFile(file.fileUrl)" :loading="previewingId === file.id"
@click="openFile(file)"
> >
{{ $t('order.myOrders.drawer.attachments.preview') }} {{ $t('order.myOrders.drawer.attachments.preview') }}
</a-button> </a-button>
@ -93,6 +93,7 @@ import {
type OrderFileItem, type OrderFileItem,
} from '@/api/order'; } from '@/api/order';
import { resolveOmsAssetUrl } from '@/api/oms/common'; import { resolveOmsAssetUrl } from '@/api/oms/common';
import { fetchPrintPreviewBlob } from '@/utils/printPreview';
const FILE_TYPE_DEFS = [ const FILE_TYPE_DEFS = [
{ {
@ -147,6 +148,7 @@ const { t } = useI18n();
const loading = ref(false); const loading = ref(false);
const uploadingType = ref<string | null>(null); const uploadingType = ref<string | null>(null);
const deletingId = ref<number | null>(null); const deletingId = ref<number | null>(null);
const previewingId = ref<number | null>(null);
const groups = ref<Record<string, OrderFileItem[]>>({}); const groups = ref<Record<string, OrderFileItem[]>>({});
const canUpload = computed(() => !props.readOnly && !props.customerView); const canUpload = computed(() => !props.readOnly && !props.customerView);
@ -165,10 +167,48 @@ function resolveUrl(path?: string) {
return resolveOmsAssetUrl(path); return resolveOmsAssetUrl(path);
} }
function openFile(path?: string) { function guessAttachMime(file: OrderFileItem, url: string): string {
const url = resolveUrl(path); const ct = String(file.contentType || '').trim().toLowerCase();
if (!url) return; if (ct && !ct.includes('octet-stream') && !ct.includes('force-download')) {
window.open(url, '_blank', 'noopener,noreferrer'); return ct;
}
const hint = `${file.fileName || ''} ${url}`;
if (/\.pdf(\?|#|$)/i.test(hint)) return 'application/pdf';
if (/\.png(\?|#|$)/i.test(hint)) return 'image/png';
if (/\.jpe?g(\?|#|$)/i.test(hint)) return 'image/jpeg';
if (/\.webp(\?|#|$)/i.test(hint)) return 'image/webp';
if (/\.gif(\?|#|$)/i.test(hint)) return 'image/gif';
if (/\.bmp(\?|#|$)/i.test(hint)) return 'image/bmp';
return ct || 'application/octet-stream';
}
/** 拉取为带正确 MIME 的 blob 再打开,走浏览器内置预览,避免 Content-Disposition 触发下载 */
async function openFile(file: OrderFileItem) {
const raw = resolveUrl(file.fileUrl);
if (!raw) return;
if (previewingId.value === file.id) return;
previewingId.value = file.id;
try {
const blob = await fetchPrintPreviewBlob(raw);
const mime = guessAttachMime(file, raw);
const typed =
blob.type && !/octet-stream|force-download/i.test(blob.type)
? blob
: new Blob([blob], { type: mime });
const blobUrl = URL.createObjectURL(typed);
const win = window.open(blobUrl, '_blank');
if (!win) {
Message.warning(t('order.myOrders.drawer.attachments.previewBlocked'));
URL.revokeObjectURL(blobUrl);
return;
}
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 120_000);
} catch (e: any) {
Message.error(e?.message || t('order.myOrders.drawer.attachments.previewFailed'));
} finally {
previewingId.value = null;
}
} }
function formatTime(value?: string) { function formatTime(value?: string) {
@ -309,6 +349,7 @@ watch(
white-space: nowrap; white-space: nowrap;
color: var(--app-primary-color); color: var(--app-primary-color);
font-size: 13px; font-size: 13px;
cursor: pointer;
} }
.attach-file-meta { .attach-file-meta {

View File

@ -27,6 +27,15 @@ import {
reschedulePickupOrder, reschedulePickupOrder,
schedulePickupOrder, schedulePickupOrder,
} from '@/api/order/pickupOrder' } from '@/api/order/pickupOrder'
import {
postOrderDownstreamForwardCreateOne,
postOrderBatchJob,
waitOrderBatchJob,
formatBatchJobDownstreamForwardResultText,
isOrderBatchQueueDisabledError,
formatBatchJobSummaryMessage,
type OrderDownstreamForwardResult,
} from '@/api/order'
import { CustomerService } from '@/api/customer' import { CustomerService } from '@/api/customer'
import { resolveCurrentCustomer } from '@/api/customer/resolve' import { resolveCurrentCustomer } from '@/api/customer/resolve'
import { $baseMessage } from '@/utils' import { $baseMessage } from '@/utils'
@ -118,9 +127,17 @@ const selectedRows = ref<any[]>([])
const batchPaying = ref(false) const batchPaying = ref(false)
const batchFreighting = ref(false) const batchFreighting = ref(false)
const batchScheduling = ref(false) const batchScheduling = ref(false)
const batchDownstreamForwarding = ref(false)
const downstreamForwardModalVisible = ref(false)
const downstreamForwardResultText = ref('')
/** batch schedule uses same schedule modal with multiple ids */ /** batch schedule uses same schedule modal with multiple ids */
const batchScheduleIds = ref<number[]>([]) const batchScheduleIds = ref<number[]>([])
const canForwardDownstream = computed(
() =>
hasPermission('Order.Pickup.ForwardDownstream') || hasPermission('Order.Pickup.Create'),
)
const selectedRowCount = computed(() => selectedRows.value.length) const selectedRowCount = computed(() => selectedRows.value.length)
const hasSelectedRows = computed(() => selectedRowCount.value > 0) const hasSelectedRows = computed(() => selectedRowCount.value > 0)
const batchPayCandidates = computed(() => const batchPayCandidates = computed(() =>
@ -585,6 +602,139 @@ function handleBatchPay() {
})() })()
} }
function extractBoundOrderRows(detailRaw: any, pickupNo?: string): Array<{ id: number; serviceNo: string; pickupNo?: string }> {
const list = detailRaw?.orders ?? detailRaw?.Orders ?? []
if (!Array.isArray(list)) return []
const rows: Array<{ id: number; serviceNo: string; pickupNo?: string }> = []
for (const raw of list) {
const row = normalizeListRow(raw)
const orderId = Number(pickField(row, 'orderId', 'OrderId', 'id', 'Id') || 0)
if (!Number.isFinite(orderId) || orderId <= 0) continue
rows.push({
id: orderId,
serviceNo: String(pickField(row, 'serviceNo', 'ServiceNo') || orderId),
pickupNo,
})
}
return rows
}
async function resolveSelectedBoundOrders(): Promise<Array<{ id: number; serviceNo: string; pickupNo?: string }>> {
const pickupRows = selectedRows.value.filter((row) => row?.id != null)
if (!pickupRows.length) {
$baseMessage(t('order.pickupOrder.message.downstreamNeedSelect'), 'warning')
return []
}
const orderMap = new Map<number, { id: number; serviceNo: string; pickupNo?: string }>()
const failedPickups: string[] = []
for (const pickup of pickupRows) {
const pickupNo = String(pickup.pickupNo || pickup.id)
try {
const detailRaw = await getPickupOrderDetail(pickup.id)
const bound = extractBoundOrderRows(detailRaw, pickupNo)
for (const item of bound) {
if (!orderMap.has(item.id)) orderMap.set(item.id, item)
}
if (!bound.length) {
failedPickups.push(pickupNo)
}
} catch {
failedPickups.push(pickupNo)
}
}
const orders = [...orderMap.values()]
if (!orders.length) {
$baseMessage(t('order.pickupOrder.message.downstreamNoBoundOrders'), 'warning')
return []
}
if (failedPickups.length) {
$baseMessage(
t('order.pickupOrder.message.downstreamPartialEmpty', { pickups: failedPickups.join(', ') }),
'warning',
)
}
return orders
}
function formatDownstreamForwardResult(waybillNo: string, result: OrderDownstreamForwardResult): string {
const lines = [
`【${waybillNo || '-'}】`,
result.success ? '成功' : '失败',
`standardCode:createOrderOne`,
]
if (result.supplierCode) lines.push(`supplierCode:${result.supplierCode}`)
if (result.downstreamServiceNo) lines.push(`服务商单号:${result.downstreamServiceNo}`)
if (result.trackNo) lines.push(`跟踪号:${result.trackNo}`)
lines.push(`请求:${result.url || '-'}`)
if (result.requestJson) lines.push(`请求体:\n${result.requestJson}`)
lines.push(`HTTP:${result.statusCode ?? '-'}`)
if (result.responseBody) lines.push(`响应:\n${result.responseBody}`)
return lines.join('\n')
}
async function runDownstreamForwardBatch(orderRows: Array<{ id: number; serviceNo: string }>) {
const orderIds = orderRows.map((row) => row.id)
try {
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${formatBatchJobDownstreamForwardResultText('预报下游', result.job, result.items, orderRows)}`
downstreamForwardModalVisible.value = true
return
}
const { job: finishedJob, items } = result
downstreamForwardResultText.value = formatBatchJobDownstreamForwardResultText(
'预报下游',
finishedJob,
items,
orderRows,
)
downstreamForwardModalVisible.value = true
return
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e
}
}
const resultBlocks: string[] = []
for (const row of orderRows) {
try {
const result = await postOrderDownstreamForwardCreateOne({ id: row.id })
resultBlocks.push(formatDownstreamForwardResult(row.serviceNo, result))
} catch (err: any) {
resultBlocks.push(`【${row.serviceNo}】失败:${err?.message || '请求异常'}`)
}
}
downstreamForwardResultText.value = resultBlocks.join('\n\n')
downstreamForwardModalVisible.value = true
}
async function handleForwardDownstreamCreate() {
if (!canForwardDownstream.value) {
$baseMessage(t('order.pickupOrder.message.noPermission'), 'warning')
return
}
if (batchDownstreamForwarding.value) return
batchDownstreamForwarding.value = true
try {
const orderRows = await resolveSelectedBoundOrders()
if (!orderRows.length) return
await runDownstreamForwardBatch(orderRows)
} catch (e: any) {
$baseMessage(e?.message || t('order.pickupOrder.message.actionFailed'), 'error')
} finally {
batchDownstreamForwarding.value = false
}
}
async function doBatchPay() { async function doBatchPay() {
const ids = batchPayCandidates.value.map((row) => row.id).filter((id) => id != null) const ids = batchPayCandidates.value.map((row) => row.id).filter((id) => id != null)
if (!ids.length) return if (!ids.length) return
@ -1336,6 +1486,15 @@ onMounted(async () => {
> >
{{ t('order.pickupOrder.actions.batchSchedule') }} {{ t('order.pickupOrder.actions.batchSchedule') }}
</a-button> </a-button>
<a-button
v-if="canForwardDownstream"
class="app-btn-toolbar"
:loading="batchDownstreamForwarding"
:disabled="!hasSelectedRows || batchDownstreamForwarding"
@click="handleForwardDownstreamCreate"
>
{{ t('order.myOrders.actions.forwardDownstreamCreate') }}
</a-button>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<span class="selected-count"> <span class="selected-count">
@ -1718,6 +1877,15 @@ onMounted(async () => {
</div> </div>
</div> </div>
</ModalPro> </ModalPro>
<a-modal
v-model:visible="downstreamForwardModalVisible"
:title="t('order.myOrders.modal.downstreamForwardTitle')"
:width="800"
:footer="false"
>
<pre class="downstream-forward-result">{{ downstreamForwardResultText }}</pre>
</a-modal>
</div> </div>
</template> </template>
@ -2105,4 +2273,17 @@ onMounted(async () => {
color: var(--color-text-3); color: var(--color-text-3);
font-size: 13px; font-size: 13px;
} }
.downstream-forward-result {
margin: 0;
max-height: 480px;
overflow: auto;
padding: 12px;
background: var(--color-fill-2);
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
</style> </style>