Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
|
||||
2. 页面、布局或交互任务同时读取 `../../docs/Admin-Client前端原型还原验收规则.md` 和后台原型 `../../docs/悦/货代SAAS平台后台/AGENTS.md`。
|
||||
订单样板和国内仓样式统一还须读取 `../../docs/Admin高密度界面规范.md`;以其已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式,公共样式按已确认菜单范围接入。
|
||||
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。
|
||||
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/跨区域认证与服务通信契约.md`。
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolve } from 'node:path'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer,
|
||||
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem,
|
||||
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, removeSmartLoadingPallet,
|
||||
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
|
||||
getContainerOverview, updateContainerPickupCode, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
||||
getCustomerContainerTransitSummary,
|
||||
@@ -131,6 +131,17 @@ describe('platform container loading API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('removes a sealed pallet through the whole-pallet command', async () => {
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
|
||||
|
||||
await removeSmartLoadingPallet('10', '9007199254740998', 6)
|
||||
|
||||
expect(http.delete).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/smart-loading/plans/10/pallet-assignments/9007199254740998',
|
||||
{ data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-pallet-') } },
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the dedicated provisional assignment command for a pending-review order', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
|
||||
|
||||
|
||||
@@ -395,6 +395,14 @@ export async function removeSmartLoadingItem(planId: string, itemId: string, exp
|
||||
return plan(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function removeSmartLoadingPallet(planId: string, palletId: string, expectedPlanVersion: number) {
|
||||
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
|
||||
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pallet-assignments/${encodeURIComponent(palletId)}`,
|
||||
{ data: { expectedPlanVersion, operationId: operationId('remove-pallet') } },
|
||||
)
|
||||
return plan(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function removeSmartLoadingGroup(planId: string, groupId: string, expectedPlanVersion: number) {
|
||||
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
|
||||
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments/${encodeURIComponent(groupId)}`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
@@ -16,6 +17,7 @@ import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups'
|
||||
const props = withDefaults(defineProps<{
|
||||
loadWarehouseOptions?: () => Promise<PlatformWarehouseSummaryResource[]>
|
||||
successMessage?: string
|
||||
dense?: boolean
|
||||
}>(), { successMessage: '模拟柜已创建,可先排货后关联真实订舱柜' })
|
||||
const emit = defineEmits<{ created: [] }>()
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
@@ -93,7 +95,7 @@ async function saveSimulation() {
|
||||
|
||||
<template>
|
||||
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
|
||||
<AppDialog v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
|
||||
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
|
||||
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
removeAllUnpickedSmartLoadingItems,
|
||||
removeSmartLoadingGroup,
|
||||
removeSmartLoadingItem,
|
||||
removeSmartLoadingPallet,
|
||||
} from '../api/platformContainerLoading'
|
||||
|
||||
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
|
||||
@@ -50,6 +51,7 @@ vi.mock('../api/platformContainerLoading', () => ({
|
||||
adjustSmartLoadingItem: vi.fn(),
|
||||
removeSmartLoadingItem: vi.fn(),
|
||||
removeSmartLoadingGroup: vi.fn(),
|
||||
removeSmartLoadingPallet: vi.fn(),
|
||||
removeAllUnpickedSmartLoadingItems: vi.fn(),
|
||||
pushSmartLoadingPreload: vi.fn(),
|
||||
}))
|
||||
@@ -861,6 +863,29 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('移除未拣'))).toBe(false)
|
||||
})
|
||||
|
||||
it('removes a sealed pallet row through the whole-pallet command', async () => {
|
||||
const palletItem = {
|
||||
...plan.items[0], unitType: 'SEALED_PALLET' as const, palletId: '801', palletNo: 'DP-260914-0021',
|
||||
indivisible: true, packageCount: 10, pickedPackageCount: 0, minPackageCount: 10,
|
||||
canAdjust: false, canRemove: true,
|
||||
}
|
||||
const palletPlan = { ...plan, items: [palletItem] }
|
||||
const emptyPlan = { ...palletPlan, version: 3, items: [] }
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(palletPlan)
|
||||
vi.mocked(removeSmartLoadingPallet).mockResolvedValue(emptyPlan)
|
||||
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const row = wrapper.findAll('tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0021'))
|
||||
expect(row).toBeDefined()
|
||||
|
||||
await row!.find('.el-button--danger').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(removeSmartLoadingPallet).toHaveBeenCalledWith(plan.id, '801', 2)
|
||||
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders final completion capability as read-only with the server reason', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
removeAllUnpickedSmartLoadingItems,
|
||||
removeSmartLoadingGroup,
|
||||
removeSmartLoadingItem,
|
||||
removeSmartLoadingPallet,
|
||||
} from '../api/platformContainerLoading'
|
||||
import type {
|
||||
SmartLoadingCandidate,
|
||||
@@ -585,10 +586,14 @@ async function remove(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value || !item.canRemove) return
|
||||
const row = source as SmartLoadingPlanRow
|
||||
const sealedPallet = item.unitType === 'SEALED_PALLET' && Boolean(item.palletId)
|
||||
const confirmed = await ElMessageBox.confirm(row.coLoadGroup
|
||||
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
|
||||
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', {
|
||||
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消',
|
||||
: sealedPallet
|
||||
? `确认从当前柜次整托移除托盘“${item.palletNo || '—'}”(${item.packageCount} 箱)吗?`
|
||||
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`,
|
||||
row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细', {
|
||||
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
|
||||
}).catch(() => false)
|
||||
if (!confirmed || !plan.value) return
|
||||
await mutate(async () => {
|
||||
@@ -598,6 +603,12 @@ async function remove(source: object) {
|
||||
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
|
||||
return
|
||||
}
|
||||
if (sealedPallet && item.palletId) {
|
||||
acceptPlan(await removeSmartLoadingPallet(plan.value!.id, item.palletId, plan.value!.version))
|
||||
await loadCandidateData()
|
||||
ElMessage.success(`托盘 ${item.palletNo || '—'} 已整托移除`)
|
||||
return
|
||||
}
|
||||
let current = plan.value!
|
||||
for (const original of item.sourceItems) {
|
||||
const currentItem = current.items.find((value) => value.id === original.id)
|
||||
|
||||
@@ -45,7 +45,7 @@ class ResizeObserverStub implements ResizeObserver {
|
||||
}
|
||||
|
||||
const tenant: PlatformTenantResource = {
|
||||
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'ACTIVE', legalName: '柏悦物流有限公司',
|
||||
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', customerNickname: '柏', status: 'ACTIVE', legalName: '柏悦物流有限公司',
|
||||
legalRepresentative: '张三', unifiedSocialCreditCode: '91440300MA5ABCDE12', contactName: '张三',
|
||||
contactPhone: '13800138000', contactEmail: 'ops@example.com', servicePackage: '基础版', contactWechat: 'baiyue-ops',
|
||||
salesOwnerName: '李四', paymentTermDays: 30, enterpriseType: '有限责任公司', establishedDate: '2020-01-01',
|
||||
|
||||
@@ -258,6 +258,7 @@ onBeforeUnmount(() => {
|
||||
<div class="panel-title"><div><h2>企业基本信息</h2><p>工商档案与合作配置</p></div><Building2 :size="19" /></div>
|
||||
<dl class="info-grid">
|
||||
<div><dt>公司名称</dt><dd>{{ displayText(customer.legalName || customer.tenantName) }}</dd></div>
|
||||
<div><dt>客户昵称</dt><dd>{{ displayText(customer.customerNickname) }}</dd></div>
|
||||
<div><dt>统一社会信用代码</dt><dd>{{ displayText(customer.unifiedSocialCreditCode) }}</dd></div>
|
||||
<div><dt>企业类型</dt><dd>{{ displayText(customer.enterpriseType) }}</dd></div>
|
||||
<div><dt>法定代表人</dt><dd>{{ displayText(customer.legalRepresentative) }}</dd></div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import CustomersView from './CustomersView.vue'
|
||||
|
||||
@@ -84,7 +85,7 @@ class ResizeObserverStub implements ResizeObserver {
|
||||
}
|
||||
|
||||
const fullTenant: PlatformTenantResource = {
|
||||
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'PROFILE_REQUIRED',
|
||||
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', customerNickname: '柏', status: 'PROFILE_REQUIRED',
|
||||
legalName: '柏悦物流有限公司', legalRepresentative: '张三', unifiedSocialCreditCode: '91440300MA5ABCDE12',
|
||||
contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com',
|
||||
servicePackage: '基础版', contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30,
|
||||
@@ -177,6 +178,8 @@ describe('CustomersView', () => {
|
||||
|
||||
it('searches the customer list by customer name only', () => {
|
||||
expect(customersSource).toContain("label: '客户名称', placeholder: '请输入客户名称'")
|
||||
expect(customersSource).toContain("label: '客户昵称'")
|
||||
expect(customersSource).toContain('中文拼音大写首字母,如 SZWL')
|
||||
expect(customersSource).not.toContain('公司名称、客户编号或联系人')
|
||||
expect(customersSource).not.toContain('row.tenantCode.toLowerCase().includes(keyword)')
|
||||
expect(customersSource).not.toContain('String(row.id).includes(keyword)')
|
||||
@@ -283,6 +286,7 @@ describe('CustomersView', () => {
|
||||
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith(
|
||||
fullTenant.id, licenseFile, null, fullTenant.unifiedSocialCreditCode, false, false,
|
||||
)
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the company contact phone editable without changing a member login phone', async () => {
|
||||
@@ -307,6 +311,7 @@ describe('CustomersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ contactPhone: '0755-12345678' }))
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(document.body.textContent).toContain('不会修改企业管理员登录手机号')
|
||||
expect(customersSource).not.toContain('联系电话(登录用户名)')
|
||||
})
|
||||
|
||||
@@ -135,9 +135,9 @@ const queryFields: QueryField[] = [
|
||||
] },
|
||||
]
|
||||
|
||||
type CustomerForm = CreatePlatformTenantRequest & { unifiedSocialCreditCode: string }
|
||||
type CustomerForm = CreatePlatformTenantRequest & { customerNickname: string; unifiedSocialCreditCode: string }
|
||||
const emptyForm = (): CustomerForm => ({
|
||||
tenantName: '', legalName: '', legalRepresentative: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '',
|
||||
tenantName: '', customerNickname: '', legalName: '', legalRepresentative: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '',
|
||||
contactEmail: '', contactWechat: '', servicePackage: '基础版', salesOwnerName: '', paymentTermDays: 30,
|
||||
enterpriseType: '', establishedDate: null, registeredCapital: 100000, registeredCapitalCurrency: 'CNY',
|
||||
registeredAddress: '', internalRemark: '',
|
||||
@@ -145,6 +145,16 @@ const emptyForm = (): CustomerForm => ({
|
||||
const form = ref(emptyForm())
|
||||
const rules: FormRules = {
|
||||
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
|
||||
customerNickname: [
|
||||
{ max: 32, message: '客户昵称最多 32 个字符', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (!editingId.value || (typeof value === 'string' && value.trim())) callback()
|
||||
else callback(new Error('请输入客户昵称'))
|
||||
},
|
||||
trigger: ['blur', 'change'],
|
||||
},
|
||||
],
|
||||
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
|
||||
legalRepresentative: [{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' }],
|
||||
unifiedSocialCreditCode: [
|
||||
@@ -199,6 +209,7 @@ const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
|
||||
const customerColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 330, slotName: 'actions' },
|
||||
{ key: 'tenantCode', prop: 'tenantCode', label: '客户编号', width: 220, className: 'customer-code-column' },
|
||||
{ key: 'customerNickname', prop: 'customerNickname', label: '客户昵称', width: 120, formatter: (row: PlatformTenantResource) => displayText(row.customerNickname) },
|
||||
{ key: 'company', label: '公司名称', width: 250, slotName: 'company' },
|
||||
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', width: 220, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
|
||||
@@ -394,7 +405,7 @@ function openCreate() {
|
||||
}
|
||||
function mapTenantToForm(row: PlatformTenantResource): CustomerForm {
|
||||
return {
|
||||
tenantName: row.tenantName, legalName: row.legalName || '', legalRepresentative: row.legalRepresentative || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '',
|
||||
tenantName: row.tenantName, customerNickname: row.customerNickname || '', legalName: row.legalName || '', legalRepresentative: row.legalRepresentative || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '',
|
||||
contactName: row.contactName || '', contactPhone: row.contactPhone || '', contactEmail: row.contactEmail || '',
|
||||
contactWechat: row.contactWechat || '', servicePackage: row.servicePackage || '基础版', salesOwnerName: row.salesOwnerName || '',
|
||||
paymentTermDays: Number(row.paymentTermDays ?? 0), enterpriseType: row.enterpriseType || '', establishedDate: row.establishedDate || null,
|
||||
@@ -574,7 +585,7 @@ async function saveCustomer() {
|
||||
let saved: PlatformTenantResource
|
||||
if (editingId.value) {
|
||||
const { unifiedSocialCreditCode: _immutable, ...payload } = form.value
|
||||
saved = await updatePlatformTenant(editingId.value, payload)
|
||||
saved = await updatePlatformTenant(editingId.value, { ...payload, customerNickname: form.value.customerNickname.trim() })
|
||||
} else {
|
||||
const { unifiedSocialCreditCode: _qualificationCreditCode, ...payload } = form.value
|
||||
const created = await createPlatformTenant(payload)
|
||||
@@ -604,6 +615,7 @@ async function saveCustomer() {
|
||||
ElMessage.success('客户资料已保存')
|
||||
}
|
||||
await loadCustomers()
|
||||
if (!failures.length) editorOpen.value = false
|
||||
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料保存失败,请稍后重试')) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
@@ -1073,6 +1085,7 @@ onMounted(() => { void loadCustomers() })
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid two-cols">
|
||||
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item>
|
||||
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
|
||||
|
||||
@@ -17,11 +17,11 @@ vi.mock('../api/dashboard', () => ({
|
||||
describe('DashboardView', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders the four live metrics and keeps unfinished task actions non-operational', async () => {
|
||||
it('renders only the four live metrics', async () => {
|
||||
const wrapper = mount(DashboardView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('四项核心指标已接入实时业务数据')
|
||||
expect(wrapper.text()).toContain('核心指标来自实时业务数据')
|
||||
expect(wrapper.text()).toContain('数据更新时间:2026-09-11 12:00')
|
||||
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(4)
|
||||
expect(wrapper.text()).toContain('12 票')
|
||||
@@ -29,31 +29,11 @@ describe('DashboardView', () => {
|
||||
expect(wrapper.text()).toContain('8 家')
|
||||
expect(wrapper.text()).toContain('7 票')
|
||||
expect(wrapper.text()).toContain('1 柜箱型未知')
|
||||
const taskActions = wrapper.findAll('.dashboard-task-action')
|
||||
expect(taskActions).toHaveLength(3)
|
||||
expect(taskActions.every((button) => button.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('switches transport series and exposes the chart values as a data table', async () => {
|
||||
const wrapper = mount(DashboardView)
|
||||
const airButton = wrapper.findAll('button').find((button) => button.text() === '空运')
|
||||
|
||||
expect(airButton).toBeDefined()
|
||||
await airButton!.trigger('click')
|
||||
|
||||
expect(airButton!.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.text()).toContain('空运普货')
|
||||
const caption = wrapper.get('.dashboard-line-chart table caption')
|
||||
expect(caption.text()).toBe('空运近 30 天运单量演示数据')
|
||||
expect(wrapper.findAll('.dashboard-line-chart tbody tr')).toHaveLength(30)
|
||||
})
|
||||
|
||||
it('preserves semantic route names and values in the volume ranking', () => {
|
||||
const wrapper = mount(DashboardView)
|
||||
const ranking = wrapper.get('.dashboard-bars')
|
||||
|
||||
expect(ranking.attributes('role')).toBeUndefined()
|
||||
expect(ranking.text()).toContain('上海 → 洛杉矶')
|
||||
expect(ranking.text()).toContain('850')
|
||||
expect(wrapper.text()).not.toContain('近 30 天运单量趋势')
|
||||
expect(wrapper.text()).not.toContain('运单状态分布')
|
||||
expect(wrapper.text()).not.toContain('TOP10 航线货量')
|
||||
expect(wrapper.text()).not.toContain('收入构成')
|
||||
expect(wrapper.text()).not.toContain('待办事项')
|
||||
expect(wrapper.text()).not.toContain('静态演示数据')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Clock3,
|
||||
Expand,
|
||||
Minimize,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
} from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import {
|
||||
getActiveCustomerCount,
|
||||
getDashboardOperationalMetrics,
|
||||
@@ -18,17 +14,7 @@ import {
|
||||
type DashboardOperationalMetrics,
|
||||
} from '../api/dashboard'
|
||||
|
||||
type TransportMode = 'sea' | 'air' | 'land'
|
||||
|
||||
interface TrendSeries {
|
||||
name: string
|
||||
color: string
|
||||
fill: string
|
||||
values: number[]
|
||||
}
|
||||
|
||||
const dashboardRef = ref<HTMLElement | null>(null)
|
||||
const activeMode = ref<TransportMode>('sea')
|
||||
const isFullscreen = ref(false)
|
||||
const metricsLoading = ref(true)
|
||||
const metricsError = ref(false)
|
||||
@@ -56,76 +42,6 @@ const metrics = computed(() => [
|
||||
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose' },
|
||||
])
|
||||
|
||||
const trendSeries: Record<TransportMode, TrendSeries[]> = {
|
||||
sea: [
|
||||
{ name: '海运 FCL', color: '#3478f6', fill: 'rgba(52, 120, 246, .13)', values: [28, 32, 25, 38, 35, 42, 30, 36, 40, 45, 33, 48, 38, 42, 50, 35, 40, 45, 38, 52, 42, 48, 55, 40, 45, 50, 38, 42, 48, 55] },
|
||||
{ name: '海运 LCL', color: '#6d3bd1', fill: 'rgba(109, 59, 209, .09)', values: [12, 15, 10, 18, 14, 20, 16, 15, 18, 22, 14, 20, 16, 18, 25, 15, 18, 20, 16, 22, 18, 20, 24, 16, 18, 22, 15, 18, 20, 24] },
|
||||
],
|
||||
air: [
|
||||
{ name: '空运普货', color: '#f79009', fill: 'rgba(247, 144, 9, .12)', values: [18, 20, 17, 24, 19, 26, 22, 25, 28, 23, 20, 29, 25, 27, 32, 26, 23, 30, 28, 34, 29, 31, 36, 30, 33, 35, 28, 32, 34, 38] },
|
||||
{ name: '空运敏感货', color: '#d92d20', fill: 'rgba(217, 45, 32, .08)', values: [6, 8, 7, 9, 8, 12, 10, 9, 11, 13, 9, 12, 10, 14, 15, 11, 10, 13, 12, 16, 13, 15, 17, 12, 14, 16, 13, 15, 17, 18] },
|
||||
],
|
||||
land: [
|
||||
{ name: '中欧班列', color: '#079455', fill: 'rgba(7, 148, 85, .11)', values: [12, 14, 13, 18, 16, 20, 17, 19, 22, 20, 18, 23, 21, 24, 27, 22, 20, 25, 23, 28, 25, 27, 30, 26, 28, 31, 25, 29, 32, 34] },
|
||||
{ name: '跨境卡航', color: '#0891b2', fill: 'rgba(8, 145, 178, .08)', values: [8, 10, 9, 12, 11, 14, 12, 13, 16, 14, 13, 17, 15, 18, 20, 16, 15, 19, 17, 21, 18, 20, 23, 19, 21, 24, 20, 22, 25, 27] },
|
||||
],
|
||||
}
|
||||
|
||||
const statusSegments = [
|
||||
{ label: '已确认', value: 16, color: '#3478f6' },
|
||||
{ label: '已订舱', value: 12, color: '#6d3bd1' },
|
||||
{ label: '已做柜', value: 9, color: '#f79009' },
|
||||
{ label: '已清关', value: 11, color: '#55b715' },
|
||||
{ label: '运输中', value: 23, color: '#168cf0' },
|
||||
{ label: '已完成', value: 29, color: '#18b7b9' },
|
||||
] as const
|
||||
|
||||
const revenueSegments = [
|
||||
{ label: '海运费', value: 58, color: '#3478f6' },
|
||||
{ label: '附加费', value: 16, color: '#6d3bd1' },
|
||||
{ label: '仓储费', value: 10, color: '#55b715' },
|
||||
{ label: '清关费', value: 8, color: '#f79009' },
|
||||
{ label: '陆运费', value: 5, color: '#e72b8c' },
|
||||
{ label: '其他', value: 3, color: '#18b7b9' },
|
||||
] as const
|
||||
|
||||
const routes = [
|
||||
['上海 → 洛杉矶', 850], ['宁波 → 长滩', 620], ['深圳 → 汉堡', 480], ['上海 → 纽约', 420],
|
||||
['深圳 → 洛杉矶', 380], ['青岛 → 鹿特丹', 350], ['上海 → 汉堡', 310], ['天津 → 长滩', 280],
|
||||
['宁波 → 纽约', 250], ['青岛 → 汉堡', 220],
|
||||
] as const
|
||||
|
||||
const tasks = [
|
||||
{ type: '订舱确认', content: 'BY20260528003 · 美森快船待确认 S/O', priority: '重要', due: '2026-06-01' },
|
||||
{ type: '账单到期', content: '3 笔应收账单即将到期,合计 ¥128,500', priority: '重要', due: '2026-06-03' },
|
||||
{ type: '报关提醒', content: 'BY20260525001 · 截关时间临近,请尽快申报', priority: '紧急', due: '2026-06-01' },
|
||||
] as const
|
||||
|
||||
const currentSeries = computed(() => trendSeries[activeMode.value])
|
||||
|
||||
function linePoints(values: number[]) {
|
||||
const max = 60
|
||||
return values.map((value, index) => {
|
||||
const x = 12 + (index * 676) / (values.length - 1)
|
||||
const y = 206 - (value / max) * 174
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
function areaPoints(values: number[]) {
|
||||
return `12,206 ${linePoints(values)} 688,206`
|
||||
}
|
||||
|
||||
function donutBackground(items: ReadonlyArray<{ value: number; color: string }>) {
|
||||
let offset = 0
|
||||
const stops = items.map((item) => {
|
||||
const start = offset
|
||||
offset += item.value
|
||||
return `${item.color} ${start}% ${offset}%`
|
||||
})
|
||||
return `conic-gradient(${stops.join(', ')})`
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) await dashboardRef.value?.requestFullscreen()
|
||||
else await document.exitFullscreen()
|
||||
@@ -162,17 +78,17 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
|
||||
<template>
|
||||
<!--
|
||||
THESIS: 用一屏可扫描的经营驾驶舱承载平台概览,拒绝把数据拆成互不关联的装饰卡片。
|
||||
THESIS: 首页只承载已经接通真实业务数据的四项核心指标。
|
||||
OWN-WORLD: 延续 Admin 冷灰工作面、白色数据面板和柏悦蓝主色;状态色只承担分类与告警。
|
||||
STORY: 运营人员先识别核心体量,再查看趋势与构成,最后定位待处理事项。
|
||||
FIRST VIEWPORT: 顶部状态栏与四项核心指标占据首屏上半部,主趋势图和状态环图紧随其后。
|
||||
STORY: 运营人员进入首页后直接识别当前核心体量。
|
||||
FIRST VIEWPORT: 顶部状态栏与四项核心指标完整占据首屏。
|
||||
FORM: 既有后台视觉体系的经营驾驶舱结构,基于用户提供的正式原型直接扩展。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||
-->
|
||||
<section ref="dashboardRef" class="dashboard-view">
|
||||
<AdminPage as="div" spacing="normal">
|
||||
<section class="dashboard-context" aria-label="数据看板说明">
|
||||
<p>{{ metricsError ? '部分核心指标加载失败,可刷新重试;下方图表及待办仍为静态演示数据。' : '四项核心指标已接入实时业务数据;下方图表及待办仍为静态演示数据。' }}</p>
|
||||
<p>{{ metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
|
||||
<div class="dashboard-head-actions">
|
||||
<span>数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||
<button type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
||||
@@ -187,106 +103,14 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ListPageLayout ariaLabel="平台运营工作台">
|
||||
<template #metrics>
|
||||
<div class="dashboard-metrics" aria-label="核心业务指标">
|
||||
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
|
||||
<span class="dashboard-metric-mark" aria-hidden="true" />
|
||||
<p>{{ metric.label }}</p>
|
||||
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
|
||||
<span>{{ metric.note }}</span>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dashboard-content">
|
||||
<div class="dashboard-grid dashboard-grid--primary">
|
||||
<article class="dashboard-panel dashboard-trend-panel">
|
||||
<div class="dashboard-panel-head">
|
||||
<h2>近 30 天运单量趋势</h2>
|
||||
<div class="dashboard-tabs" aria-label="运输方式">
|
||||
<button v-for="mode in ([['sea', '海运'], ['air', '空运'], ['land', '陆运']] as const)" :key="mode[0]" type="button" :class="{ active: activeMode === mode[0] }" :aria-pressed="activeMode === mode[0]" @click="activeMode = mode[0]">{{ mode[1] }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-legend">
|
||||
<span v-for="series in currentSeries" :key="series.name"><i :style="{ background: series.color }" />{{ series.name }}</span>
|
||||
</div>
|
||||
<div class="dashboard-line-chart">
|
||||
<svg viewBox="0 0 700 228" role="img" :aria-label="`${activeMode === 'sea' ? '海运' : activeMode === 'air' ? '空运' : '陆运'}近 30 天运单量趋势演示图`">
|
||||
<g class="dashboard-chart-grid">
|
||||
<line v-for="y in [32, 61, 90, 119, 148, 177, 206]" :key="`y-${y}`" x1="12" :y1="y" x2="688" :y2="y" />
|
||||
<line v-for="x in [12, 124, 237, 350, 463, 576, 688]" :key="`x-${x}`" :x1="x" y1="32" :x2="x" y2="206" />
|
||||
</g>
|
||||
<template v-for="series in currentSeries" :key="series.name">
|
||||
<polygon :points="areaPoints(series.values)" :fill="series.fill" />
|
||||
<polyline :points="linePoints(series.values)" fill="none" :stroke="series.color" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</template>
|
||||
<g class="dashboard-axis-labels">
|
||||
<text x="12" y="224">1 日</text><text x="124" y="224">5 日</text><text x="237" y="224">10 日</text>
|
||||
<text x="350" y="224">15 日</text><text x="463" y="224">20 日</text><text x="576" y="224">25 日</text><text x="663" y="224">30 日</text>
|
||||
</g>
|
||||
</svg>
|
||||
<table class="sr-only">
|
||||
<caption>{{ activeMode === 'sea' ? '海运' : activeMode === 'air' ? '空运' : '陆运' }}近 30 天运单量演示数据</caption>
|
||||
<thead><tr><th>日期</th><th v-for="series in currentSeries" :key="series.name">{{ series.name }}</th></tr></thead>
|
||||
<tbody><tr v-for="(_, index) in currentSeries[0]?.values" :key="index"><th>{{ index + 1 }} 日</th><td v-for="series in currentSeries" :key="series.name">{{ series.values[index] }}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="dashboard-panel">
|
||||
<div class="dashboard-panel-head"><h2>运单状态分布</h2></div>
|
||||
<div class="dashboard-donut-wrap">
|
||||
<div class="dashboard-donut" :style="{ background: donutBackground(statusSegments) }"><span><Ship :size="24" /><strong>100%</strong><small>演示样本</small></span></div>
|
||||
<div class="dashboard-legend dashboard-legend--centered">
|
||||
<span v-for="item in statusSegments" :key="item.label"><i :style="{ background: item.color }" />{{ item.label }} {{ item.value }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid dashboard-grid--equal">
|
||||
<article class="dashboard-panel">
|
||||
<div class="dashboard-panel-head"><h2>TOP10 航线货量(TEU)</h2></div>
|
||||
<div class="dashboard-bars" aria-label="TOP10 航线货量静态演示数据">
|
||||
<div v-for="route in routes" :key="route[0]" class="dashboard-bar-row">
|
||||
<span>{{ route[0] }}</span><i><b :style="{ width: `${(route[1] / 900) * 100}%` }" /></i><strong>{{ route[1] }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="dashboard-panel">
|
||||
<div class="dashboard-panel-head"><h2>收入构成</h2></div>
|
||||
<div class="dashboard-donut-wrap dashboard-donut-wrap--revenue">
|
||||
<div class="dashboard-donut dashboard-donut--solid" :style="{ background: donutBackground(revenueSegments) }" role="img" aria-label="收入构成静态演示饼图" />
|
||||
<div class="dashboard-legend dashboard-legend--centered">
|
||||
<span v-for="item in revenueSegments" :key="item.label"><i :style="{ background: item.color }" />{{ item.label }} {{ item.value }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="dashboard-panel dashboard-task-panel">
|
||||
<div class="dashboard-panel-head">
|
||||
<h2><Clock3 :size="18" aria-hidden="true" />待办事项</h2>
|
||||
<span>功能入口待真实业务模块接入</span>
|
||||
</div>
|
||||
<div class="dashboard-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>操作</th><th>类型</th><th>内容</th><th>优先级</th><th>截止时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="task in tasks" :key="task.content">
|
||||
<td><button type="button" class="dashboard-task-action" disabled>待接入 <ArrowUpRight :size="14" /></button></td>
|
||||
<td>{{ task.type }}</td><td>{{ task.content }}</td>
|
||||
<td><span class="dashboard-priority" :class="{ urgent: task.priority === '紧急' }">{{ task.priority }}</span></td>
|
||||
<td>{{ task.due }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</ListPageLayout>
|
||||
<div class="dashboard-metrics" aria-label="核心业务指标">
|
||||
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
|
||||
<span class="dashboard-metric-mark" aria-hidden="true" />
|
||||
<p>{{ metric.label }}</p>
|
||||
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
|
||||
<span>{{ metric.note }}</span>
|
||||
</article>
|
||||
</div>
|
||||
</AdminPage>
|
||||
</section>
|
||||
</template>
|
||||
@@ -294,11 +118,10 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
<style scoped>
|
||||
.dashboard-view { --dashboard-panel: var(--cc-surface, #fff); display: block; min-width: 0; color: var(--cc-text, #172033); }
|
||||
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: var(--cc-page, #f4f6f8); }
|
||||
.dashboard-content { display: flex; min-width: 0; flex-direction: column; gap: 18px; }
|
||||
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.dashboard-context p { min-width: 0; margin: 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.55; }
|
||||
.dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
|
||||
.dashboard-outline-button, .dashboard-tabs button, .dashboard-task-action { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--cc-border-strong, #d8deea); border-radius: var(--cc-radius-md, 6px); background: var(--cc-surface, #fff); color: var(--cc-text-secondary, #344054); cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
|
||||
.dashboard-outline-button { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--cc-border-strong, #d8deea); border-radius: var(--cc-radius-md, 6px); background: var(--cc-surface, #fff); color: var(--cc-text-secondary, #344054); cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
|
||||
.dashboard-outline-button { padding: 0 13px; }
|
||||
.dashboard-outline-button:hover:not(:disabled) { border-color: var(--cc-primary); color: var(--cc-primary); }
|
||||
.dashboard-outline-button:disabled { cursor: wait; opacity: .65; }
|
||||
@@ -312,69 +135,15 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
.dashboard-metric strong { position: relative; display: block; overflow-wrap: anywhere; font-size: 31px; line-height: 1; letter-spacing: -.02em; }
|
||||
.dashboard-metric strong small { color: #525866; font-size: 12px; font-weight: 600; letter-spacing: 0; }
|
||||
.dashboard-metric > span:last-child { position: relative; display: block; margin-top: 17px; color: #8a91a0; font-size: 11px; }
|
||||
.dashboard-grid { display: grid; min-width: 0; gap: 18px; }
|
||||
.dashboard-grid--primary { grid-template-columns: minmax(0, 1.8fr) minmax(300px, .9fr); }
|
||||
.dashboard-grid--equal { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dashboard-panel { min-width: 0; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--dashboard-panel); }
|
||||
.dashboard-panel-head { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.dashboard-panel-head h2 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 16px; line-height: 1.4; }
|
||||
.dashboard-panel-head > span { color: #8a91a0; font-size: 11px; }
|
||||
.dashboard-tabs { display: flex; gap: 7px; }
|
||||
.dashboard-tabs button { min-width: 58px; padding: 0 12px; }
|
||||
.dashboard-tabs button:hover { border-color: var(--cc-primary-light, #b8c2ef); color: var(--cc-primary); }
|
||||
.dashboard-tabs button.active { border-color: var(--cc-primary); background: var(--cc-primary); color: #fff; }
|
||||
.dashboard-legend { display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 12px 0; color: #667085; font-size: 11px; }
|
||||
.dashboard-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.dashboard-legend i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; }
|
||||
.dashboard-legend--centered { justify-content: center; margin-top: 18px; }
|
||||
.dashboard-line-chart { width: 100%; min-height: 240px; }
|
||||
.dashboard-line-chart svg { display: block; width: 100%; height: auto; }
|
||||
.dashboard-chart-grid line { stroke: #e8ebf1; stroke-width: 1; }
|
||||
.dashboard-axis-labels { fill: #8a91a0; font-size: 10px; }
|
||||
.dashboard-donut-wrap { display: grid; min-height: 268px; align-content: center; justify-items: center; padding-top: 10px; }
|
||||
.dashboard-donut { position: relative; display: grid; width: 166px; height: 166px; place-items: center; border-radius: 50%; transform: rotate(-90deg); }
|
||||
.dashboard-donut::after { position: absolute; width: 92px; height: 92px; border-radius: 50%; background: #fff; content: ''; }
|
||||
.dashboard-donut > span { z-index: 1; display: flex; align-items: center; flex-direction: column; color: #667085; transform: rotate(90deg); }
|
||||
.dashboard-donut > span svg { color: #667eea; }
|
||||
.dashboard-donut > span strong { margin-top: 3px; color: #172033; font-size: 18px; }
|
||||
.dashboard-donut > span small { font-size: 10px; }
|
||||
.dashboard-donut-wrap--revenue { min-height: 320px; }
|
||||
.dashboard-donut--solid { width: 210px; height: 210px; }
|
||||
.dashboard-donut--solid::after { display: none; }
|
||||
.dashboard-bars { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.dashboard-bar-row { display: grid; grid-template-columns: 104px minmax(80px, 1fr) 32px; align-items: center; gap: 9px; color: #667085; font-size: 11px; }
|
||||
.dashboard-bar-row > span { overflow: hidden; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dashboard-bar-row > i { height: 15px; overflow: hidden; border-radius: 4px; background: #f0f3f8; }
|
||||
.dashboard-bar-row > i b { display: block; height: 100%; border-radius: inherit; background: #3478f6; }
|
||||
.dashboard-bar-row > strong { color: #475467; font-size: 10px; }
|
||||
.dashboard-task-panel { padding: 0; overflow: hidden; }
|
||||
.dashboard-task-panel .dashboard-panel-head { padding: 16px 20px; }
|
||||
.dashboard-table-wrap { overflow-x: auto; border-top: 1px solid #e8ebf1; }
|
||||
.dashboard-table-wrap table { width: 100%; min-width: 760px; border-collapse: collapse; font-size: 12px; }
|
||||
.dashboard-table-wrap th, .dashboard-table-wrap td { padding: 13px 16px; border-bottom: 1px solid #edf0f4; text-align: left; }
|
||||
.dashboard-table-wrap th { background: #f8fafc; color: #475467; font-weight: 650; }
|
||||
.dashboard-table-wrap tbody tr:last-child td { border-bottom: 0; }
|
||||
.dashboard-task-action { min-height: 30px; padding: 0 9px; font-size: 11px; }
|
||||
.dashboard-task-action:disabled { border-color: #e4e7ec; background: #f9fafb; color: #98a2b3; cursor: not-allowed; }
|
||||
.dashboard-priority { display: inline-flex; min-height: 24px; align-items: center; padding: 2px 8px; border-radius: 999px; background: #fff3e8; color: #c54d0a; font-size: 11px; font-weight: 700; }
|
||||
.dashboard-priority.urgent { background: #fef3f2; color: #b42318; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.dashboard-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.dashboard-grid--primary { grid-template-columns: minmax(0, 1fr); }
|
||||
.dashboard-donut-wrap { min-height: 240px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.dashboard-context { align-items: flex-start; flex-direction: column; }
|
||||
.dashboard-head-actions { align-items: flex-start; flex-direction: column; gap: 9px; white-space: normal; }
|
||||
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dashboard-grid--equal { grid-template-columns: minmax(0, 1fr); }
|
||||
.dashboard-panel-head { align-items: flex-start; flex-direction: column; }
|
||||
.dashboard-tabs { width: 100%; overflow-x: auto; }
|
||||
.dashboard-tabs button { min-height: 44px; flex: 1; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.dashboard-content { gap: 14px; }
|
||||
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.dashboard-metric { min-height: 116px; padding: 13px; }
|
||||
.dashboard-metric-mark { top: -30px; right: -24px; width: 80px; height: 80px; }
|
||||
@@ -382,11 +151,8 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
.dashboard-metric strong { font-size: 23px; }
|
||||
.dashboard-metric strong small { font-size: 10px; }
|
||||
.dashboard-metric > span:last-child { margin-top: 10px; font-size: 10px; }
|
||||
.dashboard-panel { padding: 16px; }
|
||||
.dashboard-task-panel { padding: 0; }
|
||||
.dashboard-bar-row { grid-template-columns: 90px minmax(72px, 1fr) 28px; gap: 7px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-outline-button, .dashboard-tabs button, .dashboard-task-action { transition: none; }
|
||||
.dashboard-outline-button { transition: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dashboardRoutes = [
|
||||
component: () => import('@/modules/dashboard/pages/DashboardView.vue'),
|
||||
meta: {
|
||||
title: '首页数据看板',
|
||||
description: '平台业务概览(当前展示静态演示数据)',
|
||||
description: '平台实时核心业务指标',
|
||||
showPageHeading: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -53,7 +54,7 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="inventory-detail-page">
|
||||
<AdminPage as="div" class="inventory-detail-page admin-density">
|
||||
<header class="detail-header">
|
||||
<el-button aria-label="返回整柜业务" @click="back"><ArrowLeft :size="16" />返回</el-button>
|
||||
<h1>{{ detail?.inventory.containerNo || '整柜库存详情' }}</h1>
|
||||
@@ -96,18 +97,18 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inventory-detail-page { display: grid; min-width: 0; gap: 16px; }
|
||||
.inventory-detail-page { display: grid; min-width: 0; gap: 12px; }
|
||||
.detail-header { display: flex; align-items: center; gap: 12px; min-height: 40px; }
|
||||
.detail-header h1 { margin: 0; font-size: 20px; letter-spacing: 0; }
|
||||
.detail-header h1 { margin: 0; font-size: 16px; letter-spacing: 0; }
|
||||
.detail-section, .transactions { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-section { padding: 16px 18px 18px; }
|
||||
.detail-section h2, .transactions h2 { margin: 0; font-size: 15px; letter-spacing: 0; }
|
||||
.detail-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 14px 0 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
|
||||
.detail-fields > div { min-width: 0; min-height: 58px; padding: 10px 12px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-fields dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-fields dd { margin: 4px 0 0; overflow-wrap: anywhere; }
|
||||
.transactions { padding-top: 16px; overflow: hidden; }
|
||||
.transactions h2 { margin: 0 18px 14px; }
|
||||
.detail-section { padding: 12px; }
|
||||
.detail-section h2, .transactions h2 { margin: 0; font-size: 14px; letter-spacing: 0; }
|
||||
.detail-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 8px 0 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
|
||||
.detail-fields > div { min-width: 0; display: grid; grid-template-columns: 112px minmax(0, 1fr); min-height: 36px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-fields dt { display: flex; align-items: center; padding: 6px 8px; background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-fields dd { align-self: center; margin: 6px 8px; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.transactions { padding-top: 12px; overflow: hidden; }
|
||||
.transactions h2 { margin: 0 12px 8px; }
|
||||
.detail-loading { min-height: 180px; padding: 24px; border: 1px solid var(--cc-border); background: #fff; color: var(--cc-muted); }
|
||||
@media (max-width: 960px) { .detail-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 520px) { .detail-header { flex-wrap: wrap; } .detail-header h1 { width: 100%; font-size: 18px; } .detail-fields { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -11,7 +11,13 @@ describe('DomesticContainersView', () => {
|
||||
it('reuses the order-list tab panel and keyboard interaction', () => { expect(source).toContain('class="business-query-panel"'); expect(source).toContain('role="tablist"'); expect(source).toContain('role="tab"'); expect(source).toContain('handleTabKeydown'); expect(source).toContain(".status-tab.is-active::after{background:var(--cc-primary)}"); expect(source).not.toContain('<el-tabs') })
|
||||
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
|
||||
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
|
||||
it('matches the prototype four-column inventory summary', () => { expect(source).toContain("import './domesticContainersSummary.css'"); expect(summaryStyles).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))'); expect(summaryStyles).toContain('border: 1px solid #e7edf5'); expect(summaryStyles).toContain('font-size: 20px'); expect(summaryStyles).toContain('.inventory-summary + .data-table > .data-table__toolbar') })
|
||||
it('keeps all four inventory facts in the approved compact summary', () => {
|
||||
for (const fact of ['summary.inboundBoxes', 'summary.outboundBoxes', 'summary.availableBoxes', 'summary.inboundVolumeCbm']) expect(source).toContain(fact)
|
||||
expect(source).toContain("import './domesticContainersSummary.css'")
|
||||
expect(summaryStyles).toContain('display: flex; flex-wrap: wrap')
|
||||
expect(summaryStyles).toContain('font-size: 14px')
|
||||
expect(summaryStyles).not.toContain('position: absolute')
|
||||
})
|
||||
it('does not offer an inventory detail action before an inbound has formed inventory', () => { expect(source).toContain("canViewInventory(row as ContainerOrder | InventoryRecord)"); expect(source).toContain("row.status === 'COMPLETED'") })
|
||||
it('matches backend numeric and container-number input boundaries before submit', () => { expect(source).toContain("pattern: /^[A-Za-z0-9-]{4,32}$/"); expect(source).toContain(':max="999999999"'); expect(source).toContain(':max="999999999999.9999"') })
|
||||
it('requires a refreshed inventory version for completed outbound submission', () => { expect(source).toContain("outboundForm.inventoryVersion == null"); expect(source).toContain('库存版本未就绪,请重新选择集装箱') })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, Plus } from '@lucide/vue'
|
||||
@@ -20,10 +21,10 @@ import './domesticContainersSummary.css'
|
||||
|
||||
type TabName = 'inbound' | 'outbound' | 'inventory'
|
||||
const router = useRouter(); const access = useAccessPreviewStore(); const activeTab = ref<TabName>('inbound')
|
||||
const businessTabs: Array<{ value: TabName; label: string }> = [
|
||||
{ value: 'inbound', label: '整柜入库单' },
|
||||
{ value: 'outbound', label: '整柜出库单' },
|
||||
{ value: 'inventory', label: '整柜库存' },
|
||||
const businessTabs: Array<{ value: TabName; label: string; icon: string }> = [
|
||||
{ value: 'inbound', label: '整柜入库单', icon: '📥' },
|
||||
{ value: 'outbound', label: '整柜出库单', icon: '📤' },
|
||||
{ value: 'inventory', label: '整柜库存', icon: '📦' },
|
||||
]
|
||||
const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const rows = ref<(ContainerOrder | InventoryRecord)[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const summary = ref<InventorySummary>({ inboundBoxes: 0, outboundBoxes: 0, availableBoxes: 0, inboundVolumeCbm: 0 })
|
||||
@@ -71,9 +72,9 @@ async function submit() { if (!await formRef.value?.validate().catch(() => false
|
||||
watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) })
|
||||
</script>
|
||||
|
||||
<template><AdminPage as="div" class="domestic-containers-page">
|
||||
<template><AdminPage as="div" class="domestic-containers-page admin-density">
|
||||
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><template #toolbar><div class="business-query-panel"><div class="status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template><template v-if="loadError" #state><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><section v-if="!loadError" id="domestic-container-results" class="surface-panel table-panel"><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'"><template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" size="small" type="primary" plain @click="viewInventory(row as ContainerOrder | InventoryRecord)"><Eye :size="15" />查看</el-button><span v-else>—</span></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
|
||||
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><template #toolbar><div class="business-query-panel"><div class="status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span>{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template><template v-if="loadError" #state><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><section v-if="!loadError" id="domestic-container-results" class="surface-panel table-panel"><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'"><template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" size="small" type="primary" plain @click="viewInventory(row as ContainerOrder | InventoryRecord)"><Eye :size="15" />查看</el-button><span v-else>—</span></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
|
||||
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.status-tabs{display:flex;max-width:100%;overflow-x:auto;margin-bottom:0;border-bottom:1px solid #e5e7eb;background:#f9fafb;scrollbar-width:thin}.status-tab{position:relative;flex:0 0 auto;min-height:44px;padding:0 18px;border:0;background:transparent;color:var(--cc-muted);font:inherit;font-size:14px;cursor:pointer}.status-tab::after{position:absolute;right:12px;bottom:0;left:12px;height:3px;background:transparent;content:''}.status-tab:hover{color:var(--cc-primary);background:#f6f9fd}.status-tab:focus-visible{outline:2px solid var(--cc-primary);outline-offset:-2px}.status-tab.is-active{color:var(--cc-primary);font-weight:600}.status-tab.is-active::after{background:var(--cc-primary)}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 16px 12px;border:1px solid var(--cc-border);background:#f8fafc}.inventory-summary>div{display:flex;align-items:baseline;gap:8px;min-width:0;padding:9px 16px;border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:12px}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.status-tab{min-height:46px;padding:0 14px}.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.status-tab{padding:0 10px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:4px;background:#fff}.status-tabs{display:flex;max-width:100%;overflow-x:auto;margin-bottom:0;border-bottom:1px solid #e5e7eb;background:#f9fafb;scrollbar-width:thin}.status-tab{position:relative;flex:0 0 auto;display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 16px;border:0;background:transparent;color:var(--cc-muted);font:inherit;font-size:13px;cursor:pointer}.status-tab::after{position:absolute;right:12px;bottom:0;left:12px;height:3px;background:transparent;content:''}.status-tab:hover{color:var(--cc-primary);background:#f6f9fd}.status-tab:focus-visible{outline:2px solid var(--cc-primary);outline-offset:-2px}.status-tab.is-active{color:var(--cc-primary);font-weight:600}.status-tab.is-active::after{background:var(--cc-primary)}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 16px 12px;border:1px solid var(--cc-border);background:#f8fafc}.inventory-summary>div{display:flex;align-items:baseline;gap:8px;min-width:0;padding:9px 16px;border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:12px}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.status-tab{min-height:46px;padding:0 14px}.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.status-tab{padding:0 10px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||
|
||||
@@ -1,67 +1,9 @@
|
||||
.domestic-containers-page .table-panel .inventory-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 14px 18px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: flex; flex-wrap: wrap; gap: 6px 28px; margin: 0;
|
||||
padding: 8px 12px; border: 0; border-bottom: 1px solid var(--cc-border); background: #fff;
|
||||
}
|
||||
|
||||
.domestic-containers-page .table-panel .inventory-summary > div,
|
||||
.domestic-containers-page .table-panel .inventory-summary > div:last-child {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e7edf5;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.domestic-containers-page .table-panel .inventory-summary span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.domestic-containers-page .table-panel .inventory-summary strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #1e293b;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@media (min-width: 721px) {
|
||||
.domestic-containers-page .table-panel:has(.inventory-summary) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.domestic-containers-page .table-panel .inventory-summary {
|
||||
padding-right: 70px;
|
||||
}
|
||||
|
||||
.domestic-containers-page .inventory-summary + .data-table > .data-table__toolbar {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 35px;
|
||||
right: 18px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.domestic-containers-page .table-panel .inventory-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.domestic-containers-page .table-panel .inventory-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.domestic-containers-page .table-panel .inventory-summary > div {
|
||||
display: flex; align-items: baseline; min-width: 0; gap: 8px; padding: 0; border: 0;
|
||||
}
|
||||
.domestic-containers-page .table-panel .inventory-summary span { color: var(--cc-muted); font-size: 12px; }
|
||||
.domestic-containers-page .table-panel .inventory-summary strong { font-size: 14px; font-weight: 600; }
|
||||
|
||||
@@ -3,7 +3,6 @@ import { http } from '@/shared/api/http'
|
||||
import {
|
||||
getPlatformTenantOrderStatistics,
|
||||
acceptPlatformFirstLegOrder,
|
||||
addPlatformFirstLegOrderNote,
|
||||
approvePlatformFirstLegOrder,
|
||||
cancelPlatformFirstLegOrder,
|
||||
completePlatformFirstLegOrder,
|
||||
@@ -85,7 +84,6 @@ describe('platform first-leg order API', () => {
|
||||
await startPlatformFirstLegOrder('10001', '90001')
|
||||
await completePlatformFirstLegOrder('10001', '90001')
|
||||
await cancelPlatformFirstLegOrder('10001', '90001', '客户取消')
|
||||
await addPlatformFirstLegOrderNote('10001', '90001', '已联系客户确认')
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001', { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/orders/first-leg/90001/approve', undefined, { params: { tenantId: '10001' } })
|
||||
@@ -93,7 +91,6 @@ describe('platform first-leg order API', () => {
|
||||
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/orders/first-leg/90001/start', undefined, { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/orders/first-leg/90001/complete', undefined, { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(5, '/platform/v1/orders/first-leg/90001/cancel', { reason: '客户取消' }, { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(6, '/platform/v1/orders/first-leg/90001/internal-notes', { content: '已联系客户确认' }, { params: { tenantId: '10001' } })
|
||||
})
|
||||
|
||||
it('writes the backend express contract as names, not a guessed master-data ID', async () => {
|
||||
|
||||
@@ -192,10 +192,6 @@ export function cancelPlatformFirstLegOrder(tenantId: string, orderId: string, r
|
||||
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/cancel`, { reason }, { tenantId })
|
||||
}
|
||||
|
||||
export function addPlatformFirstLegOrderNote(tenantId: string, orderId: string, content: string) {
|
||||
return postData<number | string>(`/platform/v1/orders/first-leg/${orderId}/internal-notes`, { content }, { tenantId })
|
||||
}
|
||||
|
||||
export function updatePlatformFirstLegOrderExpress(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
|
||||
@@ -314,7 +314,7 @@ describe('platform IAM roles', () => {
|
||||
it('creates, updates, and changes customer status with the platform tenant contract', async () => {
|
||||
const tenant = { id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'ACTIVE' }
|
||||
const create = {
|
||||
tenantName: '柏悦物流', legalName: '柏悦物流有限公司', legalRepresentative: '张三',
|
||||
tenantName: '柏悦物流', customerNickname: '柏', legalName: '柏悦物流有限公司', legalRepresentative: '张三',
|
||||
contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com', servicePackage: '标准版',
|
||||
contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30, enterpriseType: '有限责任公司',
|
||||
establishedDate: '2020-01-01', registeredCapital: 1000, registeredCapitalCurrency: 'CNY',
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface PlatformTenantResource {
|
||||
id: number | string
|
||||
tenantCode: string
|
||||
tenantName: string
|
||||
customerNickname?: string | null
|
||||
status: PlatformTenantStatus
|
||||
dimensionInitializationStatus?: 'PENDING' | 'SENDING' | 'READY' | 'FAILED' | string
|
||||
legalName: string | null
|
||||
@@ -52,6 +53,7 @@ export interface PlatformTenantResource {
|
||||
|
||||
export interface CreatePlatformTenantRequest {
|
||||
tenantName: string
|
||||
customerNickname?: string
|
||||
legalName: string
|
||||
legalRepresentative?: string
|
||||
contactName: string
|
||||
@@ -69,7 +71,7 @@ export interface CreatePlatformTenantRequest {
|
||||
internalRemark: string
|
||||
}
|
||||
|
||||
export type UpdatePlatformTenantRequest = CreatePlatformTenantRequest
|
||||
export type UpdatePlatformTenantRequest = Omit<CreatePlatformTenantRequest, 'customerNickname'> & { customerNickname: string }
|
||||
|
||||
export interface PlatformTenantOrderCount {
|
||||
tenantId: number | string
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '../measurement/measurementDensity.css'
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -260,7 +262,7 @@ async function batchApproveMeasurements() {
|
||||
await ElMessageBox.confirm(
|
||||
`确认批量审核通过已勾选的 ${selected.length} 条测量数据吗?一件代发通过后进入待组托,其他订单进入待分柜。`,
|
||||
'批量审核测量数据',
|
||||
{ confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
|
||||
{ customClass: 'admin-density-confirm', confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
let successCount = 0
|
||||
let failureCount = 0
|
||||
@@ -326,7 +328,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="inbound-task-page">
|
||||
<AdminPage as="div" class="inbound-task-page admin-density">
|
||||
<ListPageLayout :aria-label="`${taskLabel}待办`">
|
||||
<template #toolbar>
|
||||
<QueryToolbar
|
||||
@@ -408,13 +410,13 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
v-if="isReview"
|
||||
v-model="reviewDialogOpen"
|
||||
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || '入仓码待补充'}` : taskLabel"
|
||||
width="min(1180px, calc(100vw - 32px))"
|
||||
width="min(1560px, calc(100vw - 48px))"
|
||||
top="4vh"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
:close-on-click-modal="!reviewNavigationLoading"
|
||||
:close-on-press-escape="!reviewNavigationLoading"
|
||||
class="inbound-task-dialog"
|
||||
class="inbound-task-dialog admin-density-dialog admin-density-workspace inbound-density"
|
||||
@closed="clearSelectedInbound"
|
||||
>
|
||||
<el-alert v-if="reviewNavigationError" type="error" :closable="false" show-icon class="review-navigation-error">
|
||||
@@ -466,8 +468,8 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.inbound-task-dialog { max-height: 92vh; overflow: hidden; }
|
||||
.inbound-task-dialog .el-dialog__body { max-height: calc(92vh - 62px); overflow: auto; padding: 20px 24px 24px; }
|
||||
.inbound-task-dialog.admin-density-workspace.el-dialog:not(.is-fullscreen) { height: auto; max-height: 92vh; overflow: hidden; }
|
||||
.inbound-task-dialog .el-dialog__body { max-height: calc(92vh - 62px); overflow: auto; padding: 12px 16px; }
|
||||
@media (max-width: 520px) {
|
||||
.inbound-task-dialog .el-dialog__body { padding: 14px; }
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ describe('InboundMeasurementDialog', () => {
|
||||
it('owns the complete measurement workspace behind one centered dialog', () => {
|
||||
expect(source).toContain("import AppDialog from '@/shared/components/AppDialog.vue'")
|
||||
expect(source).toContain('<AppDialog')
|
||||
expect(source).toContain('class="inbound-measurement-workspace-dialog"')
|
||||
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
|
||||
expect(source).toContain('class="inbound-measurement-workspace-dialog admin-density-dialog admin-density-workspace inbound-density"')
|
||||
expect(source).toContain('width="min(1560px, calc(100vw - 48px))"')
|
||||
expect(source).toContain('height: 92vh')
|
||||
expect(source).toContain('padding: 10px 20px')
|
||||
expect(source).toContain('<WeightOcrPanel')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import './measurementDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -66,9 +68,9 @@ function handleSubmitted() {
|
||||
<template>
|
||||
<AppDialog
|
||||
:model-value="modelValue"
|
||||
class="inbound-measurement-workspace-dialog"
|
||||
class="inbound-measurement-workspace-dialog admin-density-dialog admin-density-workspace inbound-density"
|
||||
:title="title"
|
||||
width="min(1240px, calc(100vw - 32px))"
|
||||
width="min(1560px, calc(100vw - 48px))"
|
||||
top="4vh"
|
||||
fullscreenable
|
||||
destroy-on-close
|
||||
@@ -139,8 +141,8 @@ function handleSubmitted() {
|
||||
<style>
|
||||
.inbound-measurement-workspace-dialog.el-dialog { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__header { flex: 0 0 auto; margin: 0; padding: 10px 20px; border-bottom: 1px solid var(--cc-border); }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__body { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 20px 24px; }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__footer { flex: 0 0 auto; padding: 14px 24px; border-top: 1px solid var(--cc-border); }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__body { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 12px 16px; }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__footer { flex: 0 0 auto; padding: 8px 16px; border-top: 1px solid var(--cc-border); }
|
||||
.inbound-measurement-workspace-dialog .measurement-panel { border: 0; border-radius: 0; box-shadow: none; }
|
||||
.inbound-measurement-workspace-dialog .measurement-panel__heading .measurement-panel__actions,
|
||||
.inbound-measurement-workspace-dialog .measurement-panel__footer { display: none; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { AlertTriangle, Image, Plus, Save, Send, Upload } from '@lucide/vue'
|
||||
@@ -860,7 +861,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<el-dialog v-model="imagePreviewOpen" title="当前有效测量图片" width="min(720px, 92vw)" destroy-on-close @closed="releaseImagePreview">
|
||||
<el-dialog class="admin-density-dialog" v-model="imagePreviewOpen" title="当前有效测量图片" width="min(720px, 92vw)" destroy-on-close @closed="releaseImagePreview">
|
||||
<div class="measurement-image-preview">
|
||||
<el-skeleton v-if="imagePreviewLoading" :rows="5" animated />
|
||||
<LoadErrorState v-else-if="imagePreviewError" title="图片加载失败" :message="imagePreviewError.message" :show-retry="false">
|
||||
@@ -871,7 +872,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="manualEntryOpen" title="手动录入箱规" width="min(680px, 92vw)" destroy-on-close>
|
||||
<el-dialog class="admin-density-dialog" v-model="manualEntryOpen" title="手动录入箱规" width="min(680px, 92vw)" destroy-on-close>
|
||||
<el-alert v-if="manualEntryErrors.length" type="error" :closable="false" show-icon class="manual-entry-alert" title="请修正以下问题">
|
||||
<ul><li v-for="error in manualEntryErrors" :key="error">{{ error }}</li></ul>
|
||||
</el-alert>
|
||||
@@ -943,7 +944,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
.measurement-section__heading > div { min-width: 0; }
|
||||
.measurement-section-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }.measurement-section-actions :deep(.el-button) { margin-left: 0; }
|
||||
.warehouse-suggestion-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }.warehouse-suggestion-form :deep(.el-select),.warehouse-suggestion-form :deep(.el-input-number) { width: 100%; }.warehouse-suggestion-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.suggestion-preview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 4px 0 0; border: 1px solid var(--cc-border); border-radius: 6px; }.suggestion-preview div { padding: 12px; border-right: 1px solid var(--cc-border); }.suggestion-preview div:last-child { border-right: 0; }.suggestion-preview dt { color: var(--cc-muted); font-size: 11px; }.suggestion-preview dd { margin: 5px 0 0; color: var(--cc-text); font-size: 13px; font-weight: 650; }
|
||||
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
|
||||
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||
.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
|
||||
.measurement-form label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
|
||||
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { width: 100%; }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@media (min-width: 961px) {
|
||||
.inbound-density.admin-density-dialog .measurement-dialog-weight { padding-bottom: 10px; }
|
||||
.inbound-density.admin-density-dialog .measurement-section { padding: 10px 0; }
|
||||
.inbound-density.admin-density-dialog .measurement-summary-section { gap: 8px; }
|
||||
.inbound-density.admin-density-dialog .measurement-section__heading { align-items: center; margin-bottom: 8px; }
|
||||
.inbound-density.admin-density-dialog .measurement-metrics { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.inbound-density.admin-density-dialog .measurement-metrics > div { min-height: 36px; padding: 6px 10px; display: flex; align-items: baseline; gap: 8px; }
|
||||
.inbound-density.admin-density-dialog .measurement-metrics dd { font-size: 14px; }
|
||||
.inbound-density.admin-density-dialog .measurement-metrics small { font-size: 12px; }
|
||||
.inbound-density.admin-density-dialog .measurement-data-grid { gap: 8px 12px; }
|
||||
.inbound-density.admin-density-dialog .measurement-declaration { padding-top: 8px; }
|
||||
.inbound-density.admin-density-dialog .measurement-declaration h4 { margin-bottom: 6px; font-size: 13px; }
|
||||
.inbound-density.admin-density-dialog .measurement-panel > .el-alert { margin: 8px 0; }
|
||||
.inbound-density.admin-density-dialog .measurement-summary-header span { font-size: 12px; }
|
||||
.inbound-density.admin-density-dialog .measurement-table .el-table__header th { padding: 4px 0; font-size: 13px; }
|
||||
.inbound-density.admin-density-dialog .weight-heading { flex-wrap: wrap; gap: 8px; padding-bottom: 8px; }
|
||||
.inbound-density.admin-density-dialog .weight-heading__actions { flex-wrap: wrap; gap: 12px; }
|
||||
.inbound-density.admin-density-dialog .pallet-picker { width: 340px; }
|
||||
.inbound-density.admin-density-dialog .weight-list { margin-top: 8px; }
|
||||
.inbound-density.admin-density-dialog .weight-grid { grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 8px; }
|
||||
.inbound-density.admin-density-dialog .weight-card { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 10px; align-items: start; padding: 8px; border-radius: 2px; }
|
||||
.inbound-density.admin-density-dialog .weight-card .weight-thumb { width: 120px; border-radius: 2px; }
|
||||
.inbound-density.admin-density-dialog .weight-card__body { min-width: 0; padding: 0; text-align: left; }
|
||||
.inbound-density.admin-density-dialog .weight-card__value { min-height: 28px; padding-right: 32px; text-align: left; }
|
||||
.inbound-density.admin-density-dialog .weight-card__value strong { font-size: 14px; }
|
||||
.inbound-density.admin-density-dialog .weight-card__facts { gap: 3px; margin-top: 4px; padding-top: 4px; }
|
||||
.inbound-density.admin-density-dialog .weight-card__facts div { font-size: 12px; }
|
||||
.inbound-density.admin-density-dialog .weight-card__correct { width: auto; margin-top: 6px; }
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
})
|
||||
|
||||
it('keeps customer context and pending actions in one non-sticky first-screen toolbar', async () => {
|
||||
it('keeps customer context and pending actions in one sticky toolbar while reviewing long content', async () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
expect(wrapper.text()).not.toContain('审核历史')
|
||||
expect(listMeasurementReviewHistory).not.toHaveBeenCalled()
|
||||
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
|
||||
expect(source).not.toContain('position: sticky')
|
||||
expect(source).toContain('position: sticky')
|
||||
expect(source).toContain('review-toolbar__context')
|
||||
expect(source.indexOf('class="review-toolbar"')).toBeLessThan(source.indexOf('<WeightOcrPanel'))
|
||||
expect(source).not.toContain('reasonDialog')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, Pencil, RotateCcw } from '@lucide/vue'
|
||||
@@ -139,7 +140,7 @@ async function confirmApprove() {
|
||||
await ElMessageBox.confirm(
|
||||
`审核通过后,入库单将进入${approvalTargetLabel.value}。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?`,
|
||||
'确认测量审核通过',
|
||||
{ confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' },
|
||||
{ customClass: 'admin-density-confirm', confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
@@ -242,7 +243,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
|
||||
<div v-if="detail.items.length" class="review-table-scroll">
|
||||
<el-table :data="detail.items" border stripe table-layout="fixed" class="review-table">
|
||||
<el-table-column type="index" label="#" width="44" align="center" />
|
||||
<el-table-column label="箱号" min-width="100" show-overflow-tooltip>
|
||||
<el-table-column label="箱号" width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="测量图片" width="98" align="center">
|
||||
@@ -257,7 +258,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="尺寸 长×宽×高(cm)" width="132" align="center">
|
||||
<el-table-column label="尺寸 长×宽×高(cm)" min-width="200" align="center">
|
||||
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="packageCount" label="箱数" width="54" align="right">
|
||||
@@ -272,7 +273,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
|
||||
<el-table-column label="体积重(kg)" width="96" align="right">
|
||||
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="测量时间" width="152" class-name="review-time-cell">
|
||||
<el-table-column label="测量时间" width="190" class-name="review-time-cell">
|
||||
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -289,7 +290,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
|
||||
/>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="imagePreviewOpen" :title="imagePreviewTitle" width="min(760px, 92vw)" destroy-on-close append-to-body @closed="releaseImagePreview">
|
||||
<el-dialog class="admin-density-dialog" v-model="imagePreviewOpen" :title="imagePreviewTitle" width="min(760px, 92vw)" destroy-on-close append-to-body @closed="releaseImagePreview">
|
||||
<div class="review-image-preview">
|
||||
<el-skeleton v-if="imagePreviewLoading" :rows="5" animated />
|
||||
<div v-else-if="imagePreviewError" class="review-image-preview__error">
|
||||
@@ -304,8 +305,8 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-panel { display: grid; gap: 16px; color: var(--cc-text); }
|
||||
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin: -20px -24px 0; padding: 14px 24px; border-bottom: 1px solid var(--cc-border); background: #fff; }
|
||||
.review-panel { display: grid; gap: 12px; color: var(--cc-text); }
|
||||
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; position: sticky; top: -12px; z-index: 3; flex-wrap: wrap; margin: -12px -16px 0; padding: 8px 16px; border-bottom: 1px solid var(--cc-border); background: #fff; }
|
||||
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.review-toolbar__status > span { color: var(--cc-muted); font-size: 12px; }
|
||||
.review-toolbar__actions { justify-content: flex-end; }
|
||||
@@ -324,14 +325,14 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
|
||||
.review-metrics, .review-declaration { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px 16px; min-width: 0; margin: 0; }
|
||||
.review-declaration { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.review-metrics > div, .review-declaration > div { display: flex; min-width: 0; align-items: baseline; gap: 6px; }
|
||||
.review-metrics dt, .review-declaration dt { color: var(--cc-muted); font-size: 11px; line-height: 1.4; }
|
||||
.review-metrics dd { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 15px; font-weight: 700; line-height: 1.35; }
|
||||
.review-metrics dd small, .review-declaration dd small { color: var(--cc-muted); font-size: 11px; font-weight: 500; }
|
||||
.review-metrics dt, .review-declaration dt { color: var(--cc-muted); font-size: 12px; line-height: 1.4; }
|
||||
.review-metrics dd { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 14px; font-weight: 600; line-height: 1.35; }
|
||||
.review-metrics dd small, .review-declaration dd small { color: var(--cc-muted); font-size: 12px; font-weight: 500; }
|
||||
.review-metrics .review-metric--primary dd { color: #067647; }
|
||||
.review-declaration dd { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; font-weight: 650; line-height: 1.4; }
|
||||
.review-table-scroll { width: 100%; min-width: 0; overflow: hidden; }
|
||||
.review-table { width: 100%; font-size: 12px; }
|
||||
.review-table :deep(.el-table__header th) { padding: 8px 0; background: #f6f8fa; color: #475467; font-size: 11px; font-weight: 700; }
|
||||
.review-table :deep(.el-table__header th) { padding: 8px 0; background: #f6f8fa; color: #475467; font-size: 12px; font-weight: 700; }
|
||||
.review-table :deep(.el-table__header .cell) { white-space: nowrap; }
|
||||
.review-table :deep(.el-table__cell) { padding: 7px 0; }
|
||||
.review-table :deep(.review-time-cell .cell) { white-space: nowrap; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
@@ -625,7 +626,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<AppDialog
|
||||
<AppDialog class="admin-density-dialog"
|
||||
v-model="previewOpen"
|
||||
title="确认称重图片"
|
||||
:loading="previewLoading"
|
||||
@@ -711,10 +712,10 @@ defineExpose({ reload: loadWorkspace })
|
||||
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
|
||||
.weight-card__correct { width: 100%; margin: 12px 0 0; }
|
||||
.weight-preview { display: block; width: 100%; max-height: min(50vh, 460px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
|
||||
.weight-key-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin-top: 14px; padding: 14px 0; border-block: 1px solid var(--cc-border); }
|
||||
.weight-key-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; margin-top: 8px; padding: 8px 0; border-block: 1px solid var(--cc-border); }
|
||||
.weight-key-facts div { display: grid; min-width: 0; gap: 4px; }
|
||||
.weight-key-facts span { color: var(--cc-muted); font-size: 12px; }
|
||||
.weight-key-facts strong { color: #101828; font-size: 18px; }
|
||||
.weight-key-facts strong { color: #101828; font-size: 14px; }
|
||||
.preview-weight { display: grid; grid-template-columns: minmax(0, 1fr) 120px; align-items: end; gap: 12px; margin-top: 14px; }
|
||||
.preview-weight label { display: grid; gap: 6px; color: #475467; font-size: 12px; }
|
||||
.preview-weight :deep(.el-input-number), .preview-weight :deep(.el-segmented) { width: 100%; }
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
preparePalletLabels,
|
||||
printPalletLabels,
|
||||
updatePreloadTransport,
|
||||
updateLoadingItemWarehouseRemark,
|
||||
updateLoadingItemRemarks,
|
||||
} from './platformLoadingLists'
|
||||
|
||||
const preloadSummary = {
|
||||
@@ -53,7 +53,7 @@ const detail = {
|
||||
customerName: '测试客户', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true, inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null,
|
||||
goodsImageUrl: null, unitType: 'SEALED_PALLET', palletId: 9007199254740993n, palletNo: 'PLT-001', indivisible: true, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1,
|
||||
dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null,
|
||||
internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null,
|
||||
internalRemark: null, customerRemark: null, warehouseRemark: null, customerServiceRemark: null, loadSequence: 1, frozenAt: null,
|
||||
}],
|
||||
histories: [{ historyId: 21, itemId: 11, operationId: 'transport-21', actionType: 'UPDATE_TRANSPORT', fromStatus: 'LOADING', toStatus: 'LOADING', beforeSnapshot: null, afterSnapshot: null, operatorId: 22, operatorName: '操作员', operationReason: '调整', occurredAt: '2026-08-10T11:00:00' }],
|
||||
}
|
||||
@@ -108,18 +108,20 @@ describe('platform loading lists API', () => {
|
||||
expect(loaded.summary.status).toBe('COMPLETED')
|
||||
})
|
||||
|
||||
it('updates one item warehouse remark with both optimistic-lock versions and operation id', async () => {
|
||||
const patch = vi.spyOn(http, 'patch').mockResolvedValue({ code: '200', message: 'ok', data: { id: 100, version: 4, items: [{ id: 11, warehouseRemark: '装柜时注意外箱', version: 5 }] } })
|
||||
const result = await updateLoadingItemWarehouseRemark('100', '11', {
|
||||
it('updates one item editable remarks with both optimistic-lock versions and operation id', async () => {
|
||||
const patch = vi.spyOn(http, 'patch').mockResolvedValue({ code: '200', message: 'ok', data: { id: 100, version: 4, items: [{ id: 11, internalRemark: '内部', warehouseRemark: '装柜时注意外箱', customerServiceRemark: '客服', version: 5 }] } })
|
||||
const result = await updateLoadingItemRemarks('100', '11', {
|
||||
internalRemark: '内部',
|
||||
warehouseRemark: '装柜时注意外箱',
|
||||
customerServiceRemark: '客服',
|
||||
expectedPlanVersion: 3,
|
||||
expectedItemVersion: 4,
|
||||
operationId: 'warehouse-remark-1',
|
||||
})
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/smart-loading/plans/100/items/11/warehouse-remark',
|
||||
{ warehouseRemark: '装柜时注意外箱', expectedPlanVersion: 3, expectedItemVersion: 4, operationId: 'warehouse-remark-1' },
|
||||
'/platform/v1/first-leg/smart-loading/plans/100/items/11/remarks',
|
||||
{ internalRemark: '内部', warehouseRemark: '装柜时注意外箱', customerServiceRemark: '客服', expectedPlanVersion: 3, expectedItemVersion: 4, operationId: 'warehouse-remark-1' },
|
||||
)
|
||||
expect(result).toMatchObject({ id: '100', version: 4, items: [{ id: '11', version: 5, warehouseRemark: '装柜时注意外箱' }] })
|
||||
})
|
||||
|
||||
@@ -111,6 +111,7 @@ export interface LoadingItem {
|
||||
internalRemark: string | null
|
||||
customerRemark: string | null
|
||||
warehouseRemark: string | null
|
||||
customerServiceRemark: string | null
|
||||
loadSequence: number
|
||||
frozenAt: string | null
|
||||
}
|
||||
@@ -161,17 +162,19 @@ export interface TransportUpdateRequest {
|
||||
operationReason: string | null
|
||||
}
|
||||
|
||||
export interface WarehouseRemarkUpdateRequest {
|
||||
export interface LoadingItemRemarksUpdateRequest {
|
||||
internalRemark: string | null
|
||||
warehouseRemark: string | null
|
||||
customerServiceRemark: string | null
|
||||
expectedPlanVersion: number
|
||||
expectedItemVersion: number
|
||||
operationId: string
|
||||
}
|
||||
|
||||
export interface WarehouseRemarkUpdateResponse {
|
||||
export interface LoadingItemRemarksUpdateResponse {
|
||||
id: string
|
||||
version: number
|
||||
items: Array<{ id: string; version: number; warehouseRemark: string | null }>
|
||||
items: Array<{ id: string; version: number; internalRemark: string | null; warehouseRemark: string | null; customerServiceRemark: string | null }>
|
||||
}
|
||||
|
||||
export interface PreloadConfirmationResponse {
|
||||
@@ -342,9 +345,9 @@ export async function updatePreloadTransport(planId: string, warehouseId: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLoadingItemWarehouseRemark(planId: string, itemId: string, request: WarehouseRemarkUpdateRequest) {
|
||||
const response = await http.patch<ApiEnvelope<WarehouseRemarkUpdateResponse>, ApiEnvelope<WarehouseRemarkUpdateResponse>>(
|
||||
`${basePath}/smart-loading/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}/warehouse-remark`,
|
||||
export async function updateLoadingItemRemarks(planId: string, itemId: string, request: LoadingItemRemarksUpdateRequest) {
|
||||
const response = await http.patch<ApiEnvelope<LoadingItemRemarksUpdateResponse>, ApiEnvelope<LoadingItemRemarksUpdateResponse>>(
|
||||
`${basePath}/smart-loading/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}/remarks`,
|
||||
request,
|
||||
)
|
||||
const result = unwrapApiResponse(response)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed } from 'vue'
|
||||
import { ArrowLeft, Check, Pencil } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -23,7 +24,7 @@ const props = defineProps<{
|
||||
confirmSaving?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: [] }>()
|
||||
const emit = defineEmits<{ back: []; retry: []; edit: []; editRemarks: [item: LoadingItem]; confirmPreload: [] }>()
|
||||
|
||||
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false }
|
||||
const baseItemColumns: DataTableColumn[] = [
|
||||
@@ -31,11 +32,11 @@ const baseItemColumns: DataTableColumn[] = [
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 155, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'unitType', label: '装柜单位', width: 110, slotName: 'unitType' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 165, className: 'identifier-column', showOverflowTooltip: true, formatter: (row: LoadingItem) => row.containerNo || '待补录' },
|
||||
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 220, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 220 },
|
||||
{ key: 'taxRefundFlag', label: '是否退税', width: 96, align: 'center', slotName: 'taxRefundFlag' },
|
||||
{ key: 'chineseName', prop: 'chineseName', label: '中文品名', minWidth: 160 },
|
||||
{ key: 'englishName', prop: 'englishName', label: '英文品名', minWidth: 170 },
|
||||
@@ -53,6 +54,7 @@ const baseItemColumns: DataTableColumn[] = [
|
||||
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 190, slotName: 'customerRemark' },
|
||||
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(内部)', minWidth: 190, slotName: 'internalRemark' },
|
||||
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 210, slotName: 'warehouseRemark' },
|
||||
{ key: 'customerServiceRemark', prop: 'customerServiceRemark', label: '备注(客服)', minWidth: 210, slotName: 'customerServiceRemark' },
|
||||
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
|
||||
]
|
||||
|
||||
@@ -146,7 +148,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="loading-detail-content" :aria-label="title" :aria-busy="loading">
|
||||
<section v-loading="loading" class="loading-detail-content" :class="{ 'admin-density preload-density': mode === 'preload' }" :aria-label="title" :aria-busy="loading">
|
||||
<section class="detail-overview" aria-label="清单概览">
|
||||
<header class="detail-page-header" :class="{ 'detail-page-header--embedded': embedded }">
|
||||
<el-button link type="primary" class="detail-page-header__back" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
@@ -155,7 +157,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细和柜次费用' }}</p>
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<PalletLabelPrintDialog v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
|
||||
<PalletLabelPrintDialog :dense="mode === 'preload'" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
|
||||
<template v-if="canEditItemRemark">
|
||||
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
|
||||
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
||||
@@ -202,7 +204,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
>
|
||||
<template #toolbar-start><div class="detail-section__heading"><div><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><p>仓库备注将随装柜完成资料下发海外仓</p></div></div></template>
|
||||
<template #actions="{ row }">
|
||||
<el-button size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的仓库备注`" @click="emit('editWarehouseRemark', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
|
||||
<el-button size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的备注`" @click="emit('editRemarks', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
|
||||
</template>
|
||||
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag><small v-if="row.indivisible">整托不可拆</small></template>
|
||||
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
|
||||
@@ -213,22 +215,20 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<template #customerRemark="{ row }"><div class="remark-cell"><span>{{ row.customerRemark || '—' }}</span><small>来源:客户下单</small></div></template>
|
||||
<template #internalRemark="{ row }"><div class="remark-cell"><span>{{ row.internalRemark || '—' }}</span><small>来源:订单内部备注</small></div></template>
|
||||
<template #warehouseRemark="{ row }"><div class="remark-cell"><span>{{ row.warehouseRemark || '—' }}</span><small>{{ mode === 'preload' ? '由仓库维护' : '装柜完成时冻结' }}</small></div></template>
|
||||
<template #customerServiceRemark="{ row }"><div class="remark-cell"><span>{{ row.customerServiceRemark || '—' }}</span><small>{{ mode === 'preload' ? '由客服维护' : '装柜完成时冻结' }}</small></div></template>
|
||||
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<section class="history-section" aria-label="操作历史">
|
||||
<header><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span></header>
|
||||
<el-empty v-if="visibleHistories.length === 0" description="暂无操作历史" :image-size="72" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item v-for="history in visibleHistories" :key="historyEventKey(history) || history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top">
|
||||
<div class="history-entry">
|
||||
<strong>{{ actionLabel(history.actionType) }}</strong>
|
||||
<span v-if="history.fromStatus || history.toStatus">{{ loadingListStatusLabel(history.fromStatus) }} → {{ loadingListStatusLabel(history.toStatus) }}</span>
|
||||
<p>{{ history.operatorName || '操作人信息缺失' }}<template v-if="history.operationReason"> · {{ history.operationReason }}</template></p>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-table :data="visibleHistories" border stripe empty-text="暂无操作历史" aria-label="清单操作历史">
|
||||
<el-table-column label="时间" width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
|
||||
<el-table-column label="事件" width="140"><template #default="{ row }">{{ actionLabel(row.actionType) }}</template></el-table-column>
|
||||
<el-table-column label="状态变化" min-width="200"><template #default="{ row }"><span v-if="row.fromStatus || row.toStatus">{{ loadingListStatusLabel(row.fromStatus) }} → {{ loadingListStatusLabel(row.toStatus) }}</span><span v-else>—</span></template></el-table-column>
|
||||
<el-table-column label="操作人" min-width="160"><template #default="{ row }">{{ row.operatorName || '操作人信息缺失' }}</template></el-table-column>
|
||||
<el-table-column label="原因 / 备注" min-width="240"><template #default="{ row }">{{ row.operationReason || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
</div>
|
||||
@@ -273,7 +273,22 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
.history-section header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 18px; }
|
||||
.history-section h2 { margin: 0; font-size: 16px; }
|
||||
.history-section header span { color: var(--cc-muted); font-size: 12px; }
|
||||
.history-entry { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.history-entry > span { color: var(--cc-muted); font-size: 12px; }
|
||||
.history-entry p { width: 100%; margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.preload-density { gap: 12px; padding: 12px; border-radius: 4px; }
|
||||
.preload-density .detail-page-header { gap: 12px; padding: 0 0 8px; }
|
||||
.preload-density .detail-page-header__identity h1 { font-size: 16px; overflow-wrap: anywhere; }
|
||||
.preload-density .detail-page-header__actions { flex-wrap: wrap; }
|
||||
.preload-density .detail-summary { gap: 0; padding: 0; border-left: 1px solid var(--cc-border); }
|
||||
.preload-density .detail-summary > div { gap: 0; align-items: stretch; min-height: 36px; border-bottom: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); }
|
||||
.preload-density .detail-summary > div > span:first-child { display: flex; align-items: center; flex: 0 0 96px; padding: 6px 8px; background: #f8fafc; }
|
||||
.preload-density .detail-summary > div > span:first-child::after { content: none; }
|
||||
.preload-density .detail-summary strong, .preload-density .detail-summary :deep(.status-badge) { align-self: center; margin: 6px 8px; font-weight: 400; }
|
||||
.preload-density .detail-summary__wide { grid-column: span 2; }
|
||||
.preload-density .detail-layout :deep(.detail-page-layout__content) { gap: 12px; }
|
||||
.preload-density .detail-section, .preload-density .history-section { padding-top: 8px; }
|
||||
.preload-density .detail-section__heading h2, .preload-density .history-section h2 { font-size: 14px; }
|
||||
.preload-density .history-section header { margin-bottom: 8px; }
|
||||
.preload-density .detail-section__heading p, .preload-density small { font-size: 12px; }
|
||||
.preload-density .readiness-note { align-items: baseline; }
|
||||
.preload-density .readiness-note span { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) { .preload-density .detail-summary { grid-template-columns: 1fr; } .preload-density .detail-summary__wide { grid-column: auto; } }
|
||||
</style>
|
||||
|
||||
@@ -169,6 +169,7 @@ describe('LoadingListsView', () => {
|
||||
'customerRemark',
|
||||
'internalRemark',
|
||||
'warehouseRemark',
|
||||
'customerServiceRemark',
|
||||
]
|
||||
let previous = detailSource.indexOf("key: 'customerName'")
|
||||
for (const field of fields) {
|
||||
@@ -180,14 +181,14 @@ describe('LoadingListsView', () => {
|
||||
expect(detailSource).not.toContain("label: '退税资料'")
|
||||
})
|
||||
|
||||
it('keeps customer and internal remarks read-only while exposing per-item warehouse remark editing', () => {
|
||||
it('keeps customer remarks read-only while exposing three editable collaboration remarks', () => {
|
||||
expect(detailSource).toContain('来源:客户下单')
|
||||
expect(detailSource).toContain('来源:订单内部备注')
|
||||
expect(detailSource).toContain('仓库备注将随装柜完成资料下发海外仓')
|
||||
expect(detailSource).toContain("emit('editWarehouseRemark', row as LoadingItem)")
|
||||
expect(detailSource).toContain("emit('editRemarks', row as LoadingItem)")
|
||||
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
|
||||
expect(detailSource).not.toContain("emit('editCustomerRemark'")
|
||||
expect(detailSource).not.toContain("emit('editInternalRemark'")
|
||||
expect(detailSource).toContain("key: 'customerServiceRemark'")
|
||||
})
|
||||
|
||||
it('uses one white detail surface, a readable four-column summary and dense item table', () => {
|
||||
@@ -229,9 +230,9 @@ describe('LoadingListsView', () => {
|
||||
expect(source).toContain("item.customerName || '客户信息缺失'")
|
||||
expect(source).not.toContain('item.customerName || item.accountId')
|
||||
expect(detailSource).toContain("item.customerName || '客户信息缺失'")
|
||||
expect(detailSource).toContain("history.operatorName || '操作人信息缺失'")
|
||||
expect(detailSource).toContain("row.operatorName || '操作人信息缺失'")
|
||||
expect(detailSource).not.toContain('item.customerName || item.accountId')
|
||||
expect(detailSource).not.toContain('history.operatorName || history.operatorId')
|
||||
expect(detailSource).not.toContain('row.operatorName || row.operatorId')
|
||||
})
|
||||
|
||||
it('does not retain the retired loading-list customs entry', () => {
|
||||
@@ -244,7 +245,7 @@ describe('LoadingListsView', () => {
|
||||
it('filters pure status no-ops and counts identity-distinct visible history events', () => {
|
||||
for (const marker of ['visibleHistories', 'STATUS_UPDATE', 'operationId', 'PUSH_PRELOAD']) expect(detailSource).toContain(marker)
|
||||
expect(detailSource).toContain('visibleHistories.length }} 条')
|
||||
expect(detailSource).toContain('v-for="history in visibleHistories"')
|
||||
expect(detailSource).toContain(':data="visibleHistories"')
|
||||
expect(detailSource).not.toContain('detail.histories.length }} 条')
|
||||
expect(detailSource).not.toContain('v-for="history in detail.histories"')
|
||||
expect(detailSource).toContain('seen.has(eventKey)')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ContainerSimulationCreate } from '@/modules/container-loading'
|
||||
@@ -181,7 +182,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="business-page loading-lists-page">
|
||||
<div class="business-page loading-lists-page" :class="{ 'admin-density': isPreload }">
|
||||
<ListPageLayout :ariaLabel="title">
|
||||
<template #toolbar>
|
||||
<QueryToolbar
|
||||
@@ -215,7 +216,7 @@ onMounted(() => {
|
||||
:aria-label="title"
|
||||
>
|
||||
<template #toolbar-actions>
|
||||
<ContainerSimulationCreate v-if="canCreate" success-message="模拟柜及预装清单已创建" @created="onSimulationCreated" />
|
||||
<ContainerSimulationCreate :dense="isPreload" v-if="canCreate" success-message="模拟柜及预装清单已创建" @created="onSimulationCreated" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="row-actions">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Printer } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -7,7 +8,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { preparePalletLabels, printPalletLabels, type PalletLabelManifest } from '../api/platformLoadingLists'
|
||||
|
||||
const props = defineProps<{ planId: string; warehouseId: string; disabled?: boolean }>()
|
||||
const props = defineProps<{ planId: string; warehouseId: string; disabled?: boolean; dense?: boolean }>()
|
||||
const open = ref(false)
|
||||
const loading = ref(false)
|
||||
const printing = ref(false)
|
||||
@@ -47,7 +48,7 @@ async function print() {
|
||||
|
||||
<template>
|
||||
<el-button :disabled="disabled" @click="show"><Printer :size="16" />打印托盘码</el-button>
|
||||
<AppDialog v-model="open" title="散货托盘码" width="min(760px, calc(100vw - 28px))" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print">
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="open" title="散货托盘码" width="min(760px, calc(100vw - 28px))" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print">
|
||||
<LoadErrorState v-if="error" compact title="托盘码操作失败" :message="error.message" :show-retry="false" />
|
||||
<template v-if="manifest">
|
||||
<el-alert type="info" :closable="false" :title="`编号规则:${manifest.containerNo}-FBA地址内部编号-三位序号;一件代发托盘不在此清单。`" />
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock('../api/platformLoadingLists', () => ({
|
||||
confirmPreloadPlan: vi.fn(),
|
||||
getConfirmedLoadingList: vi.fn(),
|
||||
getPreloadPlan: vi.fn(),
|
||||
updateLoadingItemWarehouseRemark: vi.fn(),
|
||||
updateLoadingItemRemarks: vi.fn(),
|
||||
updatePreloadTransport: vi.fn(),
|
||||
}))
|
||||
|
||||
|
||||
@@ -39,20 +39,22 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
||||
expect(source).not.toContain('saveMfNote')
|
||||
})
|
||||
|
||||
it('edits only the selected item warehouse remark with optimistic locking', () => {
|
||||
expect(source).toContain('@edit-warehouse-remark="openWarehouseRemark"')
|
||||
expect(source).toContain('updateLoadingItemWarehouseRemark(current.summary.planId, target.itemId')
|
||||
it('edits the selected item internal, warehouse and customer-service remarks with optimistic locking', () => {
|
||||
expect(source).toContain('@edit-remarks="openRemarks"')
|
||||
expect(source).toContain('updateLoadingItemRemarks(current.summary.planId, target.itemId')
|
||||
expect(source).toContain('expectedPlanVersion: current.summary.version')
|
||||
expect(source).toContain('expectedItemVersion: target.itemVersion')
|
||||
expect(source).toContain('operationId: remarkOperationId.value')
|
||||
expect(source).toContain('maxlength="1000"')
|
||||
expect(source).toContain("warehouseRemark.value.trim() || null")
|
||||
expect(source).toContain("remarkForm.internalRemark.trim() || null")
|
||||
expect(source).toContain("remarkForm.warehouseRemark.trim() || null")
|
||||
expect(source).toContain("remarkForm.customerServiceRemark.trim() || null")
|
||||
expect(source).toContain('summary: { ...current.summary, version: result.version }')
|
||||
expect(source).toContain('updatedItem?.version')
|
||||
expect(source).not.toContain('plan.remark')
|
||||
})
|
||||
|
||||
it('preserves warehouse remark input and refreshes current data on conflicts', () => {
|
||||
it('preserves remark input and refreshes current data on conflicts', () => {
|
||||
expect(source).toContain("details.code === 'A0409'")
|
||||
expect(source).toContain('await loadDetail()')
|
||||
expect(source).toContain("detail.value?.items.find((item) => item.itemId === target.itemId) || target")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
@@ -11,7 +12,7 @@ import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type Api
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, updateLoadingItemWarehouseRemark, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
|
||||
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, updateLoadingItemRemarks, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -33,7 +34,7 @@ const remarkOpen = ref(false)
|
||||
const remarkSaving = ref(false)
|
||||
const remarkError = ref<ApiErrorDetails | null>(null)
|
||||
const remarkTarget = ref<LoadingItem | null>(null)
|
||||
const warehouseRemark = ref('')
|
||||
const remarkForm = reactive({ internalRemark: '', warehouseRemark: '', customerServiceRemark: '' })
|
||||
const remarkOperationId = ref('')
|
||||
let loadSequence = 0
|
||||
const planId = computed(() => String(route.params.planId || ''))
|
||||
@@ -162,28 +163,32 @@ async function saveTransport() {
|
||||
}
|
||||
}
|
||||
|
||||
function openWarehouseRemark(item: LoadingItem) {
|
||||
function openRemarks(item: LoadingItem) {
|
||||
if (!canEdit.value || !detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
|
||||
remarkTarget.value = item
|
||||
warehouseRemark.value = item.warehouseRemark || ''
|
||||
remarkForm.internalRemark = item.internalRemark || ''
|
||||
remarkForm.warehouseRemark = item.warehouseRemark || ''
|
||||
remarkForm.customerServiceRemark = item.customerServiceRemark || ''
|
||||
remarkError.value = null
|
||||
remarkOperationId.value = `admin-preload-warehouse-remark-${detail.value.summary.planId}-${item.itemId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
remarkOperationId.value = `admin-preload-remarks-${detail.value.summary.planId}-${item.itemId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
remarkOpen.value = true
|
||||
}
|
||||
|
||||
async function saveWarehouseRemark() {
|
||||
async function saveRemarks() {
|
||||
const target = remarkTarget.value
|
||||
const current = detail.value
|
||||
if (!target || !current || !canEdit.value || remarkSaving.value) return
|
||||
if (warehouseRemark.value.length > 1000) {
|
||||
remarkError.value = { code: 'WAREHOUSE_REMARK_TOO_LONG', message: '仓库备注不能超过 1000 个字符' }
|
||||
if (Object.values(remarkForm).some((value) => value.length > 1000)) {
|
||||
remarkError.value = { code: 'REMARK_TOO_LONG', message: '每项备注不能超过 1000 个字符' }
|
||||
return
|
||||
}
|
||||
remarkSaving.value = true
|
||||
remarkError.value = null
|
||||
try {
|
||||
const result = await updateLoadingItemWarehouseRemark(current.summary.planId, target.itemId, {
|
||||
warehouseRemark: warehouseRemark.value.trim() || null,
|
||||
const result = await updateLoadingItemRemarks(current.summary.planId, target.itemId, {
|
||||
internalRemark: remarkForm.internalRemark.trim() || null,
|
||||
warehouseRemark: remarkForm.warehouseRemark.trim() || null,
|
||||
customerServiceRemark: remarkForm.customerServiceRemark.trim() || null,
|
||||
expectedPlanVersion: current.summary.version,
|
||||
expectedItemVersion: target.itemVersion,
|
||||
operationId: remarkOperationId.value,
|
||||
@@ -193,15 +198,18 @@ async function saveWarehouseRemark() {
|
||||
...current,
|
||||
summary: { ...current.summary, version: result.version },
|
||||
items: current.items.map((item) => item.itemId === target.itemId
|
||||
? { ...item, itemVersion: updatedItem?.version ?? item.itemVersion, warehouseRemark: updatedItem?.warehouseRemark ?? null }
|
||||
? { ...item, itemVersion: updatedItem?.version ?? item.itemVersion,
|
||||
internalRemark: updatedItem?.internalRemark ?? null,
|
||||
warehouseRemark: updatedItem?.warehouseRemark ?? null,
|
||||
customerServiceRemark: updatedItem?.customerServiceRemark ?? null }
|
||||
: item),
|
||||
}
|
||||
remarkOpen.value = false
|
||||
remarkTarget.value = null
|
||||
remarkOperationId.value = ''
|
||||
ElMessage.success('仓库备注已更新')
|
||||
ElMessage.success('备注已更新')
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '仓库备注保存失败,请稍后重试')
|
||||
const details = getApiErrorDetails(cause, '备注保存失败,请稍后重试')
|
||||
if (details.code === 'A0409' || /版本|并发|状态.*变化/.test(details.message)) {
|
||||
await loadDetail()
|
||||
remarkTarget.value = detail.value?.items.find((item) => item.itemId === target.itemId) || target
|
||||
@@ -244,12 +252,12 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="preload-detail-page">
|
||||
<div class="preload-detail-page admin-density">
|
||||
<OperationErrorToast :details="remarkError || editError || confirmError" />
|
||||
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-edit-transport="canCorrectTransport" :transport-note="transportCorrectionNote" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-warehouse-remark="openWarehouseRemark" @confirm-preload="openConfirmPreload" />
|
||||
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-edit-transport="canCorrectTransport" :transport-note="transportCorrectionNote" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload" />
|
||||
</div>
|
||||
|
||||
<AppDialog v-model="editOpen" class="loading-transport-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
||||
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
||||
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
|
||||
<el-form-item label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入提柜后取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item>
|
||||
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||
@@ -257,17 +265,18 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="remarkOpen" class="warehouse-remark-dialog" title="编辑仓库备注" width="min(560px, calc(100vw - 28px))" confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveWarehouseRemark">
|
||||
<el-alert type="info" :closable="false" show-icon title="仓库备注将随装柜完成资料下发海外仓" />
|
||||
<el-form label-position="top" class="warehouse-remark-form">
|
||||
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-dialog" title="编辑备注" width="min(680px, calc(100vw - 28px))" confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveRemarks">
|
||||
<el-alert type="info" :closable="false" show-icon title="客户备注来自客户下单且不可修改;其余备注可维护" />
|
||||
<el-form label-position="top" class="remarks-form">
|
||||
<el-form-item label="客户备注(只读)"><el-input :model-value="remarkTarget?.customerRemark || '—'" disabled /><small>来源:客户下单</small></el-form-item>
|
||||
<el-form-item label="内部备注(只读)"><el-input :model-value="remarkTarget?.internalRemark || '—'" disabled /><small>来源:订单内部备注</small></el-form-item>
|
||||
<el-form-item label="仓库备注"><el-input v-model="warehouseRemark" type="textarea" :rows="5" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item>
|
||||
<el-form-item label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空内部备注" /></el-form-item>
|
||||
<el-form-item label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item>
|
||||
<el-form-item label="客服备注"><el-input v-model="remarkForm.customerServiceRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空客服备注" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="remarkError" compact title="仓库备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
|
||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
|
||||
<div class="confirm-content">
|
||||
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" description="真实集装箱号可在提柜或预装详情补录,缺号不阻断此前流程;PDA 确认装柜完成前由服务端执行最终门禁。系统会原子确认本计划包含的全部入库预装票据,并由服务端按真实条件创建唯一 PDA 任务;当前页面不会伪造 PDA 成功。" />
|
||||
<el-descriptions v-if="detail" :column="1" border>
|
||||
@@ -281,12 +290,12 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preload-detail-page { display: grid; min-width: 0; max-width: 1180px; gap: 16px; }
|
||||
.preload-detail-page { display: grid; min-width: 0; width: 100%; gap: 12px; }
|
||||
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.confirm-content { display: grid; gap: 16px; }
|
||||
.warehouse-remark-form { margin-top: 14px; }
|
||||
.warehouse-remark-form small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||
.confirm-content { display: grid; gap: 12px; }
|
||||
.remarks-form { margin-top: 14px; }
|
||||
.remarks-form small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
|
||||
@media (min-width: 1920px) { .preload-detail-page { width: 100%; max-width: none; gap: 12px; } }
|
||||
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||
|---|---|---|
|
||||
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
|
||||
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。页面能力必须由正式路由、API 调用和测试共同证明 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
|
||||
|
||||
@@ -287,6 +287,40 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(payload).not.toHaveProperty('plannedWarehouseName')
|
||||
})
|
||||
|
||||
it('locks one-click dropship manual plans to the frozen source pallet count', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({
|
||||
...preview,
|
||||
sourcePalletCount: 1,
|
||||
groups: [{
|
||||
...preview.groups[0], destinationType: 'ONE_CLICK_DROPSHIP', sourcePalletCount: 1,
|
||||
orders: [{
|
||||
...preview.groups[0].orders[0], orderType: 'ONE_CLICK_DROPSHIP',
|
||||
destinationType: 'ONE_CLICK_DROPSHIP', sourcePalletCount: 1,
|
||||
targetPalletCount: 1, supplementPalletCount: 0,
|
||||
}],
|
||||
}],
|
||||
} as never)
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'ONE_CLICK_DROPSHIP', pallets: 2 })])
|
||||
await button(wrapper, '生成入库计划')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const palletInput = wrapper.find('.command-form').findComponent({ name: 'ElInputNumber' })
|
||||
expect(palletInput.props('disabled')).toBe(true)
|
||||
expect(palletInput.props('modelValue')).toBe(1)
|
||||
expect(document.body.textContent).toContain('固定按头程已封托托盘数整体入库')
|
||||
|
||||
wrapper.find('.command-form').findComponent({ name: 'ElDatePicker' })
|
||||
.vm.$emit('update:modelValue', '2026-09-08')
|
||||
await wrapper.vm.$nextTick()
|
||||
;([...document.body.querySelectorAll('button')]
|
||||
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
expect(createManualInboundPlan).toHaveBeenCalledWith(expect.objectContaining({ palletCount: 1 }),
|
||||
expect.stringContaining('manual-inbound-plan-'))
|
||||
})
|
||||
|
||||
it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => {
|
||||
const wrapper = await openPending()
|
||||
await openContainerDialog(wrapper)
|
||||
|
||||
@@ -49,6 +49,13 @@ const inboundPlanWarehouseLabel = computed(() => [actionTarget.value?.overseasWa
|
||||
.filter((value) => value && value !== '—')
|
||||
.join(' · '))
|
||||
const inboundPlanMinimumPalletCount = ref(1)
|
||||
const inboundPlanSourcePalletCount = ref<number | null>(null)
|
||||
const inboundPlanPreviewLoading = ref(false)
|
||||
const inboundPlanPreviewError = ref('')
|
||||
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
|
||||
const inboundPlanSubmitDisabled = computed(() => inboundPlanIsDropship.value
|
||||
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|
||||
|| inboundPlanSourcePalletCount.value <= 0))
|
||||
const inboundPlanForm = reactive({ plannedArrivalDate: '', inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
const holdOpen = ref(false)
|
||||
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
|
||||
@@ -481,6 +488,8 @@ function openInboundPlan(source?: object) {
|
||||
const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1
|
||||
inboundPlanMinimumPalletCount.value = Math.max(1, volumeMinimum,
|
||||
typeof target.pallets === 'number' ? target.pallets : 0)
|
||||
inboundPlanSourcePalletCount.value = null
|
||||
inboundPlanPreviewError.value = ''
|
||||
Object.assign(inboundPlanForm, { plannedArrivalDate: '', inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
actionError.value = null
|
||||
inboundPlanOpen.value = true
|
||||
@@ -488,20 +497,39 @@ function openInboundPlan(source?: object) {
|
||||
}
|
||||
async function refreshManualPalletMinimum(target: LastMileOrderListItem) {
|
||||
if (!target.sourceContainerBookingId) return
|
||||
inboundPlanPreviewLoading.value = true
|
||||
inboundPlanPreviewError.value = ''
|
||||
try {
|
||||
const preview = await getContainerInboundPlanPreview(target.sourceContainerBookingId)
|
||||
if (actionTarget.value?.id !== target.id || !inboundPlanOpen.value) return
|
||||
const order = preview.groups.flatMap((group) => group.orders).find((item) => item.orderId === target.id)
|
||||
if (target.orderTypeCode === 'ONE_CLICK_DROPSHIP') {
|
||||
const sourcePalletCount = Number(order?.sourcePalletCount || 0)
|
||||
if (sourcePalletCount <= 0) throw new Error('一件代发订单缺少头程封托托盘快照')
|
||||
inboundPlanSourcePalletCount.value = sourcePalletCount
|
||||
inboundPlanMinimumPalletCount.value = sourcePalletCount
|
||||
inboundPlanForm.palletCount = sourcePalletCount
|
||||
return
|
||||
}
|
||||
const suggested = Number(order?.suggestedPalletCount || 0)
|
||||
inboundPlanMinimumPalletCount.value = Math.max(inboundPlanMinimumPalletCount.value, suggested)
|
||||
inboundPlanForm.palletCount = Math.max(inboundPlanForm.palletCount, inboundPlanMinimumPalletCount.value)
|
||||
} catch {
|
||||
// The backend repeats the same calculation and remains authoritative on submit.
|
||||
} catch (error) {
|
||||
if (actionTarget.value?.id === target.id && inboundPlanOpen.value) {
|
||||
inboundPlanPreviewError.value = getApiErrorDetails(error, '柜级托盘预览加载失败,请重试').message
|
||||
}
|
||||
} finally {
|
||||
if (actionTarget.value?.id === target.id) inboundPlanPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
async function submitInboundPlan() {
|
||||
const row = actionTarget.value
|
||||
const frozenInboundCode = String(row?.inboundCode || '').trim()
|
||||
if (inboundPlanIsDropship.value && (inboundPlanSourcePalletCount.value == null
|
||||
|| inboundPlanForm.palletCount !== inboundPlanSourcePalletCount.value)) {
|
||||
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
|
||||
return
|
||||
}
|
||||
if (!row || !row.overseasWarehouseId || !inboundPlanForm.plannedArrivalDate || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
||||
ElMessage.warning('当前柜须已分配接柜仓,并完整填写预计到仓日期和托数')
|
||||
return
|
||||
@@ -719,6 +747,7 @@ function blockerText(blockers: string[]) {
|
||||
PLAN_ALREADY_STARTED: '已有计划已开始作业,不能再补生成托盘',
|
||||
PLAN_PALLET_BINDING_INCOMPLETE: '已有计划只绑定了部分真实托盘,请联系管理员处理',
|
||||
DROPSHIP_PALLET_SNAPSHOT_MISSING: '缺少一件代发托盘快照',
|
||||
DROPSHIP_PALLET_COUNT_MISMATCH: '一件代发计划托数与头程已封托托盘数不一致,请重新走单',
|
||||
PALLET_COUNT_NOT_READY: '计划托数暂未就绪',
|
||||
}
|
||||
return [...new Set(blockers.map((code) => labels[code] || '计划数据暂未就绪,请刷新或联系管理员'))].join(';') || '—'
|
||||
@@ -926,10 +955,11 @@ onMounted(() => { void load() })
|
||||
</LoadErrorState>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :confirm-loading="actionLoading" @confirm="submitInboundPlan">
|
||||
<AppDialog v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
|
||||
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="inboundPlanPreviewError" class="container-convert-alert" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
|
||||
<el-form class="command-form" label-position="top">
|
||||
<div class="command-form__grid"><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="预计到仓日期" required><el-date-picker v-model="inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" placeholder="请输入托数" /><small>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
|
||||
<div class="command-form__grid"><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="预计到仓日期" required><el-date-picker v-model="inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
|
||||
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
approvePlatformUnifiedOrder,
|
||||
batchApprovePlatformUnifiedOrders,
|
||||
getPlatformUnifiedOrderBatchApprovalPreview,
|
||||
addPlatformUnifiedOrderNote,
|
||||
updatePlatformUnifiedOrderRemarks,
|
||||
getPlatformUnifiedOrder,
|
||||
downloadPlatformProductImage,
|
||||
listPlatformUnifiedOrders,
|
||||
@@ -172,10 +172,11 @@ describe('unified orders API contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('writes platform internal notes through the unified order boundary', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: '91001' })
|
||||
await expect(addPlatformUnifiedOrderNote('208', '90001', '已确认船期')).resolves.toBe('91001')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/90001/internal-notes', { content: '已确认船期' }, { params: { tenantId: '208' } })
|
||||
it('updates the three editable remarks while keeping customer remark outside the request', async () => {
|
||||
const request = { expectedVersion: 7, internalRemark: '内部', warehouseRemark: '仓库', customerServiceRemark: '客服' }
|
||||
vi.mocked(http.put).mockResolvedValue({ data: { ...request, version: 8, customerRemark: '客户只读' } })
|
||||
await expect(updatePlatformUnifiedOrderRemarks('208', '90001', request)).resolves.toMatchObject({ version: 8 })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/orders/90001/remarks', request, { params: { tenantId: '208' } })
|
||||
})
|
||||
|
||||
it('updates customer declaration fields through the versioned tenant-scoped contract', async () => {
|
||||
|
||||
@@ -160,6 +160,8 @@ export interface UnifiedOrderResource {
|
||||
customerRemark?: string | null
|
||||
latestInternalNote?: string | null
|
||||
internalRemark?: string | null
|
||||
warehouseRemark?: string | null
|
||||
customerServiceRemark?: string | null
|
||||
createdBy?: string | null
|
||||
}
|
||||
|
||||
@@ -599,10 +601,25 @@ export function batchApprovePlatformUnifiedOrders(tenantId: string, items: Unifi
|
||||
).then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
export function addPlatformUnifiedOrderNote(tenantId: string, orderId: string, content: string) {
|
||||
return http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(
|
||||
`${unifiedOrdersApiPath}/${orderId}/internal-notes`,
|
||||
{ content },
|
||||
export interface UnifiedOrderRemarksUpdateRequest {
|
||||
expectedVersion: number
|
||||
internalRemark: string | null
|
||||
warehouseRemark: string | null
|
||||
customerServiceRemark: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderRemarksUpdateResponse {
|
||||
version: number
|
||||
customerRemark: string | null
|
||||
internalRemark: string | null
|
||||
warehouseRemark: string | null
|
||||
customerServiceRemark: string | null
|
||||
}
|
||||
|
||||
export function updatePlatformUnifiedOrderRemarks(tenantId: string, orderId: string, request: UnifiedOrderRemarksUpdateRequest) {
|
||||
return http.put<ApiEnvelope<UnifiedOrderRemarksUpdateResponse>, ApiEnvelope<UnifiedOrderRemarksUpdateResponse>>(
|
||||
`${unifiedOrdersApiPath}/${orderId}/remarks`,
|
||||
request,
|
||||
{ params: { tenantId } },
|
||||
).then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mock
|
||||
vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() }))
|
||||
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
|
||||
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
|
||||
addPlatformUnifiedOrderNote: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
|
||||
updatePlatformUnifiedOrderRemarks: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
|
||||
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(),
|
||||
unifiedOrderStatusOptions: [{ value: 'WAIT_SPLIT', label: '待分柜' }],
|
||||
}))
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock('@/shared/api/inboundCodeRelations', () => ({
|
||||
getLoadingAssociationSummary: vi.fn(), getOverseasInboundCodeRelationship: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
|
||||
addPlatformUnifiedOrderNote: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
|
||||
updatePlatformUnifiedOrderRemarks: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
|
||||
downloadPlatformProductImage: vi.fn(),
|
||||
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(),
|
||||
unifiedOrderStatusOptions: [],
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({
|
||||
getPlatformCoLoadGroupByInboundCode: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
|
||||
addPlatformUnifiedOrderNote: vi.fn(),
|
||||
updatePlatformUnifiedOrderRemarks: vi.fn(),
|
||||
approvePlatformUnifiedOrder: vi.fn(),
|
||||
getPlatformUnifiedOrder: vi.fn(),
|
||||
listPlatformUnifiedOrderFeeDetails: vi.fn(),
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('row.transport?.effectivePortArrivalAtEstimated')
|
||||
expect(source).not.toContain("transport.sailingAt")
|
||||
expect(source).not.toContain("transport.portArrivalAt")
|
||||
expect(source).toContain('.table-card { overflow: hidden; }')
|
||||
expect(source).toMatch(/\.table-card \{[^}]*overflow: hidden;/)
|
||||
expect(source).toContain('slotName: \'measurementReviewStatus\'')
|
||||
expect(source).toContain('unifiedOrderReviewStatusLabel(value, \'未录入\')')
|
||||
expect(source).toContain("{ key: 'customerRemark', prop: 'customerRemark', label: '客户备注'")
|
||||
@@ -225,7 +225,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('states only the domestic warehouse and channel qualification without local destination or split blocking', () => {
|
||||
const createSource = source.slice(source.indexOf('function openCoLoadCreate'), source.indexOf('async function batchApproveOrders'))
|
||||
const dialogSource = source.slice(source.indexOf('<AppDialog v-model="coLoadCreateOpen"'), source.indexOf('<AppDrawer v-model="relationOpen"'))
|
||||
const dialogSource = source.slice(source.indexOf('v-model="coLoadCreateOpen"'), source.indexOf('<AppDrawer v-model="relationOpen"'))
|
||||
expect(dialogSource).toContain('同一国内仓、同一渠道')
|
||||
expect(dialogSource).toContain('目的仓或目的港可以不同')
|
||||
expect(dialogSource).toContain('资格由服务端按完整入仓片段校验')
|
||||
@@ -375,6 +375,16 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).not.toContain("value.replaceAll('_', ' ')")
|
||||
})
|
||||
|
||||
it('keeps customer remarks read-only and edits the three collaboration remarks together', () => {
|
||||
expect(source).toContain('title="编辑订单备注"')
|
||||
expect(source).toContain('label="客户备注(只读)"')
|
||||
expect(source).toContain(':model-value="noteOrder?.customerRemark')
|
||||
expect(source).toContain('v-model="noteForm.internalRemark"')
|
||||
expect(source).toContain('v-model="noteForm.warehouseRemark"')
|
||||
expect(source).toContain('v-model="noteForm.customerServiceRemark"')
|
||||
expect(source).toContain('await updatePlatformUnifiedOrderRemarks')
|
||||
})
|
||||
|
||||
it('keeps the customer order package count independent from measured inbound totals', () => {
|
||||
expect(source).toContain("{ source: '客户下单数据', count: customerTotals.count")
|
||||
expect(source).not.toContain("{ source: '客户下单数据', count: summary?.totalPackageCount")
|
||||
@@ -403,9 +413,9 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain("updateMeasurementSectionMode('customer', $event)")
|
||||
expect(source).toContain("updateMeasurementSectionMode('actual', $event)")
|
||||
expect(source).not.toContain("updateMeasurementSectionMode('outer', $event)")
|
||||
expect(source.match(/@change="selectAndSaveOuterMeasurementMode\(\$event\)"/g)).toHaveLength(2)
|
||||
expect(source.match(/@change="selectAndSaveOuterMeasurementMode\(\$event\)"/g)).toHaveLength(1)
|
||||
expect(source).not.toContain('@visible-change="handleOuterModeDropdownVisibleChange($event)"')
|
||||
expect(source.match(/:disabled="Boolean\(measurementStrategySavingId\) \|\| !measurementStrategyEditable"/g)).toHaveLength(2)
|
||||
expect(source.match(/:disabled="Boolean\(measurementStrategySavingId\) \|\| !measurementStrategyEditable"/g)).toHaveLength(1)
|
||||
expect(source).toContain('const measurementStrategyEditable = computed')
|
||||
expect(source).toContain('客户已确认且该票已装柜完成后,箱规计算规则将冻结')
|
||||
expect(source).toContain('if (!measurementStrategyEditable.value)')
|
||||
@@ -520,7 +530,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('子单 · 第 ${row.relation.splitSequence} 次')
|
||||
expect(source).toContain('title="父子订单关联"')
|
||||
expect(source).toContain('@click="openOrderRelation(row as UnifiedOrderResource)"')
|
||||
expect(source).toContain("{ key: 'fulfillment', label: '拆分履约', icon: OrderFulfillmentIcon }")
|
||||
expect(source).toContain("{ key: 'fulfillment', label: '拆分履约', icon: '🧩' }")
|
||||
expect(source).toContain("activeDetailTab === 'fulfillment'")
|
||||
expect(source).toContain('根单、已完成分片与当前余量')
|
||||
})
|
||||
@@ -620,7 +630,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('keeps detail tabs compact on one horizontally scrollable row', () => {
|
||||
expect(source).toContain('.detail-tabs { display: flex; overflow-x: auto; overflow-y: hidden;')
|
||||
expect(source).toContain('min-width: max-content; min-height: 56px; flex: 0 0 auto;')
|
||||
expect(source).toContain('min-width: max-content; min-height: 38px; flex: 0 0 auto;')
|
||||
expect(source).not.toContain('grid-template-columns: repeat(6, minmax(124px, 1fr))')
|
||||
expect(source).not.toContain('.detail-tabs { grid-template-columns: repeat(6, 132px); }')
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
approvePlatformUnifiedOrder,
|
||||
batchApprovePlatformUnifiedOrders,
|
||||
getPlatformUnifiedOrderBatchApprovalPreview,
|
||||
addPlatformUnifiedOrderNote,
|
||||
updatePlatformUnifiedOrderRemarks,
|
||||
listPlatformUnifiedOrders,
|
||||
updatePlatformUnifiedOrderPlanning,
|
||||
unifiedOrderStatusOptions,
|
||||
@@ -85,15 +85,6 @@ import {
|
||||
} from '@/modules/inbounds'
|
||||
import { listCustomerChargeRules, type CustomerChargeRule } from '@/modules/fba-pricing'
|
||||
import { getCustomerDimensionConfig, type CustomerDimensionConfig, type CustomerDimensionRule } from '@/modules/dimension-config'
|
||||
import {
|
||||
OrderBasicInfoIcon,
|
||||
OrderCargoInfoIcon,
|
||||
OrderFeeInfoIcon,
|
||||
OrderFulfillmentIcon,
|
||||
OrderHistoryIcon,
|
||||
OrderMeasurementIcon,
|
||||
OrderOtherIcon,
|
||||
} from '@/core/navigation/prototypeMenuIcons'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
tenantId: string
|
||||
@@ -188,7 +179,7 @@ const coLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const approvalOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const noteOpen = ref(false)
|
||||
const noteSaving = ref(false)
|
||||
const noteContent = ref('')
|
||||
const noteForm = reactive({ internalRemark: '', warehouseRemark: '', customerServiceRemark: '' })
|
||||
const noteOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const holdDetail = ref<InboundHoldDetail | null>(null)
|
||||
const holdLoading = ref(false)
|
||||
@@ -334,13 +325,13 @@ const canReleaseInbound = computed(() => Boolean(
|
||||
))
|
||||
|
||||
const detailTabs = [
|
||||
{ key: 'basic', label: '基本信息', icon: OrderBasicInfoIcon },
|
||||
{ key: 'cargo', label: '货品信息', icon: OrderCargoInfoIcon },
|
||||
{ key: 'fees', label: '费用信息', icon: OrderFeeInfoIcon },
|
||||
{ key: 'history', label: '状态历史', icon: OrderHistoryIcon },
|
||||
{ key: 'measurement', label: '测量数据', icon: OrderMeasurementIcon },
|
||||
{ key: 'other', label: '其他', icon: OrderOtherIcon },
|
||||
{ key: 'fulfillment', label: '拆分履约', icon: OrderFulfillmentIcon },
|
||||
{ key: 'basic', label: '基本信息', icon: '📋' },
|
||||
{ key: 'cargo', label: '货品信息', icon: '📦' },
|
||||
{ key: 'fees', label: '费用信息', icon: '💰' },
|
||||
{ key: 'history', label: '状态历史', icon: '📝' },
|
||||
{ key: 'measurement', label: '测量数据', icon: '📐' },
|
||||
{ key: 'other', label: '其他', icon: '🗂️' },
|
||||
{ key: 'fulfillment', label: '拆分履约', icon: '🧩' },
|
||||
]
|
||||
|
||||
const customerMeasurementColumns = [
|
||||
@@ -1369,27 +1360,30 @@ function openNoteDialog(order?: UnifiedOrderResource) {
|
||||
const target = order || detail.value?.summary || null
|
||||
if (!target) return
|
||||
noteOrder.value = target
|
||||
noteContent.value = ''
|
||||
noteForm.internalRemark = target.latestInternalNote || target.internalRemark || ''
|
||||
noteForm.warehouseRemark = target.warehouseRemark || ''
|
||||
noteForm.customerServiceRemark = target.customerServiceRemark || ''
|
||||
noteOpen.value = true
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
const content = noteContent.value.trim()
|
||||
if (!noteOrder.value || !content) {
|
||||
ElMessage.warning('请填写内部备注')
|
||||
return
|
||||
}
|
||||
if (!noteOrder.value) return
|
||||
noteSaving.value = true
|
||||
try {
|
||||
const target = noteOrder.value
|
||||
await addPlatformUnifiedOrderNote(String(target.tenantId), String(target.orderId), content)
|
||||
await updatePlatformUnifiedOrderRemarks(String(target.tenantId), String(target.orderId), {
|
||||
expectedVersion: Number(target.orderVersion || 0),
|
||||
internalRemark: noteForm.internalRemark.trim() || null,
|
||||
warehouseRemark: noteForm.warehouseRemark.trim() || null,
|
||||
customerServiceRemark: noteForm.customerServiceRemark.trim() || null,
|
||||
})
|
||||
noteOpen.value = false
|
||||
await loadOrders()
|
||||
if (detail.value?.summary.orderId === target.orderId) await loadDetail(true)
|
||||
noteOrder.value = null
|
||||
ElMessage.success('内部备注已保存')
|
||||
ElMessage.success('订单备注已保存')
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, '内部备注保存失败,请稍后重试').message)
|
||||
ElMessage.error(getApiErrorDetails(error, '订单备注保存失败,请稍后重试').message)
|
||||
} finally {
|
||||
noteSaving.value = false
|
||||
}
|
||||
@@ -2199,7 +2193,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<AdminPage>
|
||||
<section class="business-page unified-orders-page">
|
||||
<AppDialog class="order-density-dialog" v-model="feeConfirmOpen" title="编辑计费数据" confirm-text="确认并发送" :confirm-loading="confirmFeeSending || feeConfirmReferenceLoading" width="min(920px, 94vw)" :teleported="false" @confirm="executeConfirmAndSendFee">
|
||||
<AppDialog class="admin-density-dialog" v-model="feeConfirmOpen" title="编辑计费数据" confirm-text="确认并发送" :confirm-loading="confirmFeeSending || feeConfirmReferenceLoading" width="min(920px, 94vw)" :teleported="false" @confirm="executeConfirmAndSendFee">
|
||||
<el-alert class="fee-confirm-price-alert" title="价格表单价仅作为参考;本次对客单价可人工修改,客户将一次确认计费量、单价和费用合计。" type="info" :closable="false" show-icon />
|
||||
<el-form label-position="top" class="fee-confirm-form">
|
||||
<el-form-item label="入仓码"><el-input :model-value="detail?.summary.inboundCode || '—'" disabled /></el-form-item>
|
||||
@@ -2257,7 +2251,7 @@ onBeforeUnmount(() => {
|
||||
</dl>
|
||||
|
||||
<div class="detail-tabs" role="tablist" aria-label="订单详情模块">
|
||||
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><component :is="tab.icon" :size="17" /><span>{{ tab.label }}</span></button>
|
||||
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><span class="detail-tab-icon" aria-hidden="true">{{ tab.icon }}</span><span>{{ tab.label }}</span></button>
|
||||
</div>
|
||||
|
||||
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
|
||||
@@ -2686,7 +2680,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
<SmartLoadingDialog v-model="smartLoadingOpen" />
|
||||
<AppDialog class="order-density-dialog" v-model="batchApprovalOpen" title="批量审核订单" confirm-text="确认批量审核" confirm-type="success" width="min(1100px, calc(100vw - 28px))" :loading="batchApprovalLoading" :confirm-loading="batchApprovalSaving" :confirm-disabled="batchApprovalLoading || !batchApprovalSelectedRows.length" destroy-on-close @confirm="batchApproveOrders">
|
||||
<AppDialog class="admin-density-dialog" v-model="batchApprovalOpen" title="批量审核订单" confirm-text="确认批量审核" confirm-type="success" width="min(1100px, calc(100vw - 28px))" :loading="batchApprovalLoading" :confirm-loading="batchApprovalSaving" :confirm-disabled="batchApprovalLoading || !batchApprovalSelectedRows.length" destroy-on-close @confirm="batchApproveOrders">
|
||||
<el-alert title="请逐票核对入仓码、客户、中文品名、渠道和目的仓;审核通过后订单进入待测量。" type="info" :closable="false" show-icon />
|
||||
<LoadErrorState v-if="batchApprovalError" compact title="批量审核预览加载失败" :message="batchApprovalError.message" :loading="batchApprovalLoading" @retry="openBatchApproval"><template #details><ApiErrorMeta :details="batchApprovalError" /></template></LoadErrorState>
|
||||
<template v-else>
|
||||
@@ -2702,12 +2696,12 @@ onBeforeUnmount(() => {
|
||||
</el-table>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<AppDialog class="order-density-dialog" v-model="coLoadCreateOpen" title="合柜报关" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="!canCreateCoLoadGroup" @confirm="createCoLoadGroup">
|
||||
<AppDialog class="admin-density-dialog" v-model="coLoadCreateOpen" title="合柜报关" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="!canCreateCoLoadGroup" @confirm="createCoLoadGroup">
|
||||
<el-alert type="warning" :closable="false" show-icon :title="`所选 ${selectedOrders.length} 票须属于同一国内仓、同一渠道,并全量进入同一柜;目的仓或目的港可以不同。资格由服务端按完整入仓片段校验。`" />
|
||||
<div class="coload-selection"><span v-for="row in selectedOrders" :key="String(row.orderId)">{{ row.inboundCode || '待生成入仓码' }}</span></div>
|
||||
<div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div>
|
||||
</AppDialog>
|
||||
<AppDialog class="order-density-dialog" v-model="coLoadDetailOpen" title="同柜组成员" :show-confirm="false" cancel-text="关闭" width="min(720px, 94vw)" :loading="coLoadDetailLoading" :confirm-loading="coLoadCancelSaving">
|
||||
<AppDialog class="admin-density-dialog" v-model="coLoadDetailOpen" title="同柜组成员" :show-confirm="false" cancel-text="关闭" width="min(720px, 94vw)" :loading="coLoadDetailLoading" :confirm-loading="coLoadCancelSaving">
|
||||
<LoadErrorState v-if="coLoadDetailError" title="同柜组成员加载失败" :message="coLoadDetailError.message" :loading="coLoadDetailLoading" @retry="loadCoLoadDetail"><template #details><ApiErrorMeta :details="coLoadDetailError" /></template></LoadErrorState>
|
||||
<template v-else-if="coLoadDetailSummary">
|
||||
<div class="coload-detail-summary"><el-tag size="small" :type="coLoadDetailSummary.status === 'ACTIVE' ? 'warning' : 'info'">{{ coLoadGroupSummary(coLoadDetailSummary) }}</el-tag><span>{{ coLoadDetailSummary.memberCount }} 个成员</span></div>
|
||||
@@ -2734,16 +2728,21 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDialog class="order-density-dialog" v-model="noteOpen" title="添加内部备注" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
|
||||
<el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" />
|
||||
<AppDialog class="admin-density-dialog" v-model="noteOpen" title="编辑订单备注" width="min(680px, calc(100vw - 28px))" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
|
||||
<el-form label-position="top" class="order-remarks-form">
|
||||
<el-form-item label="客户备注(只读)"><el-input :model-value="noteOrder?.customerRemark || '—'" disabled /></el-form-item>
|
||||
<el-form-item label="内部备注"><el-input v-model="noteForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空内部备注" /></el-form-item>
|
||||
<el-form-item label="仓库备注"><el-input v-model="noteForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item>
|
||||
<el-form-item label="客服备注"><el-input v-model="noteForm.customerServiceRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空客服备注" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="order-density-dialog" v-model="holdDialogOpen" :title="holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货'" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
|
||||
<AppDialog class="admin-density-dialog" v-model="holdDialogOpen" :title="holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货'" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
|
||||
<el-alert :title="holdAction === 'HOLD' ? '扣货后服务端将阻止后续实物履约操作。' : '解除后是否可继续履约仍以服务端状态为准。'" :type="holdAction === 'HOLD' ? 'warning' : 'info'" :closable="false" show-icon />
|
||||
<el-form label-position="top" class="hold-command-form"><el-form-item :label="holdAction === 'HOLD' ? '扣货原因' : '解除原因'" required><el-input v-model="holdReason" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="holdAction === 'HOLD' ? '请输入扣货原因' : '请输入解除扣货原因'" aria-label="扣货操作原因" /></el-form-item><el-form-item label="关联文件(选填)"><el-upload :key="holdEvidenceUploadKey" :auto-upload="false" :limit="1" accept=".txt,.pdf,.jpg,.jpeg,.png,text/plain,application/pdf,image/jpeg,image/png" :disabled="holdSaving" :on-change="selectHoldEvidenceFile" :on-remove="removeHoldEvidenceFile"><el-button :disabled="holdSaving"><FileUp :size="15" />选择文件</el-button><template #tip><div class="el-upload__tip">仅支持 TXT、PDF、JPG、PNG,单文件不超过 10MB;服务端将校验文件真实内容。</div></template></el-upload></el-form-item><LoadErrorState v-if="holdEvidenceError" compact title="关联文件处理失败" :message="holdEvidenceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="holdEvidenceError" /></template></LoadErrorState></el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="order-density-dialog" v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
|
||||
<AppDialog class="admin-density-dialog" v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
|
||||
<dl v-if="approvalOrder" class="approval-facts">
|
||||
<div><dt>入仓码</dt><dd>{{ approvalOrder.inboundCode || '—' }}</dd></div>
|
||||
<div><dt>客户</dt><dd>{{ customerName(approvalOrder) }}</dd></div>
|
||||
@@ -2762,7 +2761,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
class="order-density-dialog"
|
||||
class="admin-density-dialog"
|
||||
v-model="measurementPreviewOpen"
|
||||
:title="measurementPreviewTitle"
|
||||
width="min(920px, calc(100vw - 28px))"
|
||||
@@ -2778,7 +2777,7 @@ onBeforeUnmount(() => {
|
||||
</AppDialog>
|
||||
|
||||
<InboundMeasurementDialog
|
||||
class="order-density-dialog order-measurement-dialog"
|
||||
class="admin-density-dialog"
|
||||
v-if="measurementOrder?.inboundId"
|
||||
v-model="measurementDialogOpen"
|
||||
:customer-id="String(measurementOrder.tenantId)"
|
||||
@@ -2872,6 +2871,8 @@ onBeforeUnmount(() => {
|
||||
.table-card :deep(.el-table__body tr:hover > td.el-table__cell) { background: #f3f7fb; }
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||
.table-card :deep(.data-table__system-cell .row-actions .el-button) { height: 28px; margin-left: 0; padding: 4px 6px; font-size: 13px; }
|
||||
.table-card :deep(.data-table__system-cell .row-actions .el-dropdown) { margin-left: 0; }
|
||||
.planning-cell { display: grid; min-width: 0; gap: 4px; }
|
||||
.planning-cell > small { color: var(--cc-muted); font-size: 11px; }
|
||||
.planning-volume-input,.planning-arrival-input { width: 100%; max-width: 100%; }
|
||||
@@ -2896,6 +2897,7 @@ onBeforeUnmount(() => {
|
||||
.detail-overview { display: flex; flex-wrap: wrap; gap: 6px 24px; margin: 0; padding: 8px 12px; border: 1px solid var(--cc-border); border-top: 0; background: #fff; }
|
||||
.detail-tabs { display: flex; overflow-x: auto; overflow-y: hidden; border: 1px solid var(--cc-border); border-block: 0; background: #fff; scrollbar-width: thin; }
|
||||
.detail-tabs button { position: relative; display: inline-flex; min-width: max-content; min-height: 38px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 6px; padding: 0 16px; border: 0; background: transparent; color: #475467; font-size: 13px; font-weight: 500; cursor: pointer; }
|
||||
.detail-tab-icon { display: inline-flex; width: 18px; height: 18px; flex: 0 0 18px; align-items: center; justify-content: center; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; font-size: 17px; font-weight: 400; line-height: 1; }
|
||||
.detail-tabs button:last-child { border-right: 0; }
|
||||
.detail-tabs button::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 2px; background: transparent; content: ''; }
|
||||
.detail-tabs button:hover { background: #fafbff; color: #344054; }
|
||||
@@ -2992,12 +2994,13 @@ onBeforeUnmount(() => {
|
||||
.notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
|
||||
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: 13px; }
|
||||
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
|
||||
@media (max-width: 960px) { .detail-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 960px) { .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { flex-wrap: wrap; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
|
||||
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
|
||||
@media (max-width: 720px) { .fee-overview { grid-template-columns: 1fr; } .fee-overview > div + div { border-top: 1px solid var(--cc-border); border-left: 0; } }
|
||||
@media (max-width: 720px) { .measurement-section-title { align-items: stretch; flex-direction: column; } .measurement-section-toggle { align-items: flex-start; flex-wrap: wrap; } .measurement-section-toggle > svg { margin-left: auto; } .measurement-section-mode { width: 100%; flex-basis: auto; } }
|
||||
@media (max-width: 420px) { .weight-evidence { padding: 10px; } .weight-evidence-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 420px) { .weight-evidence { padding: 10px; } .weight-evidence-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
<style src="@/styles/adminDensity.css"></style>
|
||||
<style src="../styles/orderDensity.css"></style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Order workspace pilot. Keep these rules local until the visual baseline is approved. */
|
||||
/* Order-specific sections. Shared density lives in styles/adminDensity.css. */
|
||||
.unified-orders-page .inbound-fee-panel .fee-summary {
|
||||
grid-template-columns: minmax(230px, 1.3fr) repeat(4, minmax(150px, 1fr));
|
||||
border-top: 0;
|
||||
@@ -29,64 +29,8 @@
|
||||
.unified-orders-page .detail-panel .el-empty__image { display: none; }
|
||||
.unified-orders-page .detail-panel .el-empty__description { margin-top: 0; }
|
||||
|
||||
/* Dialogs are teleported; opt in explicitly instead of styling all AppDialog instances. */
|
||||
.order-density-dialog.el-dialog {
|
||||
--el-font-size-base: 13px;
|
||||
--el-component-size: 32px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.order-density-dialog.el-dialog:not(.is-fullscreen) { margin-top: 6vh; max-height: 88vh; }
|
||||
.order-density-dialog.el-dialog .el-dialog__header { margin: 0; padding: 10px 48px 10px 16px; border-bottom: 1px solid var(--cc-border); }
|
||||
.order-density-dialog.el-dialog .el-dialog__title { font-size: 16px; line-height: 24px; font-weight: 600; }
|
||||
.order-density-dialog.el-dialog .el-dialog__body { padding: 12px 16px; }
|
||||
.order-density-dialog.el-dialog .el-dialog__footer { padding: 8px 16px; border-top: 1px solid var(--cc-border); }
|
||||
.order-density-dialog.el-dialog .el-table { font-size: 13px; }
|
||||
.order-density-dialog.el-dialog .el-table__cell { height: 36px; padding: 4px 0; }
|
||||
.order-density-dialog.el-dialog .el-table .cell { line-height: 22px; }
|
||||
.order-density-dialog.el-dialog .el-form-item { margin-bottom: 14px; }
|
||||
.order-density-dialog.el-dialog .el-form-item__label { font-size: 13px; }
|
||||
.order-density-dialog.el-dialog .el-alert { padding: 8px 10px; border-radius: 3px; }
|
||||
.order-density-dialog.el-dialog .el-empty { padding: 12px 0; }
|
||||
.order-density-dialog.el-dialog .el-empty__image { display: none; }
|
||||
.order-density-dialog.el-dialog .el-empty__description { margin-top: 0; }
|
||||
.order-density-dialog.el-dialog .batch-approval-table .cell { white-space: normal; overflow-wrap: anywhere; }
|
||||
.order-density-dialog.el-dialog .batch-approval-table .el-tag { height: auto; min-height: 24px; white-space: normal; line-height: 20px; }
|
||||
|
||||
@media (min-width: 961px) {
|
||||
.order-measurement-dialog.el-dialog:not(.is-fullscreen) {
|
||||
/* The inherited dialog width is inline; only this order entry opts into the wider workspace. */
|
||||
width: min(1560px, calc(100vw - 48px)) !important;
|
||||
height: 92vh; max-height: 92vh; margin-top: 4vh;
|
||||
}
|
||||
.order-measurement-dialog .measurement-dialog-weight { padding-bottom: 10px; }
|
||||
.order-measurement-dialog .measurement-section { padding: 10px 0; }
|
||||
.order-measurement-dialog .measurement-summary-section { gap: 8px; }
|
||||
.order-measurement-dialog .measurement-section__heading { align-items: center; margin-bottom: 8px; }
|
||||
.order-measurement-dialog .measurement-metrics { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.order-measurement-dialog .measurement-metrics > div { min-height: 36px; padding: 6px 10px; display: flex; align-items: baseline; gap: 8px; }
|
||||
.order-measurement-dialog .measurement-metrics dd { font-size: 14px; }
|
||||
.order-measurement-dialog .measurement-metrics small { font-size: 12px; }
|
||||
.order-measurement-dialog .measurement-data-grid { gap: 8px 12px; }
|
||||
.order-measurement-dialog .measurement-declaration { padding-top: 8px; }
|
||||
.order-measurement-dialog .measurement-declaration h4 { margin-bottom: 6px; font-size: 13px; }
|
||||
.order-measurement-dialog .measurement-panel > .el-alert { margin: 8px 0; }
|
||||
.order-measurement-dialog .measurement-summary-header span { font-size: 12px; }
|
||||
.order-measurement-dialog .measurement-table .el-table__header th { padding: 4px 0; font-size: 13px; }
|
||||
.order-measurement-dialog .weight-heading { flex-wrap: wrap; gap: 8px; padding-bottom: 8px; }
|
||||
.order-measurement-dialog .weight-heading__actions { flex-wrap: wrap; gap: 12px; }
|
||||
.order-measurement-dialog .pallet-picker { width: 340px; }
|
||||
.order-measurement-dialog .weight-list { margin-top: 8px; }
|
||||
.order-measurement-dialog .weight-grid { grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 8px; }
|
||||
.order-measurement-dialog .weight-card { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 10px; align-items: start; padding: 8px; border-radius: 2px; }
|
||||
.order-measurement-dialog .weight-card .weight-thumb { width: 120px; border-radius: 2px; }
|
||||
.order-measurement-dialog .weight-card__body { min-width: 0; padding: 0; text-align: left; }
|
||||
.order-measurement-dialog .weight-card__value { min-height: 28px; padding-right: 32px; text-align: left; }
|
||||
.order-measurement-dialog .weight-card__value strong { font-size: 14px; }
|
||||
.order-measurement-dialog .weight-card__facts { gap: 3px; margin-top: 4px; padding-top: 4px; }
|
||||
.order-measurement-dialog .weight-card__facts div { font-size: 12px; }
|
||||
.order-measurement-dialog .weight-card__correct { width: auto; margin-top: 6px; }
|
||||
}
|
||||
.admin-density-dialog.el-dialog .batch-approval-table .cell { white-space: normal; overflow-wrap: anywhere; }
|
||||
.admin-density-dialog.el-dialog .batch-approval-table .el-tag { height: auto; min-height: 24px; white-space: normal; line-height: 20px; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.unified-orders-page .inbound-fee-panel .fee-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { MapPin, Pencil, Plus, Power, Save, Trash2 } from '@lucide/vue'
|
||||
@@ -434,7 +435,7 @@ async function removeLocation(row: PlatformLocationResource) {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除已停用库位“${row.locationCode}”吗?系统会保留审计记录,此操作不能直接恢复。`,
|
||||
'删除库位',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
|
||||
{ customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
@@ -480,7 +481,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="location-page">
|
||||
<AdminPage as="div" class="location-page admin-density">
|
||||
<ListPageLayout aria-label="库位列表页面">
|
||||
<template #toolbar>
|
||||
<QueryToolbar
|
||||
@@ -602,7 +603,7 @@ onMounted(() => {
|
||||
v-model="editorOpen"
|
||||
:title="editorTitle"
|
||||
width="min(560px, calc(100vw - 28px))"
|
||||
class="location-editor-dialog"
|
||||
class="location-editor-dialog admin-density-dialog"
|
||||
:confirm-loading="saving"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!saving"
|
||||
@@ -682,22 +683,22 @@ onMounted(() => {
|
||||
.location-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.location-actions :deep(.el-button) { margin-left: 0; }
|
||||
.location-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.location-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
|
||||
.location-cell > span { display: grid; width: 20px; height: 20px; flex: 0 0 20px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
|
||||
.location-cell > div, .stacked-value { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.location-cell strong, .stacked-value strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.location-cell small, .stacked-value small { color: var(--cc-muted); font-size: 11px; }
|
||||
.location-cell strong, .stacked-value strong { overflow-wrap: anywhere; }
|
||||
.location-cell small, .stacked-value small { color: var(--cc-muted); font-size: 12px; }
|
||||
.remark-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
:global(.location-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.location-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.location-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
.editor-form { padding: 8px 24px 12px; }
|
||||
.editor-form { padding: 0; }
|
||||
.editor-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
|
||||
.editor-form :deep(.el-input-number), .editor-form :deep(.el-select) { width: 100%; }
|
||||
.editor-form :deep(.el-form-item) { margin-bottom: 18px; }
|
||||
.editor-form :deep(.el-form-item__label) { padding-bottom: 7px; color: #344054; font-size: 12px; font-weight: 650; line-height: 1.4; }
|
||||
.editor-form :deep(.el-form-item) { margin-bottom: 14px; }
|
||||
.editor-form :deep(.el-form-item__label) { padding-bottom: 4px; color: #344054; font-size: 12px; font-weight: 650; line-height: 1.4; }
|
||||
.editor-remark-field { margin-bottom: 0 !important; }
|
||||
.editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.editor-footer > span { color: var(--cc-muted); font-size: 11px; }
|
||||
.editor-footer > span { color: var(--cc-muted); font-size: 12px; }
|
||||
.editor-footer > span i { color: var(--el-color-danger); font-style: normal; }
|
||||
.editor-footer > div { display: flex; gap: 8px; }
|
||||
.editor-footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Boxes, Pencil, Plus, Save, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||
@@ -343,7 +344,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="pallet-page">
|
||||
<AdminPage as="div" class="pallet-page admin-density">
|
||||
<section class="summary-grid" aria-label="托盘数量概览">
|
||||
<article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article>
|
||||
<article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article>
|
||||
@@ -423,7 +424,7 @@ onMounted(() => {
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" width="min(560px, calc(100vw - 28px))" class="pallet-editor-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" width="min(560px, calc(100vw - 28px))" class="pallet-editor-dialog admin-density-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
|
||||
<div class="editor-field-grid">
|
||||
<el-form-item label="所属仓库" prop="warehouseId" required>
|
||||
@@ -478,29 +479,29 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.pallet-page { display: grid; min-width: 0; gap: 16px; }
|
||||
.summary-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; }
|
||||
.summary-grid article { display:grid; gap:8px; padding:18px 20px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }
|
||||
.summary-grid span { color:var(--cc-muted); font-size:12px; }.summary-grid strong { font-size:26px; }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
|
||||
.surface-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.summary-grid { display:flex; flex-wrap:wrap; gap:8px 28px; padding:8px 12px; border:1px solid var(--cc-border); background:#fff; }
|
||||
.summary-grid article { display:flex; align-items:baseline; gap:8px; }
|
||||
.summary-grid span { color:var(--cc-muted); font-size:12px; }.summary-grid strong { font-size:14px; }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
|
||||
.surface-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 4px; background: #fff; }
|
||||
.table-panel { min-width: 0; }
|
||||
.context-empty { padding: 56px 20px; color: var(--cc-muted); text-align: center; }
|
||||
.context-empty { padding: 16px; color: var(--cc-muted); text-align: center; }
|
||||
.pallet-row-actions { gap: 8px; white-space: nowrap; }
|
||||
.pallet-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.pallet-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.stacked-value { display: grid; min-width: 0; gap: 3px; }
|
||||
.stacked-value strong, .stacked-value small { overflow-wrap: anywhere; }
|
||||
.stacked-value small { color: var(--cc-muted); font-size: 11px; }
|
||||
.stacked-value small { color: var(--cc-muted); font-size: 12px; }
|
||||
.pallet-cell > span { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
|
||||
.pallet-cell > span { width: 34px; height: 34px; }
|
||||
.pallet-cell > span { width: 20px; height: 20px; }
|
||||
.remark-text { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.55; }
|
||||
:global(.pallet-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.pallet-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.pallet-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
.editor-form { padding: 8px 24px 12px; }
|
||||
.editor-form { padding: 0; }
|
||||
.editor-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
|
||||
.editor-form :deep(.el-input-number), .editor-form :deep(.el-select) { width: 100%; }
|
||||
.editor-form :deep(.el-form-item) { margin-bottom: 18px; }
|
||||
.editor-form :deep(.el-form-item__label) { padding-bottom: 7px; color: #344054; font-size: 12px; font-weight: 650; line-height: 1.4; }
|
||||
.editor-form :deep(.el-form-item) { margin-bottom: 14px; }
|
||||
.editor-form :deep(.el-form-item__label) { padding-bottom: 4px; color: #344054; font-size: 12px; font-weight: 650; line-height: 1.4; }
|
||||
@media (max-width: 720px) {
|
||||
.summary-grid { grid-template-columns:1fr; }
|
||||
.editor-field-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
@@ -510,7 +511,7 @@ onMounted(() => {
|
||||
.drawer-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.drawer-footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.pallet-editor-footer { align-items: center; justify-content: space-between; }
|
||||
.pallet-editor-footer > span { color: var(--cc-muted); font-size: 11px; }
|
||||
.pallet-editor-footer > span { color: var(--cc-muted); font-size: 12px; }
|
||||
.pallet-editor-footer > span i { color: var(--el-color-danger); font-style: normal; }
|
||||
.pallet-editor-footer > div { display: flex; gap: 8px; }
|
||||
.pallet-editor-footer > div :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Explicit opt-in for approved dense business workspaces and teleported dialogs. */
|
||||
.admin-density, .admin-density-dialog {
|
||||
--el-font-size-base: 13px;
|
||||
--el-component-size: 32px;
|
||||
--cc-list-table-font-size: 13px;
|
||||
--cc-list-action-font-size: 13px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-density .surface-panel, .admin-density .query-toolbar {
|
||||
border-radius: 4px; box-shadow: none;
|
||||
}
|
||||
.admin-density .el-table, .admin-density-dialog .el-table {
|
||||
--el-table-header-bg-color: #f8fafc;
|
||||
--el-table-border-color: #e5e7eb;
|
||||
--el-table-row-hover-bg-color: #f3f7fb;
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-density .data-table .el-table .el-table__cell, .admin-density-dialog .el-table .el-table__cell { height: 36px; padding: 4px 0; }
|
||||
.admin-density .el-table .cell, .admin-density-dialog .el-table .cell { line-height: 22px; }
|
||||
.admin-density .el-table .el-button--small, .admin-density-dialog .el-table .el-button--small { height: 28px; padding: 4px 8px; }
|
||||
.admin-density .el-table__body tr.el-table__row--striped td.el-table__cell { background: #fcfcfd; }
|
||||
.admin-density .data-table .data-table__toolbar { min-height: 44px; padding: 6px 12px; }
|
||||
.admin-density .data-table .data-table__pagination { min-height: 48px; padding: 8px 12px; }
|
||||
.admin-density .el-empty { padding: 12px 0; }
|
||||
.admin-density .el-empty__image { display: none; }
|
||||
.admin-density .el-empty__description { margin-top: 0; }
|
||||
.admin-density .el-empty__description p { font-size: 13px; }
|
||||
.admin-density-dialog .el-form-item__label { line-height: 22px; }
|
||||
.admin-density-dialog .el-descriptions__label.el-descriptions__cell { font-size: 12px; background: #f8fafc; }
|
||||
.admin-density-dialog .el-descriptions__content.el-descriptions__cell { font-size: 13px; overflow-wrap: anywhere; }
|
||||
.admin-density-dialog.el-dialog.is-fullscreen { width: 100%; max-width: 100%; height: 100vh; max-height: 100vh; margin: 0; border-radius: 0; }
|
||||
.admin-density-dialog.el-dialog.admin-density-workspace:not(.is-fullscreen) { height: 92vh; max-height: 92vh; margin-top: 4vh; }
|
||||
.admin-density-dialog .el-dialog__headerbtn { top: 0; }
|
||||
.admin-density-dialog .app-dialog__native-fullscreen-control { top: 6px; }
|
||||
.admin-density .density-tab-icon { display: inline-flex; align-items: center; justify-content: center; width: 18px; font: 17px/1 "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif; }
|
||||
.admin-density-confirm.el-message-box { --el-font-size-base: 13px; border-radius: 4px; padding: 12px 16px; }
|
||||
.admin-density-confirm .el-message-box__title { font-size: 16px; }
|
||||
|
||||
/* Dialogs are teleported; opt in explicitly instead of styling all AppDialog instances. */
|
||||
.admin-density-dialog.el-dialog {
|
||||
--el-font-size-base: 13px;
|
||||
--el-component-size: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.admin-density-dialog.el-dialog:not(.is-fullscreen) { margin-top: 6vh; max-height: 88vh; }
|
||||
.admin-density-dialog.el-dialog .el-dialog__header { flex: 0 0 auto; margin: 0; padding: 10px 48px 10px 16px; border-bottom: 1px solid var(--cc-border); }
|
||||
.admin-density-dialog.el-dialog .el-dialog__title { font-size: 16px; line-height: 24px; font-weight: 600; }
|
||||
.admin-density-dialog.el-dialog .el-dialog__body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 12px 16px; }
|
||||
.admin-density-dialog.el-dialog .el-dialog__footer { flex: 0 0 auto; padding: 8px 16px; border-top: 1px solid var(--cc-border); }
|
||||
.admin-density-dialog.el-dialog .el-table { font-size: 13px; }
|
||||
.admin-density-dialog.el-dialog .el-table__cell { height: 36px; padding: 4px 0; }
|
||||
.admin-density-dialog.el-dialog .el-table .cell { line-height: 22px; }
|
||||
.admin-density-dialog.el-dialog .el-form-item { margin-bottom: 14px; }
|
||||
.admin-density-dialog.el-dialog .el-form-item__label { font-size: 13px; }
|
||||
.admin-density-dialog.el-dialog .el-alert { padding: 8px 10px; border-radius: 3px; }
|
||||
.admin-density-dialog.el-dialog .el-empty { padding: 12px 0; }
|
||||
.admin-density-dialog.el-dialog .el-empty__image { display: none; }
|
||||
.admin-density-dialog.el-dialog .el-empty__description { margin-top: 0; }
|
||||
|
||||
/* Headers must keep units visible even when a saved column width is narrow. */
|
||||
.admin-density .el-table th.el-table__cell .cell,
|
||||
.admin-density-dialog .el-table th.el-table__cell .cell { white-space: normal; overflow-wrap: anywhere; text-overflow: clip; line-height: 18px; }
|
||||
Reference in New Issue
Block a user