优化
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, toRaw, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -106,7 +107,7 @@ async function save() {
|
||||
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog admin-workbench is-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。</p>
|
||||
<section v-if="confirmMode" aria-label="本目的站可接余位">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -26,7 +27,7 @@ watch(() => [open.value, props.inquiry?.bookingNo, props.mode], () => { generati
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" v-if="!loaded" v-model="open" title="读取本票处理进度" width="1080px" :show-confirm="false">
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-if="!loaded" v-model="open" title="读取本票处理进度" width="1080px" :show-confirm="false">
|
||||
<p v-if="loading">正在读取本票真实履约资料…</p>
|
||||
<LoadErrorState v-if="error" title="读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -31,7 +32,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
|
||||
]
|
||||
</script>
|
||||
<template><DataTable :appearance="integrated ? 'integrated' : 'default'" :stripe="false" :results-visible="!failed" preference-key="fleet-capacity-inquiries" column-settings-mode="show" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="bookingNo" empty-text="当前范围暂无意向">
|
||||
<template><div class="workbench-table"><DataTable :appearance="integrated ? 'integrated' : 'default'" :stripe="false" :results-visible="!failed" preference-key="fleet-capacity-inquiries" column-settings-mode="show" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="bookingNo" empty-text="当前范围暂无意向">
|
||||
<template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template>
|
||||
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
|
||||
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
|
||||
@@ -40,5 +41,5 @@ const columns: DataTableColumn[] = [
|
||||
<template #cargo="{ row }"><div>{{ inquiryCargoLabel(row.status) }}:{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</div><div>预计实托数:{{ row.expectedPalletCount ?? '—' }}</div></template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
|
||||
</DataTable></template>
|
||||
<style scoped>.inquiry-reason{display:block;color:var(--cc-muted);font-size:var(--admin-font-caption);white-space:normal}</style>
|
||||
</DataTable></div></template>
|
||||
<style scoped>.inquiry-reason{display:block;color:var(--workbench-muted);font-size:var(--admin-font-caption);white-space:normal}</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -112,12 +113,12 @@ async function command(action: 'publish' | 'pause' | 'resume' | 'close') {
|
||||
if (guard.dirty.value) { ElMessage.warning('有未保存修改,请先确认发布或关闭后重新打开再操作'); return }
|
||||
publishAfterSave.value = false
|
||||
const labels = { publish: '发布运力', pause: '暂停接单', resume: '恢复接单', close: '关闭发布' }
|
||||
try { await ElMessageBox.confirm(`${labels[action]} ${offer.value.offerNo}?已确认预订及客户订单不会被取消。`, labels[action], { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
try { await ElMessageBox.confirm(`${labels[action]} ${offer.value.offerNo}?已确认预订及客户订单不会被取消。`, labels[action], { customClass: 'admin-density-confirm admin-workbench is-dialog' }) } catch { return }
|
||||
pending.value = { action, offerNo: offer.value.offerNo, body: { tripRecordVersion: offer.value.tripRecordVersion, offerRecordVersion: offer.value.recordVersion, idempotencyKey: crypto.randomUUID() } }; await execute()
|
||||
}
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" title="发布拼车余位" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '确认发布'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
|
||||
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog admin-workbench is-dialog" title="发布拼车余位" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '确认发布'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason && offer.status !== 'DRAFT'">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
|
||||
<template v-if="loaded && open && (!offer || offer.actions.canEdit)">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -84,7 +85,7 @@ onBeforeUnmount(() => { warehouseRequest++; editSession++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="current ? '编辑司机档案' : '新增司机档案'" width="1080px" confirm-text="保存司机" :confirm-loading="saving" @confirm="save">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="current ? '编辑司机档案' : '新增司机档案'" width="1080px" confirm-text="保存司机" :confirm-loading="saving" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="保存未完成" :message="error.message" @retry="save" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form label-position="top" class="density-form driver-form" :disabled="saving" @submit.prevent="save">
|
||||
<h3 class="full-width">基础信息</h3>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -29,7 +30,7 @@ onBeforeUnmount(() => { request++; release() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" title="驾驶证附件" width="620px" :show-confirm="false" cancel-text="关闭">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" title="驾驶证附件" width="620px" :show-confirm="false" cancel-text="关闭">
|
||||
<p>{{ driverName }} · 驾驶证附件</p>
|
||||
<LoadErrorState v-if="error" title="驾驶证读取失败" :message="error.message" @retry="load" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-loading="loading" class="license-preview">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -49,7 +50,7 @@ async function save() {
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
<template><AppDialog v-model="open" :title="title" width="780px" class="admin-density-dialog" fullscreenable :confirm-loading="saving" :show-confirm="false" :close-on-click-modal="false">
|
||||
<template><AppDialog v-model="open" :title="title" width="780px" class="admin-density-dialog admin-workbench is-dialog" fullscreenable :confirm-loading="saving" :show-confirm="false" :close-on-click-modal="false">
|
||||
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
|
||||
<p v-if="uncertain" role="status" class="density-dialog-note">保存结果尚未确认,重试将发送原请求和原内容。</p>
|
||||
<section v-if="readonly" class="density-dialog-section" data-tone="source" aria-label="车队报价资料">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -25,7 +26,7 @@ function template() { saveFleetCsv(new Blob(['\uFEFF' + fleetQuoteCsvHeader.join
|
||||
async function save() { if (saving.value || (!pending.value && (!rows.value.length || !partyId.value))) return; fileSequence++; saving.value = true; error.value = null; try { pending.value ??= { key: crypto.randomUUID(), rows: rows.value.map(r => ({ ...r })) }; await importFleetQuotes(pending.value.key, pending.value.rows); pending.value = null; rows.value = []; source.value = ''; open.value = false; emit('saved') } catch (e) { error.value = getApiErrorDetails(e, '整批导入失败,请核对报价库与目录后重试'); if (isFleetCommandRejected(e)) pending.value = null } finally { saving.value = false } }
|
||||
onBeforeUnmount(() => { fileSequence++ })
|
||||
</script>
|
||||
<template><AppDialog v-model="open" title="批量导入车队报价" width="1040px" class="admin-density-dialog" fullscreenable :confirm-loading="saving" :show-confirm="false" :close-on-click-modal="false">
|
||||
<template><AppDialog v-model="open" title="批量导入车队报价" width="1040px" class="admin-density-dialog admin-workbench is-dialog" fullscreenable :confirm-loading="saving" :show-confirm="false" :close-on-click-modal="false">
|
||||
<p class="density-dialog-note">本批{{ activeKind === 'PURCHASE' ? '采购' : '销售' }}报价使用同一往来单位;每批最多200条,服务端整批校验,任一失败全部不写入。</p>
|
||||
<el-alert v-if="error" type="error" :title="error.message" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
|
||||
<p v-if="pending" role="status" class="density-dialog-note">已保留原批次和原内容。结果未确认时请按原请求重试,避免重复导入。</p>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/outsourcedBusiness'
|
||||
import { outsourcedBusinessFixture, outsourcedBusinessCommandFixture } from './OutsourcedBusinessFixtures'
|
||||
import { acceptOutsourcedBusinessCommand, newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, outsourcedBusinessPendingKey } from './OutsourcedBusinessCommandRecovery'
|
||||
import type { OutsourcedBusinessPhoto, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
|
||||
|
||||
beforeEach(() => sessionStorage.clear())
|
||||
const no = 'DS-OUT-1'
|
||||
function operation() : OutsourcedBusinessSave { return { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null } }
|
||||
function client() {
|
||||
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
|
||||
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null }
|
||||
return { ...api, getOutsourcedBusiness: vi.fn(async () => current), getOutsourcedBusinessCommand: vi.fn(async () => outsourcedBusinessCommandFixture()), saveOutsourcedBusinessOperations: vi.fn(async () => current), bindOutsourcedBusinessPhoto: vi.fn(async () => current), uploadOutsourcedBusinessPhoto: vi.fn(async () => photo) }
|
||||
}
|
||||
describe('outsourced business exact command recovery', () => {
|
||||
it('persists the complete original payload, actor and zero auxiliary version', () => {
|
||||
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation())
|
||||
persistOutsourcedBusinessPending('actor', no, 'operations', pending)
|
||||
expect(restoreOutsourcedBusinessPending('actor', no, 'operations')).toEqual(pending)
|
||||
expect(restoreOutsourcedBusinessPending('another', no, 'operations')).toBeNull()
|
||||
const raw = JSON.parse(sessionStorage.getItem(outsourcedBusinessPendingKey('actor',no,'operations'))!)
|
||||
raw.actor = 'another'; sessionStorage.setItem(outsourcedBusinessPendingKey('actor',no,'operations'),JSON.stringify(raw))
|
||||
expect(() => restoreOutsourcedBusinessPending('actor',no,'operations')).toThrow('损坏')
|
||||
})
|
||||
it('looks up an unknown write first and retries only the unchanged original body on explicit not-found', async () => {
|
||||
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()); pending.attempted = true
|
||||
const service = client(), persist = vi.fn()
|
||||
service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
|
||||
await recoverOutsourcedBusinessCommand(pending,persist,() => {},service)
|
||||
expect(service.getOutsourcedBusinessCommand).toHaveBeenCalledWith(no,'original-key')
|
||||
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledExactlyOnceWith(no,operation())
|
||||
expect(pending.body).toEqual(operation()); expect(pending.body?.recordVersion).toBe('0')
|
||||
})
|
||||
it('retains a proven success through failed fresh reads without writing it a second time', async () => {
|
||||
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()), service = client()
|
||||
service.getOutsourcedBusiness.mockRejectedValueOnce(new Error('read offline'))
|
||||
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('read offline')
|
||||
expect(pending.confirmed?.recordVersion).toBe('1')
|
||||
await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
|
||||
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledTimes(1); expect(service.getOutsourcedBusinessCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
it('never retransmits a file for an unknown upload, including command not-found', async () => {
|
||||
const pending = newOutsourcedBusinessPending('actor',no,'UPLOAD_PHOTO',null,'LOADING_SCENE'); pending.attempted = true
|
||||
const service = client(); service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
|
||||
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('禁止重复上传')
|
||||
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(pending.key).toBeTruthy()
|
||||
})
|
||||
it('keeps the prepared file and exact binding key through unknown binding and later replacement', async () => {
|
||||
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:'2026-10-20T10:01:00Z' }
|
||||
const pending = newOutsourcedBusinessPending('actor',no,'BIND_PHOTO',{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId},photo.slot)
|
||||
pending.attempted = true
|
||||
const service = client(); const bound = outsourcedBusinessFixture(); bound.row.recordVersion='1'; bound.photos=[photo]
|
||||
const original = outsourcedBusinessCommandFixture({action:'BIND_PHOTO',idempotencyKey:'bind-key',result:bound})
|
||||
service.getOutsourcedBusinessCommand.mockResolvedValue(original)
|
||||
const current = outsourcedBusinessFixture();current.row.recordVersion='2';current.photos=[{...photo,fileId:'9007199254740994'}]
|
||||
service.getOutsourcedBusiness.mockResolvedValue(current)
|
||||
const result = await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
|
||||
expect(result.detail?.photos[0]?.fileId).toBe('9007199254740994')
|
||||
expect(pending.body).toEqual({recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})
|
||||
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled();expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled()
|
||||
})
|
||||
it('refuses wrong command identities, stale reads, definite rejection retries and scope changes', async () => {
|
||||
const pending = newOutsourcedBusinessPending('actor',no,'SAVE_OPERATIONS',operation()), service=client()
|
||||
expect(() => acceptOutsourcedBusinessCommand(pending,outsourcedBusinessCommandFixture({dispatchNo:'another'}))).toThrow('不一致')
|
||||
pending.command=outsourcedBusinessCommandFixture({recordVersion:'9007199254740993'})
|
||||
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('早于')
|
||||
pending.rejected=true;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('明确拒绝')
|
||||
pending.rejected=false;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {throw new Error('scope changed')},service)).rejects.toThrow('scope changed')
|
||||
expect(service.saveOutsourcedBusinessOperations).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/outsourcedBusiness'
|
||||
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessAction, type OutsourcedBusinessBind, type OutsourcedBusinessCommand, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
|
||||
|
||||
export interface OutsourcedBusinessPending {
|
||||
actor: string; dispatchNo: string; action: OutsourcedBusinessAction; key: string; slot?: OutsourcedBusinessPhotoSlot
|
||||
body: OutsourcedBusinessSave | OutsourcedBusinessBind | null; attempted: boolean; command: OutsourcedBusinessCommand | null; confirmed: { recordVersion: string | null; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto } | null; rejected: boolean
|
||||
}
|
||||
export const outsourcedBusinessPendingKey = (actor: string, no: string, area: string) => `outsourced-business:${encodeURIComponent(actor)}:${encodeURIComponent(no)}:${area}`
|
||||
export function restoreOutsourcedBusinessPending(actor: string, no: string, area: string): OutsourcedBusinessPending | null {
|
||||
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, no, area))
|
||||
if (!raw) return null
|
||||
const pending = JSON.parse(raw) as OutsourcedBusinessPending
|
||||
if (pending.actor !== actor || pending.dispatchNo !== no || !pending.key || !['SAVE_OPERATIONS','UPLOAD_PHOTO','BIND_PHOTO'].includes(pending.action)) throw new Error('原运营请求记录损坏,请保留现场核对')
|
||||
if (pending.action !== 'SAVE_OPERATIONS' && !outsourcedBusinessPhotoRoles.some(role => role.slot === pending.slot) || pending.action !== 'UPLOAD_PHOTO' && (!pending.body || pending.body.idempotencyKey !== pending.key)) throw new Error('原运营请求内容不完整,请保留现场核对')
|
||||
return pending
|
||||
}
|
||||
export function persistOutsourcedBusinessPending(actor: string, no: string, area: string, pending: OutsourcedBusinessPending | null) {
|
||||
if (!actor || !no || pending && (pending.actor !== actor || pending.dispatchNo !== no)) throw new Error('运营请求身份已变化')
|
||||
const key = outsourcedBusinessPendingKey(actor, no, area)
|
||||
if (pending) sessionStorage.setItem(key, JSON.stringify(pending)); else sessionStorage.removeItem(key)
|
||||
}
|
||||
export function newOutsourcedBusinessPending(actor: string, no: string, action: OutsourcedBusinessAction, body: OutsourcedBusinessPending['body'], slot?: OutsourcedBusinessPhotoSlot): OutsourcedBusinessPending {
|
||||
if (!actor || !no) throw new Error('当前运营作业身份缺失')
|
||||
const key = body?.idempotencyKey || crypto.randomUUID()
|
||||
return { actor, dispatchNo: no, action, key, slot, body: body ? structuredClone(body) : null, attempted: false, command: null, confirmed: null, rejected: false }
|
||||
}
|
||||
export function acceptOutsourcedBusinessResult(pending: OutsourcedBusinessPending, result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto) {
|
||||
if (pending.action === 'UPLOAD_PHOTO') {
|
||||
if (!('fileId' in result) || result.slot !== pending.slot || result.category !== 'LOADING' || typeof result.fileId !== 'string' || !/^[1-9]\d*$/.test(result.fileId) || !['image/jpeg','image/png'].includes(result.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
|
||||
pending.confirmed = { recordVersion: null, result }
|
||||
} else {
|
||||
if (!('row' in result) || result.row.dispatchNo !== pending.dispatchNo) throw new Error('运营保存结果与原排车单不一致')
|
||||
pending.confirmed = { recordVersion: outsourcedBusinessVersion(result.row.recordVersion), result }
|
||||
}
|
||||
}
|
||||
export function acceptOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, command: OutsourcedBusinessCommand) {
|
||||
if (command.dispatchNo !== pending.dispatchNo || command.action !== pending.action || command.idempotencyKey !== pending.key) throw new Error('返回结果与原运营请求不一致,请保留原请求核对')
|
||||
pending.command = command
|
||||
if (command.state === 'REJECTED') pending.rejected = true
|
||||
if (command.state !== 'SUCCEEDED') throw new Error(command.state === 'REJECTED' ? '原请求已明确拒绝,请核对原因后重新登记' : '原请求结果尚未确定,请保留原请求继续核对')
|
||||
if (!command.result) throw new Error('原运营命令的成功结果不完整,请继续核对')
|
||||
acceptOutsourcedBusinessResult(pending, command.result)
|
||||
if (pending.action !== 'UPLOAD_PHOTO' && (command.recordVersion == null || command.recordVersion !== pending.confirmed!.recordVersion)) throw new Error('原运营命令版本与结果不一致')
|
||||
}
|
||||
export async function recoverOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, persist: () => void, ensureScope: () => void, client = api): Promise<{ detail?: OutsourcedBusinessDetail; photo?: OutsourcedBusinessPhoto }> {
|
||||
ensureScope()
|
||||
if (pending.rejected) throw new Error('原请求已明确拒绝,请先按明确拒绝结果重新核对')
|
||||
if (pending.command?.state === 'SUCCEEDED') acceptOutsourcedBusinessCommand(pending, pending.command)
|
||||
const previous = pending.attempted
|
||||
if (!pending.confirmed) {
|
||||
let original: OutsourcedBusinessCommand | null = null
|
||||
if (previous) {
|
||||
try { original = await client.getOutsourcedBusinessCommand(pending.dispatchNo, pending.key) }
|
||||
catch (cause) { if (!(cause instanceof ApiResponseError) || !['404','A0404','HTTP_404'].includes(cause.code)) throw cause }
|
||||
ensureScope()
|
||||
}
|
||||
if (original) { try { acceptOutsourcedBusinessCommand(pending, original) } finally { persist() } }
|
||||
else {
|
||||
if (pending.action === 'UPLOAD_PHOTO') throw new Error('图片上传结果未知,禁止重复上传;请继续核对原上传请求')
|
||||
pending.attempted = true; persist()
|
||||
try {
|
||||
const result = pending.action === 'SAVE_OPERATIONS'
|
||||
? await client.saveOutsourcedBusinessOperations(pending.dispatchNo, pending.body as OutsourcedBusinessSave)
|
||||
: await client.bindOutsourcedBusinessPhoto(pending.dispatchNo, pending.slot!, pending.body as OutsourcedBusinessBind)
|
||||
ensureScope(); acceptOutsourcedBusinessResult(pending, result); persist()
|
||||
} catch (cause) {
|
||||
ensureScope()
|
||||
if (!previous && cause instanceof ApiResponseError && ['A0400','A0403','A0404','A0409','400','403','404','409','422'].includes(cause.code)) { pending.rejected = true; persist() }
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
}
|
||||
const confirmed = pending.confirmed!
|
||||
if (pending.action === 'UPLOAD_PHOTO') {
|
||||
const photo = confirmed.result as OutsourcedBusinessPhoto | null
|
||||
if (!photo || photo.slot !== pending.slot || photo.category !== 'LOADING' || typeof photo.fileId !== 'string' || !/^[1-9]\d*$/.test(photo.fileId) || !['image/jpeg','image/png'].includes(photo.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
|
||||
return { photo }
|
||||
}
|
||||
const current = await client.getOutsourcedBusiness(pending.dispatchNo); ensureScope()
|
||||
if (current.row.dispatchNo !== pending.dispatchNo || confirmed.recordVersion == null || BigInt(outsourcedBusinessVersion(current.row.recordVersion)) < BigInt(outsourcedBusinessVersion(confirmed.recordVersion))) throw new Error('当前运营版本早于原操作结果,请继续核对')
|
||||
if (pending.action === 'BIND_PHOTO') {
|
||||
const matches = (photo: OutsourcedBusinessPhoto) => photo.slot === pending.slot && photo.fileId === (pending.body as OutsourcedBusinessBind).fileId && Boolean(photo.savedAt)
|
||||
const original = confirmed.result
|
||||
const originalPhotos = original && 'row' in original ? original.photos : original && 'fileId' in original ? [original] : []
|
||||
if (!current.photos.some(matches) && !originalPhotos.some(matches)) throw new Error('图片绑定结果尚未确认,请保留原文件和请求')
|
||||
}
|
||||
return { detail: current }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
|
||||
defineProps<{ dispatchNo?: string; statusText?: string; loading?: boolean }>()
|
||||
const activeTab = defineModel<'task' | 'route' | 'history'>('activeTab', { default: 'task' })
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench" spacing="compact">
|
||||
<DetailPageLayout aria-label="外配业务详情" class="workbench-panel is-flush is-page-surface">
|
||||
<template #header>
|
||||
<div class="outsourced-business-detail-heading">
|
||||
<div><strong>外配业务</strong><span class="outsourced-business-reference">{{ dispatchNo || '—' }}</span><span v-if="statusText">{{ statusText }}</span></div>
|
||||
<div class="density-actions"><slot name="actions" /><el-button class="density-action" @click="emit('back')">返回外配业务</el-button></div>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="state" />
|
||||
<div v-loading="loading" :aria-busy="loading">
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="task" label="任务与车辆信息"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span>任务与车辆信息</template><slot v-if="activeTab === 'task'" name="task" /></el-tab-pane>
|
||||
<el-tab-pane name="route" label="路线货物与凭证"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span>路线货物与凭证</template><slot v-if="activeTab === 'route'" name="route" /></el-tab-pane>
|
||||
<el-tab-pane name="history" label="操作记录"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span>操作记录</template><slot v-if="activeTab === 'history'" name="history" /></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
|
||||
.outsourced-business-detail-heading > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
|
||||
.outsourced-business-reference { overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { OutsourcedBusinessCommand, OutsourcedBusinessDetail } from '../outsourcedBusinessTypes'
|
||||
|
||||
export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
|
||||
const review = { status: 'APPROVED' as const, reason: null, reviewerId: null, reviewedAt: '2026-10-20T10:00:00Z', reviewRound: '1', basisHash: null, invalidatedReason: null }
|
||||
return {
|
||||
row: {
|
||||
dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', plannedDepartureAt: '2026-10-20T10:00:00Z', loadingCompletedAt: '2026-10-20T09:00:00Z', departedAt: null, deliveryCompletedAt: null,
|
||||
origin: { warehouseId: '1', warehouseCode: 'WH-1', warehouseName: '起运仓' }, vehicle: { vehicleId: '2', plate: 'OUT-1', ownership: 'DISPATCHED', carrierName: '测试外配承运商' }, driver: { name: null, phone: null },
|
||||
destinations: [{ destinationKey: 'destination-1', warehouseCode: 'FBA-1', warehouseName: '目的仓', address: null, appointments: [{ appointmentNo: 'AP-1', confirmationNo: null, scheduledAt: null, allocatedPackages: '2' }], actualArrivedAt: null }],
|
||||
actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
|
||||
photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
|
||||
},
|
||||
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
||||
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
|
||||
}
|
||||
}
|
||||
export function outsourcedBusinessCommandFixture(overrides: Partial<OutsourcedBusinessCommand> = {}): OutsourcedBusinessCommand {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = overrides.recordVersion ?? '1'
|
||||
return { dispatchNo: 'DS-OUT-1', action: 'SAVE_OPERATIONS', idempotencyKey: 'original-key', state: 'SUCCEEDED', recordVersion: '1', replayed: false, result: detail, errorCode: null, retryAllowed: false, ...overrides }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import OutsourcedBusinessDetailLayout from './OutsourcedBusinessDetailLayout.vue'
|
||||
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
|
||||
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
|
||||
|
||||
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
|
||||
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
|
||||
const Pane = defineComponent({ props: ['name', 'label'], template: '<article><slot /></article>' })
|
||||
const Dialog = defineComponent({ props: ['width', 'showConfirm', 'beforeClose'], template: '<div><slot /></div>' })
|
||||
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, AppDialog: Dialog, ElTabs: Tabs, ElTabPane: Pane, ElButton: Surface }, directives: { loading: () => {} } }
|
||||
|
||||
describe('OutsourcedBusiness presentation boundaries', () => {
|
||||
it('mounts only the selected one of the three independent detail regions', async () => {
|
||||
const wrapper = mount(OutsourcedBusinessDetailLayout, { global, props: { dispatchNo: 'DS-OUT-1' }, slots: { task: '<p>task-facts</p>', route: '<p>route-facts</p>', history: '<p>history-facts</p>' } })
|
||||
expect(wrapper.findAllComponents(Pane).map(pane => pane.props('label'))).toEqual(['任务与车辆信息', '路线货物与凭证', '操作记录'])
|
||||
expect(wrapper.text()).toContain('task-facts'); expect(wrapper.text()).not.toContain('history-facts')
|
||||
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'history'); await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('history-facts'); expect(wrapper.text()).not.toContain('task-facts')
|
||||
})
|
||||
|
||||
it('preserves only five approved photo roles without demo images or uploaded claims', () => {
|
||||
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
||||
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
||||
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
|
||||
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
|
||||
})
|
||||
|
||||
it('blocks a close while busy and delegates unsaved-result decisions to the owner', async () => {
|
||||
const guard = vi.fn(), done = vi.fn()
|
||||
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true, busy: true, beforeClose: guard } })
|
||||
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||
close(done); expect(guard).not.toHaveBeenCalled(); expect(done).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ busy: false }); close(done)
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
|
||||
const done = vi.fn(), guard = vi.fn()
|
||||
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
|
||||
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||
close(done); expect(done).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ beforeClose: guard }); close(done)
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench" spacing="compact">
|
||||
<slot name="statistics" />
|
||||
<ListPageLayout aria-label="外配业务任务列表">
|
||||
<template v-if="$slots.filters" #toolbar><slot name="filters" /></template>
|
||||
<template v-if="$slots.state" #state><slot name="state" /></template>
|
||||
<slot />
|
||||
</ListPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
dispatchNo?: string
|
||||
busy?: boolean
|
||||
saveDisabled?: boolean
|
||||
pending?: boolean
|
||||
beforeClose?: (done: () => void) => void | Promise<void>
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{ save: [] }>()
|
||||
function close(done: () => void) {
|
||||
if (props.busy) return
|
||||
if (props.beforeClose) return props.beforeClose(done)
|
||||
if (!props.pending) done()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
|
||||
<p class="outsourced-business-operation-reference">排车单号 {{ dispatchNo || '—' }}</p>
|
||||
<slot name="state" />
|
||||
<fieldset class="outsourced-business-operation-section density-dialog-section" data-tone="work"><legend>整车承运金额<InlineHelp title="承运金额口径">整车只登记一次;单价采用系统返回的承运金额与实际物理托数计算结果。</InlineHelp></legend><slot name="amount" /></fieldset>
|
||||
<fieldset class="outsourced-business-operation-section density-dialog-section" data-tone="work"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
|
||||
<p class="density-dialog-note">登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-operation-reference { overflow-wrap: anywhere; }
|
||||
.outsourced-business-operation-section { min-width: 0; margin: 0 0 var(--admin-space-4); }
|
||||
.outsourced-business-operation-section legend { padding: 0 var(--admin-space-2); font-weight: 600; }
|
||||
</style>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OutsourcedBusinessOperationsEditor from './OutsourcedBusinessOperationsEditor.vue'
|
||||
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
|
||||
|
||||
const calls = vi.hoisted(() => ({ save: vi.fn(), detail: vi.fn(), command: vi.fn(), canEdit: true }))
|
||||
vi.mock('../api/outsourcedBusiness', () => ({ saveOutsourcedBusinessOperations: calls.save, getOutsourcedBusiness: calls.detail, getOutsourcedBusinessCommand: calls.command }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'actor' } }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => calls.canEdit }) }))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const Frame = defineComponent({ props: ['modelValue','busy','saveDisabled','pending'], emits: ['save','update:modelValue'], setup(props,{ slots,emit }) { return () => h('div', [...Object.values(slots).map(slot => slot?.()), h('button',{ 'data-save':'',disabled: props.busy || props.saveDisabled,onClick: () => emit('save') },'save')]) } })
|
||||
const Control = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' })
|
||||
const Field = defineComponent({ props: ['label'], template: '<label>{{label}}<slot /></label>' })
|
||||
const global = { stubs: { OutsourcedBusinessOperationsDialog: Frame, ElForm: { template:'<div><slot /></div>' }, ElFormItem: Field, ElInput: Control, ElSelect: Control, ElOption: true, ElDatePicker: Control, LoadErrorState: { props:['message'],template:'<div>{{message}}</div>' }, ApiErrorMeta: true } }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); sessionStorage.clear(); calls.canEdit = true
|
||||
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
|
||||
calls.save.mockResolvedValue(current); calls.detail.mockResolvedValue(current)
|
||||
calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
|
||||
})
|
||||
|
||||
describe('outsourced operations registration', () => {
|
||||
it('sends explicit zero and destination arrival with auxiliary zero, without fuel or mileage fields', async () => {
|
||||
const wrapper = mount(OutsourcedBusinessOperationsEditor,{ global,props:{modelValue:true,detail:outsourcedBusinessFixture()} }); await flushPromises()
|
||||
const currency = wrapper.findAll('label').find(field => field.text() === '币种')!.find('input')
|
||||
const amount = wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input')
|
||||
await currency.setValue('USD'); await amount.setValue('0.00')
|
||||
await wrapper.findAll('input').at(-1)!.setValue('2026-10-20T12:00:00+08:00')
|
||||
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
|
||||
const body = calls.save.mock.calls[0]![1]
|
||||
expect(body).toEqual({ recordVersion:'0',idempotencyKey:expect.any(String),operations:{money:{currency:'USD',amount:'0.00'},arrivals:[{destinationKey:'destination-1',actualArrivedAt:'2026-10-20T12:00:00+08:00'}]},reason:null })
|
||||
expect(calls.save).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('里程'); wrapper.unmount()
|
||||
})
|
||||
it('clears money as null and retains exactly the original request after an unknown save', async () => {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.operations.money = {currency:'CNY',amount:'123.45'}
|
||||
calls.save.mockRejectedValueOnce(new TypeError('network interrupted'))
|
||||
const wrapper = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail}}); await flushPromises()
|
||||
await wrapper.findAll('label').find(field => field.text() === '币种')!.find('input').setValue('')
|
||||
await wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input').setValue('')
|
||||
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(calls.save.mock.calls[0]![1]))
|
||||
expect(original.operations.money).toBeNull(); expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(calls.command).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',original.idempotencyKey)
|
||||
expect(calls.save).toHaveBeenNthCalledWith(2,'DS-OUT-1',original); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('does not write for an unchanged form or a user without edit permission', async () => {
|
||||
const first = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
|
||||
await first.find('[data-save]').trigger('click'); await flushPromises(); expect(calls.save).not.toHaveBeenCalled(); first.unmount()
|
||||
calls.canEdit = false
|
||||
const readonly = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
|
||||
expect(readonly.find('[data-save]').attributes('disabled')).toBeDefined(); expect(calls.save).not.toHaveBeenCalled(); readonly.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
|
||||
import { newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
|
||||
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessMoney, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { getOutsourcedBusiness } from '../api/outsourcedBusiness'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), pending = ref<OutsourcedBusinessPending | null>(null), error = ref<ApiErrorDetails | null>(null), corrupted = ref(false)
|
||||
const form = reactive({ currency: '', amount: '', reason: '', arrivals: {} as Record<string,string> })
|
||||
const active = computed(() => open.value || Boolean(pending.value))
|
||||
const registration = useUnsavedWorkRegistration({ key: `outsourced-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务运营登记'), isOpen: active, model: reactive({ form, pending }), requiredPermission: computed(() => permission.outsourcedBusinessEdit) })
|
||||
let actor = '', number = '', baseline = ''
|
||||
const writable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
|
||||
const locked = computed(() => busy.value || Boolean(pending.value))
|
||||
function persist() { persistOutsourcedBusinessPending(actor, number, 'operations', pending.value) }
|
||||
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('操作身份或排车单已变化,请重新打开') }
|
||||
watch(busy, value => emit('busy', value))
|
||||
watch(open, async value => {
|
||||
if (!value || !props.detail) return
|
||||
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo; error.value = null; corrupted.value = false
|
||||
try {
|
||||
pending.value = restoreOutsourcedBusinessPending(actor, number, 'operations')
|
||||
const operations = pending.value?.action === 'SAVE_OPERATIONS' ? (pending.value.body as OutsourcedBusinessSave).operations : props.detail.row.operations
|
||||
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', reason: pending.value ? (pending.value.body as OutsourcedBusinessSave).reason || '' : '', arrivals: Object.fromEntries(props.detail.row.destinations.map(destination => [destination.destinationKey, operations.arrivals.find(arrival => arrival.destinationKey === destination.destinationKey)?.actualArrivedAt || ''])) })
|
||||
baseline = JSON.stringify(form); await nextTick(); if (!pending.value) registration.captureBaseline()
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '运营资料恢复失败') }
|
||||
}, { immediate: true })
|
||||
function payload(): OutsourcedBusinessSave {
|
||||
const amount = outsourcedBusinessDecimal(form.amount, 2, 18)
|
||||
let money: OutsourcedBusinessMoney | null = null
|
||||
if (amount != null) { if (!['CNY','USD'].includes(form.currency)) throw new Error('请选择金额币种'); money = { currency: form.currency as OutsourcedBusinessMoney['currency'], amount } }
|
||||
else if (form.currency) throw new Error('金额和币种请成对填写,清空时同时清空两项')
|
||||
const arrivals = props.detail!.row.destinations.map(destination => {
|
||||
const value = form.arrivals[destination.destinationKey] || null
|
||||
if (value && (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))) throw new Error('实际到达时间必须包含有效时区')
|
||||
return { destinationKey: destination.destinationKey, actualArrivedAt: value }
|
||||
})
|
||||
return { recordVersion: outsourcedBusinessVersion(props.detail!.row.recordVersion), idempotencyKey: crypto.randomUUID(), operations: { money, arrivals }, reason: form.reason.trim() || null }
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || !writable.value || !props.detail) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
if (!pending.value) { if (JSON.stringify(form) === baseline) { open.value = false; return }; pending.value = newOutsourcedBusinessPending(actor, number, 'SAVE_OPERATIONS', payload()); persist() }
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.detail) throw new Error('运营登记结果未确认')
|
||||
pending.value = null; persist(); registration.captureBaseline(); emit('saved', result.detail); open.value = false; ElMessage.success('运营信息已保存')
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '运营登记结果尚未确认') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function beforeClose(done: () => void) {
|
||||
if (busy.value) return
|
||||
if (pending.value || JSON.stringify(form) !== baseline) {
|
||||
try { await ElMessageBox.confirm(pending.value ? '原请求将保留,重新打开后可核对原结果。是否关闭?' : '放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
|
||||
}
|
||||
done()
|
||||
}
|
||||
async function discardRejected() {
|
||||
if (!pending.value?.rejected || busy.value) return
|
||||
busy.value = true
|
||||
try { const current = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', current); error.value = null }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '当前运营版本核对失败') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OutsourcedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :pending="Boolean(pending)" :save-disabled="!writable" :before-close="beforeClose" @save="save">
|
||||
<template #state><LoadErrorState v-if="error" title="运营登记未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原请求已保留,字段锁定;核对结果前不会建立新请求。</p><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝结果重新编辑</el-button><p v-if="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
|
||||
<template #amount><el-form label-position="top" class="outsourced-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车承运金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
|
||||
<template #arrivals><div v-for="destination in detail?.row.destinations || []" :key="destination.destinationKey" class="outsourced-business-arrival"><span>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}<small v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}</small></span><el-date-picker v-model="form.arrivals[destination.destinationKey]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" :disabled="locked" placeholder="实际到达时间,未登记留空" /></div><p>时间按浏览器本地时区输入,提交时保留时区。</p></template>
|
||||
</OutsourcedBusinessOperationsDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
|
||||
.outsourced-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) 280px; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
|
||||
.outsourced-business-arrival small { display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { outsourcedBusinessPhotoRoles } from '../outsourcedBusinessTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
dispatchNo?: string
|
||||
busy?: boolean
|
||||
beforeClose?: (done: () => void) => void | Promise<void>
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
function close(done: () => void) {
|
||||
if (props.busy) return
|
||||
if (props.beforeClose) return props.beforeClose(done)
|
||||
done()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
|
||||
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</InlineHelp></p>
|
||||
<slot name="state" />
|
||||
|
||||
<div class="outsourced-business-photo-grid">
|
||||
<section v-for="photo in outsourcedBusinessPhotoRoles" :key="photo.slot" class="outsourced-business-photo-slot workbench-panel is-compact" :aria-label="photo.label">
|
||||
<h3>{{ photo.label }}</h3><slot :name="photo.viewSlot"><p>暂无照片</p></slot>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-photo-reference { overflow-wrap: anywhere; }
|
||||
.outsourced-business-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.outsourced-business-photo-slot { min-width: 0; }
|
||||
.outsourced-business-photo-slot h3 { margin: 0 0 var(--admin-space-3); font-size: inherit; }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OutsourcedBusinessPhotosEditor from './OutsourcedBusinessPhotosEditor.vue'
|
||||
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
|
||||
import type { OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
|
||||
|
||||
const calls=vi.hoisted(()=>({upload:vi.fn(),bind:vi.fn(),command:vi.fn(),detail:vi.fn(),content:vi.fn(),actor:{user:{id:'actor'}},canUpload:true}))
|
||||
vi.mock('../api/outsourcedBusiness',()=>({uploadOutsourcedBusinessPhoto:calls.upload,bindOutsourcedBusinessPhoto:calls.bind,getOutsourcedBusinessCommand:calls.command,getOutsourcedBusiness:calls.detail,getOutsourcedBusinessPhotoContent:calls.content}))
|
||||
vi.mock('@/core/auth/auth',()=>({useAuthStore:()=>calls.actor}))
|
||||
vi.mock('@/core/access/accessPreview',()=>({useAccessPreviewStore:()=>({can:()=>calls.canUpload})}))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration',()=>({useUnsavedWorkRegistration:()=>({captureBaseline:vi.fn()})}))
|
||||
vi.mock('element-plus',()=>({ElMessage:{success:vi.fn()},ElMessageBox:{confirm:vi.fn()}}))
|
||||
const Frame=defineComponent({props:['modelValue','busy','beforeClose'],setup(_props,{slots}){return()=>h('div',Object.values(slots).map(slot=>slot?.()))}})
|
||||
const Button=defineComponent({props:['disabled'],template:'<button :disabled="disabled"><slot /></button>'})
|
||||
const global={stubs:{OutsourcedBusinessPhotosDialog:Frame,ElButton:Button,LoadErrorState:{props:['message'],template:'<div>{{message}}<slot name="details" /></div>'},ApiErrorMeta:true}}
|
||||
const photo:OutsourcedBusinessPhoto={slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null}
|
||||
async function choose(wrapper:ReturnType<typeof mount>){const input=wrapper.find('input[type="file"]');Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['image'],'loading.png',{type:'image/png'})]});await input.trigger('change');await flushPromises()}
|
||||
beforeEach(()=>{
|
||||
vi.clearAllMocks();sessionStorage.clear();calls.actor.user.id='actor';calls.canUpload=true
|
||||
calls.upload.mockResolvedValue(photo);calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
|
||||
const current=outsourcedBusinessFixture();current.row.recordVersion='1';current.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
|
||||
calls.bind.mockResolvedValue(current);calls.detail.mockResolvedValue(current)
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
describe('outsourced business per-slot photo save',()=>{
|
||||
it('retains prepared file and original binding request through a failed bind without retransmitting the image',async()=>{
|
||||
calls.bind.mockRejectedValueOnce(new TypeError('network interrupted'))
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
const original={...calls.bind.mock.calls[0]![2]}
|
||||
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(2)
|
||||
expect(calls.bind.mock.calls[1]![2]).toEqual(original);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('0')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount()
|
||||
})
|
||||
it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{
|
||||
calls.upload.mockRejectedValue(new TypeError('network interrupted'))
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
|
||||
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined();expect(wrapper.text()).toContain('禁止重复上传');wrapper.unmount()
|
||||
})
|
||||
it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{
|
||||
calls.canUpload=false
|
||||
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false);expect(wrapper.text()).toContain('已保存');expect(calls.content).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reads a saved loading photo only on click and releases its URL on closing',async()=>{
|
||||
const create=vi.fn(()=> 'blob:outsourced-loading'),revoke=vi.fn()
|
||||
vi.stubGlobal('URL',class extends URL { static createObjectURL=create; static revokeObjectURL=revoke })
|
||||
calls.canUpload=false;calls.content.mockResolvedValue(new Blob(['image'],{type:'image/png'}))
|
||||
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
|
||||
expect(calls.content).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises()
|
||||
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
|
||||
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
|
||||
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot } from '../outsourcedBusinessTypes'
|
||||
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, uploadOutsourcedBusinessPhoto } from '../api/outsourcedBusiness'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { acceptOutsourcedBusinessResult, newOutsourcedBusinessPending, outsourcedBusinessPendingKey, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null), pending = ref<OutsourcedBusinessPending | null>(null), prepared = ref<OutsourcedBusinessPhoto | null>(null), corrupted = ref(false)
|
||||
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
|
||||
const uploadable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
|
||||
useUnsavedWorkRegistration({ key: `outsourced-business-photos:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务照片作业'), isOpen: computed(() => open.value || Boolean(pending.value) || Boolean(prepared.value)), model: reactive({ pending, prepared }), requiredPermission: computed(() => permission.outsourcedBusinessUpload) })
|
||||
let actor = '', number = '', generation = 0
|
||||
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
|
||||
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
|
||||
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
|
||||
function persist() { persistOutsourcedBusinessPending(actor, number, 'photos', pending.value) }
|
||||
function persistPrepared() {
|
||||
const key = outsourcedBusinessPendingKey(actor, number, 'prepared-photo')
|
||||
if (prepared.value) sessionStorage.setItem(key, JSON.stringify(prepared.value)); else sessionStorage.removeItem(key)
|
||||
}
|
||||
watch(busy, value => emit('busy', value))
|
||||
watch(open, value => {
|
||||
generation++; release(); reading.value = null; error.value = null; corrupted.value = false
|
||||
if (!value || !props.detail) return
|
||||
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo
|
||||
try {
|
||||
pending.value = restoreOutsourcedBusinessPending(actor, number, 'photos')
|
||||
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, number, 'prepared-photo'))
|
||||
prepared.value = raw ? JSON.parse(raw) as OutsourcedBusinessPhoto : null
|
||||
if (prepared.value && (!prepared.value.fileId || !outsourcedBusinessPhotoRoles.some(role => role.slot === prepared.value!.slot))) throw new Error('原上传文件资料损坏,请保留现场核对')
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '照片作业恢复失败') }
|
||||
}, { immediate: true })
|
||||
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
||||
onBeforeUnmount(() => { generation++; release() })
|
||||
async function preview(slot: OutsourcedBusinessPhotoSlot) {
|
||||
const photo = savedPhoto(slot), current = generation
|
||||
if (!photo || reading.value) return
|
||||
reading.value = slot; error.value = null
|
||||
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { if (current === generation) reading.value = null }
|
||||
}
|
||||
async function bindPrepared() {
|
||||
if ((!prepared.value && !pending.value) || !props.detail || !uploadable.value) return
|
||||
if (!pending.value) {
|
||||
const body = { recordVersion: outsourcedBusinessVersion(props.detail.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: prepared.value!.fileId }
|
||||
pending.value = newOutsourcedBusinessPending(actor, number, 'BIND_PHOTO', body, prepared.value!.slot); persist()
|
||||
}
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.detail) throw new Error('照片绑定尚未确认')
|
||||
pending.value = null; persist(); prepared.value = null; persistPrepared(); emit('saved', result.detail); ElMessage.success('照片已绑定保存')
|
||||
}
|
||||
async function recover() {
|
||||
if (busy.value || !uploadable.value) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
if (pending.value?.action === 'UPLOAD_PHOTO') {
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.photo) throw new Error('原上传文件未确认')
|
||||
prepared.value = result.photo; persistPrepared(); pending.value = null; persist()
|
||||
}
|
||||
await bindPrepared()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '照片结果尚未确认') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || pending.value || prepared.value || !uploadable.value) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope(); pending.value = newOutsourcedBusinessPending(actor, number, 'UPLOAD_PHOTO', null, slot); pending.value.attempted = true; persist()
|
||||
const uploaded = await uploadOutsourcedBusinessPhoto(number, slot, file, pending.value.key)
|
||||
ensureScope(); acceptOutsourcedBusinessResult(pending.value, uploaded); persist()
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.photo) throw new Error('真实上传文件未确认')
|
||||
prepared.value = result.photo; persistPrepared(); pending.value = null; persist(); await bindPrepared()
|
||||
} catch (cause) {
|
||||
if (pending.value?.action === 'UPLOAD_PHOTO' && cause instanceof ApiResponseError && ['A0400','A0403','A0404','400','403','404','422'].includes(cause.code)) { pending.value.rejected = true; persist() }
|
||||
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '图片上传或绑定结果尚未确认')
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
async function discardRejected() {
|
||||
if (busy.value || !pending.value?.rejected) return
|
||||
busy.value = true
|
||||
try { const latest = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', latest); error.value = null }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '当前照片版本读取失败') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function beforeClose(done: () => void) {
|
||||
if (busy.value) return
|
||||
if (pending.value || prepared.value) {
|
||||
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
|
||||
}
|
||||
done()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
|
||||
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原{{ pending.action === 'UPLOAD_PHOTO' ? '上传' : '绑定' }}请求已保留,不会重新上传文件。</p><p v-if="prepared">{{ prepared.fileName }} 已上传,尚未确认绑定保存。</p><el-button v-if="pending || prepared" :loading="busy" :disabled="!uploadable" @click="recover">核对原结果并继续绑定</el-button><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝重新核对,保留已上传文件</el-button><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
|
||||
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
|
||||
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
|
||||
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
|
||||
<p v-else>暂无已保存照片</p>
|
||||
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy || Boolean(pending) || Boolean(prepared)" @change="upload(role.slot, $event)" /></label>
|
||||
</template>
|
||||
</OutsourcedBusinessPhotosDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-photo { display: block; width: 100%; max-height: 160px; object-fit: contain; }
|
||||
.outsourced-business-upload { display: block; margin-top: var(--admin-space-3); }
|
||||
.outsourced-business-upload input { display: block; width: 100%; margin-top: var(--admin-space-2); }
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -9,8 +10,8 @@ const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density" spacing="compact">
|
||||
<DetailPageLayout aria-label="自有业务详情">
|
||||
<AdminPage class="admin-density admin-workbench" spacing="compact">
|
||||
<DetailPageLayout aria-label="自有业务详情" class="workbench-panel is-flush is-page-surface">
|
||||
<template #header>
|
||||
<div class="owned-business-detail-heading">
|
||||
<div><strong>自有业务</strong><span class="owned-business-reference">{{ dispatchNo || '—' }}</span><span v-if="statusText">{{ statusText }}</span></div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density" spacing="compact">
|
||||
<AdminPage class="admin-density admin-workbench" spacing="compact">
|
||||
<slot name="statistics" />
|
||||
<ListPageLayout aria-label="自有业务任务列表">
|
||||
<template v-if="$slots.filters" #toolbar><slot name="filters" /></template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -18,18 +20,18 @@ function close(done: () => void) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
|
||||
<p class="owned-business-operation-reference">排车单号 {{ dispatchNo || '—' }}</p>
|
||||
<slot name="state" />
|
||||
<fieldset class="owned-business-operation-section"><legend>整车金额</legend><slot name="amount" /><p>整车只登记一次;单价采用系统返回的总金额与实际物理托数计算结果。</p></fieldset>
|
||||
<fieldset class="owned-business-operation-section"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
|
||||
<fieldset class="owned-business-operation-section"><legend>实际里程与油耗</legend><slot name="measurements" /></fieldset>
|
||||
<p>登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
|
||||
<fieldset class="owned-business-operation-section density-dialog-section" data-tone="work"><legend>整车金额<InlineHelp title="金额口径">整车只登记一次;单价采用系统返回的总金额与实际物理托数计算结果。</InlineHelp></legend><slot name="amount" /></fieldset>
|
||||
<fieldset class="owned-business-operation-section density-dialog-section" data-tone="work"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
|
||||
<fieldset class="owned-business-operation-section density-dialog-section" data-tone="work"><legend>实际里程与油耗</legend><slot name="measurements" /></fieldset>
|
||||
<p class="density-dialog-note">登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.owned-business-operation-reference { overflow-wrap: anywhere; }
|
||||
.owned-business-operation-section { min-width: 0; margin: 0 0 var(--admin-space-4); padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
|
||||
.owned-business-operation-section { min-width: 0; margin: 0 0 var(--admin-space-4); }
|
||||
.owned-business-operation-section legend { padding: 0 var(--admin-space-2); font-weight: 600; }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
@@ -63,7 +64,7 @@ async function save() {
|
||||
async function beforeClose(done: () => void) {
|
||||
if (busy.value) return
|
||||
if (pending.value || JSON.stringify(form) !== baseline) {
|
||||
try { await ElMessageBox.confirm(pending.value ? '原请求将保留,重新打开后可核对原结果。是否关闭?' : '放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
|
||||
try { await ElMessageBox.confirm(pending.value ? '原请求将保留,重新打开后可核对原结果。是否关闭?' : '放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
|
||||
}
|
||||
done()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -25,21 +27,21 @@ function close(done: () => void) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
|
||||
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}</p>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
|
||||
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</InlineHelp></p>
|
||||
<slot name="state" />
|
||||
<p>每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</p>
|
||||
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="loading" label="装车完成">
|
||||
<div class="owned-business-photo-grid">
|
||||
<section v-for="photo in loadingPhotos" :key="photo.slot" class="owned-business-photo-slot" :aria-label="photo.label">
|
||||
<section v-for="photo in loadingPhotos" :key="photo.slot" class="owned-business-photo-slot workbench-panel is-compact" :aria-label="photo.label">
|
||||
<h3>{{ photo.label }}</h3><slot :name="photo.slot"><p>暂无照片</p></slot>
|
||||
</section>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="fuel" label="油耗图片">
|
||||
<div class="owned-business-photo-grid">
|
||||
<section v-for="photo in fuelPhotos" :key="photo.slot" class="owned-business-photo-slot" :aria-label="photo.label">
|
||||
<section v-for="photo in fuelPhotos" :key="photo.slot" class="owned-business-photo-slot workbench-panel is-compact" :aria-label="photo.label">
|
||||
<h3>{{ photo.label }}</h3><slot :name="photo.slot"><p>暂无照片</p></slot>
|
||||
</section>
|
||||
</div>
|
||||
@@ -51,6 +53,6 @@ function close(done: () => void) {
|
||||
<style scoped>
|
||||
.owned-business-photo-reference { overflow-wrap: anywhere; }
|
||||
.owned-business-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.owned-business-photo-slot { min-width: 0; padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
|
||||
.owned-business-photo-slot { min-width: 0; }
|
||||
.owned-business-photo-slot h3 { margin: 0 0 var(--admin-space-3); font-size: inherit; }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
@@ -102,7 +103,7 @@ async function discardRejected() {
|
||||
async function beforeClose(done: () => void) {
|
||||
if (busy.value) return
|
||||
if (pending.value || prepared.value) {
|
||||
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
|
||||
}
|
||||
done()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -55,13 +56,13 @@ onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning(
|
||||
watch(page, load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<DataTable v-model:page="page" :page-size="20" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="transferNo" empty-text="暂无已审核的待取货订单" @selection-change="selected = $event">
|
||||
<div class="workbench-table"><DataTable v-model:page="page" :page-size="20" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="transferNo" empty-text="暂无已审核的待取货订单" @selection-change="selected = $event">
|
||||
<template #filters><QueryToolbar ariaLabel="待取货需求查询" v-model="filters" :fields="[{ key: 'keyword', type: 'input', label: '关键词', placeholder: '转运单号 / 取货地址', width: 300 }]" @search="search" @reset="search" /></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" :disabled="!selected.length" @click="arrange">安排取货({{ selected.length }})</el-button></template>
|
||||
<template #notices><LoadErrorState v-if="error && !open" title="待取货需求加载失败" :message="error" @retry="load" /></template>
|
||||
<template #warehouse="{ row }">{{ row.warehouseName || '海外仓' }} · {{ row.warehouseCode || row.warehouseId }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :before-close="close" title="安排仓储转运取货" width="780px" :confirm-text="pending ? '重试原安排请求' : '创建取货车次'" :confirm-loading="busy" @confirm="save">
|
||||
</DataTable></div>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :before-close="close" title="安排仓储转运取货" width="780px" :confirm-text="pending ? '重试原安排请求' : '创建取货车次'" :confirm-loading="busy" @confirm="save">
|
||||
<p>已选 {{ snapshot.length }} 单:{{ snapshot.map(row => row.transferNo).join('、') }}</p>
|
||||
<el-form label-position="top" class="pickup-plan-form" :disabled="busy || !!pending">
|
||||
<el-form-item label="计划取货发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -49,7 +50,7 @@ const tooLong = computed(() => mapUrl.value.length > 2048 || externalUrl.value.l
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" title="查看路线"
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" title="查看路线"
|
||||
width="min(1100px, 96vw)" align-center :show-confirm="false" cancel-text="关闭">
|
||||
<section v-if="open" class="route-map" aria-label="起点至目的地路线">
|
||||
<strong>{{ props.origin.label || '起点未填写' }} → {{ selected?.label || '目的地未填写' }}<InlineHelp title="路线地图说明"><p>按起点和目的地规划驾车路线,不显示实时位置或实际行驶轨迹。</p></InlineHelp></strong>
|
||||
@@ -75,7 +76,7 @@ const tooLong = computed(() => mapUrl.value.length > 2048 || externalUrl.value.l
|
||||
|
||||
<style scoped>
|
||||
.route-map { display: grid; gap: var(--admin-space-3); }
|
||||
.route-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.route-map iframe { width: 100%; height: 58vh; min-height: 320px; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
|
||||
.route-help { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); color: var(--cc-muted); }
|
||||
.route-note { margin: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.route-map iframe { width: 100%; height: 58vh; min-height: 320px; border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); }
|
||||
.route-help { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); color: var(--workbench-muted); }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -110,7 +111,7 @@ async function save() {
|
||||
}))) { ElMessage.warning('拆合托后的来源须重新填写该票货物在每托的实测重量和体积,不沿用旧托测量'); return }
|
||||
if (receiptChanged.value) {
|
||||
busy.value = true
|
||||
try { await ElMessageBox.confirm(`当前实托、货量、托位或约定价格存在变化,确认按当前完整实收和 ${amount.value?.toFixed(2)} ${booking.cargo.currency || ''} 核实?容量仍由服务端复核,超容量不会保存。`, '确认实收差异', { customClass: 'admin-density-confirm', confirmButtonText: '确认当前实收与运费', cancelButtonText: '返回核对', type: 'warning' }) }
|
||||
try { await ElMessageBox.confirm(`当前实托、货量、托位或约定价格存在变化,确认按当前完整实收和 ${amount.value?.toFixed(2)} ${booking.cargo.currency || ''} 核实?容量仍由服务端复核,超容量不会保存。`, '确认实收差异', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '确认当前实收与运费', cancelButtonText: '返回核对', type: 'warning' }) }
|
||||
catch { return }
|
||||
finally { busy.value = false }
|
||||
if (current !== generation || !receiptAllowed.value) return
|
||||
@@ -132,7 +133,7 @@ async function save() {
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && (!receiptAllowed || (!pending && !ready)))" :confirm-loading="busy" @confirm="save">
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog admin-workbench is-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && (!receiptAllowed || (!pending && !ready)))" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="booking" v-loading="loading">
|
||||
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p><p v-if="pending" role="status">本票上次提交结果尚未确定,保留原内容与请求键重试;不会改为处理另一票货物。</p>
|
||||
@@ -144,7 +145,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
<template v-if="mode === 'receipt' || mode === 'view'">
|
||||
<p v-if="mode === 'receipt' && !receiptAllowed" role="alert">当前车次或预订不能核对实收,请刷新并确认车次仍在计划中、预订未绑定排车且具有核对实收权限。</p>
|
||||
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ incomingStatusLabel }}</p>
|
||||
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template><template v-for="field in (['weightKg', 'volumeCbm'] as const)" #[field]="{ row }"><div v-if="row[field] == null" class="receipt-spaces-control"><el-input-number v-model="measurements[field][row.warehousePalletId]" :min="0.001" :max="999999999.999" :precision="3" :disabled="busy || !!pending" :aria-label="`${row.palletNo} ${field === 'weightKg' ? '实测重量' : '实测体积'}`" placeholder="重新实测该票货物" /></div><span v-else>{{ row[field] }}</span></template></DataTable>
|
||||
<div class="workbench-table"><DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template><template v-for="field in (['weightKg', 'volumeCbm'] as const)" #[field]="{ row }"><div v-if="row[field] == null" class="receipt-spaces-control"><el-input-number v-model="measurements[field][row.warehousePalletId]" :min="0.001" :max="999999999.999" :precision="3" :disabled="busy || !!pending" :aria-label="`${row.palletNo} ${field === 'weightKg' ? '实测重量' : '实测体积'}`" placeholder="重新实测该票货物" /></div><span v-else>{{ row[field] }}</span></template></DataTable></div>
|
||||
<p v-if="mode === 'receipt'">托盘组成变化后须填写该票外接货的当前实测重量、体积;未变化托盘只读沿用原收货证据,不填写整托混合货的总量。</p><section v-if="mode === 'receipt'" aria-label="预报与实收对照">
|
||||
<p>{{ booking.status === 'RECEIVED' && booking.receipt ? '上次核实与当前实收对照' : '预报与实收对照' }};差异须明确确认,容量与费用仍由服务端核对。</p>
|
||||
<p v-if="booking.status !== 'CONFIRMED' && !booking.receipt">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -129,7 +130,7 @@ async function command(row: TripBooking, action: 'confirm' | 'cancel') {
|
||||
if (pendingCommand.value && (pendingCommand.value.bookingNo !== row.bookingNo || pendingCommand.value.action !== action)) { ElMessage.warning('请先重试上一条操作或刷新核对'); return }
|
||||
const current = generation
|
||||
if (!pendingCommand.value) {
|
||||
try { await ElMessageBox.confirm(action === 'confirm' ? `确认承运 ${row.bookingNo},按约定货量预留本车容量?` : `取消 ${row.bookingNo},释放尚未执行的预订容量?已到仓货物继续保留在仓库。`, action === 'confirm' ? '确认外接承运' : '取消外接预订', { customClass: 'admin-density-confirm' }) }
|
||||
try { await ElMessageBox.confirm(action === 'confirm' ? `确认承运 ${row.bookingNo},按约定货量预留本车容量?` : `取消 ${row.bookingNo},释放尚未执行的预订容量?已到仓货物继续保留在仓库。`, action === 'confirm' ? '确认外接承运' : '取消外接预订', { customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
|
||||
catch { return }
|
||||
if (current !== generation) return
|
||||
pendingCommand.value = { bookingNo: row.bookingNo, action, body: { tripRecordVersion: version.value, recordVersion: row.recordVersion, idempotencyKey: crypto.randomUUID() } }
|
||||
@@ -150,15 +151,15 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
|
||||
<div>
|
||||
|
||||
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
|
||||
<div class="workbench-table"><DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
|
||||
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button><InlineHelp title="外接预订说明"><p>询价不占位,确认承运后预留容量。实收时核对运费,转派送后按确认报价登记应收,付款状态以财务为准。</p></InlineHelp></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="canConfirmReceipt(row as TripBooking)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
|
||||
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
|
||||
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${row.agreedAmount.toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" destroy-on-close :title="readOnly ? '外接预订详情' : editing ? '编辑外接询价' : '登记外接询价'" :show-confirm="!readOnly" width="1080px" :confirm-text="pendingCreate ? '重试原登记' : '保存询价'" :confirm-loading="busy" @confirm="save">
|
||||
</DataTable></div>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" destroy-on-close :title="readOnly ? '外接预订详情' : editing ? '编辑外接询价' : '登记外接询价'" :show-confirm="!readOnly" width="1080px" :confirm-text="pendingCreate ? '重试原登记' : '保存询价'" :confirm-loading="busy" @confirm="save">
|
||||
<p v-if="editing?.offerNo">池来源意向 {{ editing.offerNo }},请从拼车需求池管理询价与承运。</p><el-form label-position="top" class="density-form booking-form" :disabled="readOnly || busy || !!pendingCreate">
|
||||
<el-form-item label="委托客户" required><el-select v-model="customerId" filterable remote :remote-method="searchCustomers" :loading="customersLoading" :disabled="!!editing" placeholder="按客户名称、编号搜索" @focus="searchCustomers()"><el-option v-if="editing" :value="editing.customerId" :label="editing.customerName" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.destinationKey" placeholder="选择顺路卸货站"><el-option v-for="stop in trip.stops.slice(1).filter(row => row.destinationKey)" :key="stop.destinationKey!" :value="stop.destinationKey!" :label="stop.name" /></el-select></el-form-item>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -148,22 +149,22 @@ onBeforeUnmount(() => { scopeGeneration++; generation++; historyGeneration++ })
|
||||
<div v-for="value in summary.totals" :key="value.currency" class="cost-summary">
|
||||
<strong>{{ value.currency }} · 已登记直接成本 {{ value.directCost.toFixed(2) }}</strong>
|
||||
<span>自有货分摊 {{ money(value.ownCost) }} · 外接货分摊 {{ money(value.externalCost) }}</span>
|
||||
<DataTable v-if="value.allocations.length" :data="value.allocations" :columns="allocationColumns" row-key="businessNo">
|
||||
<div class="workbench-table" v-if="value.allocations.length"><DataTable :data="value.allocations" :columns="allocationColumns" row-key="businessNo">
|
||||
<template #source="{ row }">{{ row.sourceType === 'EXTERNAL' ? '外接货' : '自有业务货物' }}</template>
|
||||
<template #amount="{ row }">{{ value.currency }} {{ row.amount.toFixed(2) }}</template>
|
||||
</DataTable>
|
||||
</DataTable></div>
|
||||
</div>
|
||||
</template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="costNo" empty-text="暂无直接成本记录">
|
||||
<div class="workbench-table"><DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="costNo" empty-text="暂无直接成本记录">
|
||||
<template #toolbar-actions><el-button :disabled="loading || saving" @click="load">刷新</el-button><el-button v-if="access.can(permission.tripCostCreate)" type="primary" :disabled="opening || saving" @click="beginEdit(null)">登记成本</el-button><InlineHelp title="直接成本说明"><p>按实际费用登记,各币种分别汇总。运营分摊用于单趟分析,不代表已付款,也不生成客户账单。</p></InlineHelp></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions()" @action="$event === 'edit' ? beginEdit(row as TripCost) : showHistory(row as TripCost)" /></template>
|
||||
<template #category="{ row }">{{ categories[row.details.category as CostCategory] }}</template>
|
||||
<template #amount="{ row }">{{ row.details.currency }} {{ row.details.amount.toFixed(2) }}</template>
|
||||
<template #time="{ row }">{{ time(row.details.incurredAt) }}</template>
|
||||
<template #remark="{ row }">{{ row.details.remark || '—' }}</template>
|
||||
</DataTable>
|
||||
</DataTable></div>
|
||||
</template>
|
||||
<AppDialog v-model="editorOpen" :title="editing ? '更正车次直接成本' : '登记车次直接成本'" width="680px" class="admin-density-dialog" confirm-text="保存成本" :confirm-loading="saving" :confirm-disabled="!writable" @confirm="save">
|
||||
<AppDialog v-model="editorOpen" :title="editing ? '更正车次直接成本' : '登记车次直接成本'" width="680px" class="admin-density-dialog admin-workbench is-dialog" confirm-text="保存成本" :confirm-loading="saving" :confirm-disabled="!writable" @confirm="save">
|
||||
<LoadErrorState v-if="saveError" title="成本保存未完成" :message="saveError.message" @retry="save"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="pendingCreate" class="cost-note">正在核对上次登记结果,重试会使用同一请求,不重复记账。</p>
|
||||
<el-form label-position="top" :disabled="saving || !writable || !!pendingCreate" class="cost-form">
|
||||
@@ -175,16 +176,16 @@ onBeforeUnmount(() => { scopeGeneration++; generation++; historyGeneration++ })
|
||||
<el-form-item v-if="editing" label="更正说明(选填)" class="cost-wide"><el-input v-model="reason" maxlength="1000" placeholder="补充本次更正原因" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="historyOpen" :title="`${historyNo} · 成本历史`" width="1080px" class="admin-density-dialog" :show-confirm="false" cancel-text="关闭">
|
||||
<AppDialog v-model="historyOpen" :title="`${historyNo} · 成本历史`" width="1080px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭">
|
||||
<LoadErrorState v-if="historyError" title="成本历史加载失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState>
|
||||
<DataTable v-model:page="historyPage" :page-size="10" :data="histories" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="recordVersion">
|
||||
<div class="workbench-table"><DataTable v-model:page="historyPage" :page-size="10" :data="histories" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="recordVersion">
|
||||
<template #time="{ row }">{{ time(row.occurredAt) }}</template>
|
||||
<template #action="{ row }">{{ row.action === 'CREATE' ? '登记' : '更正' }}</template>
|
||||
<template #amount="{ row }">{{ row.snapshot.cost.details.currency }} {{ row.snapshot.cost.details.amount.toFixed(2) }}</template>
|
||||
</DataTable>
|
||||
</DataTable></div>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.cost-note{color:var(--cc-muted);margin:0 0 16px}.cost-summary{display:grid;gap:12px;margin:0 0 20px}.cost-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 16px}.cost-wide{grid-column:1/-1}.cost-form :deep(.el-select),.cost-form :deep(.el-input-number),.cost-form :deep(.el-date-editor){width:100%}
|
||||
.cost-note{color:var(--workbench-muted);margin:0 0 16px}.cost-summary{display:grid;gap:12px;margin:0 0 20px}.cost-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 16px}.cost-wide{grid-column:1/-1}.cost-form :deep(.el-select),.cost-form :deep(.el-input-number),.cost-form :deep(.el-date-editor){width:100%}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -168,13 +169,13 @@ onBeforeUnmount(() => { scope++; query++; previewQuery++; vehicleQuery++; driver
|
||||
<el-button v-if="preview || pending" type="primary" :loading="busy" @click="confirm">{{ pending ? '重试原交接请求' : '确认实际交接' }}</el-button>
|
||||
</div>
|
||||
<div v-if="commandError" role="alert"><p>{{ commandError.message }}</p><ApiErrorMeta :details="commandError" /></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="handoverNo" empty-text="暂无在途交接记录">
|
||||
<div class="workbench-table"><DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="handoverNo" empty-text="暂无在途交接记录">
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row as HandoverRecord)" @action="show(row as HandoverRecord, $event)" /></template>
|
||||
<template #time="{ row }">{{ time(row.snapshot.handedOverAt) }}</template>
|
||||
<template #vehicles="{ row }">{{ row.snapshot.previous.vehiclePlate || '—' }} → {{ row.snapshot.replacement.vehiclePlate || '—' }}</template>
|
||||
<template #drivers="{ row }">{{ row.snapshot.previous.driverName || '—' }} → {{ row.snapshot.replacement.driverName || '—' }}</template>
|
||||
<template #location="{ row }">{{ row.snapshot.location }}</template>
|
||||
</DataTable>
|
||||
</DataTable></div>
|
||||
<div v-if="selected" class="handover-detail">
|
||||
<h4>交接记录 {{ selected.handoverNo }}</h4>
|
||||
<p>{{ selected.snapshot.reason }} · {{ selected.snapshot.location }}</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -95,14 +96,14 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<p v-if="info?.blockReason" role="status">{{ info.blockReason }}</p>
|
||||
<p v-else-if="!planning && savedLoad">已确认实际装载;车辆、司机或计划调整后需要重新确认。</p>
|
||||
<p v-else-if="!planning">按实物托盘核对,同一托盘中的多客户货物只计一次。发车仍由海外 PDA 完成。</p>
|
||||
<DataTable class="trip-loading-table" :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
|
||||
<div class="workbench-table"><DataTable class="trip-loading-table" :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
|
||||
<template #palletNo="{ row }"><span class="trip-pallet-code">{{ row.palletNo }}</span></template>
|
||||
<template #spaces="{ row }"><el-input-number v-model="row.palletSpaces" :min="1" :precision="0" :disabled="!editable || busy" placeholder="占用托位" /></template>
|
||||
<template #weight="{ row }"><el-input-number v-model="row.weightKg" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计重量' : '实际重量'" /></template>
|
||||
<template #volume="{ row }"><el-input-number v-model="row.volumeCbm" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计体积' : '实际体积'" /></template>
|
||||
<template #stop="{ row }"><span v-if="matchingStops(row as PalletRow).length === 1">{{ matchingStops(row as PalletRow)[0]?.name }}</span><el-select v-else v-model="row.deliveryIndex" :disabled="!editable || busy || loading || !matchingStops(row as PalletRow).length" placeholder="请先匹配货物目的地"><el-option v-for="stop in matchingStops(row as PalletRow)" :key="stop.index" :value="stop.index" :label="stop.name" /></el-select></template>
|
||||
<template v-if="!embedded" #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="loading || !!info?.blockReason || !!destinationIssue || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
|
||||
</DataTable>
|
||||
</DataTable></div>
|
||||
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import type { EChartsOption, LegendComponentOption, PieSeriesOption } from 'echarts'
|
||||
import TripOperationsCharts from './TripOperationsCharts.vue'
|
||||
import { adminWorkbenchChartTheme as chartTheme } from '@/styles/adminWorkbenchChartTheme'
|
||||
import type { TripOperationsCharts as Charts, TripOperationsSummary } from '../api/tripOperations'
|
||||
|
||||
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option', 'initOptions', 'updateOptions', 'autoresize'], template: '<div class="echarts-pie-stub"/>' } }))
|
||||
@@ -44,8 +45,8 @@ it('uses four ECharts pies with compact native legends, accurate server totals a
|
||||
expect(typeof legend.formatter === 'function' && legend.formatter('2026-10-02')).toBe('10-02')
|
||||
expect(option(wrapper, '.chart--pallets').tooltip).toMatchObject({ trigger: 'item', confine: true, renderMode: 'richText' })
|
||||
expect(series(option(wrapper, '.chart--rates')).data).toEqual([
|
||||
{ name: '2026-10-02', value: 90, itemStyle: { color: '#3b82f6' } },
|
||||
{ name: '2026-10-07', value: 30, itemStyle: { color: '#14b8a6' } },
|
||||
{ name: '2026-10-02', value: 90, itemStyle: { color: chartTheme.colors.primary } },
|
||||
{ name: '2026-10-07', value: 30, itemStyle: { color: chartTheme.colors.teal } },
|
||||
])
|
||||
expect(charts).toEqual(original)
|
||||
})
|
||||
@@ -60,7 +61,7 @@ it('switches currency without combining monetary amounts or leaving stale EChart
|
||||
await usd.trigger('click')
|
||||
expect(usd.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('USD 100.00')
|
||||
expect(series(option(wrapper, '.chart--amounts')).data).toEqual([{ name: '2026-10-03', value: 100, itemStyle: { color: '#3b82f6' } }])
|
||||
expect(series(option(wrapper, '.chart--amounts')).data).toEqual([{ name: '2026-10-03', value: 100, itemStyle: { color: chartTheme.colors.primary } }])
|
||||
await wrapper.setProps({ charts: { ...charts, externalAgreedAmounts: [charts.externalAgreedAmounts[0]!] } })
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { adminWorkbenchChartTheme as chartTheme } from '@/styles/adminWorkbenchChartTheme'
|
||||
import { computed, ref } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
@@ -12,7 +14,7 @@ use([PieChart, LegendComponent, TooltipComponent, GraphicComponent, AriaComponen
|
||||
const props = withDefaults(defineProps<{ charts: TripOperationsCharts; summary: TripOperationsSummary; detailsVisible?: boolean }>(), { detailsVisible: false })
|
||||
const emit = defineEmits<{ 'show-details': [] }>()
|
||||
const selectedSeries = ref<Record<string, string>>({})
|
||||
const palette = ['#3b82f6', '#14b8a6', '#f59e0b', '#8b5cf6', '#f46565', '#06b6d4', '#ec4899', '#84cc16', '#6366f1', '#f97316']
|
||||
const palette = [chartTheme.colors.primary, chartTheme.colors.teal, chartTheme.colors.amber, chartTheme.colors.purple, chartTheme.colors.rose, chartTheme.colors.tealLight, chartTheme.colors.green, chartTheme.colors.primaryLight, chartTheme.colors.unknown, chartTheme.colors.text]
|
||||
const sections = computed(() => [
|
||||
{ key: 'pallets', title: '实运托盘', note: '按实际发车日(UTC)分布,物理托盘去重', series: props.charts.physicalPallets, money: false },
|
||||
{ key: 'destinations', title: '派送目的地', note: '按目的地涉及车次分布,同车多站可分别计入', series: props.charts.destinationTrips, money: false },
|
||||
@@ -37,8 +39,10 @@ function pieOption(series: TripOperationsSeries, money: boolean): EChartsOption
|
||||
const hasPositive = series.points.some(point => point.value > 0)
|
||||
return {
|
||||
animation: false,
|
||||
textStyle: { fontFamily: chartTheme.fontFamily },
|
||||
aria: { enabled: true, description: `${seriesTitle(series)},完整日期、图例和数值可在统计详情读取` },
|
||||
tooltip: {
|
||||
...chartTheme.tooltip,
|
||||
trigger: 'item', confine: true, renderMode: 'richText',
|
||||
formatter: params => {
|
||||
if (Array.isArray(params)) return ''
|
||||
@@ -48,20 +52,20 @@ function pieOption(series: TripOperationsSeries, money: boolean): EChartsOption
|
||||
legend: {
|
||||
type: 'plain', left: 'center', bottom: 4, width: series.points.length > 5 ? '82%' : '96%',
|
||||
icon: 'circle', itemWidth: 9, itemHeight: 9, itemGap: 10, selectedMode: false,
|
||||
textStyle: { color: '#8492a6', fontSize: 12 },
|
||||
textStyle: { color: chartTheme.colors.muted, fontSize: 12 },
|
||||
data: series.points.slice(0, 5).map(point => point.label), formatter: legendLabel,
|
||||
tooltip: { show: true, confine: true },
|
||||
},
|
||||
series: [{
|
||||
name: seriesTitle(series), type: 'pie', radius: 64, center: ['50%', 72],
|
||||
label: { show: false }, labelLine: { show: false },
|
||||
itemStyle: { borderColor: '#fff', borderWidth: 2 },
|
||||
itemStyle: { borderColor: chartTheme.colors.surface, borderWidth: 2 },
|
||||
emphasis: { scale: false, focus: 'self' },
|
||||
stillShowZeroSum: false, showEmptyCircle: true,
|
||||
emptyCircleStyle: { color: '#f3f4f6', borderWidth: 0 },
|
||||
emptyCircleStyle: { color: chartTheme.colors.primarySoft, borderWidth: 0 },
|
||||
data: series.points.map((point, index) => ({ name: point.label, value: point.value, itemStyle: { color: color(index) } })),
|
||||
}],
|
||||
graphic: hasPositive ? [] : [{ type: 'text', left: 'center', top: 66, style: { text: '暂无正值数据', fill: '#8492a6', fontSize: 12 } }],
|
||||
graphic: hasPositive ? [] : [{ type: 'text', left: 'center', top: 66, style: { text: '暂无正值数据', fill: chartTheme.colors.muted, fontSize: 12 } }],
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -95,7 +99,7 @@ function pieOption(series: TripOperationsSeries, money: boolean): EChartsOption
|
||||
<p v-else class="chart-empty">暂无对应统计</p>
|
||||
</article>
|
||||
</section>
|
||||
<section v-if="detailsVisible && hasPoints" class="chart-details" aria-label="完整统计明细">
|
||||
<section v-if="detailsVisible && hasPoints" class="chart-details workbench-evidence" aria-label="完整统计明细">
|
||||
<div class="chart-details-grid">
|
||||
<section v-for="section in sections" :key="section.key" :aria-label="`${section.title}明细`">
|
||||
<h3>{{ section.title }}</h3>
|
||||
@@ -116,13 +120,13 @@ function pieOption(series: TripOperationsSeries, money: boolean): EChartsOption
|
||||
<style scoped>
|
||||
.operations-visualizations { display: grid; gap: var(--admin-space-3); min-width: 0; }
|
||||
.operations-charts, .chart-details-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-6); }
|
||||
.chart { min-width: 0; padding-right: var(--admin-space-6); border-right: 1px solid var(--cc-border); color: var(--cc-text); text-align: center; }
|
||||
.chart { min-width: 0; padding-right: var(--admin-space-6); border-right: 1px solid var(--workbench-border); color: var(--workbench-text); text-align: center; }
|
||||
.chart:last-child { padding-right: 0; border-right: 0; }
|
||||
.chart h3, .chart-details h3 { margin: 0 0 var(--admin-space-2); font-size: var(--admin-font-section); }
|
||||
.chart-metric { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: var(--admin-space-1) var(--admin-space-5); margin-bottom: var(--admin-space-1); font-variant-numeric: tabular-nums; }
|
||||
.chart-metric > strong { font-size: calc(var(--admin-font-title) * 1.25); overflow-wrap: anywhere; }
|
||||
.chart-metric small { font-size: var(--admin-font-caption); font-weight: normal; color: var(--cc-muted); }
|
||||
.chart-note, .chart-empty { margin: 0; font-size: var(--admin-font-caption); color: var(--cc-muted); line-height: 1.6; }
|
||||
.chart-metric small { font-size: var(--admin-font-caption); font-weight: normal; color: var(--workbench-muted); }
|
||||
.chart-note, .chart-empty { margin: 0; font-size: var(--admin-font-caption); color: var(--workbench-muted); line-height: 1.6; }
|
||||
.chart-empty { display: grid; min-height: 136px; place-content: center; }
|
||||
.series-selector { display: flex; justify-content: center; flex-wrap: wrap; gap: var(--admin-space-2); margin-top: var(--admin-space-2); }
|
||||
.series-selector :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
@@ -133,8 +137,8 @@ function pieOption(series: TripOperationsSeries, money: boolean): EChartsOption
|
||||
.chart-details-grid { margin-top: var(--admin-space-5); }
|
||||
.chart-details-grid > section { min-width: 0; }
|
||||
.chart-details table { width: 100%; table-layout: fixed; }
|
||||
.chart-details caption { text-align: left; padding: var(--admin-space-2) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.chart-details caption { text-align: left; padding: var(--admin-space-2) 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.chart-details td { overflow-wrap: anywhere; }
|
||||
@media (max-width: 1100px) { .operations-charts, .chart-details-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .chart:nth-child(2n) { padding-right: 0; border-right: 0; } }
|
||||
@media (max-width: 720px) { .operations-charts, .chart-details-grid { grid-template-columns: minmax(0, 1fr); } .chart { padding-right: 0; padding-bottom: var(--admin-space-5); border-right: 0; border-bottom: 1px solid var(--cc-border); } .chart:last-child { padding-bottom: 0; border-bottom: 0; } }
|
||||
@media (max-width: 720px) { .operations-charts, .chart-details-grid { grid-template-columns: minmax(0, 1fr); } .chart { padding-right: 0; padding-bottom: var(--admin-space-5); border-right: 0; border-bottom: 1px solid var(--workbench-border); } .chart:last-child { padding-bottom: 0; border-bottom: 0; } }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -81,7 +82,7 @@ async function save() {
|
||||
watch([open, () => props.row?.tripNo], () => { sequence++; clearPreview(); uploading.value = false; saving.value = false; uploadUncertain.value = false; uncertain.value = false; form.evidenceFiles = []; record.value = null; tab.value = 'measured'; history.value = []; page.value = 1; total.value = 0; if (open.value) void load() })
|
||||
watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') void loadHistory(); else if (tab.value === 'measured') void load() }); onBeforeUnmount(() => { sequence++; clearPreview() })
|
||||
</script>
|
||||
<template><AppDialog v-model="open" title="出车实测记录" width="820px" class="admin-density-dialog" :close-on-click-modal="false">
|
||||
<template><AppDialog v-model="open" title="出车实测记录" width="820px" class="admin-density-dialog admin-workbench is-dialog" :close-on-click-modal="false">
|
||||
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /><el-button @click="tab === 'history' ? loadHistory() : load()">重新读取</el-button></el-alert>
|
||||
<p v-if="row">{{ row.tripNo }} · {{ row.vehiclePlate || '—' }} · 实际发车 {{ row.departedAt }} · 货物结案 {{ row.completedAt || '—' }}</p>
|
||||
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs"><el-tab-pane label="实测记录" name="measured">
|
||||
@@ -100,7 +101,7 @@ watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') vo
|
||||
<p v-if="uploading">正在上传 {{ uploadName }}…</p>
|
||||
<p v-if="uploadUncertain">{{ uploadName }} 的上传结果未确认,可能已上传但尚未绑定。请先核对,不要重复上传。<el-button :disabled="uploading" @click="omitUpload">暂不绑定此附件</el-button></p>
|
||||
|
||||
</el-tab-pane><el-tab-pane label="更正历史" name="history"><DataTable v-model:page="page" v-model:page-size="size" :data="history" :columns="columns" :total="total" :loading="loading" row-key="recordVersion" empty-text="暂无实测更正历史"><template #distance="{ row: item }">{{ item.details.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row: item }">{{ item.details.actualFuelLitres ?? '—' }}</template><template #remark="{ row: item }">{{ item.details.remark || '—' }}</template><template #evidence="{ row: item }"><el-button v-for="file in item.details.evidenceFiles" :key="file.fileId" :disabled="previewLoading" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!item.details.evidenceFiles.length">—</span></template></DataTable></el-tab-pane></el-tabs>
|
||||
</el-tab-pane><el-tab-pane label="更正历史" name="history"><div class="workbench-table"><DataTable v-model:page="page" v-model:page-size="size" :data="history" :columns="columns" :total="total" :loading="loading" row-key="recordVersion" empty-text="暂无实测更正历史"><template #distance="{ row: item }">{{ item.details.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row: item }">{{ item.details.actualFuelLitres ?? '—' }}</template><template #remark="{ row: item }">{{ item.details.remark || '—' }}</template><template #evidence="{ row: item }"><el-button v-for="file in item.details.evidenceFiles" :key="file.fileId" :disabled="previewLoading" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!item.details.evidenceFiles.length">—</span></template></DataTable></div></el-tab-pane></el-tabs>
|
||||
<section v-if="previewUrl && previewFile" aria-label="实测凭证预览"><el-button @click="clearPreview">关闭预览</el-button><iframe v-if="previewFile.contentType === 'application/pdf'" :src="previewUrl" :title="previewFile.fileName" class="evidence-preview" /><img v-else :src="previewUrl" :alt="previewFile.fileName" class="evidence-preview" /></section>
|
||||
<template #footer><el-button :disabled="saving" @click="open = false">关闭</el-button><el-button v-if="!readonly && tab === 'measured'" type="primary" :loading="saving" :disabled="!record || loading || uploading || uploadUncertain" @click="save">{{ uncertain ? '按原请求重试' : '保存记录' }}</el-button></template>
|
||||
</AppDialog></template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
@@ -82,7 +83,7 @@ async function execute() {
|
||||
async function command(action: 'depart' | 'arrive' | 'complete', row: PickupTask | null = null) {
|
||||
if (pending.value || busy.value || needsTripRefresh.value) return
|
||||
const messages = { depart: '确认车辆和司机已出发执行本车取货任务?', arrive: '确认该单货物已送达选定海外仓?确认后仓库可开始 PDA 清点。', complete: '确认本车全部取货订单已送仓,结束本次取货运输?' }
|
||||
try { await ElMessageBox.confirm(messages[action], '确认取货运输', { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
try { await ElMessageBox.confirm(messages[action], '确认取货运输', { customClass: 'admin-density-confirm admin-workbench is-dialog' }) } catch { return }
|
||||
pending.value = { action, row, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), reason: messages[action] } }
|
||||
await execute()
|
||||
}
|
||||
@@ -114,12 +115,12 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<el-button v-if="pending && !open" type="primary" :loading="busy" @click="execute">重试原操作</el-button>
|
||||
</div>
|
||||
<LoadErrorState v-if="error && !open" title="取货操作未完成" :message="error" @retry="pending ? execute() : load()" />
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="transferNo" empty-text="暂无取货成员">
|
||||
<div class="workbench-table"><DataTable :data="rows" :columns="columns" :loading="loading" row-key="transferNo" empty-text="暂无取货成员">
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'ASSIGNED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="edit(row as PickupTask)">确认取货</el-button><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'PICKED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="command('arrive', row as PickupTask)">确认送仓</el-button></div></template>
|
||||
<template #status="{ row }">{{ pickupStatusLabels[row.status] || row.status }}</template><template #actual="{ row }">{{ row.actualPackages ?? '待取货' }}</template>
|
||||
<template #evidence="{ row }"><div v-for="file in row.evidence" :key="file.fileId"><el-button link @click="download(row as PickupTask, file)">{{ file.fileName }}</el-button></div></template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup">
|
||||
</DataTable></div>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup">
|
||||
<p>{{ selected?.transferNo }} · {{ selected?.pickupAddress }}</p>
|
||||
<el-form label-position="top" :disabled="busy || !!pending">
|
||||
<el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :max="5000" :precision="0" placeholder="请输入实提箱数" /></el-form-item>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -60,7 +61,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="trip ? '编辑车次计划' : '新建车次'" width="1080px" confirm-text="保存计划" :confirm-loading="saving" @confirm="save">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="trip ? '编辑车次计划' : '新建车次'" width="1080px" confirm-text="保存计划" :confirm-loading="saving" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="计划保存失败" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="warehouseError && !trip" title="起运仓加载失败" :message="warehouseError.message" @retry="loadWarehouses" />
|
||||
<p v-if="pending">上次保存结果尚未确定,再次保存会核对原请求。</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -66,7 +67,7 @@ async function save() {
|
||||
onBeforeUnmount(() => { generation++; searchSequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" title="安排车次" width="1080px" :before-close="guard.beforeClose" :show-confirm="!result" :confirm-text="pending ? '重试原请求' : '确认安排'" :confirm-loading="busy" @confirm="save">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" title="安排车次" width="1080px" :before-close="guard.beforeClose" :show-confirm="!result" :confirm-text="pending ? '重试原请求' : '确认安排'" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="安排未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p>{{ pending ? submittedSummary?.warehouseName : warehouseName }} · 已选 {{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.length }} 张排车单:{{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.join('、') }}</p>
|
||||
<p v-if="pending">提交结果尚未确定,请保留原请求重试,不另建车次。</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -96,7 +97,7 @@ onBeforeUnmount(() => { editSession++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="`${current ? '编辑' : '新增'}${vehicleCareLabels[recordType]}`" :width="meterRecord ? '680px' : '620px'" confirm-text="保存记录" :confirm-loading="saving" :confirm-disabled="!canEdit && !selected.length" @confirm="save">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="`${current ? '编辑' : '新增'}${vehicleCareLabels[recordType]}`" :width="meterRecord ? '680px' : '620px'" confirm-text="保存记录" :confirm-loading="saving" :confirm-disabled="!canEdit && !selected.length" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="保存未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="pendingCreate && !current" class="care-hint">正在核对原保存结果,请保留当前窗口并重试。</p>
|
||||
<el-form label-position="top" class="density-form care-form" :disabled="locked" @submit.prevent="save">
|
||||
@@ -136,5 +137,5 @@ onBeforeUnmount(() => { editSession++ })
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
.care-form :deep(.el-select), .care-form :deep(.el-input-number), .care-form :deep(.el-date-editor) { width: 100%; }
|
||||
.file-picker { display: grid; gap: var(--admin-space-3); }
|
||||
.care-hint { margin: var(--admin-space-3) 0; color: var(--cc-muted); }
|
||||
.care-hint { margin: var(--admin-space-3) 0; color: var(--workbench-muted); }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -185,15 +186,15 @@ onBeforeUnmount(() => { disposed = true; epoch++; readRequest++; recordsRequest+
|
||||
</template>
|
||||
</template>
|
||||
<LoadErrorState v-if="recordsError" title="财务记录读取失败" :message="recordsError.message" @retry="loadRecords"><template #details><ApiErrorMeta :details="recordsError" /></template></LoadErrorState>
|
||||
<DataTable v-if="tab === 'history'" v-model:page="historyPage" :page-size="10" :data="history" :columns="historyColumns" :total="historyTotal" :loading="recordsLoading" row-key="recordVersion">
|
||||
<div class="workbench-table" v-if="tab === 'history'"><DataTable v-model:page="historyPage" :page-size="10" :data="history" :columns="historyColumns" :total="historyTotal" :loading="recordsLoading" row-key="recordVersion">
|
||||
<template #action="{ row }">{{ ({ RECEIVE: '接收', APPROVE: '入账', VOID: '作废', PAY: '付款' } as Record<string, string>)[row.action] || '—' }}</template>
|
||||
<template #time="{ row }">{{ time(row.occurredAt) }}</template><template #amount="{ row }">{{ money(row.snapshot.source.amount) }}</template>
|
||||
</DataTable>
|
||||
<DataTable v-if="tab === 'payments'" v-model:page="paymentPage" :page-size="10" :data="payments" :columns="paymentColumns" :total="paymentTotal" :loading="recordsLoading" row-key="paymentNo">
|
||||
</DataTable></div>
|
||||
<div class="workbench-table" v-if="tab === 'payments'"><DataTable v-model:page="paymentPage" :page-size="10" :data="payments" :columns="paymentColumns" :total="paymentTotal" :loading="recordsLoading" row-key="paymentNo">
|
||||
<template #amount="{ row }">{{ money(row.amount) }}</template><template #time="{ row }">{{ time(row.paidAt) }}</template>
|
||||
<template #method="{ row }">{{ methods[row.method as keyof typeof methods] || '—' }}</template>
|
||||
<template #files="{ row }"><el-button v-for="(fileId, index) in row.voucherFileIds" :key="fileId" link type="primary" @click="preview(fileId)">凭证 {{ Number(index) + 1 }}</el-button></template>
|
||||
</DataTable>
|
||||
</DataTable></div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -177,7 +178,7 @@ onBeforeUnmount(() => { request++; operationEpoch++; supplierRequest++; disposed
|
||||
<LoadErrorState v-if="error" :title="`${name}加载失败`" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="photoError" role="alert">{{ photoError.message }}<ApiErrorMeta :details="photoError" /></p>
|
||||
<LoadErrorState v-if="financeError" title="财务状态未确认" :message="financeError.message" @retry="loadFinance"><template #details><ApiErrorMeta :details="financeError" /></template></LoadErrorState>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="recordNo" :empty-text="`暂无${name}`">
|
||||
<div class="workbench-table"><DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="recordNo" :empty-text="`暂无${name}`">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleRecordCreate)" type="primary" @click="edit(null)"><Plus :size="14" />添加{{ name }}</el-button></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions()" @action="$event === 'finance' ? openFinance(row as VehicleCareRecord) : edit(row as VehicleCareRecord)" /></template>
|
||||
<template #date="{ row }">{{ new Date(row.details.occurredAt).toLocaleString('zh-CN', { hour12: false }) }}</template>
|
||||
@@ -190,9 +191,9 @@ onBeforeUnmount(() => { request++; operationEpoch++; supplierRequest++; disposed
|
||||
<template #payment="{ row }">{{ financeLoading ? '查询中…' : financeError ? '状态未确认' : financeStates[row.recordNo] ? vehicleCareFinanceLabels[financeStates[row.recordNo]!.status] : '未查询' }}</template>
|
||||
<template #files="{ row }"><el-button v-for="file in row.files" :key="file.fileId" link type="primary" :disabled="!!openingFile" @click="viewPhoto(row as VehicleCareRecord, file)">{{ file.fileName }}</el-button><span v-if="!row.files.length">暂无附件</span></template>
|
||||
<template #status="{ row }">{{ meterRecord ? (row.details.verificationStatus === 'VERIFIED' ? '已核验' : '待核验') : processingLabels[row.details.incident?.processingStatus as keyof typeof processingLabels] || '—' }}</template>
|
||||
</DataTable>
|
||||
</DataTable></div>
|
||||
<VehicleCareEditor v-model="editorOpen" :vehicle-id="vehicleId" :plate="plate" :record-type="recordType" :record="editing" @saved="saved" />
|
||||
<AppDialog v-model="financeOpen" title="公司车辆成本" width="620px" class="admin-density-dialog" confirm-text="提交财务"
|
||||
<AppDialog v-model="financeOpen" title="公司车辆成本" width="620px" class="admin-density-dialog admin-workbench is-dialog" confirm-text="提交财务"
|
||||
:show-confirm="canSubmitFinance || !!pendingFinance" :confirm-loading="financeSaving" :confirm-disabled="ledgerBusy || !access.can(vehicleCareFinanceSubmitPermission)" @confirm="submitFinance">
|
||||
|
||||
<p v-if="financeTarget">{{ financeTarget.recordNo }} · {{ financeTarget.vehiclePlate }} · 申报费用 {{ financeTarget.details.incident?.currency || '—' }} {{ financeTarget.details.incident?.reportedCost?.toFixed(2) ?? '—' }}</p>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -60,9 +62,9 @@ onBeforeUnmount(() => { generation++ })
|
||||
<section :aria-label="title">
|
||||
<p v-if="!allowed" class="fulfillment-note">暂无{{ kind === 'deliveries' ? '派送记录' : '车次历史' }}查看权限。</p>
|
||||
<template v-else>
|
||||
<p class="fulfillment-note">{{ kind === 'deliveries' ? '计划中的派送按当前分配车辆展示,实际发车后按发车记录归属车辆。' : '按车次保存更换前后的真实记录;同车次可能关联多张排车单。' }}</p>
|
||||
<div class="fulfillment-heading"><InlineHelp :title="`${title}说明`">{{ kind === 'deliveries' ? '计划中的派送按当前分配车辆展示,实际发车后按发车记录归属车辆。' : '按车次保存更换前后的真实记录;同车次可能关联多张排车单。' }}</InlineHelp></div>
|
||||
<LoadErrorState v-if="error" :title="`${title}加载失败`" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :row-key="rowKey" :empty-text="`暂无${title}`">
|
||||
<div class="workbench-table"><DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :row-key="rowKey" :empty-text="`暂无${title}`">
|
||||
<template #toolbar-actions><el-button :disabled="loading" @click="load">刷新</el-button></template>
|
||||
<template #source="{ row }"><div>{{ row.bolNo || '—' }}</div><div>{{ row.dispatchNo || '未关联排车' }}</div></template>
|
||||
<template #customer="{ row }">{{ row.customerName || '—' }}</template>
|
||||
@@ -74,8 +76,8 @@ onBeforeUnmount(() => { generation++ })
|
||||
<template #vehicles="{ row }">{{ row.previousVehiclePlate || '—' }} → {{ row.vehiclePlate || '—' }}</template>
|
||||
<template #drivers="{ row }">{{ row.previousDriverName || '—' }} → {{ row.driverName || '—' }}</template>
|
||||
<template #operator>—</template>
|
||||
</DataTable>
|
||||
</DataTable></div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.fulfillment-note{margin:0 0 var(--admin-space-4);color:var(--cc-muted)}</style>
|
||||
<style scoped>.fulfillment-heading{display:flex;justify-content:flex-end;margin-bottom:var(--admin-space-3)}.fulfillment-note{margin:0 0 var(--admin-space-4);color:var(--workbench-muted)}</style>
|
||||
|
||||
Reference in New Issue
Block a user