front/src/views/order/waybillManage/components/OrderDetailDrawer.vue
2026-07-29 12:47:32 +08:00

3068 lines
91 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<DrawerPro
padding-bottom="16px"
@register="register"
@cancel="handleCancel"
>
<template #title />
<template #footer>
<div class="drawer-footer-banner">
<template v-if="activeTab === 'basic'">
<template v-if="basicEditing">
<a-button @click="cancelBasicEdit">{{ $t('order.myOrders.drawer.actions.cancel') }}</a-button>
<a-button type="primary" :loading="basicSaving" @click="handleBasicEditSave">
{{ $t('order.myOrders.drawer.actions.save') }}
</a-button>
</template>
<a-button v-else type="primary" @click="startBasicEdit">
{{ $t('order.myOrders.drawer.actions.edit') }}
</a-button>
</template>
<template v-else-if="activeTab === 'weight'">
<template v-if="weightEditing">
<a-button @click="cancelWeightEdit">{{ $t('order.myOrders.drawer.actions.cancel') }}</a-button>
<a-button type="primary" :loading="weightSaving" @click="handleWeightEditSave">
{{ $t('order.myOrders.drawer.actions.save') }}
</a-button>
</template>
<a-button v-else type="primary" @click="startWeightEdit">
{{ $t('order.myOrders.drawer.actions.edit') }}
</a-button>
</template>
<template v-else-if="activeTab === 'fee'">
<a-button type="outline" @click="openAddFeeModal">
{{ $t('order.myOrders.drawer.actions.addFee') }}
</a-button>
<a-button
type="primary"
:loading="recalcReceivableLoading"
@click="handleRecalculateReceivable"
>
{{ $t('order.myOrders.drawer.actions.recalculateReceivable') }}
</a-button>
<a-button
type="outline"
:loading="recalcPayableLoading"
@click="handleRecalculatePayable"
>
{{ $t('order.myOrders.drawer.actions.recalculatePayable') }}
</a-button>
</template>
</div>
</template>
<div class="order-detail-drawer">
<div class="drawer-tabs">
<a-tabs
v-model:active-key="activeTab"
type="line"
size="medium"
@change="handleTabChange"
>
<a-tab-pane
v-for="tab in tabList"
:key="tab.key"
:title="tab.label"
/>
</a-tabs>
</div>
<div class="drawer-tab-content">
<!-- 基本信息 -->
<div v-if="activeTab === 'basic'" class="tab-panel basic-info">
<div class="basic-summary">
<div class="basic-summary-top">
<div class="basic-summary-main">
<span class="basic-summary-label">{{ $t('order.myOrders.drawer.serviceNo') }}</span>
<span class="basic-summary-no">{{ getOrderValue('serviceNo') }}</span>
</div>
<a-tag
v-if="currentOrderStatusMeta"
size="small"
:style="getOrderStatusTagStyle(currentOrderStatusMeta)"
>
{{ $t(currentOrderStatusMeta.labelKey) }}
</a-tag>
</div>
<div class="basic-summary-chips">
<span class="basic-chip">
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.clientNo') }}</span>
<span class="basic-chip-value">{{ getOrderValue('clientNo') }}</span>
</span>
<span class="basic-chip">
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.trackNo') }}</span>
<span class="basic-chip-value">{{ getOrderValue('trackNo') }}</span>
</span>
<span class="basic-chip">
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.downstreamServiceNo') }}</span>
<span class="basic-chip-value">{{ getOrderValue('downstreamServiceNo') }}</span>
</span>
<span class="basic-chip">
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.channelName') }}</span>
<span class="basic-chip-value">{{ getOrderValue('channelName') }}</span>
</span>
<span class="basic-chip">
<span class="basic-chip-label">{{ $t('order.myOrders.drawer.consigneeCountryName') }}</span>
<span class="basic-chip-value">{{ getOrderValue('consigneeCountryName') }}</span>
</span>
</div>
<div class="basic-summary-actions">
<a-button type="outline" size="small" @click="handleAskAdvisor">
<template #icon><icon-robot /></template>
{{ $t('nanaAssistant.askAboutWaybill') }}
</a-button>
</div>
</div>
<div v-if="predeclareFailGuide" class="predeclare-fail-guide">
<p class="predeclare-fail-guide__reason">{{ predeclareFailGuide.reason }}</p>
<p v-if="predeclareFailGuide.tips?.[0]" class="predeclare-fail-guide__tip">
<span class="predeclare-fail-guide__tip-label">
{{ $t('order.myOrders.drawer.failGuideTip') }}
</span>
{{ predeclareFailGuide.tips[0] }}
</p>
<details
v-if="predeclareFailGuide.rawReason !== predeclareFailGuide.reason"
class="predeclare-fail-guide__details"
>
<summary>{{ $t('order.myOrders.drawer.failGuideRaw') }}</summary>
<p class="predeclare-fail-guide__raw">{{ predeclareFailGuide.rawReason }}</p>
</details>
<div class="predeclare-fail-guide__actions">
<a-button size="small" type="outline" @click="handleAskAdvisor">
{{ $t('order.myOrders.drawer.failGuideAskSupport') }}
</a-button>
<a-button size="small" type="primary" @click="startBasicEdit">
{{ $t('order.myOrders.drawer.failGuideEdit') }}
</a-button>
</div>
</div>
<div class="basic-view-switch">
<a-radio-group
v-model="basicViewMode"
type="button"
size="small"
>
<a-radio value="all">{{ $t('order.myOrders.drawer.basic.viewAll') }}</a-radio>
<a-radio
v-for="section in basicSectionDefs"
:key="section.key"
:value="section.key"
>
{{ $t(section.labelKey) }}
</a-radio>
</a-radio-group>
</div>
<template v-for="section in visibleBasicSections" :key="section.key">
<div class="detail-section" :class="section.key">
<div class="detail-section-header">
<div class="detail-section-title-wrap">
<span class="detail-section-dot" />
<span class="detail-section-title">{{ $t(section.labelKey) }}</span>
<a-tag
v-if="section.key === 'declaration'"
size="small"
color="arcoblue"
>
{{ displayDeclarationRows.length }}
</a-tag>
</div>
<a-button
v-if="section.key === 'declaration' && basicEditing"
size="mini"
type="outline"
@click="addDeclarationRow"
>
{{ $t('order.myOrders.drawer.declaration.addRow') }}
</a-button>
</div>
<!-- 订单基本信息 -->
<template v-if="section.key === 'basic'">
<div
v-for="group in orderBasicFieldGroups"
:key="group.key"
class="detail-field-group"
>
<div class="detail-field-group-title">{{ $t(group.labelKey) }}</div>
<div class="detail-fields-grid">
<div
v-for="fieldKey in group.fields"
:key="fieldKey"
class="detail-field-item"
>
<span class="detail-field-label">{{ $t(getOrderFieldLabelKey(fieldKey)) }}</span>
<div class="detail-field-value">
<template v-if="basicEditing && isOrderFieldEditable(fieldKey)">
<a-select
v-if="getOrderEditMeta(fieldKey)?.inputType === 'select'"
v-model="basicEditForm[fieldKey]"
allow-clear
size="small"
>
<a-option
v-for="opt in getOrderEditMeta(fieldKey)?.options"
:key="opt.value"
:value="opt.value"
>
{{ $t(opt.labelKey) }}
</a-option>
</a-select>
<a-switch
v-else-if="getOrderEditMeta(fieldKey)?.inputType === 'switch'"
v-model="basicEditForm[fieldKey]"
size="small"
/>
<a-input-number
v-else-if="getOrderEditMeta(fieldKey)?.inputType === 'number'"
v-model="basicEditForm[fieldKey]"
:min="0"
size="small"
style="width: 100%"
/>
<a-input
v-else
v-model="basicEditForm[fieldKey]"
allow-clear
size="small"
/>
</template>
<template v-else-if="fieldKey === 'orderStatus' && currentOrderStatusMeta">
<a-tag
size="small"
:style="getOrderStatusTagStyle(currentOrderStatusMeta)"
>
{{ getOrderValue(fieldKey) }}
</a-tag>
</template>
<template v-else-if="isOrderBooleanField(fieldKey)">
<a-tag
size="small"
:color="getOrderBooleanTagColor(fieldKey)"
>
{{ getOrderValue(fieldKey) }}
</a-tag>
</template>
<span v-else class="detail-field-text">{{ getOrderValue(fieldKey) }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- 发件人 -->
<template v-else-if="section.key === 'sender'">
<a-empty
v-if="!basicEditing && !hasSender"
:description="$t('order.myOrders.drawer.sender.empty')"
:image-style="{ height: '48px' }"
class="detail-section-empty"
/>
<div v-else class="detail-fields-grid">
<div
v-for="field in senderFields"
:key="field.key"
class="detail-field-item"
>
<span class="detail-field-label">{{ $t(field.labelKey) }}</span>
<div class="detail-field-value">
<template v-if="basicEditing && isSenderFieldEditable(field.key)">
<a-input
v-model="senderEditForm[getSenderFormKey(field.key)]"
allow-clear
size="small"
/>
</template>
<template v-else-if="field.key === 'isDefault'">
<a-tag size="small" :color="getSenderValue(field.key) === $t('order.myOrders.drawer.yes') ? 'green' : 'gray'">
{{ getSenderValue(field.key) }}
</a-tag>
</template>
<span v-else class="detail-field-text">{{ getSenderValue(field.key) }}</span>
</div>
</div>
</div>
</template>
<!-- 收件人 -->
<template v-else-if="section.key === 'recipient'">
<div class="detail-fields-grid">
<div
v-for="field in recipientFields"
:key="field.key"
class="detail-field-item"
>
<span class="detail-field-label">{{ $t(field.labelKey) }}</span>
<div class="detail-field-value">
<template v-if="basicEditing && isRecipientFieldEditable(field.key)">
<a-input
v-model="receiverEditForm[getRecipientFormKey(field.key)]"
allow-clear
size="small"
/>
</template>
<span v-else class="detail-field-text">{{ getRecipientValue(field.key) }}</span>
</div>
</div>
</div>
</template>
<!-- 申报信息 -->
<template v-else-if="section.key === 'declaration'">
<div v-if="displayDeclarationRows.length" class="declaration-table-wrap">
<div class="declaration-table">
<div class="table-header">
<div
v-for="col in declarationColumns"
:key="col.key"
class="header-cell"
:style="{ minWidth: col.width ? `${col.width}px` : '80px' }"
>
{{ $t(col.labelKey) }}
</div>
<div v-if="basicEditing" class="header-cell action-cell">
{{ $t('order.myOrders.drawer.declaration.action') }}
</div>
</div>
<div
v-for="(row, idx) in displayDeclarationRows"
:key="idx"
class="table-row"
>
<div
v-for="col in declarationColumns"
:key="col.key"
class="table-cell"
:style="{ minWidth: col.width ? `${col.width}px` : '80px' }"
>
<template v-if="basicEditing">
<a-switch
v-if="col.key === 'isMagnetic'"
v-model="row.isMagnetic"
size="small"
/>
<a-input-number
v-else-if="isDeclarationNumberCol(col.key)"
v-model="row[col.key]"
:min="0"
size="mini"
style="width: 100%"
/>
<a-input
v-else
v-model="row[col.key]"
size="mini"
allow-clear
/>
</template>
<template v-else>
<a-tag
v-if="col.key === 'isMagnetic'"
size="small"
:color="row[col.key] ? 'orange' : 'gray'"
>
{{
row[col.key] == null
? '--'
: (row[col.key] ? $t('order.myOrders.drawer.yes') : $t('order.myOrders.drawer.no'))
}}
</a-tag>
<span v-else>{{ row[col.key] ?? '--' }}</span>
</template>
</div>
<div v-if="basicEditing" class="table-cell action-cell">
<a-button
type="text"
size="mini"
status="danger"
@click="removeDeclarationRow(idx)"
>
{{ $t('order.myOrders.drawer.declaration.delete') }}
</a-button>
</div>
</div>
</div>
</div>
<a-empty
v-else
:description="$t('order.myOrders.drawer.declaration.empty')"
:image-style="{ height: '48px' }"
class="detail-section-empty"
/>
</template>
</div>
</template>
</div>
<!-- 件重尺 -->
<div v-if="activeTab === 'weight'" class="tab-panel weight-info">
<div class="weight-summary">
<button
v-for="section in weightTypeDefs"
:key="section.key"
type="button"
class="weight-summary-card"
:class="[section.key, { active: weightViewMode === section.key }]"
@click="setWeightViewMode(section.key)"
>
<span class="weight-summary-label">{{ $t(section.labelKey) }}</span>
<span class="weight-summary-value">
{{ formatWeightChargeable(getWeightRowByType(section.key)) }}
</span>
<span class="weight-summary-meta">
{{ $t('order.myOrders.drawer.weight.actualMeta', {
value: formatWeightNumber(getWeightRowByType(section.key)?.actualWeight),
}) }}
</span>
</button>
</div>
<div class="weight-view-switch">
<a-radio-group
v-model="weightViewMode"
type="button"
size="small"
>
<a-radio value="all">{{ $t('order.myOrders.drawer.weight.viewAll') }}</a-radio>
<a-radio
v-for="section in weightTypeDefs"
:key="section.key"
:value="section.key"
>
{{ $t(section.labelKey) }}
</a-radio>
</a-radio-group>
</div>
<template v-for="section in visibleWeightSections" :key="section.key">
<div class="weight-section" :class="section.key">
<div class="weight-section-header">
<div class="weight-section-title-wrap">
<span class="weight-section-dot" />
<span class="weight-section-title">{{ $t(section.labelKey) }}</span>
</div>
<span class="weight-section-total">
{{ formatWeightChargeable(section.row) }}
</span>
</div>
<div
v-if="section.hasData || (weightEditing && hasWeightRow(section.key))"
class="weight-metrics-grid"
>
<div
v-for="field in weightMetricFields"
:key="field.key"
class="weight-metric-item"
:class="{ highlight: field.highlight }"
>
<span class="metric-label">{{ $t(field.labelKey) }}</span>
<div class="metric-value-wrap">
<template v-if="weightEditing && field.editable && hasWeightRow(section.key)">
<a-input-number
v-model="section.row[field.key]"
:min="0"
size="small"
style="width: 100%"
/>
</template>
<span
v-else
class="metric-value"
:class="{ 'metric-value-highlight': field.highlight }"
>
{{ formatWeightCellValue(section.row, field.key) }}
</span>
</div>
</div>
</div>
<a-empty
v-else
:description="$t(section.emptyKey)"
:image-style="{ height: '48px' }"
class="weight-section-empty"
/>
</div>
</template>
<a-empty
v-if="!weightList.length && !weightEditing"
:description="$t('order.myOrders.drawer.weight.emptyAll')"
:image-style="{ height: '60px' }"
class="weight-empty-all"
/>
</div>
<!-- 应收应付 -->
<div v-if="activeTab === 'fee'" class="tab-panel fee-info">
<div class="fee-summary">
<button
type="button"
class="fee-summary-card receivable"
:class="{ active: feeViewMode === 'receivable' }"
@click="setFeeViewMode('receivable')"
>
<span class="fee-summary-label">{{ $t('finance.receivablePayable.feeType.receivable') }}</span>
<span class="fee-summary-amount">{{ formatFeeTotals(receivableTotals) }}</span>
<span class="fee-summary-meta">
{{ $t('order.myOrders.drawer.fee.itemCount', { count: receivableFeeList.length }) }}
</span>
</button>
<button
type="button"
class="fee-summary-card payable"
:class="{ active: feeViewMode === 'payable' }"
@click="setFeeViewMode('payable')"
>
<span class="fee-summary-label">{{ $t('finance.receivablePayable.feeType.payable') }}</span>
<span class="fee-summary-amount">{{ formatFeeTotals(payableTotals) }}</span>
<span class="fee-summary-meta">
{{ $t('order.myOrders.drawer.fee.itemCount', { count: payableFeeList.length }) }}
</span>
</button>
<div class="fee-summary-card gross-profit">
<span class="fee-summary-label">{{ $t('order.myOrders.drawer.fee.grossProfit') }}</span>
<span class="fee-summary-amount">{{ formatGrossProfitTotals(feeGrossProfitTotals) }}</span>
<span class="fee-summary-meta">{{ grossProfitMarginText }}</span>
</div>
</div>
<div class="fee-profit-formula">
<span class="fee-profit-formula-label">{{ $t('order.myOrders.drawer.fee.grossProfitFormula') }}</span>
<span class="fee-profit-formula-value">
<span class="amount-receivable">{{ formatFeeTotals(receivableTotals) }}</span>
<span class="fee-profit-operator">−</span>
<span class="amount-payable">{{ formatFeeTotals(payableTotals) }}</span>
<span class="fee-profit-operator">=</span>
<span class="fee-profit-result">
{{ formatGrossProfitTotals(feeGrossProfitTotals) }}
</span>
</span>
</div>
<div class="fee-view-switch">
<a-radio-group
v-model="feeViewMode"
type="button"
size="small"
>
<a-radio value="all">{{ $t('order.myOrders.drawer.fee.viewAll') }}</a-radio>
<a-radio value="receivable">{{ $t('finance.receivablePayable.feeType.receivable') }}</a-radio>
<a-radio value="payable">{{ $t('finance.receivablePayable.feeType.payable') }}</a-radio>
</a-radio-group>
</div>
<template v-for="section in visibleFeeSections" :key="section.key">
<div class="fee-section" :class="section.key">
<div class="fee-section-header">
<div class="fee-section-title-wrap">
<span class="fee-section-dot" />
<span class="fee-section-title">{{ $t(section.titleKey) }}</span>
<a-tag size="small" :color="section.tagColor">{{ section.list.length }}</a-tag>
</div>
<span class="fee-section-total">{{ formatFeeTotals(section.totals) }}</span>
</div>
<div v-if="section.list.length" class="fee-table-wrap">
<div class="fee-table">
<div class="table-header">
<div
v-for="col in feeDetailColumns"
:key="col.key"
class="header-cell"
:class="col.key"
>
{{ $t(col.labelKey) }}
</div>
</div>
<div
v-for="(row, idx) in section.list"
:key="`${section.key}-${idx}`"
class="table-row"
>
<div
v-for="col in feeDetailColumns"
:key="col.key"
class="table-cell"
:class="col.key"
>
<template v-if="col.key === 'amount'">
<span class="fee-amount" :class="section.amountClass">
{{ formatFeeAmount(row.amount) }}
</span>
</template>
<template v-else-if="col.key === 'chargingStage'">
<a-tag
v-if="getChargingStage(row)"
size="small"
:color="getChargingStageColor(getChargingStage(row))"
>
{{ getChargingStageLabel(getChargingStage(row)) }}
</a-tag>
<span v-else>--</span>
</template>
<template v-else>
{{ getFeeCellValue(row, col) }}
</template>
</div>
</div>
</div>
</div>
<a-empty
v-else
:description="$t(section.emptyKey)"
:image-style="{ height: '48px' }"
class="fee-section-empty"
/>
</div>
</template>
</div>
<!-- 轨迹 -->
<div v-if="activeTab === 'track'" class="tab-panel track-info">
<div class="track-toolbar">
<div class="track-toolbar-left">
<span class="track-toolbar-label">{{ $t('order.myOrders.drawer.trackNo') }}</span>
<span class="track-toolbar-value">{{ trackOrderNumber || '--' }}</span>
</div>
<div class="track-toolbar-right">
<a-tag v-if="trackTimeline.length" size="small" color="arcoblue">
{{ $t('order.myOrders.drawer.track.count', { count: trackTimeline.length }) }}
</a-tag>
<a-button
type="text"
size="small"
:loading="trackLoading"
@click="loadTrackTimeline"
>
<template #icon>
<icon-refresh />
</template>
{{ $t('order.myOrders.drawer.track.refresh') }}
</a-button>
</div>
</div>
<a-spin :loading="trackLoading" class="track-spin">
<div v-if="displayTrackTimeline.length" class="track-timeline">
<div
v-for="(item, i) in displayTrackTimeline"
:key="i"
class="track-item"
:class="{ active: isTrackNodeActive(item, i) }"
>
<div class="track-left">
<span v-if="isTrackNodeActive(item, i)" class="track-icon active">
<icon-check-circle-fill />
</span>
<span v-else class="track-icon">{{ displayTrackTimeline.length - i }}</span>
<span
v-if="i < displayTrackTimeline.length - 1"
class="track-line"
:class="{ active: isTrackNodeActive(item, i) }"
/>
</div>
<div class="track-right">
<div class="track-card">
<div class="track-card-head">
<div class="track-title">{{ item.content || '--' }}</div>
<a-tag v-if="isTrackNodeActive(item, i)" size="small" color="green">
{{ $t('order.myOrders.drawer.track.latest') }}
</a-tag>
</div>
<div v-if="formatTrackPlace(item)" class="track-meta track-place">
<icon-location class="track-meta-icon" />
<span>{{ formatTrackPlace(item) }}</span>
</div>
<div v-if="formatTrackTimeDisplay(item)" class="track-meta track-time">
<span>{{ item.trackTimePrefix || '' }}{{ formatTrackTimeDisplay(item) }}</span>
<span v-if="item.trackTimeZone" class="track-timezone">
{{ $t('order.myOrders.drawer.track.timezone', { zone: item.trackTimeZone }) }}
</span>
</div>
<div v-if="item.remark" class="track-meta track-remark">
{{ $t('order.myOrders.drawer.track.remark') }}:{{ item.remark }}
</div>
<div v-if="item.trackCode" class="track-meta track-code">
{{ $t('order.myOrders.drawer.track.code') }}:{{ item.trackCode }}
</div>
</div>
</div>
</div>
</div>
<a-empty
v-else-if="!trackLoading"
:description="$t('order.myOrders.drawer.track.empty')"
:image-style="{ height: '60px' }"
class="track-empty"
/>
</a-spin>
</div>
<!-- 附件 -->
<div v-if="activeTab === 'attachments'" class="tab-panel attachments-info">
<OrderAttachmentsTab
:order-id="orderId"
:active="activeTab === 'attachments'"
/>
</div>
<!-- 绑定包裹/库存 -->
<div v-if="activeTab === 'stockBind'" class="tab-panel stock-bind-info">
<OrderStockBindTab
:order-id="orderId"
:active="activeTab === 'stockBind'"
/>
</div>
</div>
</div>
</DrawerPro>
<AddOrderFeeModal
v-model:visible="addFeeModalVisible"
:order-id="orderId"
:default-type="addFeeDefaultType"
@success="handleAddFeeSuccess"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconRefresh, IconCheckCircleFill, IconLocation, IconRobot } from '@arco-design/web-vue/es/icon';
import useDrawer from '@/components/DrawerPro/useDrawer';
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
import {
postOrderUpdate,
postOrderWeightUpdate,
postOrderRecalculateReceivable,
postOrderRecalculatePayable,
} from '@/api/order';
import { getDictListAll, type DictDto } from '@/api/system/dict';
import { getOrderStatusMeta, resolveOrderStatusCode } from '@/constants/orderStatus';
import AddOrderFeeModal from './AddOrderFeeModal.vue';
import OrderStockBindTab from '../../myOrders/components/OrderStockBindTab.vue';
import OrderAttachmentsTab from '../../components/OrderAttachmentsTab.vue';
import { openAskAdvisor } from '@/composables/useAskAdvisor';
import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide';
const { t } = useI18n();
const router = useRouter();
interface OrderRow {
id?: number;
waybillNo?: string;
customerOrderNo?: string;
trackingNo?: string;
[key: string]: any;
}
interface Props {
order?: OrderRow | null;
}
const props = withDefaults(defineProps<Props>(), {
order: null,
});
const emit = defineEmits<{
cancel: [];
updated: [order: OrderRow];
}>();
const detail = ref<OrderRow | null>(null);
const basicEditing = ref(false);
const weightEditing = ref(false);
const basicSaving = ref(false);
const weightSaving = ref(false);
const recalcReceivableLoading = ref(false);
const recalcPayableLoading = ref(false);
const addFeeModalVisible = ref(false);
const addFeeDefaultType = ref<'receivable' | 'payable'>('receivable');
type BasicViewMode = 'all' | 'basic' | 'sender' | 'recipient' | 'declaration';
const basicViewMode = ref<BasicViewMode>('all');
const basicSectionDefs = [
{ key: 'basic' as const, labelKey: 'order.myOrders.drawer.orderBasic' },
{ key: 'sender' as const, labelKey: 'order.myOrders.drawer.sender' },
{ key: 'recipient' as const, labelKey: 'order.myOrders.drawer.recipient' },
{ key: 'declaration' as const, labelKey: 'order.myOrders.drawer.declaration' },
];
const visibleBasicSections = computed(() => {
if (basicViewMode.value === 'all') return basicSectionDefs;
return basicSectionDefs.filter((section) => section.key === basicViewMode.value);
});
const currentOrderStatusMeta = computed(() => {
const code = resolveOrderStatusCode(detail.value);
return code ? getOrderStatusMeta(code) : null;
});
const predeclareFailGuide = computed(() => {
const status = resolveOrderStatusCode(detail.value);
if (status !== 'draft') return null;
const reason = String(getOrderValue('predeclareFailReason') || '').trim();
if (!reason) return null;
return buildFailGuideFromReason(reason);
});
type FieldInputType = 'text' | 'number' | 'select' | 'switch';
interface FormFieldDef {
key: string;
labelKey: string;
inputType?: FieldInputType;
span?: 2;
options?: Array<{ value: string; labelKey: string }>;
}
const orderEditFieldDefs: FormFieldDef[] = [
{ key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' },
{ key: 'trackNo', labelKey: 'order.myOrders.drawer.trackNo' },
{ key: 'channelCode', labelKey: 'order.myOrders.drawer.channelCode' },
{
key: 'cargoAttribute',
labelKey: 'order.myOrders.drawer.cargoAttribute',
inputType: 'select',
options: [
{ value: 'general', labelKey: 'order.myOrders.drawer.cargoAttribute.general' },
{ value: 'electronics', labelKey: 'order.myOrders.drawer.cargoAttribute.electronics' },
{ value: 'sensitive', labelKey: 'order.myOrders.drawer.cargoAttribute.sensitive' },
],
},
{ key: 'isCod', labelKey: 'order.myOrders.drawer.isCod', inputType: 'switch' },
{ key: 'codCurrency', labelKey: 'order.myOrders.drawer.codCurrency' },
{ key: 'codSum', labelKey: 'order.myOrders.drawer.codSum', inputType: 'number' },
];
const orderEditMetaMap = Object.fromEntries(
orderEditFieldDefs.map((def) => [def.key, def])
) as Record<string, FormFieldDef>;
const SENDER_READONLY_KEYS = new Set(['isDefault']);
const SENDER_FORM_KEY_MAP: Record<string, string> = { country: 'countryCode' };
const RECIPIENT_FORM_KEY_MAP: Record<string, string> = { country: 'countryCode' };
function isOrderFieldEditable(key: string) {
return key in orderEditMetaMap;
}
function getOrderEditMeta(key: string) {
return orderEditMetaMap[key];
}
function isSenderFieldEditable(key: string) {
return !SENDER_READONLY_KEYS.has(key);
}
function getSenderFormKey(key: string) {
return SENDER_FORM_KEY_MAP[key] ?? key;
}
function isRecipientFieldEditable(_key: string) {
return true;
}
function getRecipientFormKey(key: string) {
return RECIPIENT_FORM_KEY_MAP[key] ?? key;
}
const basicEditForm = reactive({
clientNo: '',
trackNo: '',
channelCode: '',
cargoAttribute: 'general',
isCod: false,
codSum: undefined as number | undefined,
codCurrency: '',
});
const senderEditForm = reactive<Record<string, string>>({
senderName: '',
company: '',
countryCode: '',
province: '',
city: '',
zipCode: '',
streetAddress1: '',
streetAddress2: '',
streetAddress3: '',
houseNumber: '',
phone: '',
telePhone: '',
email: '',
});
const receiverEditForm = reactive<Record<string, string>>({
recipientsName: '',
companyName: '',
countryCode: '',
phone: '',
telePhone: '',
zipCode: '',
province: '',
city: '',
streetNumber: '',
streetAddress1: '',
streetAddress2: '',
streetAddress3: '',
email: '',
taxType: '',
taxNo: '',
certificateType: '',
certificateNo: '',
eori: '',
vat: '',
});
const declarationEditList = ref<any[]>([]);
const weightEditForm = ref<
Array<{
type: string;
length?: number;
width?: number;
height?: number;
actualWeight?: number;
}>
>([]);
const hasSender = computed(() => Object.keys(sender.value).length > 0);
const orderId = computed(() => detail.value?.id);
const displayDeclarationRows = computed(() =>
basicEditing.value ? declarationEditList.value : declarationList.value
);
function getOrderFieldLabelKey(fieldKey: string) {
return orderBasicFields.find((field) => field.key === fieldKey)?.labelKey ?? fieldKey;
}
function isOrderBooleanField(fieldKey: string) {
return ['isCod', 'isIntercept', 'isRemote', 'isPaid'].includes(fieldKey);
}
function getOrderStatusTagStyle(meta: { tagColor?: string; tagBgColor?: string }) {
return {
color: meta.tagColor || '#4e5969',
background: meta.tagBgColor || '#f2f3f5',
border: 'none',
};
}
function getOrderBooleanTagColor(fieldKey: string) {
const val = getOrderValue(fieldKey);
const yes = t('order.myOrders.drawer.yes');
if (fieldKey === 'isPaid') return val === yes ? 'green' : 'orange';
if (fieldKey === 'isIntercept') return val === yes ? 'red' : 'gray';
if (fieldKey === 'isRemote') return val === yes ? 'orange' : 'gray';
return val === yes ? 'arcoblue' : 'gray';
}
function isDeclarationNumberCol(key: string) {
return ['unitPrice', 'unitWeight', 'quantity', 'amount', 'weight'].includes(key);
}
function cloneDeclarationRows(rows: any[]) {
return (rows || []).map((row) => ({
...row,
isMagnetic: !!row.isMagnetic,
}));
}
function addDeclarationRow() {
declarationEditList.value.push({
cnName: '',
enName: '',
sku: '',
unit: '',
quantity: 1,
isMagnetic: false,
});
}
function removeDeclarationRow(index: number) {
declarationEditList.value.splice(index, 1);
}
function buildDeclarationPayload() {
return declarationEditList.value.map((row) => ({
cnName: row.cnName || undefined,
enName: row.enName || undefined,
sku: row.sku || undefined,
unit: row.unit || undefined,
unitPrice: row.unitPrice != null ? Number(row.unitPrice) : undefined,
unitWeight: row.unitWeight != null ? Number(row.unitWeight) : undefined,
quantity: row.quantity != null ? Number(row.quantity) : undefined,
amount: row.amount != null ? Number(row.amount) : undefined,
weight: row.weight != null ? Number(row.weight) : undefined,
imHsCode: row.hsCode || undefined,
exHsCode: row.hsCode || undefined,
skuImage: row.skuImage || undefined,
category: row.category || undefined,
brand: row.brand || undefined,
brandType: row.brandType || undefined,
isMagnetic: !!row.isMagnetic,
distribution: row.distribution || undefined,
material: row.material || undefined,
enMaterial: row.enMaterial || undefined,
purpose: row.purpose || undefined,
attributes: row.attributes || undefined,
originCountry: row.originCountry || undefined,
salesLink: row.salesLink || undefined,
model: row.model || undefined,
specifications: row.specifications || undefined,
comment: row.comment || undefined,
}));
}
function parseOptionalInt(value: string) {
const trimmed = String(value ?? '').trim();
if (!trimmed) return undefined;
const num = Number(trimmed);
return Number.isNaN(num) ? undefined : num;
}
const displayWeightRows = computed(() => {
if (!weightEditing.value) {
return weightList.value;
}
return weightEditForm.value.map((row) => {
const orig = weightList.value.find((w: any) => w.type === row.type) || {};
return { ...orig, ...row };
});
});
function resetEditState() {
basicEditing.value = false;
weightEditing.value = false;
}
const [register, { openDrawer: innerOpenDrawer, setDrawerProps, closeDrawer }] = useDrawer();
const activeTab = ref('basic');
const tabList = computed(() => [
{ key: 'basic', label: t('order.myOrders.drawer.tabs.basic') },
{ key: 'weight', label: t('order.myOrders.drawer.tabs.weight') },
{ key: 'fee', label: t('order.myOrders.drawer.tabs.fee') },
{ key: 'track', label: t('order.myOrders.drawer.tabs.track') },
{ key: 'attachments', label: t('order.myOrders.drawer.tabs.attachments') },
{ key: 'stockBind', label: t('order.myOrders.drawer.tabs.stockBind') },
]);
// 订单基本信息字段 (t_order) - 按文档表结构
const orderBasicFields = [
{ key: 'serviceNo', labelKey: 'order.myOrders.drawer.serviceNo' },
{ key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' },
{ key: 'trackNo', labelKey: 'order.myOrders.drawer.trackNo' },
{ key: 'downstreamServiceNo', labelKey: 'order.myOrders.drawer.downstreamServiceNo' },
{ key: 'productCode', labelKey: 'order.myOrders.drawer.productCode' },
{ key: 'productName', labelKey: 'order.myOrders.drawer.productName' },
{ key: 'channelCode', labelKey: 'order.myOrders.drawer.channelCode' },
{ key: 'channelName', labelKey: 'order.myOrders.drawer.channelName' },
{ key: 'consigneeCountry', labelKey: 'order.myOrders.drawer.consigneeCountry' },
{ key: 'consigneeCountryName', labelKey: 'order.myOrders.drawer.consigneeCountryName' },
{ key: 'cargoAttribute', labelKey: 'order.myOrders.drawer.cargoAttribute' },
{ key: 'orderStatus', labelKey: 'order.myOrders.drawer.orderStatus' },
{ key: 'predeclareFailReason', labelKey: 'order.myOrders.drawer.predeclareFailReason' },
{ key: 'allocationPendingReason', labelKey: 'order.myOrders.drawer.allocationPendingReason' },
{ key: 'warehouse', labelKey: 'order.myOrders.drawer.warehouse' },
{ key: 'receiveTime', labelKey: 'order.myOrders.drawer.receiveTime' },
{ key: 'shipTime', labelKey: 'order.myOrders.drawer.shipTime' },
{ key: 'payTime', labelKey: 'order.myOrders.drawer.payTime' },
{ key: 'isPaid', labelKey: 'order.myOrders.drawer.isPaid' },
{ key: 'signTime', labelKey: 'order.myOrders.drawer.signTime' },
{ key: 'isCod', labelKey: 'order.myOrders.drawer.isCod' },
{ key: 'codCurrency', labelKey: 'order.myOrders.drawer.codCurrency' },
{ key: 'codSum', labelKey: 'order.myOrders.drawer.codSum' },
{ key: 'isIntercept', labelKey: 'order.myOrders.drawer.isIntercept' },
{ key: 'isRemote', labelKey: 'order.myOrders.drawer.isRemote' },
{ key: 'createTime', labelKey: 'order.myOrders.drawer.createTime' },
{ key: 'createBy', labelKey: 'order.myOrders.drawer.createBy' },
{ key: 'updateTime', labelKey: 'order.myOrders.drawer.updateTime' },
{ key: 'updateBy', labelKey: 'order.myOrders.drawer.updateBy' },
];
const orderBasicFieldGroups = [
{
key: 'identity',
labelKey: 'order.myOrders.drawer.basic.group.identity',
fields: ['serviceNo', 'clientNo', 'trackNo', 'downstreamServiceNo'],
},
{
key: 'product',
labelKey: 'order.myOrders.drawer.basic.group.product',
fields: [
'productCode',
'productName',
'channelCode',
'channelName',
'warehouse',
'consigneeCountry',
'consigneeCountryName',
'cargoAttribute',
],
},
{
key: 'timeline',
labelKey: 'order.myOrders.drawer.basic.group.timeline',
fields: ['orderStatus', 'predeclareFailReason', 'allocationPendingReason', 'receiveTime', 'shipTime', 'signTime', 'payTime', 'isPaid'],
},
{
key: 'flags',
labelKey: 'order.myOrders.drawer.basic.group.flags',
fields: ['isCod', 'codCurrency', 'codSum', 'isIntercept', 'isRemote'],
},
{
key: 'audit',
labelKey: 'order.myOrders.drawer.basic.group.audit',
fields: ['createTime', 'createBy', 'updateTime', 'updateBy'],
},
];
// 发件人表单字段 (t_order_sender) - 补齐全部字段
const senderFields = [
{ key: 'senderName', labelKey: 'order.myOrders.drawer.senderName' },
{ key: 'company', labelKey: 'order.myOrders.drawer.company' },
{ key: 'country', labelKey: 'order.myOrders.drawer.country' },
{ key: 'province', labelKey: 'order.myOrders.drawer.province' },
{ key: 'city', labelKey: 'order.myOrders.drawer.city' },
{ key: 'zipCode', labelKey: 'order.myOrders.drawer.zipCode' },
{ key: 'streetAddress1', labelKey: 'order.myOrders.drawer.streetAddress1' },
{ key: 'streetAddress2', labelKey: 'order.myOrders.drawer.streetAddress2' },
{ key: 'streetAddress3', labelKey: 'order.myOrders.drawer.streetAddress3' },
{ key: 'houseNumber', labelKey: 'order.myOrders.drawer.houseNumber' },
{ key: 'phone', labelKey: 'order.myOrders.drawer.phone' },
{ key: 'telePhone', labelKey: 'order.myOrders.drawer.telePhone' },
{ key: 'email', labelKey: 'order.myOrders.drawer.email' },
{ key: 'isDefault', labelKey: 'order.myOrders.drawer.isDefault' },
];
// 收件人表单字段 (t_order_recipient) - 补齐全部字段
const recipientFields = [
{ key: 'recipientsName', labelKey: 'order.myOrders.drawer.recipientsName' },
{ key: 'companyName', labelKey: 'order.myOrders.drawer.companyName' },
{ key: 'country', labelKey: 'order.myOrders.drawer.recipientCountry' },
{ key: 'phone', labelKey: 'order.myOrders.drawer.recipientPhone' },
{ key: 'telePhone', labelKey: 'order.myOrders.drawer.recipientTelePhone' },
{ key: 'zipCode', labelKey: 'order.myOrders.drawer.recipientZipCode' },
{ key: 'province', labelKey: 'order.myOrders.drawer.province' },
{ key: 'city', labelKey: 'order.myOrders.drawer.city' },
{ key: 'streetNumber', labelKey: 'order.myOrders.drawer.streetNumber' },
{ key: 'streetAddress1', labelKey: 'order.myOrders.drawer.streetAddress1' },
{ key: 'streetAddress2', labelKey: 'order.myOrders.drawer.streetAddress2' },
{ key: 'streetAddress3', labelKey: 'order.myOrders.drawer.streetAddress3' },
{ key: 'email', labelKey: 'order.myOrders.drawer.email' },
{ key: 'taxType', labelKey: 'order.myOrders.drawer.taxType' },
{ key: 'taxNo', labelKey: 'order.myOrders.drawer.taxNo' },
{ key: 'certificateType', labelKey: 'order.myOrders.drawer.certificateType' },
{ key: 'certificateNo', labelKey: 'order.myOrders.drawer.certificateNo' },
{ key: 'eori', labelKey: 'order.myOrders.drawer.eori' },
{ key: 'vat', labelKey: 'order.myOrders.drawer.vat' },
];
// 申报信息列表列 (t_order_declaration) - 补齐全部字段
const declarationColumns = [
{ key: 'cnName', labelKey: 'order.myOrders.drawer.declarationCnName', width: 120 },
{ key: 'enName', labelKey: 'order.myOrders.drawer.declarationEnName', width: 120 },
{ key: 'sku', labelKey: 'order.myOrders.drawer.declarationSku', width: 100 },
{ key: 'unit', labelKey: 'order.myOrders.drawer.declarationUnit', width: 60 },
{ key: 'unitPrice', labelKey: 'order.myOrders.drawer.declarationUnitPrice', width: 80 },
{ key: 'unitWeight', labelKey: 'order.myOrders.drawer.declarationUnitWeight', width: 80 },
{ key: 'quantity', labelKey: 'order.myOrders.drawer.declarationQuantity', width: 60 },
{ key: 'amount', labelKey: 'order.myOrders.drawer.declarationAmount', width: 80 },
{ key: 'weight', labelKey: 'order.myOrders.drawer.declarationWeight', width: 80 },
{ key: 'hsCode', labelKey: 'order.myOrders.drawer.declarationHsCode', width: 100 },
{ key: 'skuImage', labelKey: 'order.myOrders.drawer.declarationSkuImage', width: 100 },
{ key: 'category', labelKey: 'order.myOrders.drawer.declarationCategory', width: 80 },
{ key: 'brand', labelKey: 'order.myOrders.drawer.declarationBrand', width: 80 },
{ key: 'brandType', labelKey: 'order.myOrders.drawer.declarationBrandType', width: 80 },
{ key: 'isMagnetic', labelKey: 'order.myOrders.drawer.declarationIsMagnetic', width: 80 },
{ key: 'distribution', labelKey: 'order.myOrders.drawer.declarationDistribution', width: 100 },
{ key: 'material', labelKey: 'order.myOrders.drawer.declarationMaterial', width: 80 },
{ key: 'enMaterial', labelKey: 'order.myOrders.drawer.declarationEnMaterial', width: 100 },
{ key: 'purpose', labelKey: 'order.myOrders.drawer.declarationPurpose', width: 80 },
{ key: 'attributes', labelKey: 'order.myOrders.drawer.declarationAttributes', width: 80 },
{ key: 'originCountry', labelKey: 'order.myOrders.drawer.declarationOriginCountry', width: 80 },
{ key: 'salesLink', labelKey: 'order.myOrders.drawer.declarationSalesLink', width: 120 },
{ key: 'model', labelKey: 'order.myOrders.drawer.declarationModel', width: 80 },
{ key: 'specifications', labelKey: 'order.myOrders.drawer.declarationSpecifications', width: 80 },
{ key: 'comment', labelKey: 'order.myOrders.drawer.declarationComment', width: 120 },
];
// 件重尺:类型独立展示,明细区不再含类型列
type WeightViewMode = 'all' | 'forecast' | 'receive' | 'ship' | 'downstream';
const weightViewMode = ref<WeightViewMode>('all');
const weightTypeDefs = [
{
key: 'forecast' as const,
labelKey: 'order.myOrders.drawer.weightType.forecast',
emptyKey: 'order.myOrders.drawer.weight.empty.forecast',
},
{
key: 'receive' as const,
labelKey: 'order.myOrders.drawer.weightType.receive',
emptyKey: 'order.myOrders.drawer.weight.empty.receive',
},
{
key: 'ship' as const,
labelKey: 'order.myOrders.drawer.weightType.ship',
emptyKey: 'order.myOrders.drawer.weight.empty.ship',
},
{
key: 'downstream' as const,
labelKey: 'order.myOrders.drawer.weightType.downstream',
emptyKey: 'order.myOrders.drawer.weight.empty.downstream',
},
];
const weightMetricFields = [
{ key: 'length', labelKey: 'order.myOrders.drawer.weightLength', editable: true, highlight: false },
{ key: 'width', labelKey: 'order.myOrders.drawer.weightWidth', editable: true, highlight: false },
{ key: 'height', labelKey: 'order.myOrders.drawer.weightHeight', editable: true, highlight: false },
{ key: 'actualWeight', labelKey: 'order.myOrders.drawer.actualWeight', editable: true, highlight: false },
{ key: 'volumetricWeight', labelKey: 'order.myOrders.drawer.volumetricWeight', editable: false, highlight: false },
{ key: 'chargeableWeight', labelKey: 'order.myOrders.drawer.chargeableWeight', editable: false, highlight: true },
{ key: 'cubic', labelKey: 'order.myOrders.drawer.cubic', editable: false, highlight: false },
];
// 费用明细列(类型已从列表拆出,按应收/应付分区展示)
const feeDetailColumns = [
{ key: 'costItem', labelKey: 'order.myOrders.drawer.costItem' },
{ key: 'amount', labelKey: 'order.myOrders.drawer.amount' },
{ key: 'currency', labelKey: 'order.myOrders.drawer.currency' },
{ key: 'chargingStage', labelKey: 'order.myOrders.drawer.chargingStage' },
{ key: 'downstreamProvider', labelKey: 'order.myOrders.drawer.downstreamProvider' },
{ key: 'priceNo', labelKey: 'order.myOrders.drawer.priceNo' },
{ key: 'priceEffectiveTime', labelKey: 'order.myOrders.drawer.priceEffectiveTime' },
];
type FeeViewMode = 'all' | 'receivable' | 'payable';
const feeViewMode = ref<FeeViewMode>('all');
// 详情数据(从 order 或 API 获取)
const sender = ref<Record<string, any>>({});
const recipient = ref<Record<string, any>>({});
const declarationList = ref<any[]>([]);
const weightList = ref<any[]>([]);
const feeList = ref<any[]>([]);
const feeItemDictRows = ref<DictDto[]>([]);
let feeItemDictLoadPromise: Promise<void> | null = null;
const trackTimeline = ref<any[]>([]);
const trackLoading = ref(false);
const trackOrderNumber = computed(
() =>
detail.value?.trackNo ||
detail.value?.trackingNo ||
detail.value?.waybillNo ||
detail.value?.customerOrderNo ||
''
);
function getWeightRowByType(type: string) {
return displayWeightRows.value.find((row) => row.type === type);
}
function hasWeightRow(type: string) {
return displayWeightRows.value.some((row) => row.type === type);
}
function hasWeightData(row: any) {
if (!row) return false;
return weightMetricFields.some((field) => {
const val = row[field.key];
return val != null && val !== '';
});
}
const visibleWeightSections = computed(() => {
const sections = weightTypeDefs.map((def) => {
const row = getWeightRowByType(def.key) ?? { type: def.key };
return {
...def,
row,
hasData: hasWeightData(row),
};
});
if (weightViewMode.value === 'all') return sections;
return sections.filter((section) => section.key === weightViewMode.value);
});
function setWeightViewMode(mode: WeightViewMode) {
weightViewMode.value = weightViewMode.value === mode ? 'all' : mode;
}
function formatWeightNumber(value: unknown) {
const num = Number(value);
return Number.isFinite(num) ? num.toFixed(3).replace(/\.?0+$/, '') : '--';
}
function formatWeightChargeable(row?: { chargeableWeight?: number | string | null }) {
const val = row?.chargeableWeight;
if (val == null || val === '') return '--';
return `${formatWeightNumber(val)} kg`;
}
function formatWeightCellValue(row: any, key: string) {
const val = row?.[key];
if (val == null || val === '') return '--';
if (['length', 'width', 'height'].includes(key)) return `${formatWeightNumber(val)} cm`;
if (['actualWeight', 'volumetricWeight', 'chargeableWeight'].includes(key)) {
return `${formatWeightNumber(val)} kg`;
}
if (key === 'cubic') return `${formatWeightNumber(val)} m³`;
return String(val);
}
function handleAskAdvisor() {
const pick = (key: string) => {
const text = getOrderValue(key);
return text && text !== '--' ? text : undefined;
};
openAskAdvisor(router, {
orderId: orderId.value != null ? String(orderId.value) : undefined,
waybillNo: pick('serviceNo'),
trackingNo: pick('trackNo'),
customerOrderNo: pick('clientNo'),
from: 'waybillManage',
orderStatus: resolveOrderStatusCode(detail.value) || undefined,
});
}
// 订单基本信息取值(兼容 API 驼峰与 mock 字段名)
function getOrderValue(key: string) {
const o = detail.value;
if (!o) return '--';
const fieldMap: Record<string, string[]> = {
serviceNo: ['serviceNo', 'waybillNo'],
clientNo: ['clientNo', 'customerOrderNo'],
trackNo: ['trackNo', 'trackingNo'],
downstreamServiceNo: ['downstreamServiceNo', 'DownstreamServiceNo'],
productCode: ['productCode'],
productName: ['productName', 'shippingMethod'],
channelCode: ['channelCode'],
channelName: ['channelName', 'serviceChannel'],
consigneeCountry: ['consigneeCountry'],
consigneeCountryName: ['consigneeCountryName'],
cargoAttribute: ['cargoAttribute'],
orderStatus: ['status', 'orderStatus'],
predeclareFailReason: ['predeclareFailReason', 'PredeclareFailReason'],
allocationPendingReason: ['allocationPendingReason', 'AllocationPendingReason'],
warehouse: ['warehouse', 'warehouseName'],
receiveTime: ['receiveTime'],
shipTime: ['shipTime'],
payTime: ['payTime'],
signTime: ['signTime'],
isCod: ['isCod'],
codCurrency: ['codCurrency'],
codSum: ['codSum'],
isIntercept: ['isIntercept'],
isRemote: ['isRemote'],
createTime: ['createTime'],
createBy: ['createBy'],
updateTime: ['updateTime'],
updateBy: ['updateBy'],
};
const keys = fieldMap[key] || [key];
let val: any;
for (const k of keys) {
if (o[k] != null && o[k] !== '') {
val = o[k];
break;
}
}
if (val == null || val === '') return '--';
if (key === 'isCod' || key === 'isIntercept' || key === 'isRemote' || key === 'isPaid') return val ? t('order.myOrders.drawer.yes') : t('order.myOrders.drawer.no');
if (key === 'orderStatus') {
const code = resolveOrderStatusCode(o);
const meta = getOrderStatusMeta(code);
if (meta) return t(meta.labelKey);
const statusKey = `order.myOrders.status.${val}`;
const translated = t(statusKey);
return translated !== statusKey ? translated : String(val);
}
if (key === 'cargoAttribute') {
const cargoKey = `order.myOrders.drawer.cargoAttribute.${val}`;
const translated = t(cargoKey);
return translated !== cargoKey ? translated : String(val);
}
return String(val);
}
function getSenderValue(key: string) {
if (key === 'country') {
return sender.value.country ?? sender.value.countryCode ?? '--';
}
const val = sender.value[key];
if (key === 'isDefault') {
return val == null ? '--' : (val ? t('order.myOrders.drawer.yes') : t('order.myOrders.drawer.no'));
}
return val ?? '--';
}
function getRecipientValue(key: string) {
if (key === 'country') {
return recipient.value.country ?? recipient.value.countryCode ?? '--';
}
return recipient.value[key] ?? '--';
}
function formatTrackTime(timestamp?: number | string) {
if (timestamp == null || timestamp === '') return '';
const raw = typeof timestamp === 'string' ? Number(timestamp) : timestamp;
if (!Number.isFinite(raw)) return String(timestamp);
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return String(timestamp);
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
function formatTrackTimeDisplay(item: any) {
return formatTrackTime(item?.trackTime);
}
function formatTrackPlace(item: any) {
const parts = [
item?.trackPlace,
item?.trackPlaceCity,
item?.trackPlaceProvince,
item?.trackPlaceNation,
]
.map((part) => (part == null ? '' : String(part).trim()))
.filter(Boolean);
return [...new Set(parts)].join(' · ');
}
const displayTrackTimeline = computed(() => {
const list = trackTimeline.value;
if (!list.length) return [];
if (list.some((item) => item?.isHighlight)) return [...list];
return [...list].reverse();
});
function isTrackNodeActive(item: any, index: number) {
if (item?.isHighlight) return true;
const list = displayTrackTimeline.value;
if (list.some((node) => node?.isHighlight)) return false;
return index === 0;
}
function normalizeFeeType(row: any): 'receivable' | 'payable' | '' {
const raw = String(row?.type ?? row?.feeType ?? row?.FeeType ?? '').toLowerCase();
if (raw === 'receivable' || raw === 'payable') return raw;
return '';
}
function sumFeesByCurrency(list: any[]) {
const totals = new Map<string, number>();
for (const row of list) {
const currency = String(row?.currency ?? 'CNY').trim() || 'CNY';
const amount = Number(row?.amount);
if (!Number.isFinite(amount)) continue;
totals.set(currency, (totals.get(currency) ?? 0) + amount);
}
return totals;
}
const receivableFeeList = computed(() =>
feeList.value.filter((row) => normalizeFeeType(row) === 'receivable')
);
const payableFeeList = computed(() =>
feeList.value.filter((row) => normalizeFeeType(row) === 'payable')
);
const receivableTotals = computed(() => sumFeesByCurrency(receivableFeeList.value));
const payableTotals = computed(() => sumFeesByCurrency(payableFeeList.value));
function calcGrossProfitTotals(
receivable: Map<string, number>,
payable: Map<string, number>
) {
const currencies = new Set([...receivable.keys(), ...payable.keys()]);
const result = new Map<string, number>();
for (const currency of currencies) {
result.set(currency, (receivable.get(currency) ?? 0) - (payable.get(currency) ?? 0));
}
return result;
}
const feeGrossProfitTotals = computed(() =>
calcGrossProfitTotals(receivableTotals.value, payableTotals.value)
);
const grossProfitMarginText = computed(() => {
const margins: string[] = [];
for (const [currency, recv] of receivableTotals.value) {
if (!recv) continue;
const profit = feeGrossProfitTotals.value.get(currency) ?? 0;
margins.push(
t('order.myOrders.drawer.fee.grossMargin', {
value: `${((profit / recv) * 100).toFixed(1)}%`,
})
);
}
return margins.length ? margins.join(' / ') : t('order.myOrders.drawer.fee.grossMarginEmpty');
});
const visibleFeeSections = computed(() => {
const sections = [
{
key: 'receivable' as const,
titleKey: 'order.myOrders.drawer.fee.receivableTitle',
emptyKey: 'order.myOrders.drawer.fee.emptyReceivable',
list: receivableFeeList.value,
totals: receivableTotals.value,
tagColor: 'green',
amountClass: 'amount-receivable',
},
{
key: 'payable' as const,
titleKey: 'order.myOrders.drawer.fee.payableTitle',
emptyKey: 'order.myOrders.drawer.fee.emptyPayable',
list: payableFeeList.value,
totals: payableTotals.value,
tagColor: 'red',
amountClass: 'amount-payable',
},
];
if (feeViewMode.value === 'all') return sections;
return sections.filter((section) => section.key === feeViewMode.value);
});
function setFeeViewMode(mode: FeeViewMode) {
feeViewMode.value = feeViewMode.value === mode ? 'all' : mode;
}
function openAddFeeModal() {
if (orderId.value == null) return;
addFeeDefaultType.value =
feeViewMode.value === 'payable'
? 'payable'
: feeViewMode.value === 'receivable'
? 'receivable'
: 'receivable';
addFeeModalVisible.value = true;
}
function handleAddFeeSuccess(order: OrderRow) {
applyDetailFromApi(order);
void ensureFeeItemDictLoaded();
}
function formatFeeAmount(value: unknown) {
const num = Number(value);
return Number.isFinite(num) ? num.toFixed(2) : '--';
}
function formatFeeTotals(totals: Map<string, number>) {
if (!totals.size) return '--';
return [...totals.entries()]
.map(([currency, amount]) => `${currency} ${amount.toFixed(2)}`)
.join(' + ');
}
function formatGrossProfitTotals(totals: Map<string, number>) {
if (!totals.size) return '--';
return [...totals.entries()]
.map(([currency, amount]) => {
const sign = amount > 0 ? '+' : '';
return `${sign}${currency} ${amount.toFixed(2)}`;
})
.join(' + ');
}
function getChargingStage(row: any) {
const stage = row?.chargingStage ?? row?.ChargingStage;
return stage != null && stage !== '' ? String(stage) : '';
}
function getChargingStageLabel(stage: string) {
const key = `finance.receivablePayable.chargingStage.${stage}`;
const translated = t(key);
return translated !== key ? translated : stage;
}
function getChargingStageColor(stage: string) {
const map: Record<string, string> = {
forecast: 'arcoblue',
inbound: 'orange',
outbound: 'purple',
};
return map[stage] || 'gray';
}
async function ensureFeeItemDictLoaded() {
if (feeItemDictRows.value.length) return;
if (feeItemDictLoadPromise) {
await feeItemDictLoadPromise;
return;
}
feeItemDictLoadPromise = (async () => {
try {
feeItemDictRows.value = await getDictListAll({ dictType: 'fee_item' });
} catch {
feeItemDictRows.value = [];
}
})();
await feeItemDictLoadPromise;
}
function getCostItemLabel(row: any) {
const fromApi = row?.costItemName ?? row?.CostItemName;
if (fromApi != null && fromApi !== '') return String(fromApi);
const code = row?.costItem ?? row?.CostItem;
if (code == null || code === '') return '--';
const c = String(code).trim();
const hit = feeItemDictRows.value.find(
(r) => String(r.dictCode ?? (r as any).DictCode ?? '').trim() === c
);
if (hit?.dictName) return hit.dictName;
const byName = feeItemDictRows.value.find(
(r) => String(r.dictName ?? (r as any).DictName ?? '').trim() === c
);
return byName?.dictName || c;
}
function getFeeCellValue(row: any, col: { key: string }) {
if (col.key === 'costItem') return getCostItemLabel(row);
if (col.key === 'priceEffectiveTime') {
const v = row[col.key];
if (v == null) return '--';
if (typeof v === 'number') return formatTrackTime(v);
return String(v);
}
return row[col.key] ?? '--';
}
// 加载轨迹(复用 track/order 的接口)
async function loadTrackTimeline() {
const orderNumber = detail.value?.trackingNo || detail.value?.waybillNo || detail.value?.customerOrderNo;
if (!orderNumber) {
trackTimeline.value = [];
return;
}
trackLoading.value = true;
try {
const res: any = await postGetDetailTimeline({ orderNumber });
trackTimeline.value = res?.data || res || [];
} catch {
trackTimeline.value = [];
} finally {
trackLoading.value = false;
}
}
// 加载详情数据(优先使用 list 接口返回的 sender/receiver/items/weightList,与 parcel/create 对应)
function loadDetailData() {
const o = detail.value;
if (!o) return;
// 发件人
if (o.sender && typeof o.sender === 'object') {
sender.value = { ...o.sender, country: o.sender.country ?? o.sender.countryCode };
} else {
sender.value = {};
}
// 收件人:优先使用 list 返回的 receiver
if (o.receiver && typeof o.receiver === 'object') {
recipient.value = { ...o.receiver, country: o.receiver.country ?? o.receiver.countryCode };
} else {
recipient.value = {
recipientsName: o.consignee || '',
companyName: '',
country: o.consigneeCountry || '',
phone: o.consigneePhone || '',
telePhone: '',
zipCode: o.consigneePostcode || '',
province: o.consigneeState || '',
city: o.consigneeCity || '',
streetNumber: '',
streetAddress1: o.consigneeAddress || '',
streetAddress2: '',
streetAddress3: '',
email: '',
taxType: '',
taxNo: '',
certificateType: '',
certificateNo: '',
eori: o.eori || '',
vat: '',
};
}
// 申报信息
declarationList.value = Array.isArray(o.items) && o.items.length > 0 ? o.items : [];
// 件重尺
weightList.value = Array.isArray(o.weightList) && o.weightList.length > 0 ? o.weightList : [];
// 费用:接口字段可为 feeList / orderFees / FeeList
const fees = o.feeList ?? o.FeeList ?? o.orderFees ?? o.fees;
feeList.value = Array.isArray(fees) ? fees : [];
}
function applyDetailFromApi(raw: any) {
if (!raw || typeof raw !== 'object') return;
detail.value = { ...(detail.value || {}), ...raw };
loadDetailData();
emit('updated', detail.value as OrderRow);
}
function fillBasicEditForm() {
const o = detail.value;
if (!o?.id) return;
basicEditForm.clientNo = String(o.clientNo ?? o.customerOrderNo ?? '');
basicEditForm.trackNo = String(o.trackNo ?? o.trackingNo ?? '');
basicEditForm.channelCode = String(o.channelCode ?? '');
basicEditForm.cargoAttribute = String(o.cargoAttribute ?? 'general');
basicEditForm.isCod = !!(o.isCod ?? o.IsCod);
basicEditForm.codSum = o.codSum != null ? Number(o.codSum) : undefined;
basicEditForm.codCurrency = String(o.codCurrency ?? '');
senderEditForm.senderName = String(sender.value.senderName ?? '');
senderEditForm.company = String(sender.value.company ?? '');
senderEditForm.countryCode = String(sender.value.countryCode ?? sender.value.country ?? '');
senderEditForm.province = String(sender.value.province ?? '');
senderEditForm.city = String(sender.value.city ?? '');
senderEditForm.zipCode = String(sender.value.zipCode ?? '');
senderEditForm.streetAddress1 = String(sender.value.streetAddress1 ?? '');
senderEditForm.streetAddress2 = String(sender.value.streetAddress2 ?? '');
senderEditForm.streetAddress3 = String(sender.value.streetAddress3 ?? '');
senderEditForm.houseNumber = String(sender.value.houseNumber ?? '');
senderEditForm.phone = String(sender.value.phone ?? '');
senderEditForm.telePhone = String(sender.value.telePhone ?? '');
senderEditForm.email = String(sender.value.email ?? '');
receiverEditForm.recipientsName = String(recipient.value.recipientsName ?? o.consignee ?? '');
receiverEditForm.companyName = String(recipient.value.companyName ?? '');
receiverEditForm.countryCode = String(
recipient.value.countryCode ?? recipient.value.country ?? o.consigneeCountry ?? ''
);
receiverEditForm.phone = String(recipient.value.phone ?? o.consigneePhone ?? '');
receiverEditForm.telePhone = String(recipient.value.telePhone ?? '');
receiverEditForm.zipCode = String(recipient.value.zipCode ?? o.consigneePostcode ?? '');
receiverEditForm.province = String(recipient.value.province ?? o.consigneeState ?? '');
receiverEditForm.city = String(recipient.value.city ?? o.consigneeCity ?? '');
receiverEditForm.streetNumber = String(recipient.value.streetNumber ?? '');
receiverEditForm.streetAddress1 = String(recipient.value.streetAddress1 ?? o.consigneeAddress ?? '');
receiverEditForm.streetAddress2 = String(recipient.value.streetAddress2 ?? '');
receiverEditForm.streetAddress3 = String(recipient.value.streetAddress3 ?? '');
receiverEditForm.email = String(recipient.value.email ?? '');
receiverEditForm.taxType = recipient.value.taxType != null ? String(recipient.value.taxType) : '';
receiverEditForm.taxNo = String(recipient.value.taxNo ?? '');
receiverEditForm.certificateType =
recipient.value.certificateType != null ? String(recipient.value.certificateType) : '';
receiverEditForm.certificateNo = String(recipient.value.certificateNo ?? '');
receiverEditForm.eori = String(recipient.value.eori ?? o.eori ?? '');
receiverEditForm.vat = String(recipient.value.vat ?? '');
declarationEditList.value = cloneDeclarationRows(declarationList.value);
}
function startBasicEdit() {
fillBasicEditForm();
basicEditing.value = true;
}
function cancelBasicEdit() {
basicEditing.value = false;
declarationEditList.value = [];
loadDetailData();
}
function startWeightEdit() {
weightEditForm.value = (weightList.value || []).map((row: any) => ({
type: row.type,
length: row.length != null ? Number(row.length) : undefined,
width: row.width != null ? Number(row.width) : undefined,
height: row.height != null ? Number(row.height) : undefined,
actualWeight: row.actualWeight != null ? Number(row.actualWeight) : undefined,
}));
weightEditing.value = true;
}
function cancelWeightEdit() {
weightEditing.value = false;
loadDetailData();
}
async function handleBasicEditSave() {
const id = orderId.value;
if (id == null) return;
basicSaving.value = true;
try {
const result = await postOrderUpdate({
id,
clientNo: basicEditForm.clientNo,
trackNo: basicEditForm.trackNo,
channelCode: basicEditForm.channelCode,
cargoAttribute: basicEditForm.cargoAttribute,
isCod: basicEditForm.isCod,
codSum: basicEditForm.codSum,
codCurrency: basicEditForm.codCurrency,
sender: hasSender.value
? {
senderName: senderEditForm.senderName,
company: senderEditForm.company,
countryCode: senderEditForm.countryCode,
province: senderEditForm.province,
city: senderEditForm.city,
zipCode: senderEditForm.zipCode,
streetAddress1: senderEditForm.streetAddress1,
streetAddress2: senderEditForm.streetAddress2,
streetAddress3: senderEditForm.streetAddress3,
houseNumber: senderEditForm.houseNumber,
phone: senderEditForm.phone,
telePhone: senderEditForm.telePhone,
email: senderEditForm.email,
}
: undefined,
receiver: {
recipientsName: receiverEditForm.recipientsName,
companyName: receiverEditForm.companyName,
countryCode: receiverEditForm.countryCode,
phone: receiverEditForm.phone,
telePhone: receiverEditForm.telePhone,
zipCode: receiverEditForm.zipCode,
province: receiverEditForm.province,
city: receiverEditForm.city,
streetNumber: receiverEditForm.streetNumber,
streetAddress1: receiverEditForm.streetAddress1,
streetAddress2: receiverEditForm.streetAddress2,
streetAddress3: receiverEditForm.streetAddress3,
email: receiverEditForm.email,
taxType: parseOptionalInt(receiverEditForm.taxType),
taxNo: receiverEditForm.taxNo,
certificateType: parseOptionalInt(receiverEditForm.certificateType),
certificateNo: receiverEditForm.certificateNo,
eori: receiverEditForm.eori,
vat: receiverEditForm.vat,
},
items: buildDeclarationPayload(),
});
applyDetailFromApi(result);
basicEditing.value = false;
Message.success(t('order.myOrders.drawer.message.saveSuccess'));
} catch (e: any) {
Message.error(e?.message || '保存失败');
} finally {
basicSaving.value = false;
}
}
async function handleWeightEditSave() {
const id = orderId.value;
if (id == null) return;
weightSaving.value = true;
try {
const result = await postOrderWeightUpdate({
id,
weights: weightEditForm.value.map((row) => ({
type: row.type,
length: row.length,
width: row.width,
height: row.height,
actualWeight: row.actualWeight,
})),
});
applyDetailFromApi(result);
weightEditing.value = false;
Message.success(t('order.myOrders.drawer.message.saveSuccess'));
} catch (e: any) {
Message.error(e?.message || '保存失败');
} finally {
weightSaving.value = false;
}
}
async function handleRecalculateReceivable() {
const id = orderId.value;
if (id == null) return;
recalcReceivableLoading.value = true;
try {
const result = await postOrderRecalculateReceivable({ id });
applyDetailFromApi(result);
Message.success(t('order.myOrders.drawer.message.recalculateSuccess'));
} catch (e: any) {
Message.error(e?.message || '重算应收失败');
} finally {
recalcReceivableLoading.value = false;
}
}
async function handleRecalculatePayable() {
const id = orderId.value;
if (id == null) return;
recalcPayableLoading.value = true;
try {
const result = await postOrderRecalculatePayable({ id });
applyDetailFromApi(result);
Message.success(t('order.myOrders.drawer.message.recalculateSuccess'));
} catch (e: any) {
Message.error(e?.message || '重算应付失败');
} finally {
recalcPayableLoading.value = false;
}
}
function handleTabChange(tabKey: string) {
resetEditState();
if (tabKey === 'basic') {
basicViewMode.value = 'all';
}
if (tabKey === 'weight') {
weightViewMode.value = 'all';
}
if (tabKey === 'fee') {
feeViewMode.value = 'all';
void ensureFeeItemDictLoaded();
}
if (tabKey === 'track') {
loadTrackTimeline();
}
}
function handleCancel() {
resetEditState();
emit('cancel');
}
watch(
() => props.order,
(newOrder) => {
detail.value = newOrder ? { ...newOrder } : null;
resetEditState();
if (newOrder) {
loadDetailData();
if (activeTab.value === 'fee') {
void ensureFeeItemDictLoaded();
}
if (activeTab.value === 'track') {
loadTrackTimeline();
}
}
},
{ immediate: true, deep: true }
);
defineExpose({
openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'attachments' | 'stockBind' }) => {
activeTab.value = opts?.tab || 'basic';
basicViewMode.value = 'all';
feeViewMode.value = 'all';
weightViewMode.value = 'all';
resetEditState();
setDrawerProps({
content: '',
placement: 'right',
width: 1000,
title: '',
});
innerOpenDrawer();
if (opts?.tab === 'track') {
void loadTrackTimeline();
} else if (opts?.tab === 'fee') {
void ensureFeeItemDictLoaded();
}
},
startBasicEdit,
setDrawerProps,
closeDrawer,
});
</script>
<style lang="less" scoped>
:deep(.arco-drawer-header) {
display: none;
}
.order-detail-drawer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.drawer-tabs {
margin-bottom: 16px;
flex-shrink: 0;
}
.drawer-tab-content {
flex: 1;
overflow-y: auto;
min-height: 0;
padding-bottom: 8px;
}
.tab-panel {
padding: 0 4px;
}
.drawer-footer-banner {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
width: 100%;
}
.inline-edit-form {
:deep(.arco-form-item) {
margin-bottom: 12px;
}
:deep(.arco-form-item-label) {
font-size: 13px;
color: #86909c;
}
}
.edit-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
margin-bottom: 12px;
.edit-field-label {
font-size: 13px;
color: #86909c;
line-height: 20px;
}
:deep(.arco-input),
:deep(.arco-select),
:deep(.arco-input-number) {
width: 100%;
}
}
.readonly-field {
.readonly-value {
min-height: 32px;
padding: 4px 0;
font-size: 13px;
color: #1d2129;
word-break: break-all;
}
}
.form-grid-empty {
grid-column: 1 / -1;
}
.declaration-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.readonly-inline {
align-items: flex-start;
}
.edit-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.edit-form-span-2 {
grid-column: span 2;
}
.basic-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.basic-summary {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px;
border: 1px solid #e5e6eb;
border-radius: 6px;
background: #fff;
}
.basic-summary-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.basic-summary-main {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.basic-summary-label {
font-size: 12px;
color: #86909c;
line-height: 20px;
}
.basic-summary-no {
font-size: 18px;
font-weight: 600;
color: #1d2129;
line-height: 26px;
word-break: break-all;
}
.basic-summary-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.basic-summary-actions {
margin-top: 12px;
}
.predeclare-fail-guide {
margin-top: 12px;
padding: 12px 14px;
border-radius: 8px;
background: #fff7f7;
border-left: 3px solid #f53f3f;
border-top: 1px solid #ffd8d2;
border-right: 1px solid #ffd8d2;
border-bottom: 1px solid #ffd8d2;
&__reason {
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.45;
color: #1d2129;
}
&__tip {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.5;
color: #4e5969;
}
&__tip-label {
margin-right: 4px;
color: var(--app-primary-color, #2d8c8c);
font-weight: 500;
}
&__details {
margin-top: 8px;
font-size: 12px;
color: #86909c;
summary {
cursor: pointer;
user-select: none;
}
}
&__raw {
margin: 6px 0 0;
font-size: 12px;
color: #86909c;
word-break: break-word;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
}
.basic-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 4px 10px;
border-radius: 4px;
background: #f7f8fa;
font-size: 12px;
line-height: 20px;
}
.basic-chip-label {
color: #86909c;
flex-shrink: 0;
}
.basic-chip-value {
color: #1d2129;
font-weight: 500;
word-break: break-all;
}
.basic-view-switch {
display: flex;
justify-content: flex-end;
}
.detail-section {
border: 1px solid #e5e6eb;
border-radius: 6px;
overflow: hidden;
background: #fff;
&.basic .detail-section-dot {
background: var(--app-primary-color, #2d8c8c);
}
&.sender .detail-section-dot {
background: #165dff;
}
&.recipient .detail-section-dot {
background: #00b42a;
}
&.declaration .detail-section-dot {
background: #ff7d00;
}
}
.detail-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #f7f8fa;
border-bottom: 1px solid #e5e6eb;
}
.detail-section-title-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.detail-section-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.detail-section-title {
font-size: 13px;
font-weight: 600;
color: #1d2129;
}
.detail-field-group {
padding: 12px 12px 0;
&:last-child {
padding-bottom: 12px;
}
}
.detail-field-group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: #86909c;
line-height: 20px;
}
.detail-fields-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 12px;
}
.detail-field-item {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 8px 10px;
border: 1px solid #f2f3f5;
border-radius: 4px;
background: #fafbfc;
}
.detail-field-label {
font-size: 12px;
color: #86909c;
line-height: 18px;
}
.detail-field-value {
min-height: 24px;
display: flex;
align-items: center;
:deep(.arco-input),
:deep(.arco-select),
:deep(.arco-input-number) {
width: 100%;
}
}
.detail-field-text {
font-size: 13px;
color: #1d2129;
font-weight: 500;
word-break: break-all;
line-height: 20px;
}
.detail-section-empty {
padding: 16px 0;
}
.declaration-table-wrap {
max-height: 280px;
overflow: auto;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: #c9cdd4;
border-radius: 3px;
}
}
@media (max-width: 900px) {
.detail-fields-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.declaration-table {
min-width: max-content;
.table-header {
display: flex;
background: #f7f8fa;
font-size: 12px;
font-weight: 500;
color: #1d2129;
.header-cell {
padding: 8px 12px;
border-right: 1px solid #e5e6eb;
flex: 1;
flex-shrink: 0;
&:last-child {
border-right: none;
}
}
}
.table-row {
display: flex;
font-size: 12px;
color: #4e5969;
border-top: 1px solid #f2f3f5;
&:hover {
background: #fafbfc;
}
.table-cell {
padding: 8px 12px;
border-right: 1px solid #e5e6eb;
flex: 1;
flex-shrink: 0;
display: flex;
align-items: center;
&:last-child {
border-right: none;
}
&.action-cell {
flex: 0 0 72px;
min-width: 72px;
justify-content: center;
}
}
}
.header-cell.action-cell {
flex: 0 0 72px;
min-width: 72px;
}
}
.weight-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.weight-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.weight-summary-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid #e5e6eb;
border-radius: 6px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
min-width: 0;
&:hover {
border-color: var(--app-primary-color, #2d8c8c);
}
&.active {
border-color: var(--app-primary-color, #2d8c8c);
box-shadow: 0 0 0 1px var(--app-primary-color, #2d8c8c);
}
&.forecast .weight-summary-value {
color: #165dff;
}
&.receive .weight-summary-value {
color: #ff7d00;
}
&.ship .weight-summary-value {
color: #00b42a;
}
&.downstream .weight-summary-value {
color: #722ed1;
}
}
.weight-summary-label {
font-size: 12px;
color: #86909c;
line-height: 20px;
}
.weight-summary-value {
font-size: 16px;
font-weight: 600;
line-height: 24px;
word-break: break-all;
}
.weight-summary-meta {
font-size: 12px;
color: #4e5969;
line-height: 20px;
}
.weight-view-switch {
display: flex;
justify-content: flex-end;
}
.weight-section {
border: 1px solid #e5e6eb;
border-radius: 6px;
overflow: hidden;
background: #fff;
&.forecast .weight-section-dot {
background: #165dff;
}
&.receive .weight-section-dot {
background: #ff7d00;
}
&.ship .weight-section-dot {
background: #00b42a;
}
&.downstream .weight-section-dot {
background: #722ed1;
}
}
.weight-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #f7f8fa;
border-bottom: 1px solid #e5e6eb;
}
.weight-section-title-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.weight-section-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.weight-section-title {
font-size: 13px;
font-weight: 600;
color: #1d2129;
}
.weight-section-total {
font-size: 13px;
font-weight: 600;
color: #1d2129;
flex-shrink: 0;
}
.weight-metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 12px;
}
.weight-metric-item {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 8px 10px;
border: 1px solid #f2f3f5;
border-radius: 4px;
background: #fafbfc;
&.highlight {
border-color: #b9f0c2;
background: #f6ffed;
}
}
.metric-label {
font-size: 12px;
color: #86909c;
line-height: 18px;
}
.metric-value-wrap {
min-height: 24px;
}
.metric-value {
font-size: 13px;
color: #1d2129;
font-weight: 500;
font-variant-numeric: tabular-nums;
word-break: break-all;
&.metric-value-highlight {
color: #00b42a;
font-weight: 600;
}
}
.weight-section-empty {
padding: 16px 0;
}
.weight-empty-all {
margin-top: 8px;
}
@media (max-width: 900px) {
.weight-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.weight-metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.fee-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.fee-summary {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.fee-summary-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
border: 1px solid #e5e6eb;
border-radius: 6px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: var(--app-primary-color, #2d8c8c);
}
&.active {
border-color: var(--app-primary-color, #2d8c8c);
box-shadow: 0 0 0 1px var(--app-primary-color, #2d8c8c);
}
&.receivable .fee-summary-amount {
color: #00b42a;
}
&.payable .fee-summary-amount {
color: #f53f3f;
}
&.gross-profit {
cursor: default;
&:hover {
border-color: #e5e6eb;
}
.fee-summary-amount,
.fee-summary-meta {
color: #ff7d00;
}
}
}
.fee-profit-formula {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px dashed #e5e6eb;
border-radius: 6px;
background: #fafbfc;
font-size: 12px;
flex-wrap: wrap;
}
.fee-profit-formula-label {
color: #86909c;
flex-shrink: 0;
}
.fee-profit-formula-value {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-weight: 500;
color: #1d2129;
}
.fee-profit-operator {
color: #86909c;
}
.fee-profit-result {
font-weight: 600;
color: #ff7d00;
}
.fee-summary-label {
font-size: 12px;
color: #86909c;
line-height: 20px;
}
.fee-summary-amount {
font-size: 18px;
font-weight: 600;
line-height: 26px;
word-break: break-all;
}
.fee-summary-meta {
font-size: 12px;
color: #4e5969;
line-height: 20px;
}
.fee-view-switch {
display: flex;
justify-content: flex-end;
}
.fee-section {
border: 1px solid #e5e6eb;
border-radius: 6px;
overflow: hidden;
background: #fff;
&.receivable {
.fee-section-dot {
background: #00b42a;
}
}
&.payable {
.fee-section-dot {
background: #f53f3f;
}
}
}
.fee-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #f7f8fa;
border-bottom: 1px solid #e5e6eb;
}
.fee-section-title-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.fee-section-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.fee-section-title {
font-size: 13px;
font-weight: 600;
color: #1d2129;
}
.fee-section-total {
font-size: 13px;
font-weight: 600;
color: #1d2129;
flex-shrink: 0;
}
.fee-table-wrap {
max-height: 220px;
overflow: auto;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: #c9cdd4;
border-radius: 3px;
}
}
.fee-table {
min-width: 100%;
.table-header,
.table-row {
display: grid;
grid-template-columns: 1.2fr 0.9fr 0.6fr 0.8fr 1fr 1fr 1.3fr;
align-items: center;
}
.table-header {
position: sticky;
top: 0;
z-index: 1;
background: #fafbfc;
font-size: 12px;
font-weight: 500;
color: #86909c;
border-bottom: 1px solid #e5e6eb;
}
.table-row {
font-size: 12px;
color: #4e5969;
border-bottom: 1px solid #f2f3f5;
&:last-child {
border-bottom: none;
}
&:hover {
background: #fafbfc;
}
}
.header-cell,
.table-cell {
padding: 8px 12px;
min-width: 0;
word-break: break-all;
&.amount {
text-align: right;
}
&.priceEffectiveTime {
min-width: 152px;
white-space: nowrap;
word-break: normal;
}
}
.header-cell.amount {
text-align: right;
}
}
.fee-amount {
font-weight: 600;
font-variant-numeric: tabular-nums;
&.amount-receivable {
color: #00b42a;
}
&.amount-payable {
color: #f53f3f;
}
}
.fee-section-empty {
padding: 16px 0;
}
.fee-empty-all {
margin-top: 8px;
}
.track-info {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 200px;
}
.track-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e5e6eb;
border-radius: 6px;
background: #f7f8fa;
}
.track-toolbar-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.track-toolbar-label {
font-size: 12px;
color: #86909c;
flex-shrink: 0;
}
.track-toolbar-value {
font-size: 13px;
font-weight: 600;
color: #1d2129;
word-break: break-all;
}
.track-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.track-spin {
width: 100%;
min-height: 160px;
}
.track-empty {
padding: 24px 0;
}
.track-timeline {
padding: 4px 0;
.track-item {
display: flex;
margin-bottom: 0;
&.active {
.track-card {
border-color: #b9f0c2;
background: #f6ffed;
}
.track-title {
color: #1d2129;
}
}
.track-left {
display: flex;
flex-direction: column;
align-items: center;
width: 28px;
margin-right: 12px;
flex-shrink: 0;
.track-icon {
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
background: #f2f3f5;
color: #4e5969;
border-radius: 50%;
font-size: 12px;
flex-shrink: 0;
&.active {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--app-primary-color, #2d8c8c);
font-size: 22px;
line-height: 1;
}
}
.track-line {
width: 2px;
flex: 1;
min-height: 20px;
background: #e5e6eb;
margin: 4px 0;
&.active {
background: var(--app-primary-color, #2d8c8c);
}
}
}
.track-right {
flex: 1;
min-width: 0;
padding-bottom: 16px;
}
.track-card {
padding: 10px 12px;
border: 1px solid #e5e6eb;
border-radius: 6px;
background: #fff;
}
.track-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.track-title {
font-size: 14px;
font-weight: 600;
color: #1d2129;
line-height: 22px;
word-break: break-all;
}
.track-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 8px;
font-size: 12px;
color: #86909c;
margin-top: 6px;
line-height: 20px;
word-break: break-all;
}
.track-place {
color: #4e5969;
}
.track-meta-icon {
font-size: 14px;
color: #86909c;
flex-shrink: 0;
}
.track-timezone {
color: #86909c;
}
.track-remark,
.track-code {
color: #86909c;
}
}
}
</style>