feat: H5 底栏 Liquid Glass 与揽收预报下游
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m23s
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:
parent
2b3797d548
commit
5d47aa9d90
@ -3709,11 +3709,11 @@
|
||||
/>
|
||||
<a-button
|
||||
size="mini"
|
||||
class="content-type-copy-btn"
|
||||
class="content-type-copy-btn section-icon-btn"
|
||||
title="复制当前参数为带描述的 JSON"
|
||||
@click="copyBodyParamsAsCommentedJson"
|
||||
>
|
||||
复制
|
||||
<IconCopy />
|
||||
</a-button>
|
||||
</div>
|
||||
<!-- Query tab 内容 -->
|
||||
@ -4489,11 +4489,11 @@
|
||||
/>
|
||||
<a-button
|
||||
size="mini"
|
||||
class="content-type-copy-btn"
|
||||
class="content-type-copy-btn section-icon-btn"
|
||||
title="复制当前参数为带描述的 JSON"
|
||||
@click="copyResponseParamsAsCommentedJson"
|
||||
>
|
||||
复制
|
||||
<IconCopy />
|
||||
</a-button>
|
||||
</div>
|
||||
<!-- Response body tab 内容 -->
|
||||
@ -5230,8 +5230,9 @@
|
||||
.content-type-copy-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@ const CORE_GROUPS: PageButtonGroup[] = [
|
||||
{ code: 'Query', name: '查询' },
|
||||
{ code: 'Search', name: '搜索' },
|
||||
{ code: 'Create', name: '创建揽收订单' },
|
||||
{ code: 'ForwardDownstream', name: '预报下游订单' },
|
||||
{ code: 'DownstreamForecastResult', name: '下游预报结果' },
|
||||
]),
|
||||
B('Order.Customs9610', [
|
||||
{ code: 'Query', name: '查询' },
|
||||
|
||||
@ -829,6 +829,9 @@ export default {
|
||||
'order.pickupOrder.message.batchScheduleNoCandidates': 'No draft/scheduled sheets selected for forecast',
|
||||
'order.pickupOrder.message.batchScheduleHint': 'Draft-forecast {count} pickup sheet(s)',
|
||||
'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.pending': 'Pending',
|
||||
'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.attachments.upload': 'Upload',
|
||||
'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.empty': 'No attachments',
|
||||
'order.myOrders.drawer.attachments.loadFailed': 'Failed to load attachments',
|
||||
|
||||
@ -825,6 +825,9 @@ export default {
|
||||
'order.pickupOrder.message.batchScheduleNoCandidates': '所选揽收单无草稿/已预约可预报项',
|
||||
'order.pickupOrder.message.batchScheduleHint': '将对已选 {count} 张揽收单统一草稿预报',
|
||||
'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.pending': '待处理',
|
||||
'order.platformStoreOrder.status.toShip': '待发货',
|
||||
@ -1771,6 +1774,8 @@ export default {
|
||||
'order.myOrders.drawer.issueHandling.processFailed': '标记已处理失败',
|
||||
'order.myOrders.drawer.attachments.upload': '上传',
|
||||
'order.myOrders.drawer.attachments.preview': '预览',
|
||||
'order.myOrders.drawer.attachments.previewFailed': '预览失败',
|
||||
'order.myOrders.drawer.attachments.previewBlocked': '请允许弹出窗口以预览附件',
|
||||
'order.myOrders.drawer.attachments.delete': '删除',
|
||||
'order.myOrders.drawer.attachments.empty': '暂无附件',
|
||||
'order.myOrders.drawer.attachments.loadFailed': '加载附件失败',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/** 从订单详情组装 createOrderOne 标准请求体(与 parcel/create 一致) */
|
||||
/** 从订单详情组装 createOrderOne 标准请求体(ForwardWithSubscribe) */
|
||||
|
||||
function pick(raw: Record<string, any> | undefined, ...keys: string[]) {
|
||||
if (!raw) return undefined
|
||||
@ -62,9 +62,20 @@ export function buildCountryIdToCodeMap(countries: any[]): Map<number, string> {
|
||||
return map
|
||||
}
|
||||
|
||||
export type BuildCreateOrderOneBodyOptions = {
|
||||
/** 服务渠道「预报渠道 code」,写入 productCode;不传则不带 productCode */
|
||||
forecastCode?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装 createOrderOne 请求体。
|
||||
* - productCode:取渠道「预报渠道 code」(forecastCode),不是销售产品 code
|
||||
* - 不传 channelCode / requestId / exportCustomsMode 等 OMS 扩展字段
|
||||
*/
|
||||
export function buildCreateOrderOneBody(
|
||||
order: Record<string, any>,
|
||||
countryIdToCode: Map<number, string>
|
||||
countryIdToCode: Map<number, string>,
|
||||
options?: BuildCreateOrderOneBodyOptions
|
||||
): Record<string, unknown> {
|
||||
const weightList = (order.weightList ?? order.WeightList ?? []) as any[]
|
||||
const forecast =
|
||||
@ -88,21 +99,22 @@ export function buildCreateOrderOneBody(
|
||||
countryIdToCode
|
||||
)
|
||||
|
||||
return {
|
||||
requestId: pick(order, 'requestId', 'RequestId'),
|
||||
const forecastCode = String(options?.forecastCode ?? '').trim()
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
clientNo: pick(order, 'clientNo', 'ClientNo'),
|
||||
trackNo: pick(order, 'trackNo', 'TrackNo'),
|
||||
serviceNo: pick(order, 'serviceNo', 'ServiceNo'),
|
||||
productCode: pick(order, 'productCode', 'ProductCode'),
|
||||
channelCode: pick(order, 'channelCode', 'ChannelCode'),
|
||||
cargoAttribute: pick(order, 'cargoAttribute', 'CargoAttribute'),
|
||||
exportCustomsMode: pick(order, 'exportCustomsMode', 'ExportCustomsMode'),
|
||||
warehouseId: pick(order, 'warehouseId', 'WarehouseId'),
|
||||
customerId: pick(order, 'customerId', 'CustomerId'),
|
||||
length: pick(forecast, 'length', 'Length'),
|
||||
width: pick(forecast, 'width', 'Width'),
|
||||
height: pick(forecast, 'height', 'Height'),
|
||||
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,
|
||||
codSum: pick(order, 'codSum', 'CodSum') != null ? String(pick(order, 'codSum', 'CodSum')) : undefined,
|
||||
codCurrency: pick(order, 'codCurrency', 'CodCurrency'),
|
||||
@ -153,4 +165,10 @@ export function buildCreateOrderOneBody(
|
||||
},
|
||||
items: itemsRaw.map((row) => mapItem(row)),
|
||||
}
|
||||
|
||||
if (forecastCode) {
|
||||
body.productCode = forecastCode
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
@ -236,8 +236,11 @@ export function computeSnapshotDiff(
|
||||
modifiedRaw: unknown
|
||||
): { length: number; rows: SnapshotDiffRow[]; equal: boolean } {
|
||||
try {
|
||||
const original = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw))
|
||||
const modified = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw))
|
||||
const originalNorm = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw))
|
||||
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 modifiedText = JSON.stringify(modified)
|
||||
if (originalText === modifiedText) {
|
||||
|
||||
@ -297,7 +297,9 @@
|
||||
>
|
||||
<div class="fixed-parameter-body">
|
||||
<a-alert type="info" :show-icon="false" class="modal-form-tip">
|
||||
配置当前服务商的公共参数,供其下接口共用。路径:修改 → 保存 → 发布映射缓存后才对线上生效。
|
||||
配置当前服务商的公共参数,供其下接口共用。路径:修改 → 保存 → 发布映射缓存后才对线上生效。若赋值到
|
||||
Body,需填写完整参数路径,例如
|
||||
<code>[*].autoWaybillNo</code>。
|
||||
</a-alert>
|
||||
|
||||
<div class="table-toolbar">
|
||||
@ -375,6 +377,16 @@
|
||||
.fixed-parameter-body {
|
||||
.modal-form-tip {
|
||||
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 {
|
||||
|
||||
@ -367,7 +367,7 @@
|
||||
}
|
||||
|
||||
function handlerSendMap() {
|
||||
if (!diffLength.value) {
|
||||
if (publishDiffStatus.value === 'synced') {
|
||||
$baseMessage(
|
||||
t('system.interface.mapping.message.noContentToPublish'),
|
||||
'warning',
|
||||
@ -514,11 +514,11 @@
|
||||
<template #header-actions>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="diffLength === 0"
|
||||
:disabled="publishDiffStatus === 'synced'"
|
||||
@click="handlerSendMap"
|
||||
>
|
||||
{{
|
||||
diffLength === 0
|
||||
publishDiffStatus === 'synced'
|
||||
? t('system.interface.mapping.modal.noNeedPublish')
|
||||
: t('system.interface.mapping.modal.confirmPublish')
|
||||
}}
|
||||
|
||||
@ -328,7 +328,7 @@
|
||||
}
|
||||
|
||||
function handlerSendMap() {
|
||||
if (!diffLength.value) {
|
||||
if (publishDiffStatus.value === 'synced') {
|
||||
return $baseMessage(
|
||||
t('system.interface.mapping.message.noContentToPublish'),
|
||||
'warning',
|
||||
@ -454,11 +454,11 @@
|
||||
<template #header-actions>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="diffLength === 0"
|
||||
:disabled="publishDiffStatus === 'synced'"
|
||||
@click="handlerSendMap"
|
||||
>
|
||||
{{
|
||||
diffLength === 0
|
||||
publishDiffStatus === 'synced'
|
||||
? t('system.interface.mapping.modal.noNeedPublish')
|
||||
: t('system.interface.mapping.modal.confirmPublish')
|
||||
}}
|
||||
|
||||
@ -1807,7 +1807,7 @@
|
||||
const getModifiedModel = computed(() => formatSnapshotJson(modifiedModel.value || {}));
|
||||
|
||||
const handlePublishConfirm = () => {
|
||||
if (!publishDiffLength.value) {
|
||||
if (publishDiffStatus.value === 'synced') {
|
||||
Message.info(t('channel.salesProducts.publish.status.synced'));
|
||||
return;
|
||||
}
|
||||
@ -4945,7 +4945,7 @@
|
||||
<a-button
|
||||
type="primary"
|
||||
long
|
||||
:disabled="publishDiffLength === 0"
|
||||
:disabled="publishDiffStatus === 'synced'"
|
||||
@click="openPricePublishAiAssist"
|
||||
>
|
||||
{{
|
||||
@ -5076,11 +5076,11 @@
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="publishDiffLength === 0"
|
||||
:disabled="publishDiffStatus === 'synced'"
|
||||
@click="handlePublishConfirm"
|
||||
>
|
||||
{{
|
||||
publishDiffLength === 0
|
||||
publishDiffStatus === 'synced'
|
||||
? t('channel.salesProducts.publish.noNeed')
|
||||
: t('channel.salesProducts.publish.confirm')
|
||||
}}
|
||||
|
||||
@ -927,11 +927,11 @@
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="publishDiffLength === 0"
|
||||
:disabled="publishDiffStatus === 'synced'"
|
||||
@click="handlePublishConfirm"
|
||||
>
|
||||
{{
|
||||
publishDiffLength === 0
|
||||
publishDiffStatus === 'synced'
|
||||
? t('channel.serviceChannels.publish.noNeed')
|
||||
: t('channel.serviceChannels.publish.confirm')
|
||||
}}
|
||||
@ -4435,7 +4435,7 @@ const disabledTime = () => {
|
||||
|
||||
// 发布确认
|
||||
const handlePublishConfirm = () => {
|
||||
if (!publishDiffLength.value) {
|
||||
if (publishDiffStatus.value === 'synced') {
|
||||
Message.info(t('channel.serviceChannels.publish.status.synced'))
|
||||
return
|
||||
}
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<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
|
||||
v-for="item in leftTabs"
|
||||
:key="item.path"
|
||||
@ -49,7 +57,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { IconRobot, IconUser, IconPlus } from '@arco-design/web-vue/es/icon';
|
||||
@ -70,6 +78,16 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
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(() => [
|
||||
{
|
||||
@ -101,6 +119,116 @@ const rightTabs = computed(() => [
|
||||
function isActive(path: string) {
|
||||
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>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -112,40 +240,132 @@ function isActive(path: string) {
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* 下沉贴近底部圆角 / Home Indicator,略侵入 safe-area */
|
||||
padding: 0 14px var(--h5-dock-bottom-inset, max(4px, calc(env(safe-area-inset-bottom, 0px) - 16px)));
|
||||
/* 16px 侧边 + 半高圆角 ≈ iPhone 屏幕连续圆角 */
|
||||
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;
|
||||
}
|
||||
|
||||
.h5-dock {
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
width: min(100%, 400px);
|
||||
min-height: 48px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, #fff 72%, transparent);
|
||||
backdrop-filter: blur(22px) saturate(185%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(185%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.78);
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
height: var(--h5-dock-height, 68px);
|
||||
min-height: var(--h5-dock-height, 68px);
|
||||
padding: 5px;
|
||||
border-radius: var(--h5-dock-radius, 34px);
|
||||
corner-shape: squircle;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 10px 36px rgba(15, 23, 42, 0.14),
|
||||
0 2px 8px rgba(15, 23, 42, 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
inset 0 -1px 0 rgba(15, 23, 42, 0.04);
|
||||
0 12px 40px rgba(15, 23, 42, 0.14),
|
||||
0 2px 8px rgba(15, 23, 42, 0.05);
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 0 2px;
|
||||
color: #86909c;
|
||||
@ -155,21 +375,24 @@ function isActive(path: string) {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: color 0.2s ease;
|
||||
touch-action: manipulation;
|
||||
transition: color 0.22s ease;
|
||||
|
||||
&.is-active {
|
||||
color: var(--app-primary-color);
|
||||
|
||||
.h5-dock__icon-wrap {
|
||||
background: color-mix(in srgb, var(--app-primary-color) 14%, #fff);
|
||||
color: var(--app-primary-color);
|
||||
border-radius: 9999px;
|
||||
.h5-dock__icon {
|
||||
animation: h5-dock-icon-pop 0.42s cubic-bezier(0.22, 1.35, 0.36, 1);
|
||||
}
|
||||
|
||||
.h5-dock__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(.h5-dock__item--create) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.h5-dock__item--create {
|
||||
@ -185,6 +408,7 @@ function isActive(path: string) {
|
||||
|
||||
&:active .h5-dock__icon-wrap--create {
|
||||
filter: brightness(0.96);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.h5-dock__label {
|
||||
@ -198,23 +422,21 @@ function isActive(path: string) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 24px;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
line-height: 1;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.h5-dock__icon-wrap--create {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.h5-dock__icon {
|
||||
font-size: 18px;
|
||||
font-size: 24px;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
@ -243,11 +465,33 @@ function isActive(path: string) {
|
||||
|
||||
.h5-dock__label {
|
||||
max-width: 100%;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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>
|
||||
|
||||
@ -23,9 +23,12 @@
|
||||
--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-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
/* 底部 dock 下沉贴近圆角;有 Home Indicator 时略侵入 safe-area */
|
||||
--h5-dock-bottom-inset: max(4px, calc(var(--h5-safe-bottom) - 16px));
|
||||
--h5-dock-offset: calc(56px + var(--h5-dock-bottom-inset));
|
||||
/* 底部 dock:侧边 inset + 胶囊半径与 iPhone 连续圆角同心 */
|
||||
--h5-dock-bottom-inset: max(8px, calc(var(--h5-safe-bottom) - 12px));
|
||||
--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);
|
||||
color: var(--h5-text);
|
||||
|
||||
@ -40,9 +40,8 @@
|
||||
>
|
||||
<a
|
||||
class="attach-file-name"
|
||||
:href="resolveUrl(file.fileUrl)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="javascript:;"
|
||||
@click.prevent="openFile(file)"
|
||||
>
|
||||
{{ file.fileName || file.fileUrl }}
|
||||
</a>
|
||||
@ -54,7 +53,8 @@
|
||||
<a-button
|
||||
type="text"
|
||||
size="mini"
|
||||
@click="openFile(file.fileUrl)"
|
||||
:loading="previewingId === file.id"
|
||||
@click="openFile(file)"
|
||||
>
|
||||
{{ $t('order.myOrders.drawer.attachments.preview') }}
|
||||
</a-button>
|
||||
@ -93,6 +93,7 @@ import {
|
||||
type OrderFileItem,
|
||||
} from '@/api/order';
|
||||
import { resolveOmsAssetUrl } from '@/api/oms/common';
|
||||
import { fetchPrintPreviewBlob } from '@/utils/printPreview';
|
||||
|
||||
const FILE_TYPE_DEFS = [
|
||||
{
|
||||
@ -147,6 +148,7 @@ const { t } = useI18n();
|
||||
const loading = ref(false);
|
||||
const uploadingType = ref<string | null>(null);
|
||||
const deletingId = ref<number | null>(null);
|
||||
const previewingId = ref<number | null>(null);
|
||||
const groups = ref<Record<string, OrderFileItem[]>>({});
|
||||
|
||||
const canUpload = computed(() => !props.readOnly && !props.customerView);
|
||||
@ -165,10 +167,48 @@ function resolveUrl(path?: string) {
|
||||
return resolveOmsAssetUrl(path);
|
||||
}
|
||||
|
||||
function openFile(path?: string) {
|
||||
const url = resolveUrl(path);
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
function guessAttachMime(file: OrderFileItem, url: string): string {
|
||||
const ct = String(file.contentType || '').trim().toLowerCase();
|
||||
if (ct && !ct.includes('octet-stream') && !ct.includes('force-download')) {
|
||||
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) {
|
||||
@ -309,6 +349,7 @@ watch(
|
||||
white-space: nowrap;
|
||||
color: var(--app-primary-color);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attach-file-meta {
|
||||
|
||||
@ -27,6 +27,15 @@ import {
|
||||
reschedulePickupOrder,
|
||||
schedulePickupOrder,
|
||||
} from '@/api/order/pickupOrder'
|
||||
import {
|
||||
postOrderDownstreamForwardCreateOne,
|
||||
postOrderBatchJob,
|
||||
waitOrderBatchJob,
|
||||
formatBatchJobDownstreamForwardResultText,
|
||||
isOrderBatchQueueDisabledError,
|
||||
formatBatchJobSummaryMessage,
|
||||
type OrderDownstreamForwardResult,
|
||||
} from '@/api/order'
|
||||
import { CustomerService } from '@/api/customer'
|
||||
import { resolveCurrentCustomer } from '@/api/customer/resolve'
|
||||
import { $baseMessage } from '@/utils'
|
||||
@ -118,9 +127,17 @@ const selectedRows = ref<any[]>([])
|
||||
const batchPaying = ref(false)
|
||||
const batchFreighting = 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 */
|
||||
const batchScheduleIds = ref<number[]>([])
|
||||
|
||||
const canForwardDownstream = computed(
|
||||
() =>
|
||||
hasPermission('Order.Pickup.ForwardDownstream') || hasPermission('Order.Pickup.Create'),
|
||||
)
|
||||
|
||||
const selectedRowCount = computed(() => selectedRows.value.length)
|
||||
const hasSelectedRows = computed(() => selectedRowCount.value > 0)
|
||||
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() {
|
||||
const ids = batchPayCandidates.value.map((row) => row.id).filter((id) => id != null)
|
||||
if (!ids.length) return
|
||||
@ -1336,6 +1486,15 @@ onMounted(async () => {
|
||||
>
|
||||
{{ t('order.pickupOrder.actions.batchSchedule') }}
|
||||
</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 class="toolbar-right">
|
||||
<span class="selected-count">
|
||||
@ -1718,6 +1877,15 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -2105,4 +2273,17 @@ onMounted(async () => {
|
||||
color: var(--color-text-3);
|
||||
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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user