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
|
<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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: '查询' },
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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': '加载附件失败',
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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')
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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')
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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')
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user