feat: add tail order controls and terminal drayage UI

This commit is contained in:
2026-09-07 23:58:18 +08:00
parent 36d34b7d1d
commit 74d883a443
11 changed files with 664 additions and 51 deletions
@@ -11,12 +11,15 @@ import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import {
changeContainerReceivingWarehouse,
createManualInboundPlan,
convertLastMileContainerToInboundPlans,
convertLastMileOrderToInboundPlan,
getContainerPlanProgress,
listContainerPlanProgress,
listLastMileOrders,
transferLastMileOrders,
getLastMileOrderManualNodes,
updateLastMileOrderHold,
updateLastMileOrderManualNodes,
} from '../api/lastMileOrders'
import LastMileOrdersView from './LastMileOrdersView.vue'
@@ -26,7 +29,8 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
changeContainerReceivingWarehouse: vi.fn(),
convertLastMileOrderToInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
downloadLastMileOrders: vi.fn(),
}))
@@ -121,7 +125,10 @@ describe('LastMileOrdersView pending order and container conversion', () => {
summary,
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
})
vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({})
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 })
vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 })
vi.mocked(updateLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [], version: 1 })
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never)
vi.mocked(changeContainerReceivingWarehouse).mockReset().mockResolvedValue({ ...summary, overseasWarehouseId: '9007199254740996', overseasWarehouseCode: 'LAX02', overseasWarehouseName: '洛杉矶二号接柜仓', assignmentVersion: 8 })
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never)
@@ -180,13 +187,15 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).not.toContain('尾程订单加载失败')
})
it('keeps single-order conversion isolated with long order and customer ids', async () => {
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledTimes(1)
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ orderId: '2092873463568596994', customerId })
expect(document.body.textContent).toContain('计划入库仓')
expect(document.body.textContent).toContain('预计到仓日期')
expect(document.body.textContent).toContain('托盘类型')
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
expect(listContainerPlanProgress).not.toHaveBeenCalled()
})
@@ -211,7 +220,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId })
expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled()
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('已按柜生成 2 个独立入库计划')
expect(listLastMileOrders).toHaveBeenCalledTimes(3)
})
@@ -435,7 +444,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(source).toContain('.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }')
expect(source).toContain("{ key: 'actions', label: '操作', width: 110, fixed: 'left'")
expect(source).toContain("{ key: 'actions', label: '操作', width: 178, fixed: 'left'")
})
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
@@ -13,7 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto } from '../api/lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
@@ -40,6 +40,27 @@ const actionLoading = ref(false)
const exportLoading = ref(false)
async function exportOrders() { exportLoading.value = true; try { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
const actionError = ref<ApiErrorDetails | null>(null)
const actionTarget = ref<LastMileOrderListItem | null>(null)
const inboundPlanOpen = ref(false)
const inboundPlanWarehouseLoading = ref(false)
const inboundPlanWarehouses = ref<TransferWarehouseOption[]>([])
const inboundPlanForm = reactive({ warehouseId: '', plannedArrivalDate: '', inboundCode: '', palletCount: 1, palletType: 'STANDARD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
const holdOpen = ref(false)
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
const manualNodesOpen = ref(false)
const manualNodesLoading = ref(false)
const manualNodesVersion = ref(0)
const manualNodeDefinitions: Array<{ code: ManualNodeCode; label: string }> = [
{ code: 'DOMESTIC_CUSTOMS_COMPLETED', label: '国内报关完成' },
{ code: 'ESTIMATED_DEPARTURE', label: '预计开船' },
{ code: 'ACTUAL_DEPARTURE', label: '实际开船' },
{ code: 'ESTIMATED_ARRIVAL', label: '预计到港' },
{ code: 'OVERSEAS_CUSTOMS_COMPLETED', label: '国外清关完成' },
{ code: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', label: '实际到港等待卸船' },
{ code: 'PICKUP_APPOINTED', label: '预约提柜' },
{ code: 'UNLOADED', label: '卸柜' },
]
const manualNodeValues = reactive<Record<ManualNodeCode, string | null>>(Object.fromEntries(manualNodeDefinitions.map(({ code }) => [code, null])) as Record<ManualNodeCode, string | null>)
const transferOpen = ref(false)
const transferWarehouseLoading = ref(false)
const transferWarehouseError = ref<ApiErrorDetails | null>(null)
@@ -136,7 +157,7 @@ function newReceivingWarehouseIdempotencyKey() {
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 178, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
@@ -412,26 +433,94 @@ async function submitTransfer() {
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
finally { actionLoading.value = false }
}
async function convertInboundPlan() {
if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
const selected = selectedRows.value[0]
const customerId = typeof selected.customerId === 'string' ? selected.customerId.trim() : ''
if (!customerId) {
actionError.value = { message: '所选尾程订单缺少客户标识,无法安全生成入库计划' }
function idempotencyKey(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
async function loadInboundPlanWarehouses() {
inboundPlanWarehouseLoading.value = true
try {
inboundPlanWarehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })).map(toTransferWarehouseOption)
const targetWarehouse = inboundPlanWarehouses.value.find((item) => actionTarget.value?.warehouse.includes(item.warehouseName))
if (targetWarehouse) inboundPlanForm.warehouseId = targetWarehouse.warehouseId
} catch (error) {
actionError.value = getApiErrorDetails(error, '入库仓候选加载失败,请重试')
} finally {
inboundPlanWarehouseLoading.value = false
}
}
function openInboundPlan(source?: object) {
const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null)
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
actionTarget.value = target
Object.assign(inboundPlanForm, { warehouseId: '', plannedArrivalDate: '', inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: typeof target.pallets === 'number' && target.pallets > 0 ? target.pallets : 1, palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
inboundPlanWarehouses.value = []
actionError.value = null
inboundPlanOpen.value = true
void loadInboundPlanWarehouses()
}
async function submitInboundPlan() {
const row = actionTarget.value
const warehouse = inboundPlanWarehouses.value.find((item) => item.warehouseId === inboundPlanForm.warehouseId)
if (!row || !warehouse || !inboundPlanForm.plannedArrivalDate || !inboundPlanForm.inboundCode.trim() || inboundPlanForm.palletCount < 1) {
ElMessage.warning('请完整填写计划入库仓、预计到仓日期、入仓码和托数')
return
}
actionLoading.value = true
actionError.value = null
actionLoading.value = true; actionError.value = null
try {
await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId })
selectedRows.value = []
ElMessage.success('入库计划生成成功')
await load()
} catch (error) {
actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试')
} finally {
actionLoading.value = false
}
await createManualInboundPlan({
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
plannedWarehouseId: warehouse.warehouseId, plannedWarehouseCode: warehouse.warehouseCode,
plannedWarehouseName: warehouse.warehouseName, plannedArrivalDate: inboundPlanForm.plannedArrivalDate,
inboundCode: inboundPlanForm.inboundCode.trim(), palletCount: inboundPlanForm.palletCount,
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
remark: inboundPlanForm.remark.trim() || undefined,
}, idempotencyKey('manual-inbound-plan'))
inboundPlanOpen.value = false; selectedRows.value = []; ElMessage.success('入库计划已生成'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') }
finally { actionLoading.value = false }
}
function openHold(source: object) {
const row = source as LastMileOrderListItem
actionTarget.value = row
Object.assign(holdForm, { held: !row.detained, chargeable: row.detained ? false : true, reason: '' })
actionError.value = null
holdOpen.value = true
}
async function submitHold() {
const row = actionTarget.value
if (!row || !holdForm.reason.trim()) { ElMessage.warning('请输入变更原因'); return }
actionLoading.value = true; actionError.value = null
try {
await updateLastMileOrderHold(row.id, { held: holdForm.held, chargeable: holdForm.chargeable, reason: holdForm.reason.trim(), expectedVersion: row.version }, idempotencyKey('last-mile-hold'))
holdOpen.value = false; ElMessage.success(holdForm.held ? '订单已暂扣' : '订单已解除暂扣'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '暂扣状态保存失败,请重试') }
finally { actionLoading.value = false }
}
async function openManualNodes(source: object) {
const row = source as LastMileOrderListItem
actionTarget.value = row
manualNodesOpen.value = true
manualNodesLoading.value = true
actionError.value = null
manualNodeDefinitions.forEach(({ code }) => { manualNodeValues[code] = null })
try {
const result = await getLastMileOrderManualNodes(row.id)
manualNodesVersion.value = result.version
result.nodes.forEach((node) => { manualNodeValues[node.nodeCode] = node.nodeAt })
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点加载失败,请重试') }
finally { manualNodesLoading.value = false }
}
async function submitManualNodes() {
const row = actionTarget.value
if (!row || !manualNodeDefinitions.some(({ code }) => Boolean(manualNodeValues[code]))) { ElMessage.warning('请至少填写一个节点时间'); return }
actionLoading.value = true; actionError.value = null
try {
await updateLastMileOrderManualNodes(row.id, manualNodesVersion.value, manualNodeDefinitions.map(({ code }) => ({ nodeCode: code, nodeAt: manualNodeValues[code] })), idempotencyKey('last-mile-manual-nodes'))
manualNodesOpen.value = false; ElMessage.success('人工节点已保存,该订单不再继承柜级节点'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点保存失败,请重试') }
finally { actionLoading.value = false }
}
async function loadContainerOptions(containerNo = '') {
const request = ++containerOptionRequest
@@ -552,8 +641,8 @@ onMounted(() => { void load() })
</div>
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-dropdown v-if="access.can(permission.lastMileOrderTransfer) || access.can(permission.inboundOrderCreate)" trigger="click"><el-button size="small" plain>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" @click="openInboundPlan(row)">填写入库计划</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
@@ -626,10 +715,37 @@ onMounted(() => { void load() })
</LoadErrorState>
</el-form>
</AppDialog>
<AppDialog v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :loading="inboundPlanWarehouseLoading" :confirm-loading="actionLoading" @confirm="submitInboundPlan">
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
<el-form class="command-form" label-position="top">
<div class="command-form__grid"><el-form-item label="计划入库仓" required><el-select v-model="inboundPlanForm.warehouseId" filterable placeholder="请选择海外仓"><el-option v-for="warehouse in inboundPlanWarehouses" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" /></el-select></el-form-item><el-form-item label="预计到仓日期" required><el-date-picker v-model="inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码" required><el-input v-model="inboundPlanForm.inboundCode" maxlength="128" placeholder="请输入入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="1" :max="999" controls-position="right" placeholder="请输入托数" /></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="holdOpen" :title="holdForm.held ? '暂扣订单' : '解除暂扣'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
<el-form class="command-form" label-position="top"><div class="command-form__grid"><el-form-item label="暂扣状态" required><el-radio-group v-model="holdForm.held"><el-radio :value="true">暂扣</el-radio><el-radio :value="false">解除暂扣</el-radio></el-radio-group></el-form-item><el-form-item label="是否计费" required><el-radio-group v-model="holdForm.chargeable"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item></div><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入暂扣或解除暂扣原因" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="manualNodesOpen" title="人工修改节点" width="min(700px, calc(100vw - 28px))" confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<div class="manual-node-order"><span>尾程单号</span><strong>{{ actionTarget?.orderNo || '—' }}</strong></div>
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
<div class="manual-node-table"><div class="manual-node-table__head"><span>节点</span><span>节点时间</span></div><div v-for="node in manualNodeDefinitions" :key="node.code" class="manual-node-table__row"><span>{{ node.label }}</span><el-date-picker v-model="manualNodeValues[node.code]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></div></div>
</AppDialog>
</template>
<style scoped>
.last-mile-toolbar { padding: 14px 16px 0; }
.row-actions { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.command-form { margin-top: 16px; }
.command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.command-form :deep(.el-select), .command-form :deep(.el-date-editor), .command-form :deep(.el-input-number) { width: 100%; }
.manual-node-order { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); }
.manual-node-table { max-height: 420px; margin-top: 14px; overflow: auto; border: 1px solid var(--cc-border); border-radius: 6px; }
.manual-node-table__head, .manual-node-table__row { display: grid; grid-template-columns: 42% 58%; align-items: center; gap: 12px; padding: 10px 14px; }
.manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: 13px; font-weight: 600; }
.manual-node-table__row { border-top: 1px solid var(--cc-border); }
.manual-node-table__row :deep(.el-date-editor) { width: 100%; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }