优化
This commit is contained in:
@@ -79,8 +79,11 @@
|
||||
|
||||
## UI 与交互
|
||||
|
||||
- 已向客户讲解或确认过的产品原型是页面排版、配色、间距、尺寸、图标、按钮、表格、Tab 和组件形态的直接视觉事实来源;实现时必须按原型还原,不得脱离原型自行重新设计、添加装饰或替换风格。只有用户当前明确要求、阶段范围或可访问性/真实数据约束与原型冲突时才允许调整,并须先说明具体差异及原因。
|
||||
- 原型已经给出明确图标、字符图标或按钮形态时直接使用原型方案,不得以 Lucide、Element Plus 默认样式或个人偏好替换;仅当原型没有定义对应图标时,才按本节的默认图标语义补齐。
|
||||
- 已向客户讲解或确认过的产品原型是页面视觉与布局实现的直接事实来源。字段顺序、字段分组、字段摆放、列宽、控件宽高、对齐方式、换行方式、区块层级、上下左右边距、内边距、间隙、留白、圆角、边框、阴影、背景色、文字颜色和字号均必须按原型还原,不得只参考大致风格后自行重新排版。
|
||||
- 原型中的按钮位置、顺序、尺寸、圆角、边框、背景色、文字色、图标、悬停态、激活态、禁用态和危险语义必须直接按原型实现;不得擅自改为文字链接、不同颜色、不同按钮类型、折叠菜单或自创交互效果,也不得增加原型没有的上浮、缩放、渐变线、装饰图标或其他视觉元素。
|
||||
- 原型已经给出明确图标、字符图标、图片资源或按钮内图标时,直接使用原型中的同一方案和资源,不得以 Lucide、Element Plus 默认图标、手绘图标或个人偏好替换。仅当原型没有定义对应图标时,才按本节的默认图标语义补齐。
|
||||
- 上述原型还原规则优先于本文件中“延续现有视觉体系”、默认组件样式和默认图标库等一般性规则。只有用户当前明确要求、阶段范围、真实字段长度、响应式、可访问性或接口事实与原型冲突时才允许做最小调整,并须在修改前说明具体差异、原因和影响范围;不得以技术实现方便为由偏离原型。
|
||||
- 详情、新增、编辑、审核等操作采用独立页面、居中弹框、侧滑面板或其他承载方式,必须逐页面按对应原型确认,不得全局写死为某一种容器。原型为独立页面时使用正式路由和页面布局;原型为居中弹框时使用模态弹框;只有原型明确为侧滑面板时才使用抽屉。不得因为已有公共组件或改动方便,把原型中的独立页面改成弹框、把弹框改成抽屉,或反向替换。
|
||||
- 延续现有后台视觉体系:克制的企业级界面、清晰的信息层级和高密度但可扫描的数据展示。
|
||||
- 路由级页面标题和说明统一由 `AppLayout` 顶部 `page-heading` 根据路由 `meta.title`、`meta.description` 展示。标准列表页正文不得重复渲染 `PageToolbar`、页面标题或说明,只保留查询区与表格区。`QueryToolbar` 只承载筛选项及其内置查询、重置按钮,不把新增、导入、导出或批量操作放入其 `actions` 插槽;重新执行查询即刷新当前结果,标准列表页任何位置都不得再提供独立刷新按钮。新增、导入、导出和批量操作统一放入 `DataTable` 的 `toolbar-actions` 插槽并靠表格工具栏右侧展示。仅没有查询组件的实时工作台或详情页在业务确实需要主动重载时,才可保留刷新;仅详情页或工作台在路由明确设置 `showPageHeading: false` 时,才可在正文使用独立详情头。
|
||||
- 标准列表页的查询面板与表格面板使用 `ListPageLayout` 保持单一 `16px` 间距,不再叠加页面级 margin、空白操作区或额外容器间距。`DataTable` 必须位于连续的 `surface-panel table-panel` 中,使右侧业务按钮、列设置、表头和分页属于同一表格面板;不得使用 `toolbar-start` 重复显示列表标题、数量或说明,数量统一放在 `pagination-left`。当工具栏只有右侧操作和列设置时使用紧凑高度,不保留空白的左侧标题区域。
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
Building2,
|
||||
BarChart3,
|
||||
ContactRound,
|
||||
ClipboardList,
|
||||
CircleDollarSign,
|
||||
@@ -15,6 +14,21 @@ import {
|
||||
Users,
|
||||
Warehouse as WarehouseIcon,
|
||||
} from '@lucide/vue'
|
||||
import {
|
||||
PrototypeContainerIcon,
|
||||
PrototypeCustomerIcon,
|
||||
PrototypeDashboardIcon,
|
||||
PrototypeDeliveryIcon,
|
||||
PrototypeDomesticWarehouseIcon,
|
||||
PrototypeFinanceIcon,
|
||||
PrototypeGeneralIcon,
|
||||
PrototypeOverseasBusinessIcon,
|
||||
PrototypeOverseasWarehouseIcon,
|
||||
PrototypeSalesIcon,
|
||||
PrototypeServiceIcon,
|
||||
PrototypeSupplierIcon,
|
||||
PrototypeSystemIcon,
|
||||
} from './prototypeMenuIcons'
|
||||
|
||||
export interface AdminNavigationNode {
|
||||
key: string
|
||||
@@ -36,12 +50,11 @@ export interface AdminNavigationLeaf extends AdminNavigationNode {
|
||||
}
|
||||
|
||||
export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'dashboard', title: '首页数据看板', icon: BarChart3, path: '/dashboard' },
|
||||
{ key: 'dashboard', title: '首页数据看板', icon: PrototypeDashboardIcon, path: '/dashboard' },
|
||||
{
|
||||
key: 'customers',
|
||||
title: '客户管理',
|
||||
icon: Building2,
|
||||
iconColor: '#52c41a',
|
||||
icon: PrototypeCustomerIcon,
|
||||
children: [
|
||||
{ key: 'customers-list', title: '客户列表', icon: Building2, path: '/customers', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
|
||||
{ key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/customers/overseas-storage-fees', permission: 'platform:overseas-storage-fee:list' },
|
||||
@@ -50,8 +63,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'domestic-warehouse',
|
||||
title: '国内仓',
|
||||
icon: WarehouseIcon,
|
||||
iconColor: '#52c41a',
|
||||
icon: PrototypeDomesticWarehouseIcon,
|
||||
children: [
|
||||
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
|
||||
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup', hidden: true },
|
||||
@@ -63,7 +75,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'overseas-delivery',
|
||||
title: '海外派送',
|
||||
icon: Truck,
|
||||
icon: PrototypeDeliveryIcon,
|
||||
hidden: true,
|
||||
children: [
|
||||
{ key: 'outbound-dispatch', title: '派送单列表', icon: Truck, path: '/prototype/overseas-delivery/dispatch-list' },
|
||||
@@ -75,7 +87,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'service-center',
|
||||
title: '服务中心',
|
||||
icon: ClipboardList,
|
||||
icon: PrototypeServiceIcon,
|
||||
children: [
|
||||
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
|
||||
{ key: 'port-schedules', title: '船期信息', icon: ClipboardList, path: '/port-schedules', permission: 'platform:firstleg:port-schedule:list' },
|
||||
@@ -87,7 +99,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'supplier-center',
|
||||
title: '供应商中心',
|
||||
icon: ContactRound,
|
||||
icon: PrototypeSupplierIcon,
|
||||
children: [
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/prototype/supplier-center/supplier-list', hidden: true },
|
||||
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
|
||||
@@ -97,7 +109,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'container-management',
|
||||
title: '货柜管理',
|
||||
icon: WarehouseIcon,
|
||||
icon: PrototypeContainerIcon,
|
||||
children: [
|
||||
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
|
||||
{ key: 'container-smart-loading', title: '智能装柜', icon: ClipboardList, path: '/container-loading/smart-loading', permission: 'platform:firstleg:smart-loading:list' },
|
||||
@@ -108,7 +120,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'overseas-business',
|
||||
title: '海外业务',
|
||||
icon: Building2,
|
||||
icon: PrototypeOverseasBusinessIcon,
|
||||
hidden: true,
|
||||
children: [
|
||||
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/prototype/overseas-business/purchase-orders' },
|
||||
@@ -119,7 +131,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'overseas-inventory',
|
||||
title: '海外仓库存',
|
||||
icon: WarehouseIcon,
|
||||
icon: PrototypeOverseasWarehouseIcon,
|
||||
hidden: true,
|
||||
children: [
|
||||
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/prototype/fba-returns/inbound-orders' },
|
||||
@@ -135,7 +147,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'finance',
|
||||
title: '财务管理',
|
||||
icon: CircleDollarSign,
|
||||
icon: PrototypeFinanceIcon,
|
||||
children: [
|
||||
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:account:view' },
|
||||
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:bill:view' },
|
||||
@@ -147,7 +159,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'sales',
|
||||
title: '销售管理',
|
||||
icon: Users,
|
||||
icon: PrototypeSalesIcon,
|
||||
hidden: true,
|
||||
children: [
|
||||
{ key: 'sales-visits', title: '拜访记录', icon: Users, path: '/prototype/sales/visit-records' },
|
||||
@@ -157,8 +169,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'general',
|
||||
title: '通用',
|
||||
icon: Database,
|
||||
iconColor: '#722ed1',
|
||||
icon: PrototypeGeneralIcon,
|
||||
children: [
|
||||
// { key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list', hidden: true },
|
||||
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
|
||||
@@ -172,8 +183,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'system',
|
||||
title: '系统管理',
|
||||
icon: Settings,
|
||||
iconColor: '#8c8c8c',
|
||||
icon: PrototypeSystemIcon,
|
||||
children: [
|
||||
{ key: 'internal-users', title: '用户管理', icon: Users, path: '/system/users', permission: 'platform:user:list' },
|
||||
{ key: 'roles', title: '角色权限', icon: ShieldCheck, path: '/system/roles', permission: 'platform:role:list' },
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PrototypeCustomerIcon,
|
||||
PrototypeDashboardIcon,
|
||||
PrototypeFinanceIcon,
|
||||
} from './prototypeMenuIcons'
|
||||
|
||||
describe('prototype menu icons', () => {
|
||||
it('renders the prototype shapes at the requested size', () => {
|
||||
const wrapper = mount(PrototypeDashboardIcon, { props: { size: 22 } })
|
||||
|
||||
expect(wrapper.attributes()).toMatchObject({
|
||||
width: '22',
|
||||
height: '22',
|
||||
viewBox: '0 0 24 24',
|
||||
'aria-hidden': 'true',
|
||||
})
|
||||
expect(wrapper.findAll('rect')).toHaveLength(4)
|
||||
expect(wrapper.findAll('rect').map((node) => node.attributes('fill'))).toEqual([
|
||||
'#1677ff',
|
||||
'#1677ff',
|
||||
'#1677ff',
|
||||
'#1677ff',
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves the original menu colors', () => {
|
||||
expect(mount(PrototypeCustomerIcon).find('path').attributes('fill')).toBe('#52c41a')
|
||||
expect(mount(PrototypeFinanceIcon).find('circle').attributes('stroke')).toBe('#f5a623')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { defineComponent, h, type Component, type SVGAttributes } from 'vue'
|
||||
|
||||
type SvgChild = ReturnType<typeof h>
|
||||
|
||||
function createPrototypeMenuIcon(name: string, children: SvgChild[]): Component {
|
||||
return defineComponent({
|
||||
name,
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
size: { type: [Number, String], default: 20 },
|
||||
},
|
||||
setup(props, { attrs }) {
|
||||
return () => h('svg', {
|
||||
...attrs,
|
||||
width: props.size,
|
||||
height: props.size,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
'aria-hidden': 'true',
|
||||
} satisfies SVGAttributes, children)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const PrototypeDashboardIcon = createPrototypeMenuIcon('PrototypeDashboardIcon', [
|
||||
h('rect', { x: 3, y: 3, width: 7, height: 8, rx: 1.5, fill: '#1677ff' }),
|
||||
h('rect', { x: 14, y: 3, width: 7, height: 5, rx: 1.5, fill: '#1677ff', opacity: 0.6 }),
|
||||
h('rect', { x: 3, y: 14, width: 7, height: 7, rx: 1.5, fill: '#1677ff', opacity: 0.3 }),
|
||||
h('rect', { x: 14, y: 11, width: 7, height: 10, rx: 1.5, fill: '#1677ff', opacity: 0.8 }),
|
||||
])
|
||||
|
||||
export const PrototypeCustomerIcon = createPrototypeMenuIcon('PrototypeCustomerIcon', [
|
||||
h('path', { d: 'M3 21h7v-7H3v7zm0-9h7V3H3v9zm11 9h7v-5h-7v5zm0-9h7V3h-7v9z', fill: '#52c41a' }),
|
||||
])
|
||||
|
||||
export const PrototypeDomesticWarehouseIcon = createPrototypeMenuIcon('PrototypeDomesticWarehouseIcon', [
|
||||
h('path', { d: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2', stroke: '#52c41a', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
h('path', { d: 'M9 14l2 2 4-4', stroke: '#52c41a', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }),
|
||||
])
|
||||
|
||||
export const PrototypeOverseasWarehouseIcon = createPrototypeMenuIcon('PrototypeOverseasWarehouseIcon', [
|
||||
h('path', { d: 'M4 12a8 8 0 112.34 5.6', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
h('path', { d: 'M4 12V7M4 12h5', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
h('circle', { cx: 12, cy: 12, r: 2.4, fill: '#eb2f96' }),
|
||||
])
|
||||
|
||||
export const PrototypeDeliveryIcon = createPrototypeMenuIcon('PrototypeDeliveryIcon', [
|
||||
h('path', { d: 'M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z', fill: '#1890ff' }),
|
||||
])
|
||||
|
||||
export const PrototypeServiceIcon = createPrototypeMenuIcon('PrototypeServiceIcon', [
|
||||
h('path', { d: 'M4 6h16v12H4z', stroke: '#722ed1', 'stroke-width': 2 }),
|
||||
h('path', { d: 'M4 10h16M8 14h3', stroke: '#722ed1', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
])
|
||||
|
||||
export const PrototypeSupplierIcon = createPrototypeMenuIcon('PrototypeSupplierIcon', [
|
||||
h('path', { d: 'M3 21V9l9-6 9 6v12', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linejoin': 'round' }),
|
||||
h('path', { d: 'M9 21v-6h6v6', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linejoin': 'round' }),
|
||||
])
|
||||
|
||||
export const PrototypeContainerIcon = createPrototypeMenuIcon('PrototypeContainerIcon', [
|
||||
h('rect', { x: 2, y: 3, width: 20, height: 18, rx: 1, stroke: '#52c41a', 'stroke-width': 2 }),
|
||||
h('path', { d: 'M2 8h20M8 3v5M16 3v5', stroke: '#52c41a', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
])
|
||||
|
||||
export const PrototypeOverseasBusinessIcon = createPrototypeMenuIcon('PrototypeOverseasBusinessIcon', [
|
||||
h('circle', { cx: 12, cy: 12, r: 9, stroke: '#8c8c8c', 'stroke-width': 1.5 }),
|
||||
h('path', { d: 'M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }),
|
||||
])
|
||||
|
||||
export const PrototypeFinanceIcon = createPrototypeMenuIcon('PrototypeFinanceIcon', [
|
||||
h('circle', { cx: 12, cy: 12, r: 10, stroke: '#f5a623', 'stroke-width': 2 }),
|
||||
h('path', { d: 'M12 6v6l4 2', stroke: '#f5a623', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
h('circle', { cx: 12, cy: 12, r: 2, fill: '#f5a623' }),
|
||||
])
|
||||
|
||||
export const PrototypeSalesIcon = createPrototypeMenuIcon('PrototypeSalesIcon', [
|
||||
h('circle', { cx: 12, cy: 12, r: 10, stroke: '#1677ff', 'stroke-width': 2 }),
|
||||
h('path', { d: 'M12 6v12M6 12h12', stroke: '#1677ff', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
])
|
||||
|
||||
export const PrototypeGeneralIcon = createPrototypeMenuIcon('PrototypeGeneralIcon', [
|
||||
h('rect', { x: 3, y: 3, width: 18, height: 18, rx: 2, stroke: '#722ed1', 'stroke-width': 2 }),
|
||||
h('path', { d: 'M8 12h8M12 8v8', stroke: '#722ed1', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
])
|
||||
|
||||
export const PrototypeSystemIcon = createPrototypeMenuIcon('PrototypeSystemIcon', [
|
||||
h('circle', { cx: 12, cy: 12, r: 3, fill: '#8c8c8c' }),
|
||||
h('path', { d: 'M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }),
|
||||
])
|
||||
@@ -158,4 +158,26 @@ describe('ContainersView', () => {
|
||||
expect(source).toContain("if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()")
|
||||
expect(source).not.toContain('@reset=')
|
||||
})
|
||||
|
||||
it('uses the frozen prototype modal contract for create and edit while retaining the detail drawer', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('<AppDrawer v-model="detailOpen"')
|
||||
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
|
||||
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
|
||||
expect(source).toContain('max-height: 90vh')
|
||||
expect(source).toContain('overflow-y: auto')
|
||||
expect(source).toContain('grid-template-columns: minmax(0,1fr)')
|
||||
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
})
|
||||
|
||||
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
const labels = ['集装箱号', '柜号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
|
||||
let previous = -1
|
||||
for (const label of labels) {
|
||||
const current = source.indexOf(`label="${label}"`, previous + 1)
|
||||
expect(current, label).toBeGreaterThan(previous)
|
||||
previous = current
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/ma
|
||||
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
@@ -75,7 +76,7 @@ const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const editorTitle = computed(() => form.id ? (detail.value?.planStatus === 'LOADING' ? '修改运输信息' : '编辑货柜') : '新增货柜')
|
||||
const editorTitle = computed(() => form.id ? '编辑' : '新增')
|
||||
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
|
||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
|
||||
const truckingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.some((type) => ['CARRIER', 'SUPPLIER'].includes(type))))
|
||||
@@ -341,35 +342,41 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadBusinessOptions(), loa
|
||||
<el-descriptions v-else-if="detail" :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDrawer v-model="editorOpen" :title="editorTitle" size="min(860px, 96vw)" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="保存" @confirm="save">
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
|
||||
<LoadErrorState v-if="warehouseError && !transportOnly" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<el-alert v-if="businessOptionsError && !transportOnly" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
|
||||
<el-form v-if="!warehouseError || transportOnly" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
|
||||
<el-form-item label="柜次号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" placeholder="请输入柜次号" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="请输入集装箱号,开始装柜前必填" /></el-form-item>
|
||||
<el-form-item label="柜号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" placeholder="请输入柜号" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库" :disabled="transportOnly"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="柜型" prop="containerType"><el-select v-model="form.containerType" placeholder="请选择柜型" :disabled="transportOnly"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="容量上限(m³)"><el-input-number v-model="form.capacityCbm" :min="0.001" :precision="3" controls-position="right" placeholder="请输入容量上限" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="重量上限(kg)"><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" placeholder="请输入重量上限" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" :disabled="transportOnly" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select v-model="form.containerType" placeholder="请选择箱型" :disabled="transportOnly"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" :disabled="transportOnly" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" :disabled="transportOnly" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
|
||||
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" :disabled="transportOnly" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
|
||||
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" :disabled="transportOnly" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
|
||||
<el-form-item label="货柜容量(m³)"><el-input-number v-model="form.capacityCbm" :min="0.001" :precision="3" controls-position="right" placeholder="请输入货柜容量" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="重量上限(kg)"><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" placeholder="请输入重量上限" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="拖车公司"><el-select v-model="form.terminalTruckingCompany" filterable clearable allow-create default-first-option placeholder="请选择或输入拖车公司" :loading="businessOptionsLoading" loading-text="拖车公司加载中" no-data-text="暂无合作单位,可直接输入" :disabled="transportOnly"><el-option v-for="partner in truckingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="partner.partnerName" /></el-select></el-form-item>
|
||||
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" :disabled="transportOnly" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDrawer>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 16px; margin-top: 16px; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }@media(max-width:720px){.form-grid { grid-template-columns: 1fr; }.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }
|
||||
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
|
||||
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
|
||||
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
|
||||
:global(.container-editor-dialog .el-dialog__body) { min-height: 0; padding: 28px; overflow-y: auto; }
|
||||
:global(.container-editor-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 16px 28px; border-top: 1px solid #e5eaf1; background: #f8fafc; }
|
||||
@media(max-width:720px){:global(.container-editor-dialog.el-dialog){max-height:calc(100vh - 20px);margin-block:10px!important}:global(.container-editor-dialog .el-dialog__header),:global(.container-editor-dialog .el-dialog__body),:global(.container-editor-dialog .el-dialog__footer){padding-inline:16px}.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
@@ -26,6 +28,8 @@ import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import CustomersView from './CustomersView.vue'
|
||||
|
||||
const customersSource = readFileSync(resolve(process.cwd(), 'src/modules/customers/pages/CustomersView.vue'), 'utf8')
|
||||
|
||||
const { routerPush, routerReplace, routeControl } = vi.hoisted(() => ({
|
||||
routerPush: vi.fn(),
|
||||
routerReplace: vi.fn(),
|
||||
@@ -152,6 +156,15 @@ describe('CustomersView', () => {
|
||||
vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('matches the frozen prototype containers for customer operations', () => {
|
||||
expect(customersSource).toContain("router.push({ name: 'customer-detail'")
|
||||
expect(customersSource).toContain("'min(640px, calc(100vw - 28px))'")
|
||||
expect(customersSource).toContain("'min(680px, calc(100vw - 28px))'")
|
||||
expect(customersSource).toContain('width="min(520px, calc(100vw - 28px))" class="customer-password-dialog"')
|
||||
expect(customersSource).toContain('width="min(900px, calc(100vw - 28px))" class="review-decision-dialog"')
|
||||
expect(customersSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
})
|
||||
|
||||
it('loads the complete customer detail before editing masked list fields', async () => {
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -368,7 +381,7 @@ describe('CustomersView', () => {
|
||||
tableButtonByText('审核')?.click()
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '确认通过')?.click()
|
||||
.find((button) => button.textContent?.trim() === '审核通过')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过')
|
||||
@@ -398,7 +411,7 @@ describe('CustomersView', () => {
|
||||
tableButtonByText('审核')?.click()
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '确认通过')?.click()
|
||||
.find((button) => button.textContent?.trim() === '审核通过')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过')
|
||||
|
||||
@@ -39,7 +39,6 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -204,6 +203,9 @@ let loadSequence = 0
|
||||
let reviewLoadSequence = 0
|
||||
let editorLoadSequence = 0
|
||||
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
|
||||
const editorDialogWidth = computed(() => editingId.value
|
||||
? 'min(640px, calc(100vw - 28px))'
|
||||
: 'min(680px, calc(100vw - 28px))')
|
||||
const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value))
|
||||
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
|
||||
const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading))
|
||||
@@ -963,7 +965,7 @@ onMounted(() => { void loadCustomers() })
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer v-model="editorOpen" :title="editorTitle" size="760px" class="business-drawer customer-editor">
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close>
|
||||
<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>
|
||||
@@ -1006,7 +1008,7 @@ onMounted(() => { void loadCustomers() })
|
||||
</FormSection>
|
||||
</el-form>
|
||||
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer"><Save :size="16" />保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||
</AppDrawer>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="qualificationPreviewOpen" :title="qualificationPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="qualification-preview-dialog" destroy-on-close :show-confirm="false" cancel-text="关闭预览">
|
||||
<div class="qualification-preview">
|
||||
@@ -1026,16 +1028,35 @@ onMounted(() => { void loadCustomers() })
|
||||
<template #footer><el-button type="primary" @click="closeApprovalResult">我已保存,关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="decisionOpen" :title="decision.type === 'approve' ? '确认审核通过' : '确认驳回企业资料'" width="min(520px, calc(100vw - 28px))" class="review-decision-dialog" :close-on-click-modal="false">
|
||||
<div v-if="selectedReview" class="decision-summary"><strong>{{ selectedReview.tenantName || '当前客户' }}</strong><span>客户资料审核</span></div>
|
||||
<el-dialog v-model="decisionOpen" title="客户入驻审核" width="min(900px, calc(100vw - 28px))" class="review-decision-dialog" :close-on-click-modal="false">
|
||||
<div v-if="selectedReview" class="review-dialog-grid">
|
||||
<section class="review-dialog-section">
|
||||
<h3>企业基本信息</h3>
|
||||
<dl class="review-info-grid">
|
||||
<div><dt>客户名称</dt><dd>{{ selectedReview.tenantName || '—' }}</dd></div>
|
||||
<div><dt>客户编号</dt><dd>{{ selectedReview.tenantCode || '—' }}</dd></div>
|
||||
<div><dt>联系人</dt><dd>{{ selectedReview.contactName || '—' }}</dd></div>
|
||||
<div><dt>联系电话</dt><dd>{{ selectedReview.contactPhone || '—' }}</dd></div>
|
||||
<div><dt>联系邮箱</dt><dd>{{ selectedReview.contactEmail || '—' }}</dd></div>
|
||||
<div><dt>提交时间</dt><dd>{{ formatDate(selectedReview.submittedAt || selectedReview.createdAt) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="review-dialog-section">
|
||||
<h3>资质文件</h3>
|
||||
<div class="review-document-table" role="table" aria-label="待审核客户资质">
|
||||
<div class="review-document-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">证书编号</span><span role="columnheader">状态</span></div>
|
||||
<div class="review-document-row" role="row"><span role="cell">{{ selectedReview.qualificationType || '企业资质' }}</span><span role="cell">{{ selectedReview.certificateNo || '—' }}</span><span role="cell">待审核</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<el-radio-group v-if="access.can('platform:customer:approve') && access.can('platform:customer:reject')" v-model="decision.type" class="decision-type" aria-label="审核决定"><el-radio-button label="approve">通过</el-radio-button><el-radio-button label="reject">驳回</el-radio-button></el-radio-group>
|
||||
<el-form ref="decisionFormRef" :model="decision" :rules="decisionRules" label-position="top">
|
||||
<el-form-item prop="comment" :label="decision.type === 'approve' ? '审核说明' : '驳回原因'" required><el-input v-model="decision.comment" type="textarea" :rows="4" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '请填写审核通过说明' : '请明确说明需要企业修改的内容'" /></el-form-item>
|
||||
<el-form-item prop="comment" :label="decision.type === 'approve' ? '审核意见' : '驳回原因'" required><el-input v-model="decision.comment" type="textarea" :rows="3" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '可填写审核意见' : '请明确说明需要企业修改的内容'" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="reviewSubmitting" @click="decisionOpen = false">取消</el-button><el-button :type="decision.type === 'approve' ? 'success' : 'danger'" :loading="reviewSubmitting" @click="submitDecision">{{ decision.type === 'approve' ? '确认通过' : '确认驳回' }}</el-button></div></template>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="reviewSubmitting" @click="decisionOpen = false">返回</el-button><el-button v-if="access.can('platform:customer:reject') && decision.type === 'reject'" type="danger" :loading="reviewSubmitting" @click="submitDecision">确认驳回</el-button><el-button v-if="access.can('platform:customer:approve') && decision.type === 'approve'" type="primary" :loading="reviewSubmitting" @click="submitDecision">审核通过</el-button></div></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(520px, calc(100vw - 28px))" class="customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
|
||||
<el-alert v-if="passwordError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ passwordError.message }}</span><ApiErrorMeta :details="passwordError" /></div></template></el-alert>
|
||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top">
|
||||
@@ -1115,12 +1136,29 @@ onMounted(() => { void loadCustomers() })
|
||||
.qualification-preview img { display:block; max-width:100%; height:auto; }
|
||||
.qualification-preview iframe { width:100%; min-height:min(68vh,720px); border:0; background:#fff; }
|
||||
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:12px; padding:10px 12px; border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:12px; }
|
||||
.customer-editor :deep(.el-drawer__body) { min-height:0; overflow-y:auto; overscroll-behavior:contain; }
|
||||
.customer-editor :deep(.el-drawer__footer) { flex:0 0 auto; }
|
||||
:global(.customer-editor-dialog.el-dialog) { display:flex; max-height:min(90vh,900px); flex-direction:column; }
|
||||
:global(.customer-editor-dialog .el-dialog__body) { min-height:0; overflow-y:auto; overscroll-behavior:contain; }
|
||||
:global(.customer-editor-dialog .el-dialog__footer) { flex:0 0 auto; }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; } .customer-editor-footer > span { color:var(--cc-muted); font-size:12px; text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:8px; } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; padding:12px 14px; border-radius:8px; background:#f8fafc; } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:13px; } .decision-summary span { color:var(--cc-muted); font-size:11px; }
|
||||
:global(.review-decision-dialog.el-dialog) { display:flex; max-height:min(90vh,820px); flex-direction:column; }
|
||||
:global(.review-decision-dialog .el-dialog__body) { min-height:0; overflow-y:auto; padding:20px 28px; }
|
||||
:global(.review-decision-dialog .el-dialog__footer) { flex:0 0 auto; }
|
||||
.review-dialog-grid { display:grid; grid-template-columns:1fr 1fr; gap:24px; margin-bottom:18px; }
|
||||
.review-dialog-section { min-width:0; }
|
||||
.review-dialog-section h3 { margin:0 0 12px; padding-bottom:8px; border-bottom:2px solid #667eea; color:#1a1a2e; font-size:14px; font-weight:600; }
|
||||
.review-info-grid { display:grid; gap:7px; margin:0; }
|
||||
.review-info-grid > div { display:grid; grid-template-columns:110px minmax(0,1fr); gap:12px; }
|
||||
.review-info-grid dt { color:#64748b; font-size:12px; }
|
||||
.review-info-grid dd { min-width:0; margin:0; overflow-wrap:anywhere; color:#1e293b; font-size:13px; }
|
||||
.review-document-table { overflow:hidden; border:1px solid #e4e4e7; border-radius:6px; }
|
||||
.review-document-header,.review-document-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) 76px; }
|
||||
.review-document-header { background:rgba(102,126,234,.08); color:#52525b; font-size:12px; font-weight:600; }
|
||||
.review-document-header > span,.review-document-row > span { min-width:0; padding:9px 10px; overflow-wrap:anywhere; border-right:1px solid #e4e4e7; }
|
||||
.review-document-header > span:last-child,.review-document-row > span:last-child { border-right:0; }
|
||||
.review-document-row { border-top:1px solid #e4e4e7; color:#334155; font-size:13px; }
|
||||
.decision-summary > div > span { display:block; margin-top:4px; }.decision-type { margin:0 0 16px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:8px; }.dialog-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; }.archive-confirm { display:grid; gap:8px; margin:16px 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:12px; }
|
||||
.approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; }
|
||||
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
|
||||
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.review-dialog-grid { grid-template-columns:1fr; gap:18px; }.review-info-grid > div { grid-template-columns:92px minmax(0,1fr); }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
|
||||
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -15,10 +15,39 @@ describe('InboundWorkflowWorkspace', () => {
|
||||
expect(source).toContain('getPlatformInbound')
|
||||
expect(source).toContain('StatusBadge')
|
||||
expect(source).toContain('label="基本信息"')
|
||||
expect(source).toContain('label="货物与测量"')
|
||||
expect(source).toContain('label="货品与测量"')
|
||||
expect(source).toContain('label="测量审核"')
|
||||
expect(source).toContain('label="费用信息"')
|
||||
expect(source).toContain('label="状态与其他"')
|
||||
expect(source).toContain('label="状态历史"')
|
||||
expect(source).toContain('label="其他记录"')
|
||||
})
|
||||
|
||||
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
|
||||
expect(source).toContain('title="编辑入库测量数据"')
|
||||
expect(source).toContain('width="min(1900px, calc(100vw - 32px))"')
|
||||
expect(source).toContain('title="审核入库测量数据"')
|
||||
expect(source).toContain('width="min(1700px, calc(100vw - 32px))"')
|
||||
expect(source).toContain('height: 92vh')
|
||||
expect(source).toContain('max-height: 92vh')
|
||||
expect(source).toContain('.el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto;')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
expect(source).toContain('@media (max-width: 360px)')
|
||||
})
|
||||
|
||||
it('keeps domain writes inside their existing panels and mounts them only in dialog bodies', () => {
|
||||
expect(source).toContain('@click="measurementDialogOpen = true"')
|
||||
expect(source).toContain('@click="reviewDialogOpen = true"')
|
||||
expect(source).toContain(':key="`measurement-dialog-${revision}`"')
|
||||
expect(source).toContain(':key="`review-dialog-${revision}`"')
|
||||
expect(source).toContain('@changed="handleReviewChanged"')
|
||||
expect(source).toContain('<template #footer>')
|
||||
expect(source).toContain('measurementPanelRef?.saveDraft()')
|
||||
expect(source).toContain('measurementPanelRef?.submitForReview()')
|
||||
expect(source).toContain("reviewPanelRef?.openReasonDialog('reject')")
|
||||
expect(source).toContain('reviewPanelRef?.confirmApprove()')
|
||||
expect(source).toContain('inbound-operation-dialog__footer')
|
||||
expect(source).not.toContain('approveMeasurement')
|
||||
expect(source).not.toContain('submitInboundMeasurement')
|
||||
})
|
||||
|
||||
it('uses the established first-leg detail density without nested framed surfaces', () => {
|
||||
@@ -54,7 +83,8 @@ describe('InboundWorkflowWorkspace', () => {
|
||||
|
||||
it('keeps pallet and location results read-only in the overview', () => {
|
||||
expect(source).toContain(':pallet-locations="detail.palletLocations"')
|
||||
expect(source).toContain('label="状态与其他"')
|
||||
expect(source).toContain(':sections="[\'history\']"')
|
||||
expect(source).toContain(':sections="[\'allocations\', \'files\', \'notes\', \'remark\']"')
|
||||
expect(source).not.toContain('reserveLocation')
|
||||
expect(source).not.toContain('allocatePallet')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
import { Box, ClipboardCheck, Pencil, RefreshCw } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -27,6 +27,10 @@ const emit = defineEmits<{
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const activeTab = ref('overview')
|
||||
const measurementDialogOpen = ref(false)
|
||||
const reviewDialogOpen = ref(false)
|
||||
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
|
||||
const reviewPanelRef = ref<InstanceType<typeof MeasurementReviewPanel> | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<InboundOrderDetail | null>(null)
|
||||
@@ -71,6 +75,11 @@ function handleDomainChanged() {
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
function handleReviewChanged() {
|
||||
reviewDialogOpen.value = false
|
||||
handleDomainChanged()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.customerId, props.inboundId],
|
||||
() => {
|
||||
@@ -94,6 +103,7 @@ onBeforeUnmount(() => {
|
||||
<header class="workspace-header">
|
||||
<div>
|
||||
<div class="workspace-title-row">
|
||||
<span class="workspace-mark" aria-hidden="true"><Box :size="20" /></span>
|
||||
<h2>入库码:{{ title }}</h2>
|
||||
<StatusBadge v-if="detail" :status="statusLabel" />
|
||||
</div>
|
||||
@@ -124,14 +134,12 @@ onBeforeUnmount(() => {
|
||||
<el-tab-pane label="基本信息" name="overview">
|
||||
<InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="货物与测量" name="measurement" lazy>
|
||||
<el-tab-pane label="货品与测量" name="measurement" lazy>
|
||||
<div class="workflow-launch-panel">
|
||||
<div><h3>编辑入库测量数据</h3><p>核对原单数据、确认扫描箱数,并查看服务端计算的重量和体积结果。</p></div>
|
||||
<el-button type="primary" @click="measurementDialogOpen = true"><Pencil :size="16" />编辑测量数据</el-button>
|
||||
</div>
|
||||
<div class="measurement-workflow">
|
||||
<InboundMeasurementPanel
|
||||
:key="`measurement-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
<WeightOcrPanel
|
||||
:key="`weight-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
@@ -141,13 +149,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="测量审核" name="review" lazy>
|
||||
<MeasurementReviewPanel
|
||||
v-if="canViewMeasurement"
|
||||
:key="`review-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
<div v-if="canViewMeasurement" class="workflow-launch-panel">
|
||||
<div><h3>审核入库测量数据</h3><p>对照原单、设备实测和称重 OCR 结果后作出审核决定。</p></div>
|
||||
<el-button type="primary" @click="reviewDialogOpen = true"><ClipboardCheck :size="16" />审核测量数据</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
v-else
|
||||
title="无测量审核查看权限"
|
||||
@@ -165,16 +170,83 @@ onBeforeUnmount(() => {
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="状态与其他" name="records" lazy>
|
||||
<el-tab-pane label="状态历史" name="history" lazy>
|
||||
<InboundHistoryPanel
|
||||
:pallet-locations="[]"
|
||||
:files="[]"
|
||||
:internal-notes="[]"
|
||||
:status-history="detail.statusHistory"
|
||||
:public-remark="null"
|
||||
:sections="['history']"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="其他记录" name="records" lazy>
|
||||
<InboundHistoryPanel
|
||||
:pallet-locations="detail.palletLocations"
|
||||
:files="detail.files"
|
||||
:internal-notes="detail.internalNotes"
|
||||
:status-history="detail.statusHistory"
|
||||
:public-remark="null"
|
||||
:status-history="[]"
|
||||
:public-remark="detail.publicRemark"
|
||||
:sections="['allocations', 'files', 'notes', 'remark']"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog
|
||||
v-model="measurementDialogOpen"
|
||||
class="inbound-operation-dialog inbound-measurement-dialog"
|
||||
title="编辑入库测量数据"
|
||||
width="min(1900px, calc(100vw - 32px))"
|
||||
top="4vh"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="inbound-operation-dialog__body">
|
||||
<InboundMeasurementPanel
|
||||
ref="measurementPanelRef"
|
||||
:key="`measurement-dialog-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="inbound-operation-dialog__footer">
|
||||
<el-button @click="measurementDialogOpen = false">取消</el-button>
|
||||
<el-button v-if="measurementPanelRef?.canEdit" type="primary" plain :loading="measurementPanelRef?.saving" :disabled="!measurementPanelRef?.mayMaintain" @click="measurementPanelRef?.saveDraft()">保存确认箱数</el-button>
|
||||
<el-button v-if="measurementPanelRef?.canSubmit" type="primary" :loading="measurementPanelRef?.submitting" :disabled="!measurementPanelRef?.maySubmit" @click="measurementPanelRef?.submitForReview()">提交审核</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="reviewDialogOpen"
|
||||
class="inbound-operation-dialog inbound-review-dialog"
|
||||
title="审核入库测量数据"
|
||||
width="min(1700px, calc(100vw - 32px))"
|
||||
top="4vh"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="inbound-operation-dialog__body">
|
||||
<MeasurementReviewPanel
|
||||
v-if="canViewMeasurement"
|
||||
ref="reviewPanelRef"
|
||||
:key="`review-dialog-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleReviewChanged"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="inbound-operation-dialog__footer">
|
||||
<el-button :disabled="Boolean(reviewPanelRef?.actionLoading)" @click="reviewDialogOpen = false">取消</el-button>
|
||||
<el-button v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview" type="danger" plain :loading="reviewPanelRef?.actionLoading === 'reject'" @click="reviewPanelRef?.openReasonDialog('reject')">驳回</el-button>
|
||||
<el-button v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview" type="primary" :loading="reviewPanelRef?.actionLoading === 'approve'" @click="reviewPanelRef?.confirmApprove()">通过并完成入库</el-button>
|
||||
<el-button v-if="reviewPanelRef?.isRejected && reviewPanelRef?.canRelease" type="warning" plain :loading="reviewPanelRef?.actionLoading === 'release'" @click="reviewPanelRef?.openReasonDialog('release')">异常放行</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -182,6 +254,7 @@ onBeforeUnmount(() => {
|
||||
.inbound-workspace { min-width: 0; }
|
||||
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 24px 16px; border-bottom: 1px solid var(--cc-border); }
|
||||
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.workspace-mark { display: grid; width: 36px; height: 36px; flex: 0 0 36px; place-items: center; border-radius: 8px; background: #1e293b; color: #fff; }
|
||||
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 16px; line-height: 1.35; }
|
||||
.workspace-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.workspace-state { margin: 24px; }
|
||||
@@ -193,6 +266,19 @@ onBeforeUnmount(() => {
|
||||
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding: 0 18px; color: #475467; font-size: 13px; font-weight: 500; }
|
||||
.workflow-tabs :deep(.el-tabs__item.is-active) { color: var(--cc-primary); font-weight: 650; }
|
||||
.measurement-workflow { display: grid; min-width: 0; gap: 28px; }
|
||||
.workflow-launch-panel { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 20px; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.workflow-launch-panel h3 { margin: 0; color: var(--cc-text); font-size: 15px; }
|
||||
.workflow-launch-panel p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.inbound-operation-dialog__body { min-width: 0; }
|
||||
.inbound-operation-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.inbound-operation-dialog__footer :deep(.el-button) { margin-left: 0; }
|
||||
:global(.inbound-operation-dialog.el-dialog) { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
|
||||
:global(.inbound-operation-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 18px 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
:global(.inbound-operation-dialog .el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 20px 24px; }
|
||||
:global(.inbound-operation-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 14px 24px; border-top: 1px solid var(--cc-border); }
|
||||
:global(.inbound-measurement-dialog .measurement-panel) { border: 0; border-radius: 0; box-shadow: none; }
|
||||
:global(.inbound-measurement-dialog .measurement-panel__heading .measurement-panel__actions), :global(.inbound-measurement-dialog .measurement-panel__footer) { display: none; }
|
||||
:global(.inbound-review-dialog .review-actions .action-buttons) { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
.workspace-header { align-items: stretch; flex-direction: column; padding: 16px; }
|
||||
.workspace-header :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
@@ -200,5 +286,17 @@ onBeforeUnmount(() => {
|
||||
.workflow-tabs { padding: 0 16px 16px; }
|
||||
.workflow-tabs :deep(.el-tabs__nav-wrap) { overflow-x: auto; }
|
||||
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding-inline: 14px; }
|
||||
.workflow-launch-panel { align-items: stretch; flex-direction: column; padding: 16px; }
|
||||
.workflow-launch-panel :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
:global(.inbound-operation-dialog.el-dialog) { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); height: 92vh; }
|
||||
:global(.inbound-operation-dialog .el-dialog__header) { padding: 16px; }
|
||||
:global(.inbound-operation-dialog .el-dialog__body) { padding: 14px; }
|
||||
.inbound-operation-dialog__footer { display: grid; grid-template-columns: 1fr; }
|
||||
.inbound-operation-dialog__footer :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
:global(.inbound-operation-dialog.el-dialog) { width: calc(100vw - 12px) !important; max-width: calc(100vw - 12px); }
|
||||
:global(.inbound-operation-dialog .el-dialog__header) { padding: 14px 12px; }
|
||||
:global(.inbound-operation-dialog .el-dialog__body) { padding: 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
import type { InboundFileSummary, InboundInternalNote, InboundPalletLocation, InboundStatusHistory } from './types'
|
||||
import { inboundHistoryActionLabel, inboundProcessStatusLabels, inboundStatusLabel } from '../status'
|
||||
|
||||
defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
palletLocations: InboundPalletLocation[]
|
||||
files: InboundFileSummary[]
|
||||
internalNotes: InboundInternalNote[]
|
||||
statusHistory: InboundStatusHistory[]
|
||||
publicRemark: string | null
|
||||
}>()
|
||||
sections?: Array<'allocations' | 'files' | 'notes' | 'history' | 'remark'>
|
||||
}>(), {
|
||||
sections: () => ['allocations', 'files', 'notes', 'history', 'remark'],
|
||||
})
|
||||
|
||||
function shows(section: 'allocations' | 'files' | 'notes' | 'history' | 'remark') {
|
||||
return props.sections.includes(section)
|
||||
}
|
||||
|
||||
function value(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function number(value: number | null) { return value === null ? '—' : value.toFixed(3) }
|
||||
@@ -21,7 +28,7 @@ function date(value: string | null) {
|
||||
|
||||
<template>
|
||||
<div class="history-panel">
|
||||
<section class="history-section">
|
||||
<section v-if="shows('allocations')" class="history-section">
|
||||
<div class="section-heading"><h3>托盘与库位</h3><span>{{ palletLocations.length }} 条分配</span></div>
|
||||
<div v-if="palletLocations.length" class="allocation-list">
|
||||
<div v-for="item in palletLocations" :key="`${item.allocationNo}-${item.palletId}-${item.locationId}`" class="allocation-row">
|
||||
@@ -32,22 +39,22 @@ function date(value: string | null) {
|
||||
</div>
|
||||
<p v-else class="muted-empty">暂无托盘库位分配记录</p>
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<section v-if="shows('files')" class="history-section">
|
||||
<div class="section-heading"><h3>文件记录</h3><span>{{ files.length }} 个文件</span></div>
|
||||
<div v-if="files.length" class="simple-list"><div v-for="file in files" :key="`${file.fileId}-${file.fileVersion}`"><strong>{{ value(file.fileNameSnapshot) }}</strong><span>{{ value(file.fileType) }} · {{ date(file.uploadedAt) }}</span></div></div>
|
||||
<p v-else class="muted-empty">暂无文件记录</p>
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<section v-if="shows('notes')" class="history-section">
|
||||
<div class="section-heading"><h3>内部备注</h3><span>{{ internalNotes.length }} 条</span></div>
|
||||
<div v-if="internalNotes.length" class="simple-list"><div v-for="note in internalNotes" :key="String(note.id)"><strong>{{ value(note.createdByName || note.createdBy) }}</strong><span>{{ value(note.content) }} · {{ date(note.createdAt) }}</span></div></div>
|
||||
<p v-else class="muted-empty">暂无内部备注</p>
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<section v-if="shows('history')" class="history-section">
|
||||
<div class="section-heading"><h3>状态历史</h3><span>{{ statusHistory.length }} 条</span></div>
|
||||
<ol v-if="statusHistory.length" class="timeline"><li v-for="(history, index) in statusHistory" :key="`${history.occurredAt}-${index}`"><span class="timeline-dot" /><div><strong>{{ inboundHistoryActionLabel(history.action) }}</strong><span>{{ inboundStatusLabel(history.fromStatus, inboundProcessStatusLabels) }} → {{ inboundStatusLabel(history.toStatus, inboundProcessStatusLabels) }} · {{ value(history.operatorName || history.operatorId) }}</span><small>{{ date(history.occurredAt) }}<template v-if="history.reason"> · {{ history.reason }}</template></small></div></li></ol>
|
||||
<p v-else class="muted-empty">暂无状态历史</p>
|
||||
</section>
|
||||
<section v-if="publicRemark" class="history-section remark-section"><div class="section-heading"><h3>公开备注</h3></div><p>{{ publicRemark }}</p></section>
|
||||
<section v-if="shows('remark') && publicRemark" class="history-section remark-section"><div class="section-heading"><h3>公开备注</h3></div><p>{{ publicRemark }}</p></section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -70,4 +70,3 @@ describe('inbound fee API', () => {
|
||||
expect(second).not.toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -32,4 +32,3 @@ export function createFeeAdjustmentIdempotencyKey() {
|
||||
if (uuid) return `fee-adjust-${uuid}`
|
||||
return `fee-adjust-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
||||
}
|
||||
|
||||
|
||||
@@ -10,4 +10,3 @@ export type {
|
||||
InboundFeeSnapshot,
|
||||
InboundFeeStatus,
|
||||
} from './types'
|
||||
|
||||
|
||||
@@ -51,4 +51,3 @@ export interface AdjustPublishedInboundFeeRequest {
|
||||
additionalAmount: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
|
||||
@@ -214,6 +214,11 @@ describe('InboundMeasurementPanel', () => {
|
||||
'机器编号',
|
||||
'测量时间',
|
||||
])
|
||||
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
|
||||
expect(source).toContain('.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||
expect(source).toContain('.measurement-item__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));')
|
||||
expect(source).toContain('.measurement-form-grid, .measurement-item__grid, .measurement-item__advanced .measurement-item__grid { grid-template-columns: 1fr; }')
|
||||
expect(source).toContain('defineExpose({ saveDraft, submitForReview, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })')
|
||||
})
|
||||
|
||||
it('reuses the established first-leg detail surface and status patterns', async () => {
|
||||
|
||||
@@ -438,6 +438,8 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(releaseImagePreview)
|
||||
|
||||
defineExpose({ saveDraft, submitForReview, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -672,7 +674,8 @@ onBeforeUnmount(releaseImagePreview)
|
||||
.measurement-section { padding: 22px 20px; border-top: 1px solid var(--cc-border); }
|
||||
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||
.measurement-section__heading > div { min-width: 0; }
|
||||
.measurement-form-grid, .measurement-item__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 15px; }
|
||||
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
|
||||
.measurement-item__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 15px; }
|
||||
.measurement-form label, .measurement-item 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%; }
|
||||
.measurement-image-field :deep(.el-button) { width: 100%; margin: 0; }
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InboundOrderDetailView from './InboundOrderDetailView.vue'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrderDetailView.vue'), 'utf8')
|
||||
|
||||
describe('InboundOrderDetailView', () => {
|
||||
it('maps the compatible route parameter to customer context without a detail-page selector', () => {
|
||||
it('maps the compatible route parameter to customer context without a detail-page selector', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'dashboard', component: { template: '<div>首页</div>' } },
|
||||
{ path: '/inbounds', name: 'inbound-orders', component: { template: '<div>入库单列表</div>' } },
|
||||
{ path: '/inbounds/:inboundId', name: 'inbound-order-detail', component: InboundOrderDetailView },
|
||||
],
|
||||
})
|
||||
await router.push('/inbounds/19001?tenantId=10002')
|
||||
await router.isReady()
|
||||
const wrapper = mount(InboundOrderDetailView, {
|
||||
global: {
|
||||
plugins: [router, ElementPlus],
|
||||
stubs: {
|
||||
InboundWorkflowWorkspace: {
|
||||
props: ['customerId', 'inboundId'],
|
||||
template: '<div data-testid="workspace">{{ customerId }}:{{ inboundId }}</div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[aria-label="面包屑"]').text()).toContain('首页入库单列表入库单详情')
|
||||
expect(wrapper.get('[data-testid="workspace"]').text()).toBe('10002:19001')
|
||||
expect(wrapper.findAll('button').filter((button) => button.text().includes('返回入库单列表'))).toHaveLength(2)
|
||||
await wrapper.findAll('button').filter((button) => button.text().includes('返回入库单列表')).at(-1)?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('inbound-orders')
|
||||
expect(router.currentRoute.value.query.tenantId).toBe('10002')
|
||||
|
||||
expect(source).toContain('route.query.tenantId')
|
||||
expect(source).toContain('const customerId = computed')
|
||||
expect(source).toContain("name: 'inbound-orders'")
|
||||
@@ -19,5 +53,9 @@ describe('InboundOrderDetailView', () => {
|
||||
expect(source).toContain('class="surface-panel detail-page"')
|
||||
expect(source).toContain('class="detail-page-actions"')
|
||||
expect(source).toContain('返回入库单列表')
|
||||
expect(source).toContain('class="detail-breadcrumb"')
|
||||
expect(source).toContain('class="detail-page-footer"')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
expect(source).toContain('@media (max-width: 360px)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, RefreshCw } from '@lucide/vue'
|
||||
import { ArrowLeft, ChevronRight, Home } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
@@ -22,10 +22,21 @@ const customerId = computed(() => routeValue(route.query.tenantId))
|
||||
function backToList() {
|
||||
void router.push({ name: 'inbound-orders', query: customerId.value ? { tenantId: customerId.value } : undefined })
|
||||
}
|
||||
|
||||
function backToHome() {
|
||||
void router.push({ name: 'dashboard' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="business-page inbound-detail-page">
|
||||
<nav class="detail-breadcrumb" aria-label="面包屑">
|
||||
<button type="button" @click="backToHome"><Home :size="14" />首页</button>
|
||||
<ChevronRight :size="14" aria-hidden="true" />
|
||||
<button type="button" @click="backToList">入库单列表</button>
|
||||
<ChevronRight :size="14" aria-hidden="true" />
|
||||
<span aria-current="page">入库单详情</span>
|
||||
</nav>
|
||||
<DetailPageLayout class="surface-panel detail-page" aria-label="入库单详情">
|
||||
<template #header>
|
||||
<PageToolbar title="入库单详情" description="查看基本信息、货品与测量、费用信息、状态历史及其他记录。">
|
||||
@@ -46,18 +57,28 @@ function backToList() {
|
||||
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<footer class="detail-page-footer">
|
||||
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
|
||||
</footer>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-detail-page { min-width: 0; }
|
||||
.detail-breadcrumb { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 6px; padding: 0 2px; color: var(--cc-muted); font-size: 13px; }
|
||||
.detail-breadcrumb button { display: inline-flex; min-height: 32px; align-items: center; gap: 5px; padding: 4px 6px; border: 0; border-radius: 4px; background: transparent; color: var(--cc-primary); cursor: pointer; font: inherit; }
|
||||
.detail-breadcrumb button:hover { background: #f0f7ff; }
|
||||
.detail-breadcrumb button:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
|
||||
.detail-page { overflow: hidden; padding: 20px 24px; }
|
||||
.detail-page-actions { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
|
||||
.detail-page-footer { display: flex; justify-content: flex-start; padding: 20px 0 0; border-top: 1px solid var(--cc-border); }
|
||||
@media (max-width: 720px) {
|
||||
.detail-page { padding: 16px; }
|
||||
.detail-page-actions { width: 100%; align-items: stretch; flex-direction: column; }
|
||||
.detail-page-actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
.detail-page-footer :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
}
|
||||
@media (max-width: 360px) { .detail-page { padding: 12px; } }
|
||||
</style>
|
||||
|
||||
@@ -10,12 +10,18 @@ import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from '../core/api'
|
||||
import type { InboundOrderDetail, InboundOrderSummary } from '../core/types'
|
||||
import { downloadInboundCartonLabels, listPlatformInbounds } from '../core/api'
|
||||
import type { InboundOrderSummary } from '../core/types'
|
||||
|
||||
const routerPush = vi.fn()
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('vue-router')>(),
|
||||
useRouter: () => ({ push: routerPush }),
|
||||
}))
|
||||
|
||||
vi.mock('../core/api', () => ({
|
||||
downloadInboundCartonLabels: vi.fn(),
|
||||
getPlatformInbound: vi.fn(),
|
||||
listPlatformInbounds: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -46,13 +52,6 @@ const summary: InboundOrderSummary = {
|
||||
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00',
|
||||
}
|
||||
|
||||
const detail: InboundOrderDetail = {
|
||||
summary,
|
||||
publicRemark: '请优先安排测量',
|
||||
palletLocations: [{ allocationNo: 1, warehouseId: '20001', palletId: '21001', palletCode: 'P-001', palletCapacityCbm: 2, allocatedVolumeCbm: 1.2, allocatedPackageCount: 6, locationId: '22001', locationCode: 'A-01', reservationNo: 'RES-1', allocationStatus: 'PREPARED' }],
|
||||
files: [], internalNotes: [], statusHistory: [{ fromStatus: null, toStatus: 'WAIT_MEASURE', action: 'CREATE', reason: null, operatorId: '2', operatorName: '管理员', occurredAt: '2026-08-01T10:00:00' }],
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountPage() {
|
||||
@@ -76,7 +75,6 @@ describe('InboundOrdersView', () => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().permissions = ['platform:firstleg:inbound:view']
|
||||
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformInbound).mockResolvedValue(detail)
|
||||
vi.mocked(downloadInboundCartonLabels).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: customers, page: 1, size: 200, total: 2, pages: 1 })
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValue({
|
||||
@@ -171,7 +169,7 @@ describe('InboundOrdersView', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('passes the selected backend tab and opens an aggregate read-only detail', async () => {
|
||||
it('passes the selected backend tab and routes view to the independent detail page', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
toolbar.vm.$emit('search')
|
||||
@@ -183,9 +181,12 @@ describe('InboundOrdersView', () => {
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ tab: 'WAIT_REVIEW', page: 1 }))
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].filter((button) => button.textContent?.trim() === '查看').at(-1)?.click()
|
||||
await flushPromises()
|
||||
expect(getPlatformInbound).toHaveBeenCalledWith('10002', '19001')
|
||||
expect(document.body.textContent).toContain('托盘与库位')
|
||||
expect(document.body.textContent).toContain('请优先安排测量')
|
||||
expect(routerPush).toHaveBeenCalledWith({
|
||||
name: 'inbound-order-detail',
|
||||
params: { inboundId: '19001' },
|
||||
query: { tenantId: '10002' },
|
||||
})
|
||||
expect(wrapper.find('[aria-label="入库单详情抽屉"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -6,21 +6,16 @@ import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Printer, RefreshCw, Slider
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from '../core/api'
|
||||
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
|
||||
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
|
||||
import type { InboundListTab, InboundOrderDetail, InboundOrderSummary } from '../core/types'
|
||||
import { downloadInboundCartonLabels, listPlatformInbounds } from '../core/api'
|
||||
import type { InboundListTab, InboundOrderSummary } from '../core/types'
|
||||
import { inboundFeeStatusLabels, inboundLoadingStatusLabels, inboundOrderStatusLabels, inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
@@ -47,11 +42,6 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<InboundOrderDetail | null>(null)
|
||||
const detailTarget = ref<InboundOrderSummary | null>(null)
|
||||
const printingInboundId = ref('')
|
||||
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
||||
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
|
||||
@@ -66,7 +56,6 @@ const warehousesLoading = ref(false)
|
||||
const warehousesUnavailable = ref(false)
|
||||
const showAdvancedFilters = ref(false)
|
||||
let loadRequestId = 0
|
||||
let detailRequestId = 0
|
||||
let customerRequestId = 0
|
||||
let warehouseRequestId = 0
|
||||
let disposed = false
|
||||
@@ -293,35 +282,7 @@ function applyFilters() {
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function resetDetail() {
|
||||
detailRequestId += 1
|
||||
detail.value = null
|
||||
detailTarget.value = null
|
||||
detailError.value = null
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
async function openDetail(row: InboundOrderSummary) {
|
||||
const requestId = ++detailRequestId
|
||||
detailOpen.value = true
|
||||
detailTarget.value = row
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
detail.value = null
|
||||
try {
|
||||
const response = await getPlatformInbound(row.tenantId, row.id)
|
||||
if (disposed || requestId !== detailRequestId) return
|
||||
detail.value = response
|
||||
} catch (error) {
|
||||
if (disposed || requestId !== detailRequestId) return
|
||||
detailError.value = getApiErrorDetails(error, '入库单详情加载失败')
|
||||
} finally {
|
||||
if (!disposed && requestId === detailRequestId) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() { resetDetail() }
|
||||
function openWorkspace(row: InboundOrderSummary) {
|
||||
function openDetail(row: InboundOrderSummary) {
|
||||
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: { tenantId: row.tenantId } })
|
||||
}
|
||||
async function printCartonLabels(row: InboundOrderSummary) {
|
||||
@@ -372,8 +333,6 @@ function resetFilters() {
|
||||
filters.value = { ...filters.value, customerId: '', warehouseId: '', deliveryWarehouseId: '' }
|
||||
warehouseOptions.value = []
|
||||
warehousesUnavailable.value = false
|
||||
resetDetail()
|
||||
detailOpen.value = false
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
@@ -397,14 +356,12 @@ watch(() => filters.value.customerId, (customerId) => {
|
||||
if (customerId === selectedCustomerId.value) return
|
||||
selectedCustomerId.value = customerId
|
||||
filters.value = { ...filters.value, warehouseId: '', deliveryWarehouseId: '' }
|
||||
resetDetail()
|
||||
detailOpen.value = false
|
||||
void loadWarehouseOptions()
|
||||
})
|
||||
watch(tab, () => { if (page.value === 1) void loadInbounds(); else page.value = 1 })
|
||||
watch([page, pageSize], () => { void loadInbounds() })
|
||||
onMounted(() => { void loadCustomers(); void loadInbounds() })
|
||||
onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId += 1; customerRequestId += 1; warehouseRequestId += 1 })
|
||||
onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId += 1; warehouseRequestId += 1 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -454,7 +411,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
||||
</header>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
|
||||
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
|
||||
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
|
||||
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button link type="primary" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
|
||||
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
||||
<template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
|
||||
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
|
||||
@@ -499,15 +456,6 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer v-model="detailOpen" :title="detail ? `入库单详情 · ${detail.summary.inboundCode || detail.summary.id}` : '入库单详情'" size="min(720px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
|
||||
<DetailPageLayout aria-label="入库单详情抽屉">
|
||||
<section v-if="detailError" class="detail-error" role="alert"><strong>入库单详情暂时无法加载</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /><el-button type="primary" :disabled="!detailTarget" @click="detailTarget && openDetail(detailTarget)"><RefreshCw :size="16" />重新加载</el-button></section>
|
||||
<div v-else-if="detail" class="detail-content">
|
||||
<FormSection title="入库概览"><InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" /></FormSection>
|
||||
<FormSection title="资源与状态记录" :bordered="false"><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="null" /></FormSection>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -535,11 +483,6 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
||||
.primary-cell, .status-stack { display: grid; min-width: 0; justify-items: start; gap: 4px; }
|
||||
.primary-cell strong, .primary-cell span, .status-stack span, .status-stack small { overflow-wrap: anywhere; }
|
||||
.primary-cell span, .status-stack span, .status-stack small { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-content { display: grid; gap: 24px; min-width: 0; }
|
||||
.detail-error { display: flex; min-height: 240px; align-items: flex-start; justify-content: center; flex-direction: column; }
|
||||
.detail-error strong { color: #b42318; font-size: 16px; }
|
||||
.detail-error p { margin: 7px 0; color: #172033; font-size: 13px; }
|
||||
.detail-error :deep(.el-button) { margin-top: 14px; }
|
||||
@media (max-width: 900px) { .inbound-tabs { width: 100%; } }
|
||||
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 0 10px; } .inbound-stats-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .inbound-stats-row .total-info { justify-content: flex-start; } }
|
||||
@media (max-width: 360px) { .inbound-tab { padding-inline: 12px; } .row-actions { align-items: flex-start; flex-direction: column; } }
|
||||
|
||||
@@ -58,6 +58,8 @@ describe('MeasurementReviewPanel', () => {
|
||||
expect(wrapper.text()).toContain('通过并完成入库')
|
||||
expect(wrapper.text()).not.toContain('确认费用')
|
||||
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
|
||||
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
|
||||
expect(source).toContain('defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })')
|
||||
})
|
||||
|
||||
it('shows the rejected release action only when the permission and rejected state exist', async () => {
|
||||
|
||||
@@ -207,6 +207,8 @@ watch(() => [customerId.value, props.inboundId], () => {
|
||||
onMounted(() => {
|
||||
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
|
||||
})
|
||||
|
||||
defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFees, rejectContainerFee, voidContainerFee } from './containerFees'
|
||||
|
||||
describe('container fee API', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
it('passes accountId and exact controller DTOs', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [] })
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, sourceVersion: 1 } })
|
||||
const values = { feeItemId: '7', direction: 'PAYABLE' as const, businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, remark: null }
|
||||
await listContainerFees('9', '11'); await createContainerFee('9', '11', values)
|
||||
await approveContainerFee('9', '11', '3'); await rejectContainerFee('9', '11', '3', '资料不全')
|
||||
await adjustContainerFee('9', '11', '3', 2, values); await voidContainerFee('9', '11', '3', 3)
|
||||
expect(get).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details', { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details', values, { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/adjust', { expectedSourceVersion: 2, ...values }, { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/void', { expectedSourceVersion: 3 }, { params: { accountId: '11' } })
|
||||
})
|
||||
|
||||
it('reads allocation rows from Finance and never calculates them', async () => {
|
||||
const get = vi.spyOn(http, 'get')
|
||||
.mockResolvedValueOnce({ code: '200', data: [{ id: 81, containerId: 99 }] })
|
||||
.mockResolvedValueOnce({ code: '200', data: { cost: { id: 81, containerId: 99, supplierName: '承运商A', feeItemName: '拖车费', payableAmount: 300 }, allocations: [{ id: 1, orderId: 8, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120 }] } })
|
||||
const result = await listContainerCostDetails('11', '99')
|
||||
expect(result[0]).toMatchObject({ supplierName: '承运商A', allocations: [{ orderId: '8', allocatedAmount: 120 }] })
|
||||
expect(get).toHaveBeenNthCalledWith(2, '/platform/finance/v1/cost-entries/81', { params: { customerId: '11' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type ContainerFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
||||
export type ContainerFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
|
||||
export interface ContainerFeeResource {
|
||||
id: string
|
||||
loadingPlanId: string
|
||||
containerBookingId: string | null
|
||||
containerNo: string | null
|
||||
feeItemId: string
|
||||
feeItemName: string
|
||||
direction: ContainerFeeDirection
|
||||
businessDate: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
settlementIncluded: boolean
|
||||
reviewStatus: ContainerFeeReviewStatus
|
||||
sourceStatus: 'ACTIVE' | 'VOIDED'
|
||||
sourceVersion: number
|
||||
remark: string | null
|
||||
reviewerName: string | null
|
||||
reviewedAt: string | null
|
||||
reviewReason: string | null
|
||||
}
|
||||
|
||||
export interface ContainerFeeSaveRequest {
|
||||
feeItemId: string
|
||||
direction: ContainerFeeDirection
|
||||
businessDate: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
settlementIncluded: boolean
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface ApplicableFeeItem {
|
||||
id: string
|
||||
name: string
|
||||
warehouseReceivable: boolean
|
||||
warehousePayable: boolean
|
||||
pickupReceivable: boolean
|
||||
pickupPayable: boolean
|
||||
}
|
||||
|
||||
export interface ContainerCostAllocation {
|
||||
id: string
|
||||
orderId: string
|
||||
inboundId: string | null
|
||||
volumeCbm: number
|
||||
totalVolumeCbm: number
|
||||
allocationRate: number
|
||||
allocatedAmount: number
|
||||
roundingAdjustment: boolean
|
||||
}
|
||||
|
||||
export interface ContainerCostDetail {
|
||||
id: string
|
||||
containerId: string | null
|
||||
supplierName: string | null
|
||||
feeItemName: string
|
||||
amount: number
|
||||
allocations: ContainerCostAllocation[]
|
||||
}
|
||||
|
||||
const id = (value: unknown) => value == null ? '' : String(value)
|
||||
const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0
|
||||
const base = (planId: string) => `/platform/v1/loading-plans/${encodeURIComponent(planId)}/fee-details`
|
||||
const params = (accountId: string) => ({ accountId })
|
||||
|
||||
function normalizeFee(raw: Record<string, unknown>): ContainerFeeResource {
|
||||
return {
|
||||
id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), containerBookingId: raw.containerBookingId == null ? null : id(raw.containerBookingId),
|
||||
containerNo: raw.containerNo == null ? null : String(raw.containerNo), feeItemId: id(raw.feeItemId),
|
||||
feeItemName: String(raw.feeItemNameSnapshot || ''), direction: raw.direction as ContainerFeeDirection,
|
||||
businessDate: String(raw.businessDate || ''), amount: numberValue(raw.amount), currency: 'CNY',
|
||||
settlementIncluded: Boolean(raw.settlementIncluded), reviewStatus: raw.reviewStatus as ContainerFeeReviewStatus,
|
||||
sourceStatus: raw.sourceStatus as ContainerFeeResource['sourceStatus'], sourceVersion: numberValue(raw.sourceVersion),
|
||||
remark: raw.remark == null ? null : String(raw.remark), reviewerName: raw.reviewerName == null ? null : String(raw.reviewerName),
|
||||
reviewedAt: raw.reviewedAt == null ? null : String(raw.reviewedAt), reviewReason: raw.reviewReason == null ? null : String(raw.reviewReason),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listContainerFees(planId: string, accountId: string) {
|
||||
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>(base(planId), { params: params(accountId) })
|
||||
return unwrapApiResponse(response).map(normalizeFee)
|
||||
}
|
||||
|
||||
export async function createContainerFee(planId: string, accountId: string, payload: ContainerFeeSaveRequest) {
|
||||
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(base(planId), payload, { params: params(accountId) })
|
||||
return normalizeFee(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function approveContainerFee(planId: string, accountId: string, feeId: string) {
|
||||
await http.post(`${base(planId)}/${encodeURIComponent(feeId)}/approve`, undefined, { params: params(accountId) })
|
||||
}
|
||||
|
||||
export async function rejectContainerFee(planId: string, accountId: string, feeId: string, reason: string) {
|
||||
await http.post(`${base(planId)}/${encodeURIComponent(feeId)}/reject`, { reason }, { params: params(accountId) })
|
||||
}
|
||||
|
||||
export async function adjustContainerFee(planId: string, accountId: string, feeId: string, expectedSourceVersion: number, values: ContainerFeeSaveRequest) {
|
||||
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(`${base(planId)}/${encodeURIComponent(feeId)}/adjust`, { expectedSourceVersion, ...values }, { params: params(accountId) })
|
||||
return normalizeFee(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function voidContainerFee(planId: string, accountId: string, feeId: string, expectedSourceVersion: number) {
|
||||
await http.post(`${base(planId)}/${encodeURIComponent(feeId)}/void`, { expectedSourceVersion }, { params: params(accountId) })
|
||||
}
|
||||
|
||||
export async function listApplicableFeeItems() {
|
||||
const response = await http.get<ApiEnvelope<{ records: Record<string, unknown>[] }>, ApiEnvelope<{ records: Record<string, unknown>[] }>>('/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } })
|
||||
return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
|
||||
}
|
||||
|
||||
export async function listContainerCostDetails(customerId: string, containerId: string): Promise<ContainerCostDetail[]> {
|
||||
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>('/platform/finance/v1/cost-entries', { params: { customerId } })
|
||||
const costs = unwrapApiResponse(response).filter((raw) => id(raw.containerId) === containerId)
|
||||
return Promise.all(costs.map(async (raw) => {
|
||||
const costId = id(raw.id)
|
||||
const detailResponse = await http.get<ApiEnvelope<{ cost: Record<string, unknown>; allocations: Record<string, unknown>[] }>, ApiEnvelope<{ cost: Record<string, unknown>; allocations: Record<string, unknown>[] }>>(`/platform/finance/v1/cost-entries/${encodeURIComponent(costId)}`, { params: { customerId } })
|
||||
const detail = unwrapApiResponse(detailResponse)
|
||||
return { id: costId, containerId: detail.cost.containerId == null ? null : id(detail.cost.containerId), supplierName: detail.cost.supplierName == null ? null : String(detail.cost.supplierName), feeItemName: String(detail.cost.feeItemName || ''), amount: numberValue(detail.cost.payableAmount), allocations: (detail.allocations || []).map((item) => ({ id: id(item.id), orderId: id(item.orderId), inboundId: item.inboundId == null ? null : id(item.inboundId), volumeCbm: numberValue(item.volumeCbm), totalVolumeCbm: numberValue(item.totalVolumeCbm), allocationRate: numberValue(item.allocationRate), allocatedAmount: numberValue(item.allocatedAmount), roundingAdjustment: Boolean(item.roundingAdjustment) })) }
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
|
||||
const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
||||
|
||||
describe('ContainerFeesPanel contract', () => {
|
||||
it('is exposed from loading-list detail and derives customer context from real items', () => {
|
||||
expect(detail).toContain('<ContainerFeesPanel :plan-id="summary.planId" :customers="feeCustomers" />')
|
||||
expect(detail).toContain('item.accountId')
|
||||
expect(panel).toContain('该柜次包含多个客户,请先选择费用归属客户')
|
||||
expect(panel).toContain('装柜明细缺少客户归属,无法安全维护费用')
|
||||
})
|
||||
|
||||
it('covers create, review, reject, approved adjustment and void actions', () => {
|
||||
expect(panel).toContain('新增费用')
|
||||
expect(panel).toContain("row.reviewStatus === 'PENDING'")
|
||||
expect(panel).toContain('approveContainerFee')
|
||||
expect(panel).toContain('rejectContainerFee')
|
||||
expect(panel).toContain("row.reviewStatus === 'APPROVED'")
|
||||
expect(panel).toContain('adjustContainerFee')
|
||||
expect(panel).toContain('voidContainerFee')
|
||||
expect(panel).toContain('调整后版本递增并重新进入待审核状态')
|
||||
})
|
||||
|
||||
it('shows only server allocations and supplier snapshots', () => {
|
||||
expect(panel).toContain('listContainerCostDetails')
|
||||
expect(panel).toContain('仅展示 Finance 已持久化结果')
|
||||
expect(panel).toContain("cost.supplierName || '服务端未返回'")
|
||||
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
|
||||
expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Check, CircleX, Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFees, rejectContainerFee, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeDirection, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
||||
|
||||
const props = defineProps<{ planId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const accountId = ref('')
|
||||
const fees = ref<ContainerFeeResource[]>([])
|
||||
const feeItems = ref<ApplicableFeeItem[]>([])
|
||||
const costs = ref<ContainerCostDetail[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
const rejectOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const target = ref<ContainerFeeResource | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
|
||||
const rejection = ref('')
|
||||
|
||||
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
const canEdit = computed(() => access.can('firstleg:inbound:fee-detail:edit'))
|
||||
const canReview = computed(() => access.can('firstleg:inbound:fee-detail:review'))
|
||||
const customerOptions = computed(() => {
|
||||
const unique = new Map<string, string>()
|
||||
for (const item of props.customers) if (item.accountId) unique.set(item.accountId, item.customerName || '客户信息缺失')
|
||||
return [...unique].map(([value, label]) => ({ value, label }))
|
||||
})
|
||||
const selectedCustomer = computed(() => customerOptions.value.find((item) => item.value === accountId.value))
|
||||
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
|
||||
? item.warehouseReceivable || item.pickupReceivable : item.warehousePayable || item.pickupPayable))
|
||||
const containerId = computed(() => fees.value.find((item) => item.containerBookingId)?.containerBookingId || null)
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 300, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 },
|
||||
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
|
||||
{ key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' },
|
||||
{ key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 },
|
||||
{ key: 'reviewStatus', label: '审核状态', width: 110, slotName: 'reviewStatus' },
|
||||
{ key: 'sourceStatus', label: '来源状态', width: 100, slotName: 'sourceStatus' },
|
||||
{ key: 'settlementIncluded', label: '参与结算', width: 100, slotName: 'settlementIncluded' },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
|
||||
]
|
||||
const allocationColumns: DataTableColumn[] = [
|
||||
{ key: 'orderId', prop: 'orderId', label: '订单', fixed: 'left', minWidth: 150 },
|
||||
{ key: 'inboundId', prop: 'inboundId', label: '入库单', minWidth: 150 },
|
||||
{ key: 'volumeCbm', label: '订单体积(m³)', width: 125, align: 'right', slotName: 'volume' },
|
||||
{ key: 'allocationRate', label: '分摊比例', width: 110, align: 'right', slotName: 'rate' },
|
||||
{ key: 'allocatedAmount', label: '分摊金额(CNY)', width: 135, align: 'right', slotName: 'allocated' },
|
||||
]
|
||||
const rules: FormRules = { feeItemId: [{ required: true, message: '请选择费用项目', trigger: 'change' }], businessDate: [{ required: true, message: '请选择业务日期', trigger: 'change' }], amount: [{ type: 'number', min: 0, message: '金额不能小于 0', trigger: 'change' }] }
|
||||
|
||||
function resetForm(item?: ContainerFeeResource) {
|
||||
target.value = item || null
|
||||
Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', settlementIncluded: item.settlementIncluded, remark: item.remark }
|
||||
: { feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!accountId.value || !canView.value) return
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const [feeRows, items] = await Promise.all([listContainerFees(props.planId, accountId.value), listApplicableFeeItems()])
|
||||
fees.value = feeRows; feeItems.value = items
|
||||
costs.value = []
|
||||
const currentContainerId = feeRows.find((item) => item.containerBookingId)?.containerBookingId
|
||||
if (currentContainerId) costs.value = await listContainerCostDetails(accountId.value, currentContainerId)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '柜次费用加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() { resetForm(); editorOpen.value = true }
|
||||
function openAdjust(item: ContainerFeeResource) { resetForm(item); editorOpen.value = true }
|
||||
async function save() {
|
||||
if (!accountId.value || !await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, { ...form })
|
||||
else await createContainerFee(props.planId, accountId.value, { ...form })
|
||||
editorOpen.value = false; ElMessage.success(target.value ? '费用调整已提交审核' : '费用已新增'); await load()
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '费用保存失败').message) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function approve(item: ContainerFeeResource) { await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
|
||||
function openReject(item: ContainerFeeResource) { target.value = item; rejection.value = ''; rejectOpen.value = true }
|
||||
async function reject() {
|
||||
if (!target.value || !rejection.value.trim()) return ElMessage.warning('请输入驳回原因')
|
||||
saving.value = true
|
||||
try { await rejectContainerFee(props.planId, accountId.value, target.value.id, rejection.value.trim()); rejectOpen.value = false; ElMessage.success('费用已驳回'); await load() }
|
||||
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '驳回失败').message) } finally { saving.value = false }
|
||||
}
|
||||
async function voidFee(item: ContainerFeeResource) {
|
||||
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { type: 'warning', confirmButtonText: '确认作废' })
|
||||
await voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion).then(() => ElMessage.success('费用已作废')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '作废失败').message))
|
||||
}
|
||||
function money(value: number) { return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
|
||||
function initializeCustomer() { accountId.value = customerOptions.value.length === 1 ? customerOptions.value[0].value : '' }
|
||||
watch(customerOptions, initializeCustomer, { immediate: true })
|
||||
watch(accountId, () => { fees.value = []; costs.value = []; void load() })
|
||||
watch(() => form.direction, () => { if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = '' })
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="fees-panel surface-panel" aria-label="柜次费用维护">
|
||||
<header class="fees-header"><div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div><el-button v-if="canEdit && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button></header>
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon title="当前账号没有查看柜次费用的权限" />
|
||||
<template v-else>
|
||||
<div v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></div>
|
||||
<el-alert v-if="customerOptions.length === 0" type="warning" :closable="false" show-icon title="装柜明细缺少客户归属,无法安全维护费用" />
|
||||
<el-alert v-else-if="!accountId" type="info" :closable="false" show-icon title="该柜次包含多个客户,请先选择费用归属客户" />
|
||||
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
||||
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template><template #settlementIncluded="{ row }">{{ row.settlementIncluded ? '是' : '否' }}</template>
|
||||
</DataTable>
|
||||
|
||||
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>往来单位:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
|
||||
</template>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save"><el-alert v-if="target" type="info" :closable="false" show-icon title="调整后版本递增并重新进入待审核状态" /><el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form"><el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }]" /></el-form-item><el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item><el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form></AppDialog>
|
||||
<AppDialog v-model="rejectOpen" title="驳回柜次费用" confirm-text="确认驳回" :confirm-loading="saving" @confirm="reject"><el-input v-model="rejection" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fees-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px; }
|
||||
.fees-header h2, .allocation-section h3 { margin: 0; font-size: 16px; }.fees-header p, .allocation-section p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.customer-context { display: flex; align-items: center; gap: 12px; padding: 0 18px 16px; }.customer-context > span { color: var(--cc-muted); font-size: 13px; }.customer-context :deep(.el-select) { width: min(320px, 100%); }
|
||||
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 12px 20px; padding: 14px 18px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
@media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-form { grid-template-columns: 1fr; }.fee-form :deep(.el-form-item:last-child) { grid-column: auto; }.customer-context { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Pencil } from '@lucide/vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||
import { loadingListStatusLabel } from '../status'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import type { LoadingDetail, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
|
||||
@@ -19,7 +19,6 @@ const props = defineProps<{
|
||||
canEdit: boolean
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{ retry: []; edit: [] }>()
|
||||
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
@@ -43,6 +42,11 @@ const itemColumns: DataTableColumn[] = [
|
||||
|
||||
const summary = computed<LoadingListSummary | null>(() => props.detail?.summary || null)
|
||||
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
|
||||
const feeCustomers = computed(() => {
|
||||
const customers = new Map<string, string | null>()
|
||||
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
||||
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
|
||||
})
|
||||
const title = computed(() => {
|
||||
if (!summary.value) return props.mode === 'preload' ? '预装清单详情' : '装柜清单详情'
|
||||
const number = props.mode === 'preload'
|
||||
@@ -70,14 +74,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDrawer
|
||||
v-model="open"
|
||||
:title="title"
|
||||
size="min(1180px, 96vw)"
|
||||
:loading="loading"
|
||||
:show-footer="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<section v-loading="loading" class="loading-detail-content" :aria-label="title" :aria-busy="loading">
|
||||
<LoadErrorState v-if="error" title="清单详情加载失败" :message="error.message" @retry="emit('retry')">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
@@ -122,6 +119,8 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<ContainerFeesPanel :plan-id="summary.planId" :customers="feeCustomers" />
|
||||
|
||||
<section class="history-section surface-panel" aria-label="操作历史">
|
||||
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
|
||||
<el-empty v-if="detail.histories.length === 0" description="暂无操作历史" :image-size="72" />
|
||||
@@ -137,11 +136,11 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-layout { display: grid; gap: 16px; }
|
||||
.loading-detail-content, .detail-layout { display: grid; min-width: 0; gap: 16px; }
|
||||
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||
.detail-summary > div { display: flex; min-width: 0; min-height: 78px; flex-direction: column; justify-content: center; gap: 7px; padding: 14px 16px; background: #fff; }
|
||||
.detail-summary span { color: var(--cc-muted); font-size: 12px; }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListsView.vue'), 'utf8')
|
||||
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
||||
const preloadDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/PreloadPlanDetailView.vue'), 'utf8')
|
||||
|
||||
describe('LoadingListsView', () => {
|
||||
it('keeps the operation column first and fixed left in both lists', () => {
|
||||
@@ -24,13 +25,31 @@ describe('LoadingListsView', () => {
|
||||
expect(source).toContain('MISSING_WAREHOUSE_CONTEXT')
|
||||
})
|
||||
|
||||
it('uses the shared query, table, drawer, dialog and error components', () => {
|
||||
it('uses the shared query, table, dialog and error components without a detail drawer', () => {
|
||||
expect(source).toContain('QueryToolbar')
|
||||
expect(source).toContain('DataTable')
|
||||
expect(source).toContain('AppDialog')
|
||||
expect(source).toContain('LoadErrorState')
|
||||
expect(source).toContain('ApiErrorMeta')
|
||||
expect(detailSource).toContain('AppDrawer')
|
||||
expect(detailSource).not.toContain('AppDrawer')
|
||||
expect(detailSource).toContain('ContainerFeesPanel')
|
||||
})
|
||||
|
||||
it('opens preload detail as a route and confirmed detail inline on the list page', () => {
|
||||
expect(source).toContain("name: 'preload-plan-detail'")
|
||||
expect(source).toContain('v-if="!isPreload && (detailLoading || detailError || detail)"')
|
||||
expect(source).toContain('class="confirmed-inline-detail surface-panel"')
|
||||
expect(preloadDetailSource).toContain('返回列表')
|
||||
expect(preloadDetailSource).toContain('router.back()')
|
||||
})
|
||||
|
||||
it('keeps the real transport edit contract in a 620px centered modal', () => {
|
||||
expect(source).toContain('class="loading-transport-dialog"')
|
||||
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
|
||||
expect(preloadDetailSource).toContain('width="min(620px, calc(100vw - 28px))"')
|
||||
expect(source).not.toContain('备注信息(仅自己可见)')
|
||||
expect(source).not.toContain('备注信息(客户可见)')
|
||||
expect(source).not.toContain('备注信息(仓库可见)')
|
||||
})
|
||||
|
||||
it('only exposes transport editing for LOADING preload details', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, Pencil } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
|
||||
const props = defineProps<{ mode: LoadingListMode }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<LoadingListSummary[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -39,7 +40,6 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const query = reactive<QueryModel>({ keyword: '', createdRange: [] })
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<LoadingDetail | null>(null)
|
||||
@@ -150,11 +150,14 @@ async function openDeepLinkedDetail() {
|
||||
const key = `${props.mode}:${planId}:${warehouseId}`
|
||||
if (handledDeepLink.value === key) return
|
||||
handledDeepLink.value = key
|
||||
if (isPreload.value) {
|
||||
await router.replace({ name: 'preload-plan-detail', params: { planId }, query: { warehouseId } })
|
||||
return
|
||||
}
|
||||
selectedPlanId.value = planId
|
||||
selectedWarehouseId.value = warehouseId
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailOpen.value = true
|
||||
await loadDetail()
|
||||
const expectedStatuses = isPreload.value ? ['EDITING', 'LOADING'] : ['COMPLETED']
|
||||
const loadedDetail = detail.value as LoadingDetail | null
|
||||
@@ -198,11 +201,14 @@ async function loadDetail() {
|
||||
|
||||
function openDetail(rowValue: object) {
|
||||
const row = rowValue as LoadingListSummary
|
||||
if (isPreload.value) {
|
||||
void router.push({ name: 'preload-plan-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
|
||||
return
|
||||
}
|
||||
selectedPlanId.value = String(row.planId)
|
||||
selectedWarehouseId.value = String(row.warehouseId || '')
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailOpen.value = true
|
||||
void loadDetail()
|
||||
}
|
||||
|
||||
@@ -213,7 +219,6 @@ async function openEditFromRow(rowValue: object) {
|
||||
selectedWarehouseId.value = String(row.warehouseId || '')
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailOpen.value = true
|
||||
await loadDetail()
|
||||
const loadedDetail = detail.value as LoadingDetail | null
|
||||
if (loadedDetail && ['EDITING', 'LOADING'].includes(loadedDetail.summary.status)) openEdit()
|
||||
@@ -317,7 +322,8 @@ onMounted(() => {
|
||||
</ListPageLayout>
|
||||
|
||||
<LoadingListDetailDrawer
|
||||
v-model="detailOpen"
|
||||
v-if="!isPreload && (detailLoading || detailError || detail)"
|
||||
class="confirmed-inline-detail surface-panel"
|
||||
:mode="mode"
|
||||
:loading="detailLoading"
|
||||
:detail="detail"
|
||||
@@ -329,7 +335,9 @@ onMounted(() => {
|
||||
|
||||
<AppDialog
|
||||
v-model="editOpen"
|
||||
class="loading-transport-dialog"
|
||||
title="编辑预装运输信息"
|
||||
width="min(620px, calc(100vw - 28px))"
|
||||
confirm-text="保存"
|
||||
:confirm-loading="editSaving"
|
||||
:confirm-disabled="!canEdit"
|
||||
@@ -352,7 +360,9 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.loading-lists-page { display: grid; min-width: 0; gap: 16px; }
|
||||
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.confirmed-inline-detail { padding: 20px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 16px; }
|
||||
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
@media (max-width: 640px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
|
||||
@media (max-width: 720px) { .confirmed-inline-detail { padding: 12px; } .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/PreloadPlanDetailView.vue'), 'utf8')
|
||||
|
||||
describe('PreloadPlanDetailView prototype contract', () => {
|
||||
it('is a formal responsive detail page with a return action', () => {
|
||||
expect(source).toContain('<AdminPage>')
|
||||
expect(source).toContain('返回列表')
|
||||
expect(source).toContain('<ArrowLeft')
|
||||
expect(source).toContain("router.push({ name: 'loading-plans' })")
|
||||
expect(source).not.toContain('AppDrawer')
|
||||
})
|
||||
|
||||
it('requires the real warehouse context and preserves status boundaries', () => {
|
||||
expect(source).toContain('MISSING_WAREHOUSE_CONTEXT')
|
||||
expect(source).toContain("['EDITING', 'LOADING'].includes(result.summary.status)")
|
||||
expect(source).toContain('MODE_STATUS_MISMATCH')
|
||||
})
|
||||
|
||||
it('uses the real optimistic-lock transport endpoint without inventing note writes', () => {
|
||||
expect(source).toContain('updatePreloadTransport')
|
||||
expect(source).toContain('expectedVersion: detail.value.summary.version')
|
||||
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
|
||||
expect(source).not.toContain('saveMfNote')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||
import { getPreloadPlan, updatePreloadTransport, type LoadingDetail, type PreloadSummary } from '../api/platformLoadingLists'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const detail = ref<LoadingDetail<PreloadSummary> | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const editOpen = ref(false)
|
||||
const editSaving = ref(false)
|
||||
const editError = ref<ApiErrorDetails | null>(null)
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
|
||||
const planId = computed(() => String(route.params.planId || ''))
|
||||
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
||||
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
|
||||
const editRules: FormRules = {
|
||||
containerNo: [{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }],
|
||||
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
|
||||
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!planId.value || !warehouseId.value) {
|
||||
error.value = { code: 'MISSING_WAREHOUSE_CONTEXT', message: '该清单缺少所属仓库信息,无法安全查询详情,请返回列表重新进入。' }
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await getPreloadPlan(planId.value, warehouseId.value)
|
||||
if (!['EDITING', 'LOADING'].includes(result.summary.status)) {
|
||||
detail.value = null
|
||||
error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划不属于预装清单状态,请从对应清单入口查看。' }
|
||||
return
|
||||
}
|
||||
detail.value = result
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '预装清单详情加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else void router.push({ name: 'loading-plans' })
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (!detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
|
||||
editForm.containerNo = detail.value.summary.containerNo || ''
|
||||
editForm.voyageNo = detail.value.summary.voyageNo || ''
|
||||
editForm.operationReason = ''
|
||||
editError.value = null
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function saveTransport() {
|
||||
if (!detail.value || !(await editFormRef.value?.validate().catch(() => false))) return
|
||||
editSaving.value = true
|
||||
editError.value = null
|
||||
try {
|
||||
detail.value = await updatePreloadTransport(planId.value, warehouseId.value, {
|
||||
containerNo: editForm.containerNo.trim() || null,
|
||||
voyageNo: editForm.voyageNo.trim() || null,
|
||||
operationReason: editForm.operationReason.trim() || null,
|
||||
expectedVersion: detail.value.summary.version,
|
||||
})
|
||||
editOpen.value = false
|
||||
ElMessage.success('运输信息已更新')
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
|
||||
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
|
||||
await loadDetail()
|
||||
editError.value = { ...details, message: '运输信息已被其他人修改,系统已刷新最新版本;当前输入已保留,请核对后重新保存。' }
|
||||
} else editError.value = details
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadDetail())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="preload-detail-page">
|
||||
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<LoadErrorState v-if="error && !detail" title="预装清单详情加载失败" :message="error.message" :loading="loading" @retry="loadDetail">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" @retry="loadDetail" @edit="openEdit" />
|
||||
</div>
|
||||
|
||||
<AppDialog v-model="editOpen" class="loading-transport-dialog" title="编辑预装运输信息" 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="请输入集装箱号" /></el-form-item>
|
||||
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||
<el-form-item label="修改原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" placeholder="选填,填写后将记录在操作历史中" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preload-detail-page { display: grid; min-width: 0; max-width: 1180px; gap: 16px; }
|
||||
.back-button { width: fit-content; }
|
||||
.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; }
|
||||
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
|
||||
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||
</style>
|
||||
@@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import { loadingListRoutes } from './routes'
|
||||
|
||||
describe('loading list routes', () => {
|
||||
it('declares separate preload and completed list entries with backend permissions', () => {
|
||||
it('declares preload list/detail and completed list entries with backend permissions', () => {
|
||||
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
|
||||
['loading-plans', 'platform:firstleg:preload:list'],
|
||||
['loading-plans/:planId', 'platform:firstleg:preload:view'],
|
||||
['loading-lists', 'platform:firstleg:loading-list:list'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -11,6 +11,16 @@ export const loadingListRoutes = [
|
||||
permission: 'platform:firstleg:preload:list',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'loading-plans/:planId',
|
||||
name: 'preload-plan-detail',
|
||||
component: () => import('./pages/PreloadPlanDetailView.vue'),
|
||||
meta: {
|
||||
title: '预装清单详情',
|
||||
description: '查看预装清单基本信息、装柜货物明细和操作历史',
|
||||
permission: 'platform:firstleg:preload:view',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'loading-lists',
|
||||
name: 'loading-lists',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails,
|
||||
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
|
||||
} from './inboundFeeDetails'
|
||||
|
||||
vi.mock('@/shared/api/http', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
|
||||
return { ...actual, http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }
|
||||
})
|
||||
|
||||
describe('inbound fee-detail API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('maps the complete write contract and optimistic version', async () => {
|
||||
const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, settlementIncluded: true, partnerId: '41', partnerName: '测试供应商', remark: '验收' }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: payload })
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
await listInboundFeeDetails('100', '88'); await createInboundFeeDetail('100', '88', payload)
|
||||
await updateInboundFeeDetail('100', '88', '11', payload); await reviewInboundFeeDetail('100', '88', '11', 'approve')
|
||||
await reviewInboundFeeDetail('100', '88', '11', 'reject', '金额有误'); await adjustInboundFeeDetail('100', '88', '11', 3, payload)
|
||||
await voidInboundFeeDetail('100', '88', '11', 3); await deleteInboundFeeDetail('100', '88', '11')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details', { params: { accountId: '100' } })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11/adjust', payload, { params: { accountId: '100', expectedSourceVersion: 3 } })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11/void', { expectedSourceVersion: 3 }, { params: { accountId: '100' } })
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11', { params: { accountId: '100' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type InboundFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
||||
export type InboundFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
|
||||
export interface InboundFeeDetail {
|
||||
id: string | number
|
||||
inboundId: string | number
|
||||
feeItemId: string | number
|
||||
sourceType: string
|
||||
sourceFeeVersion: number
|
||||
inboundCode: string | null
|
||||
direction: InboundFeeDirection
|
||||
partnerId: string | number | null
|
||||
partnerName: string | null
|
||||
businessDate: string
|
||||
customerName: string | null
|
||||
feeTypeNameSnapshot: string
|
||||
unitPrice: number
|
||||
currency: string
|
||||
amount: number
|
||||
remark: string | null
|
||||
reviewStatus: InboundFeeReviewStatus
|
||||
reviewReason: string | null
|
||||
reviewerName: string | null
|
||||
reviewedAt: string | null
|
||||
publicationStatus: string
|
||||
settlementIncluded: boolean
|
||||
}
|
||||
|
||||
export interface SaveInboundFeeDetailRequest {
|
||||
feeItemId: string | number
|
||||
direction: InboundFeeDirection
|
||||
businessDate: string
|
||||
unitPrice: number
|
||||
currency: string
|
||||
amount: number
|
||||
settlementIncluded: boolean
|
||||
partnerId: string | number | null
|
||||
partnerName: string | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface InboundFeeItemOption {
|
||||
id: string | number
|
||||
feeTypeName: string
|
||||
warehousePayable: boolean
|
||||
warehouseReceivable: boolean
|
||||
}
|
||||
|
||||
export interface InboundFeePartnerOption { id: string | number; partnerName: string }
|
||||
|
||||
function basePath(inboundId: string) { return `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details` }
|
||||
const config = (accountId: string) => ({ params: { accountId } })
|
||||
|
||||
export async function listInboundFeeDetails(accountId: string, inboundId: string) {
|
||||
const response = await http.get<unknown, ApiEnvelope<InboundFeeDetail[]>>(basePath(inboundId), config(accountId))
|
||||
return unwrapApiResponse(response) || []
|
||||
}
|
||||
|
||||
export async function createInboundFeeDetail(accountId: string, inboundId: string, payload: SaveInboundFeeDetailRequest) {
|
||||
return unwrapApiResponse(await http.post<unknown, ApiEnvelope<InboundFeeDetail>>(basePath(inboundId), payload, config(accountId)))
|
||||
}
|
||||
|
||||
export async function updateInboundFeeDetail(accountId: string, inboundId: string, detailId: string, payload: SaveInboundFeeDetailRequest) {
|
||||
return unwrapApiResponse(await http.put<unknown, ApiEnvelope<InboundFeeDetail>>(`${basePath(inboundId)}/${encodeURIComponent(detailId)}`, payload, config(accountId)))
|
||||
}
|
||||
|
||||
export async function deleteInboundFeeDetail(accountId: string, inboundId: string, detailId: string) {
|
||||
return unwrapApiResponse(await http.delete<unknown, ApiEnvelope<null>>(`${basePath(inboundId)}/${encodeURIComponent(detailId)}`, config(accountId)))
|
||||
}
|
||||
|
||||
export async function reviewInboundFeeDetail(accountId: string, inboundId: string, detailId: string, action: 'approve' | 'reject', reason?: string) {
|
||||
return unwrapApiResponse(await http.post<unknown, ApiEnvelope<null>>(
|
||||
`${basePath(inboundId)}/${encodeURIComponent(detailId)}/${action}`,
|
||||
action === 'reject' ? { reason } : undefined, config(accountId),
|
||||
))
|
||||
}
|
||||
|
||||
export async function adjustInboundFeeDetail(accountId: string, inboundId: string, detailId: string, expectedSourceVersion: number, payload: SaveInboundFeeDetailRequest) {
|
||||
return unwrapApiResponse(await http.post<unknown, ApiEnvelope<InboundFeeDetail>>(
|
||||
`${basePath(inboundId)}/${encodeURIComponent(detailId)}/adjust`, payload,
|
||||
{ params: { accountId, expectedSourceVersion } },
|
||||
))
|
||||
}
|
||||
|
||||
export async function voidInboundFeeDetail(accountId: string, inboundId: string, detailId: string, expectedSourceVersion: number) {
|
||||
return unwrapApiResponse(await http.post<unknown, ApiEnvelope<null>>(
|
||||
`${basePath(inboundId)}/${encodeURIComponent(detailId)}/void`, { expectedSourceVersion }, config(accountId),
|
||||
))
|
||||
}
|
||||
|
||||
export async function listInboundFeeItemOptions() {
|
||||
const response = await http.get<unknown, ApiEnvelope<{ records: InboundFeeItemOption[] }>>(
|
||||
'/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } },
|
||||
)
|
||||
return unwrapApiResponse(response).records || []
|
||||
}
|
||||
|
||||
export async function listInboundFeePartnerOptions() {
|
||||
const response = await http.get<unknown, ApiEnvelope<{ records: InboundFeePartnerOption[] }>>(
|
||||
'/platform/v1/partners', { params: { status: 'ACTIVE', page: 1, size: 200 } },
|
||||
)
|
||||
return unwrapApiResponse(response).records || []
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import {
|
||||
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
|
||||
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions,
|
||||
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
|
||||
} from '@/modules/unified-orders/api/inboundFeeDetails'
|
||||
import InboundFeeDetailManager from './InboundFeeDetailManager.vue'
|
||||
import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDetails'
|
||||
|
||||
vi.mock('@/modules/unified-orders/api/inboundFeeDetails', () => ({
|
||||
listInboundFeeDetails: vi.fn(), listInboundFeeItemOptions: vi.fn(), listInboundFeePartnerOptions: vi.fn(),
|
||||
createInboundFeeDetail: vi.fn(), updateInboundFeeDetail: vi.fn(), deleteInboundFeeDetail: vi.fn(),
|
||||
reviewInboundFeeDetail: vi.fn(), adjustInboundFeeDetail: vi.fn(), voidInboundFeeDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
const pending: InboundFeeDetail = {
|
||||
id: '11', inboundId: '88', feeItemId: '31', sourceType: 'MANUAL', sourceFeeVersion: 1,
|
||||
inboundCode: 'RK-88', direction: 'RECEIVABLE', partnerId: null, partnerName: null,
|
||||
businessDate: '2026-08-12', customerName: '测试客户', feeTypeNameSnapshot: '操作费',
|
||||
unitPrice: 10, currency: 'CNY', amount: 100, remark: 'marker', reviewStatus: 'PENDING',
|
||||
reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT', settlementIncluded: true,
|
||||
}
|
||||
const approved: InboundFeeDetail = { ...pending, id: '12', sourceFeeVersion: 3, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED' }
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountManager(permissions: string[]) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(InboundFeeDetailManager, { attachTo: document.body, props: { accountId: '100', inboundId: '88' }, global: { plugins: [pinia, ElementPlus] } })
|
||||
}
|
||||
|
||||
describe('InboundFeeDetailManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); document.body.innerHTML = ''
|
||||
vi.mocked(listInboundFeeDetails).mockResolvedValue([pending, approved])
|
||||
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', warehouseReceivable: true, warehousePayable: true }])
|
||||
vi.mocked(listInboundFeePartnerOptions).mockResolvedValue([{ id: '41', partnerName: '测试往来单位' }])
|
||||
vi.mocked(createInboundFeeDetail).mockResolvedValue(pending)
|
||||
vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending)
|
||||
vi.mocked(adjustInboundFeeDetail).mockResolvedValue({ ...approved, reviewStatus: 'PENDING' })
|
||||
vi.mocked(reviewInboundFeeDetail).mockResolvedValue(null)
|
||||
vi.mocked(deleteInboundFeeDetail).mockResolvedValue(null)
|
||||
vi.mocked(voidInboundFeeDetail).mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('renders actions from backend status and exact permissions', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review'])
|
||||
await flushPromises()
|
||||
expect(listInboundFeeDetails).toHaveBeenCalledWith('100', '88')
|
||||
const first = wrapper.findAll('.el-table__row')[0]!.text()
|
||||
const second = wrapper.findAll('.el-table__row')[1]!.text()
|
||||
const secondButtons = wrapper.findAll('.el-table__row')[1]!.findAll('button').map((button) => button.text())
|
||||
expect(first).toContain('编辑'); expect(first).toContain('审核'); expect(first).toContain('驳回')
|
||||
expect(second).toContain('调整'); expect(second).toContain('作废'); expect(secondButtons).not.toContain('审核')
|
||||
expect(wrapper.find('th').text()).toContain('操作')
|
||||
})
|
||||
|
||||
it('does not request or expose fee rows without view permission', async () => {
|
||||
mountManager([]); await flushPromises()
|
||||
expect(listInboundFeeDetails).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('没有查看费用明细的权限')
|
||||
})
|
||||
|
||||
it('approves a pending fee then refreshes rows and emits changed', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:review'])
|
||||
await flushPromises()
|
||||
wrapper.findAll('button').find((button) => button.text().includes('审核'))?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(reviewInboundFeeDetail).toHaveBeenCalledWith('100', '88', '11', 'approve')
|
||||
expect(listInboundFeeDetails).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves request and trace IDs when a write fails', async () => {
|
||||
vi.mocked(reviewInboundFeeDetail).mockRejectedValue(new ApiResponseError('状态冲突', 'A0409', 409, null, 'request-fee', 'trace-fee'))
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:review'])
|
||||
await flushPromises()
|
||||
wrapper.findAll('button').find((button) => button.text().includes('审核'))?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('request-fee')
|
||||
expect(document.body.textContent).toContain('trace-fee')
|
||||
})
|
||||
|
||||
it('keeps currency fixed to CNY in the form and write payload', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
|
||||
await flushPromises()
|
||||
const currency = document.querySelector<HTMLInputElement>('input[aria-label="货币 CNY"]')
|
||||
expect(currency?.readOnly).toBe(true)
|
||||
expect(currency?.value).toBe('CNY')
|
||||
})
|
||||
|
||||
it('uses only the Element Plus table scroller without an outer horizontal scrollbar', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view'])
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.fee-table-shell').exists()).toBe(true)
|
||||
expect(wrapper.find('.detail-table-wrap').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Check, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Undo2, X } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import {
|
||||
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
|
||||
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions,
|
||||
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
|
||||
} from '@/modules/unified-orders/api/inboundFeeDetails'
|
||||
import type {
|
||||
InboundFeeDetail, InboundFeeItemOption,
|
||||
InboundFeePartnerOption, SaveInboundFeeDetailRequest,
|
||||
} from '@/modules/unified-orders/api/inboundFeeDetails'
|
||||
|
||||
const props = defineProps<{ accountId: string; inboundId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<InboundFeeDetail[]>([])
|
||||
const feeItems = ref<InboundFeeItemOption[]>([])
|
||||
const partners = ref<InboundFeePartnerOption[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
const editorMode = ref<'create' | 'edit' | 'adjust'>('create')
|
||||
const target = ref<InboundFeeDetail | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const rejectOpen = ref(false)
|
||||
const rejectReason = ref('')
|
||||
|
||||
const emptyForm = (): SaveInboundFeeDetailRequest => ({
|
||||
feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10),
|
||||
unitPrice: 0, currency: 'CNY', amount: 0, settlementIncluded: true,
|
||||
partnerId: null, partnerName: null, remark: null,
|
||||
})
|
||||
const form = reactive<SaveInboundFeeDetailRequest>(emptyForm())
|
||||
const rules: FormRules = {
|
||||
feeItemId: [{ required: true, message: '请选择费用项目', trigger: 'change' }],
|
||||
businessDate: [{ required: true, message: '请选择业务发生日', trigger: 'change' }],
|
||||
unitPrice: [{ required: true, type: 'number', min: 0, message: '单价不能小于 0', trigger: 'change' }],
|
||||
amount: [{ required: true, type: 'number', min: 0, message: '金额不能小于 0', trigger: 'change' }],
|
||||
currency: [{ required: true, pattern: /^[A-Z]{3}$/, message: '货币须为三个大写字母', trigger: 'blur' }],
|
||||
}
|
||||
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable))
|
||||
|
||||
function canEdit(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus !== 'APPROVED' }
|
||||
function canDelete(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:delete') && row.reviewStatus !== 'APPROVED' }
|
||||
function canReview(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'PENDING' }
|
||||
function canAdjust(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
|
||||
function canVoid(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
|
||||
function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
|
||||
function directionLabel(value: string) { return value === 'RECEIVABLE' ? '应收' : '应付' }
|
||||
|
||||
async function load() {
|
||||
if (!canView.value) return
|
||||
loading.value = true; loadError.value = null
|
||||
try { rows.value = await listInboundFeeDetails(props.accountId, props.inboundId) }
|
||||
catch (error) { loadError.value = getApiErrorDetails(error, '费用明细加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try { [feeItems.value, partners.value] = await Promise.all([listInboundFeeItemOptions(), listInboundFeePartnerOptions()]) }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '费用维护选项加载失败') }
|
||||
}
|
||||
|
||||
function assignForm(row?: InboundFeeDetail) {
|
||||
Object.assign(form, row ? {
|
||||
feeItemId: row.feeItemId, direction: row.direction, businessDate: row.businessDate,
|
||||
unitPrice: Number(row.unitPrice), currency: row.currency, amount: Number(row.amount),
|
||||
settlementIncluded: Boolean(row.settlementIncluded), partnerId: row.partnerId,
|
||||
partnerName: row.partnerName, remark: row.remark,
|
||||
} : emptyForm())
|
||||
}
|
||||
|
||||
async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDetail) {
|
||||
editorMode.value = mode; target.value = row || null; assignForm(row); actionError.value = null
|
||||
editorOpen.value = true
|
||||
if (!feeItems.value.length) await loadOptions()
|
||||
}
|
||||
|
||||
function normalizePayload(): SaveInboundFeeDetailRequest {
|
||||
const partner = partners.value.find((item) => String(item.id) === String(form.partnerId))
|
||||
return { ...form, currency: 'CNY', partnerId: form.direction === 'PAYABLE' ? form.partnerId : null,
|
||||
partnerName: form.direction === 'PAYABLE' ? partner?.partnerName || form.partnerName : null,
|
||||
remark: form.remark?.trim() || null }
|
||||
}
|
||||
|
||||
async function finishAction(message: string) {
|
||||
await load(); emit('changed'); ElMessage.success(message)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!await formRef.value?.validate()) return
|
||||
if (form.direction === 'PAYABLE' && !form.partnerId) { actionError.value = { message: '应付费用必须选择往来单位' }; return }
|
||||
saving.value = true; actionError.value = null
|
||||
try {
|
||||
const payload = normalizePayload()
|
||||
if (editorMode.value === 'create') await createInboundFeeDetail(props.accountId, props.inboundId, payload)
|
||||
else if (editorMode.value === 'edit' && target.value) await updateInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), payload)
|
||||
else if (target.value) await adjustInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), target.value.sourceFeeVersion, payload)
|
||||
editorOpen.value = false
|
||||
await finishAction(editorMode.value === 'adjust' ? '费用调整已提交审核' : '费用明细已保存')
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '费用明细保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function remove(row: InboundFeeDetail) {
|
||||
try {
|
||||
await ElMessageBox.confirm('删除后无法恢复,是否继续?', '删除费用明细', { type: 'warning', confirmButtonText: '删除' })
|
||||
await deleteInboundFeeDetail(props.accountId, props.inboundId, String(row.id)); await finishAction('费用明细已删除')
|
||||
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用明细删除失败') }
|
||||
}
|
||||
|
||||
async function approve(row: InboundFeeDetail) {
|
||||
try { await reviewInboundFeeDetail(props.accountId, props.inboundId, String(row.id), 'approve'); await finishAction('费用审核通过') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '费用审核失败') }
|
||||
}
|
||||
|
||||
function openReject(row: InboundFeeDetail) { target.value = row; rejectReason.value = ''; actionError.value = null; rejectOpen.value = true }
|
||||
async function reject() {
|
||||
if (!target.value || !rejectReason.value.trim()) { actionError.value = { message: '请填写驳回原因' }; return }
|
||||
saving.value = true
|
||||
try { await reviewInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), 'reject', rejectReason.value.trim()); rejectOpen.value = false; await finishAction('费用已驳回') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '费用驳回失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function voidRow(row: InboundFeeDetail) {
|
||||
try {
|
||||
await ElMessageBox.confirm('作废会同步撤销尚未结算的财务费用来源,是否继续?', '作废费用明细', { type: 'warning', confirmButtonText: '作废' })
|
||||
await voidInboundFeeDetail(props.accountId, props.inboundId, String(row.id), row.sourceFeeVersion); await finishAction('费用明细已作废')
|
||||
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用作废失败') }
|
||||
}
|
||||
|
||||
watch(() => [props.accountId, props.inboundId], () => void load())
|
||||
watch(() => form.direction, () => { if (form.direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null } })
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="fee-detail-manager" aria-label="运单费用维护">
|
||||
<div class="fee-toolbar">
|
||||
<span>共 {{ rows.length }} 条应收应付费用明细</span>
|
||||
<div>
|
||||
<el-tooltip content="刷新费用明细" placement="top"><el-button circle aria-label="刷新费用明细" :loading="loading" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-button v-if="access.can('firstleg:inbound:fee-detail:edit')" type="primary" @click="openEditor('create')"><Plus :size="16" />新增费用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="!canView" title="当前账号没有查看费用明细的权限" type="warning" :closable="false" show-icon />
|
||||
<LoadErrorState v-else-if="loadError" title="费用明细加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else>
|
||||
<el-alert v-if="actionError && !editorOpen && !rejectOpen" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<div v-loading="loading" class="fee-table-shell">
|
||||
<el-table :data="rows" empty-text="暂无费用数据" row-key="id">
|
||||
<el-table-column label="操作" fixed="left" width="250">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canEdit(row as InboundFeeDetail)" link type="primary" @click="openEditor('edit', row as InboundFeeDetail)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="canReview(row as InboundFeeDetail)" link type="success" @click="approve(row as InboundFeeDetail)"><Check :size="15" />审核</el-button>
|
||||
<el-button v-if="canReview(row as InboundFeeDetail)" link type="warning" @click="openReject(row as InboundFeeDetail)"><Undo2 :size="15" />驳回</el-button>
|
||||
<el-button v-if="canAdjust(row as InboundFeeDetail)" link type="primary" @click="openEditor('adjust', row as InboundFeeDetail)"><Pencil :size="15" />调整</el-button>
|
||||
<el-button v-if="canVoid(row as InboundFeeDetail)" link type="danger" @click="voidRow(row as InboundFeeDetail)"><X :size="15" />作废</el-button>
|
||||
<el-dropdown v-if="canDelete(row as InboundFeeDetail)">
|
||||
<el-button link aria-label="更多费用操作"><MoreHorizontal :size="17" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item @click="remove(row as InboundFeeDetail)"><Trash2 :size="15" />删除</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核状态" width="120"><template #default="{ row }"><StatusBadge :status="reviewLabel(row.reviewStatus)" /></template></el-table-column>
|
||||
<el-table-column label="应收/应付" width="110"><template #default="{ row }">{{ directionLabel(row.direction) }}</template></el-table-column>
|
||||
<el-table-column prop="feeTypeNameSnapshot" label="费用类型" min-width="150" />
|
||||
<el-table-column label="往来单位/客户" min-width="170"><template #default="{ row }">{{ row.direction === 'PAYABLE' ? row.partnerName : row.customerName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="businessDate" label="业务发生日" width="130" />
|
||||
<el-table-column prop="currency" label="货币" width="90" />
|
||||
<el-table-column label="金额" min-width="120"><template #default="{ row }">{{ Number(row.amount).toFixed(2) }}</template></el-table-column>
|
||||
<el-table-column label="单价" min-width="110"><template #default="{ row }">{{ Number(row.unitPrice).toFixed(4) }}</template></el-table-column>
|
||||
<el-table-column label="参与结算" width="110"><template #default="{ row }">{{ row.settlementIncluded ? '是' : '否' }}</template></el-table-column>
|
||||
<el-table-column prop="sourceFeeVersion" label="版本" width="85" />
|
||||
<el-table-column prop="publicationStatus" label="发布状态" min-width="110" />
|
||||
<el-table-column prop="reviewReason" label="驳回原因" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
|
||||
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form">
|
||||
<el-form-item label="应收/应付" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }]" /></el-form-item>
|
||||
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable><el-option v-for="item in applicableItems" :key="item.id" :label="item.feeTypeName" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item>
|
||||
<el-form-item label="业务发生日" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="货币" prop="currency"><el-input model-value="CNY" readonly aria-label="货币 CNY" /></el-form-item>
|
||||
<el-form-item label="单价" prop="unitPrice"><el-input-number v-model="form.unitPrice" :min="0" :precision="4" /></el-form-item>
|
||||
<el-form-item label="金额" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" /></el-form-item>
|
||||
<el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="rejectOpen" title="驳回费用" :confirm-loading="saving" confirm-text="确认驳回" confirm-type="warning" @confirm="reject">
|
||||
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="驳回原因" required><el-input v-model="rejectReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fee-detail-manager { display: grid; min-width: 0; max-width: 100%; gap: 12px; }
|
||||
.fee-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.fee-toolbar > div { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.fee-toolbar :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }
|
||||
.fee-form :deep(.el-select), .fee-form :deep(.el-date-editor), .fee-form :deep(.el-input-number) { width: 100%; }
|
||||
.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; }
|
||||
.fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; }
|
||||
@media (max-width: 720px) { .fee-toolbar { align-items: flex-start; flex-direction: column; } .fee-form { grid-template-columns: 1fr; } .fee-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||
</style>
|
||||
@@ -242,7 +242,8 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain("partyValue(detail.order.parties, 'RECEIVER', 'phone')")
|
||||
expect(source).not.toContain("partyValue(detail.order.parties, 'CONSIGNEE'")
|
||||
expect(source).toContain("cargoTotal(detail.order.cargoes, 'netWeightKg')")
|
||||
expect(source).toContain('暂无费用数据')
|
||||
expect(source).toContain('InboundFeeDetailManager')
|
||||
expect(source).toContain('@changed="loadDetail(true)"')
|
||||
expect(source).toContain('firstLegTransportModeLabel(detail.order.order?.transportMode)')
|
||||
expect(source).toContain('firstLegPickupMethodLabel(detail.order.order?.pickupMethod)')
|
||||
expect(source).toContain('firstLegDestinationTypeLabel(detail.order.order?.destinationType)')
|
||||
|
||||
@@ -7,15 +7,14 @@ import {
|
||||
getPlatformUnifiedOrder,
|
||||
approvePlatformUnifiedOrder,
|
||||
addPlatformUnifiedOrderNote,
|
||||
listPlatformUnifiedOrderFeeDetails,
|
||||
listPlatformUnifiedOrders,
|
||||
unifiedOrderStatusOptions,
|
||||
type UnifiedOrderDetailResource,
|
||||
type UnifiedOrderMainStatus,
|
||||
type UnifiedOrderResource,
|
||||
type UnifiedOrderFeeDetailResource,
|
||||
} from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'
|
||||
import InboundFeeDetailManager from '@/modules/unified-orders/components/InboundFeeDetailManager.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
@@ -100,9 +99,6 @@ const noteContent = ref('')
|
||||
const noteOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const relationOpen = ref(false)
|
||||
const relationOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const feeDetails = ref<UnifiedOrderFeeDetailResource[]>([])
|
||||
const feeLoading = ref(false)
|
||||
const feeLoadedKey = ref('')
|
||||
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
|
||||
const measurementLoading = ref(false)
|
||||
const measurementLoadedKey = ref('')
|
||||
@@ -384,43 +380,10 @@ function cargoImage(cargo: Record<string, unknown>) {
|
||||
return typeof value === 'string' && value ? value : ''
|
||||
}
|
||||
|
||||
function feeDate(value: string | null | undefined) {
|
||||
return value ? formatDate(value) : '—'
|
||||
}
|
||||
|
||||
function feeText(value: string | null | undefined) {
|
||||
return value || '—'
|
||||
}
|
||||
|
||||
function feeNumber(value: number | null | undefined) {
|
||||
return value === null || value === undefined ? '—' : formatNumber(value, 4)
|
||||
}
|
||||
|
||||
function feeReviewLabel(value: string | null | undefined) {
|
||||
return unifiedOrderReviewStatusLabel(value)
|
||||
}
|
||||
|
||||
function feeDirectionLabel(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
return ({ RECEIVABLE: '应收', PAYABLE: '应付' } as Record<string, string>)[value] || '未知方向'
|
||||
}
|
||||
|
||||
async function loadFeeDetails() {
|
||||
const summary = detail.value?.summary
|
||||
if (!summary?.inboundId || !can('firstleg:inbound:fee-detail:view')) return
|
||||
const key = `${summary.tenantId}:${summary.inboundId}`
|
||||
if (feeLoadedKey.value === key || feeLoading.value) return
|
||||
feeLoading.value = true
|
||||
try {
|
||||
feeDetails.value = await listPlatformUnifiedOrderFeeDetails(String(summary.tenantId), String(summary.inboundId))
|
||||
feeLoadedKey.value = key
|
||||
} catch {
|
||||
feeDetails.value = []
|
||||
} finally {
|
||||
feeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function measurementValue(row: Record<string, unknown>, key: string, customerSource = false) {
|
||||
if (customerSource && key === 'unitWeightKg') {
|
||||
const total = Number(row.grossWeightKg)
|
||||
@@ -492,7 +455,6 @@ async function loadMeasurementDetails() {
|
||||
|
||||
function selectDetailTab(key: string) {
|
||||
activeDetailTab.value = key
|
||||
if (key === 'fees') void loadFeeDetails()
|
||||
if (key === 'measurement') void loadMeasurementDetails()
|
||||
}
|
||||
|
||||
@@ -740,8 +702,6 @@ async function loadDetail(force = false) {
|
||||
if (!force && (detailLoadingKey === requestKey || detail.value?.summary.orderId === orderId.value && String(detail.value.summary.tenantId) === tenantId)) return
|
||||
const serial = ++detailRequestSerial
|
||||
detailLoadingKey = requestKey
|
||||
feeDetails.value = []
|
||||
feeLoadedKey.value = ''
|
||||
measurementDetail.value = null
|
||||
measurementLoadedKey.value = ''
|
||||
revokeMeasurementImages()
|
||||
@@ -914,36 +874,12 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeDetailTab === 'fees'">
|
||||
<div class="panel-title"><div><h2>费用信息</h2><p>共 {{ feeDetails.length }} 条应收应付费用明细</p></div><WalletCards :size="19" /></div>
|
||||
<el-alert v-if="!can('firstleg:inbound:fee-detail:view')" title="当前账号没有查看费用明细的权限" type="warning" :closable="false" show-icon />
|
||||
<div v-else v-loading="feeLoading" class="detail-table-wrap">
|
||||
<el-table :data="feeDetails" empty-text="暂无费用数据" row-key="id">
|
||||
<el-table-column label="审核状态" min-width="120"><template #default="{ row }"><StatusBadge :status="feeReviewLabel((row as UnifiedOrderFeeDetailResource).reviewStatus)" /></template></el-table-column>
|
||||
<el-table-column label="应收/应付" min-width="110"><template #default="{ row }">{{ feeDirectionLabel((row as UnifiedOrderFeeDetailResource).direction) }}</template></el-table-column>
|
||||
<el-table-column label="客户公司名" min-width="170"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).customerName) }}</template></el-table-column>
|
||||
<el-table-column label="裹箱头" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).invoiceTitle) }}</template></el-table-column>
|
||||
<el-table-column label="入仓码" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).inboundCode) }}</template></el-table-column>
|
||||
<el-table-column label="业务发生日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).businessDate) }}</template></el-table-column>
|
||||
<el-table-column label="渠道" min-width="130"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).channelName) }}</template></el-table-column>
|
||||
<el-table-column label="目的仓" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).destinationName) }}</template></el-table-column>
|
||||
<el-table-column label="FBA编号" min-width="130"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).fbaNo) }}</template></el-table-column>
|
||||
<el-table-column label="箱数" width="90"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).packageCount) }}</template></el-table-column>
|
||||
<el-table-column label="毛重" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).actualGrossWeightKg) }}</template></el-table-column>
|
||||
<el-table-column label="体积" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).actualVolumeCbm) }}</template></el-table-column>
|
||||
<el-table-column label="费用类型" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).feeTypeNameSnapshot) }}</template></el-table-column>
|
||||
<el-table-column label="货币" width="90"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).currency) }}</template></el-table-column>
|
||||
<el-table-column label="应收金额" min-width="120"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).amount) }}</template></el-table-column>
|
||||
<el-table-column label="计费体称/重量" min-width="150"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).chargeableQuantity) }} {{ (row as UnifiedOrderFeeDetailResource).chargeUnit || '' }}</template></el-table-column>
|
||||
<el-table-column label="单价" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).unitPrice) }}</template></el-table-column>
|
||||
<el-table-column label="入仓时间" min-width="150"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).inboundedAt) }}</template></el-table-column>
|
||||
<el-table-column label="出仓时间" min-width="150"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).outboundAt) }}</template></el-table-column>
|
||||
<el-table-column label="仓储天数" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).storageDays) }}</template></el-table-column>
|
||||
<el-table-column label="参与结算" min-width="110"><template #default="{ row }">{{ (row as UnifiedOrderFeeDetailResource).settlementIncluded == null ? '—' : (row as UnifiedOrderFeeDetailResource).settlementIncluded ? '是' : '否' }}</template></el-table-column>
|
||||
<el-table-column label="账单编号" min-width="140"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).billNo) }}</template></el-table-column>
|
||||
<el-table-column label="结算起始日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).settlementStartDate) }}</template></el-table-column>
|
||||
<el-table-column label="结算结束日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).settlementEndDate) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="panel-title"><div><h2>费用信息</h2><p>维护运单与入库单应收应付费用</p></div><WalletCards :size="19" /></div>
|
||||
<InboundFeeDetailManager
|
||||
:account-id="String(detail.summary.tenantId)"
|
||||
:inbound-id="String(detail.summary.inboundId)"
|
||||
@changed="loadDetail(true)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeDetailTab === 'history'">
|
||||
|
||||
Reference in New Issue
Block a user