This commit is contained in:
2026-08-19 16:45:51 +08:00
parent d1de19dc82
commit 65cd9ecefa
79 changed files with 3397 additions and 38 deletions
+7 -3
View File
@@ -3,7 +3,7 @@
## 项目定位
- 本项目是虫虫出海内部运营与管理后台,仅服务平台员工。
- 当前范围包括工作台、客户管理、头程订单、仓储与国内出仓协同、财务管理、内部用户、角色管理和菜单权限。
- 当前范围包括工作台、客户管理、头程订单、仓储与国内出仓协同、财务管理、四期海外接柜入库与库存管理、内部用户、角色管理和菜单权限。
- 不在本项目承载企业客户自助流程、品牌官网、微信小程序、独立 APP 或仓库 PDA 作业。
## 技术基线
@@ -58,11 +58,15 @@
| 财务账户、费用来源、应收统计、对账单、收付款与成本 | `modules/finance/`;应收统计从 `pages/FinanceReceivableStatisticsView.vue`、`api/finance.ts` 进入 | `finance/`;应收统计只汇总指定生成月份的 CNY 正式应收结算单,导出使用 `FINANCE_RECEIVABLE_STATISTICS` 进入 File 下载中心;费用来源与柜次成本快照联查 `firstleg/`,凭证联查 `file/` |
| 货柜与智能排柜 | `modules/container-loading/` | `firstleg/loading/`、`firstleg/split/` |
| 预装详情、装柜清单与柜次费用 | `modules/loading-lists/pages/PreloadPlanDetailView.vue`、`modules/loading-lists/components/LoadingListDetailDrawer.vue`、`modules/loading-lists/components/ContainerFeesPanel.vue`、`modules/loading-lists/api/containerFees.ts` | `firstleg/loading/`、`firstleg` 柜次费用、`finance/` 成本分摊、`file/`、`warehouse/` |
| 海运订舱与船期 | `modules/ocean-bookings/`、`modules/port-schedules/`;订舱状态、柜型需求和 API 分别从 `status.ts`、`containerRequirements.ts`、`api/platformOceanBookings.ts` 进入 | `firstleg/oceanbooking/`、`firstleg/portschedule/`、`integration-service/`;订舱仅有 `DRAFT`、`RELEASED`,草稿可编辑/删除,放舱后永久只读;创建和放舱只保存订舱聚合与柜型需求,不生成真实柜号或货柜分配。渠道来自起运仓当前 ACTIVE 价格版本;订舱代理联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml` |
| 三期海运订舱与出仓前船期资料 | `modules/ocean-bookings/`、`modules/port-schedules/`;订舱状态、柜型需求和 API 分别从 `status.ts`、`containerRequirements.ts`、`api/platformOceanBookings.ts` 进入 | `firstleg/oceanbooking/`、`firstleg/portschedule/` 的 `PlatformPortScheduleController`;订舱仅有 `DRAFT`、`RELEASED`,草稿可编辑/删除,放舱后永久只读;创建和放舱只保存订舱聚合与柜型需求,不生成真实柜号或货柜分配。渠道来自起运仓当前 ACTIVE 价格版本;订舱代理联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml`。`CarrierScheduleQuery` 及柜子出仓后的船节点查询归四期 |
| 拖车预约 | `modules/trucking/` | `firstleg/outbound/dispatch/`、`firstleg/`;承运商联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/truck-appointments-v1.yaml` |
| 报关协同 | `modules/customs/` | `firstleg/customs/`、`file/`;报关代理联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/customs-v1.yaml` |
| 国内出仓确认 | `modules/outbound-dispatch/` | `firstleg/outbound/dispatch/` |
| 主数据与下载 | `modules/master-data/`、`modules/downloads/` | `warehouse/`、`file/`;Finance 导出联查 `finance/` 内部 Export Provider 与 File 导出定义 |
| 四期海外仓接柜、入库与库内库存 | `modules/overseas-warehouse/`;先读 `../../docs/四期/README.md`,不得以 `src/prototype/` 作为业务实现 | 海外区域 API/服务;原型定向读取 `03-overseas-warehouse.html`。包含尾程订单、入库、换标、托盘、箱号、产品和库存,不包含海外仓出库及海外派送 |
| 四期海外业务 | `modules/overseas-business/`;先读 `../../docs/四期/README.md` | 海外区域 API/服务;原型定向读取 `07-overseas-business.html`;包含采购订单、本土卡车和本土散板 |
| 四期仓库与通用资料扩展 | 海外仓合作仓类型进入 `modules/warehouses/`;海外仓报价、汇率和车辆进入 `modules/overseas-master-data/` | 仓库类型联查 Backend `warehouse/`;海外报价、汇率和车辆联查海外区域 API/服务;原型定向读取 `10-general.html` |
| 四期国内出仓后船节点定时查询 | 现有 `CarrierScheduleQuery` 整体归此范围;入口为 `modules/port-schedules/`、`modules/ocean-bookings/` 及货柜详情,先读 `../../docs/四期/README.md` | `firstleg/portschedule/`、`integration-service/`;从确认离开国内仓库后查询,展示最新节点、来源、观测/更新时间和历史并衔接目的港提柜;不归三期或海外派送 |
## Admin 全量验收快速索引
@@ -74,7 +78,7 @@
| 二期及三期履约 | `modules/inbounds/`、`warehouse-resources/`、`fba-pricing/`、`dimension-config/`、`fee-items/`、`unified-orders/`、`container-loading/`、`loading-lists/`、`ocean-bookings/`、`port-schedules/`、`trucking/`、`customs/`、`outbound-dispatch/` | 按真实服务端契约核对全链路状态、权限、异常和证据 |
| 平台公共运营 | `modules/suppliers/`、`domestic-containers/`、`audit/`、`notifications/`,客户运营指标复用 `customers/` | 核对公共作用域、列表/详情只读权限、写操作独立权限、审计脱敏和统计口径 |
阶段文档入口(路径相对本文件所在目录):按验收业务选择 `../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/` 或 `../../docs/财务管理/` 的需求确认、契约和验收规则;进度、排期或验收状态问题再读取相应进度表和验收报告。
阶段文档入口(路径相对本文件所在目录):全量验收按业务选择 `../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/` 或 `../../docs/财务管理/` 的需求确认、契约和验收规则;四期开发范围单独读取 `../../docs/四期/README.md`,不因进入开发就视为已纳入全量验收。进度、排期或验收状态问题再读取相应进度表和验收报告。
## 业务与安全约束
+1 -1
View File
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
const pages = [
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:list', '@retry="loadRows"'],
['src/modules/loading-lists/components/LoadingListsView.vue', ':loading="loading"', 'LoadErrorState', ':empty-text="`暂无符合筛选条件的${title}`"', 'loadingListStatusLabel(row.status)', 'canView', '@retry="load"'],
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', '<Eye :size="15" />查看', '@retry="load"'],
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', "{{ row.status === 'DRAFT' ? '编辑' : '查看' }}", '@retry="load"'],
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', '<Eye :size="15" />详情', '@retry="load"'],
] as const
+9 -2
View File
@@ -60,6 +60,7 @@ function mockViewport(isMobile: boolean) {
describe('AppLayout', () => {
beforeEach(() => {
window.localStorage.clear()
vi.mocked(ElMessageBox.confirm).mockReset()
mockViewport(false)
})
@@ -111,10 +112,16 @@ describe('AppLayout', () => {
expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', changeHandler)
})
it('shows the authenticated identity without a role preview switcher', async () => {
it('shows the authenticated identity and the localhost full-access control', async () => {
const { wrapper } = await mountLayout()
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
expect(wrapper.text()).not.toContain('权限预览')
const accessToggle = wrapper.get('[role="switch"]')
expect(accessToggle.text()).toContain('本地全权限')
expect(accessToggle.text()).toContain('已关闭')
expect(accessToggle.attributes('aria-checked')).toBe('false')
await accessToggle.trigger('click')
expect(accessToggle.text()).toContain('已开启')
expect(accessToggle.attributes('aria-checked')).toBe('true')
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('消息管理')
expect(wrapper.text()).toContain('模板管理')
+20 -2
View File
@@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, FileText, LogOut, Mail, Menu } from '@lucide/vue'
import { Bell, FileText, LogOut, Mail, Menu, ShieldCheck } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
@@ -159,6 +159,19 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<!-- <h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p> -->
</div>
<div class="header-user">
<button
v-if="access.localFullAccessAvailable"
class="local-full-access-toggle"
type="button"
role="switch"
:aria-checked="access.localFullAccessEnabled"
:title="access.localFullAccessEnabled ? '关闭后恢复服务端权限' : '开启后显示全部前端页面和按钮'"
@click="access.toggleLocalFullAccess"
>
<ShieldCheck :size="17" />
<span>本地全权限</span>
<strong>{{ access.localFullAccessEnabled ? '已开启' : '已关闭' }}</strong>
</button>
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end">
<button class="notification-trigger" type="button" aria-label="消息中心">
<Bell :size="18" /><span>消息中心</span>
@@ -192,9 +205,14 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<style scoped>
.app-header--without-page-heading .header-user { margin-left: auto; }
.local-full-access-toggle { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid rgba(255, 255, 255, .3); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
.local-full-access-toggle:hover { border-color: rgba(255, 255, 255, .55); background: rgba(255, 255, 255, .22); }
.local-full-access-toggle:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
.local-full-access-toggle[aria-checked="true"] { border-color: #86efac; background: rgba(22, 163, 74, .3); }
.local-full-access-toggle strong { color: #dcfce7; font-size: 12px; }
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid rgba(255, 255, 255, .28); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease, transform .2s ease; }
.notification-trigger:hover { border-color: rgba(255, 255, 255, .48); background: rgba(255, 255, 255, .24); transform: translateY(-1px); }
.notification-trigger:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
.notification-trigger > svg { flex: 0 0 auto; }
@media (max-width: 720px) { .notification-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } .notification-trigger > span { display: none; } }
@media (max-width: 720px) { .local-full-access-toggle > span { display: none; } .notification-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } .notification-trigger > span { display: none; } }
</style>
+24
View File
@@ -79,6 +79,30 @@ describe('admin router access metadata', () => {
expect(route?.meta.permission).toBe('platform:counterparty:list')
})
it('registers the confirmed phase-four routes with stable list permissions', () => {
const expected = [
['overseas-last-mile-orders', '/overseas-warehouse/last-mile-orders', 'platform:overseas-warehouse:last-mile-order:list'],
['overseas-inbound-orders', '/overseas-warehouse/inbound-orders', 'platform:overseas-warehouse:inbound-order:list'],
['overseas-relabel-orders', '/overseas-warehouse/relabel-orders', 'platform:overseas-warehouse:relabel-order:list'],
['overseas-pallets', '/overseas-warehouse/pallets', 'platform:overseas-warehouse:pallet:list'],
['overseas-cartons', '/overseas-warehouse/cartons', 'platform:overseas-warehouse:carton:list'],
['overseas-products', '/overseas-warehouse/products', 'platform:overseas-warehouse:product:list'],
['overseas-inventory', '/overseas-warehouse/inventory', 'platform:overseas-warehouse:inventory:list'],
['overseas-business-purchases', '/overseas-business/purchases', 'platform:overseas-business:purchase:list'],
['overseas-business-trucks', '/overseas-business/trucks', 'platform:overseas-business:truck:list'],
['overseas-business-looseboards', '/overseas-business/looseboards', 'platform:overseas-business:looseboard:list'],
['overseas-master-data-quotes', '/overseas-master-data/quotes', 'platform:overseas-master-data:quote:list'],
['overseas-master-data-exchange-rates', '/overseas-master-data/exchange-rates', 'platform:overseas-master-data:exchange-rate:list'],
['overseas-master-data-vehicles', '/overseas-master-data/vehicles', 'platform:overseas-master-data:vehicle:list'],
]
for (const [name, path, permission] of expected) {
const route = router.getRoutes().find((item) => item.name === name)
expect(route?.path).toBe(path)
expect(route?.meta.permission).toBe(permission)
}
})
it('protects the download center with its stable list permission', () => {
const route = router.getRoutes().find((item) => item.name === 'download-center')
expect(route?.path).toBe('/downloads')
+6
View File
@@ -27,6 +27,9 @@ import { financeRoutes } from '@/modules/finance/routes'
import { notificationRoutes } from '@/modules/notifications/routes'
import { domesticContainerRoutes } from '@/modules/domestic-containers/routes'
import { supplierRoutes } from '@/modules/suppliers/routes'
import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes'
import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
const router = createRouter({
history: createWebHistory(),
@@ -58,6 +61,9 @@ const router = createRouter({
...notificationRoutes,
...domesticContainerRoutes,
...supplierRoutes,
...overseasWarehouseRoutes,
...overseasBusinessRoutes,
...overseasMasterDataRoutes,
...customerRoutes,
...warehouseRoutes,
...unifiedOrderRoutes,
+30 -2
View File
@@ -1,10 +1,13 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { isLocalFullAccessHost, useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
describe('server access store', () => {
beforeEach(() => setActivePinia(createPinia()))
beforeEach(() => {
window.localStorage.clear()
setActivePinia(createPinia())
})
it('uses only the authenticated permission snapshot', () => {
const auth = useAuthStore()
@@ -24,4 +27,29 @@ describe('server access store', () => {
expect(access.filteredNavigation).toHaveLength(1)
expect(access.firstAccessiblePath).toBe('/dashboard')
})
it('allows every frontend permission only while the localhost control is enabled', () => {
const access = useAccessPreviewStore()
expect(access.localFullAccessAvailable).toBe(true)
expect(access.can('platform:anything:create')).toBe(false)
access.setLocalFullAccessEnabled(true)
expect(access.can('platform:anything:create')).toBe(true)
expect(access.permissionCodes.has('platform:anything:edit')).toBe(true)
expect(access.filteredNavigation.length).toBeGreaterThan(1)
expect(window.localStorage.getItem('baiyue-admin-local-full-access')).toBe('true')
access.setLocalFullAccessEnabled(false)
expect(access.can('platform:anything:create')).toBe(false)
expect(window.localStorage.getItem('baiyue-admin-local-full-access')).toBeNull()
})
it('recognizes only local preview hosts', () => {
expect(isLocalFullAccessHost('localhost')).toBe(true)
expect(isLocalFullAccessHost('127.0.0.1')).toBe(true)
expect(isLocalFullAccessHost('::1')).toBe(true)
expect(isLocalFullAccessHost('admin.example.com')).toBe(false)
})
})
+44 -2
View File
@@ -7,11 +7,38 @@ import {
} from '@/core/navigation/adminNavigation'
import { useAuthStore } from '@/core/auth/auth'
const LOCAL_FULL_ACCESS_STORAGE_KEY = 'baiyue-admin-local-full-access'
class LocalFullAccessPermissionSet extends Set<string> {
override has(_permissionCode: string): boolean {
return true
}
}
export function isLocalFullAccessHost(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
}
export const useAccessPreviewStore = defineStore('access-preview', () => {
const auth = useAuthStore()
const initialized = ref(false)
const permissionCodes = computed<ReadonlySet<string>>(() => auth.permissionCodes)
const permissionSignature = computed(() => [...permissionCodes.value].sort().join('|'))
const localFullAccessAvailable = import.meta.env.DEV
&& typeof window !== 'undefined'
&& isLocalFullAccessHost(window.location.hostname)
const localFullAccessEnabled = ref(
localFullAccessAvailable
&& window.localStorage.getItem(LOCAL_FULL_ACCESS_STORAGE_KEY) === 'true',
)
const permissionCodes = computed<ReadonlySet<string>>(() => (
localFullAccessEnabled.value
? new LocalFullAccessPermissionSet(auth.permissionCodes)
: auth.permissionCodes
))
const permissionSignature = computed(() => (
localFullAccessEnabled.value
? '__LOCAL_FULL_ACCESS__'
: [...permissionCodes.value].sort().join('|')
))
const filteredNavigation = computed(() => filterAdminNavigation(adminNavigation, permissionCodes.value))
const firstAccessiblePath = computed(() => getFirstNavigationLeaf(filteredNavigation.value)?.path ?? null)
@@ -23,18 +50,33 @@ export const useAccessPreviewStore = defineStore('access-preview', () => {
return permissionCodes.value.has(permissionCode)
}
function setLocalFullAccessEnabled(enabled: boolean) {
if (!localFullAccessAvailable) return
localFullAccessEnabled.value = enabled
if (enabled) window.localStorage.setItem(LOCAL_FULL_ACCESS_STORAGE_KEY, 'true')
else window.localStorage.removeItem(LOCAL_FULL_ACCESS_STORAGE_KEY)
}
function toggleLocalFullAccess() {
setLocalFullAccessEnabled(!localFullAccessEnabled.value)
}
function reset() {
initialized.value = false
}
return {
initialized,
localFullAccessAvailable,
localFullAccessEnabled,
permissionCodes,
permissionSignature,
filteredNavigation,
firstAccessiblePath,
initializeAfterLogin,
can,
setLocalFullAccessEnabled,
toggleLocalFullAccess,
reset,
}
})
+41 -4
View File
@@ -17,7 +17,7 @@ describe('adminNavigation', () => {
'供应商中心',
'货柜管理',
'海外业务',
'海外仓库存',
'海外仓',
'财务管理',
'销售管理',
'通用',
@@ -49,19 +49,20 @@ describe('adminNavigation', () => {
'装柜清单',
])
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
'尾程订单',
'入库单',
'换标单',
'出库单',
'托盘管理',
'箱号管理',
'产品库',
'退货库存',
'暂扣库存',
'库存管理',
])
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
'产品信息',
'费用项目',
'汇率管理',
'仓库管理',
'海外仓报价',
'船期价格表',
'车辆列表',
'下载中心',
@@ -137,6 +138,42 @@ describe('adminNavigation', () => {
])
})
it('shows only the confirmed phase-four pages with their list permissions', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:overseas-warehouse:last-mile-order:list',
'platform:overseas-warehouse:inbound-order:list',
'platform:overseas-warehouse:relabel-order:list',
'platform:overseas-warehouse:pallet:list',
'platform:overseas-warehouse:carton:list',
'platform:overseas-warehouse:product:list',
'platform:overseas-warehouse:inventory:list',
'platform:overseas-business:purchase:list',
'platform:overseas-business:truck:list',
'platform:overseas-business:looseboard:list',
'platform:overseas-master-data:exchange-rate:list',
'platform:overseas-master-data:quote:list',
'platform:overseas-master-data:vehicle:list',
]))
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(paths).toEqual(expect.arrayContaining([
'/overseas-warehouse/last-mile-orders',
'/overseas-warehouse/inbound-orders',
'/overseas-warehouse/relabel-orders',
'/overseas-warehouse/pallets',
'/overseas-warehouse/cartons',
'/overseas-warehouse/products',
'/overseas-warehouse/inventory',
'/overseas-business/purchases',
'/overseas-business/trucks',
'/overseas-business/looseboards',
'/overseas-master-data/exchange-rates',
'/overseas-master-data/quotes',
'/overseas-master-data/vehicles',
]))
expect(paths.some((path) => path.includes('outbound') || path.includes('delivery'))).toBe(false)
})
it('filters protected pages without mutating the source tree', () => {
const sourceSnapshot = JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)
const filtered = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
+14 -15
View File
@@ -110,27 +110,24 @@ export const adminNavigation: AdminNavigationNode[] = [
key: 'overseas-business',
title: '海外业务',
icon: PrototypeOverseasBusinessIcon,
hidden: true,
children: [
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/prototype/overseas-business/purchase-orders' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/prototype/overseas-business/local-truck' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/prototype/overseas-business/local-looseboard' },
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
],
},
{
key: 'overseas-inventory',
title: '海外仓库存',
title: '海外仓',
icon: PrototypeOverseasWarehouseIcon,
hidden: true,
children: [
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/prototype/fba-returns/inbound-orders' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/prototype/fba-returns/relabel-orders' },
{ key: 'fba-outbound-order', title: '出库单', icon: ClipboardList, path: '/prototype/fba-returns/outbound-orders' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/prototype/fba-returns/pallet-management' },
{ key: 'fba-carton', title: '箱号管理', icon: ClipboardList, path: '/prototype/fba-returns/carton-management' },
{ key: 'fba-product', title: '产品库', icon: Database, path: '/prototype/fba-returns/product-catalog' },
{ key: 'fba-return-stock', title: '退货库存', icon: WarehouseIcon, path: '/prototype/fba-returns/return-stock' },
{ key: 'overseas-held-stock', title: '暂扣库存', icon: WarehouseIcon, path: '/prototype/overseas-inventory/stock-list' },
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'fba-carton', title: '箱号管理', icon: ClipboardList, path: '/overseas-warehouse/cartons', permission: 'platform:overseas-warehouse:carton:list' },
{ key: 'fba-product', title: '产品库', icon: Database, path: '/overseas-warehouse/products', permission: 'platform:overseas-warehouse:product:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
],
},
{
@@ -163,9 +160,11 @@ export const adminNavigation: AdminNavigationNode[] = [
children: [
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
{ key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' },
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
{ key: 'overseas-quotes', title: '海外仓报价', icon: CircleDollarSign, path: '/overseas-master-data/quotes', permission: 'platform:overseas-master-data:quote:list' },
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/prototype/vehicle-management/vehicle-list', hidden: true },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
],
},
@@ -21,4 +21,11 @@ describe('ContainerDetailView', () => {
expect(source).toContain('cancelContainerException')
expect(source).toContain('downloadContainerPdaPhoto(photo.downloadPath)')
})
it('carries the existing read-only CarrierScheduleQuery by booking and container', () => {
expect(source).toContain('CarrierScheduleQueryPanel')
expect(source).toContain("from '@/modules/port-schedules'")
expect(source).toContain(':ocean-booking-id="detail.oceanBookingId"')
expect(source).toContain(':container-booking-id="detail.id"')
})
})
@@ -10,6 +10,7 @@ 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 { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
@@ -153,6 +154,7 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></section>
<div class="surface-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" /></div>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocument" @download-document="downloadDocument" @manage-exception="openException" @preview-photo="previewPhoto" />
</template>
+5 -4
View File
@@ -6,7 +6,7 @@ function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8')
}
describe('finance five-page delivery contract', () => {
describe('finance six-page delivery contract', () => {
const routes = source('src/modules/finance/routes.ts')
const navigation = source('src/core/navigation/adminNavigation.ts')
const api = source('src/modules/finance/api/finance.ts')
@@ -14,19 +14,20 @@ describe('finance five-page delivery contract', () => {
const pages = [
['finance/customer-accounts', '客户账户'],
['finance/customer-arap', '客户应收应付'],
['finance/receivable-statistics', '应收款统计表'],
['finance/customer-statements', '客户对账单'],
['finance/settlement-receivables', '结算单应收'],
['finance/settlement-payables', '结算单应付'],
] as const
it('registers the five menu routes plus hidden detail routes', () => {
it('registers the six menu routes plus hidden detail routes', () => {
for (const [path, title] of pages) {
expect(routes).toContain(`path: '${path}'`)
expect(routes).toContain(`title: '${title}'`)
expect(navigation).toContain(`path: '/${path}'`)
expect(navigation).toContain(`title: '${title}'`)
}
expect(navigation.match(/path: '\/finance\//g)).toHaveLength(5)
expect(navigation.match(/path: '\/finance\//g)).toHaveLength(6)
expect(routes.match(/path: 'finance\/customer-accounts\/:customerId'/g)).toHaveLength(1)
expect(routes.match(/path: 'finance\/customer-arap\/:feeSourceId'/g)).toHaveLength(1)
expect(navigation).not.toContain('/finance/customer-accounts/:customerId')
@@ -41,7 +42,7 @@ describe('finance five-page delivery contract', () => {
})
it('keeps production reads on fixed DTO endpoints only', () => {
for (const endpoint of ['customer-accounts', 'fee-pool', 'statements', 'receivable-settlements', 'payable-settlements']) {
for (const endpoint of ['customer-accounts', 'fee-pool', 'receivable-statistics', 'statements', 'receivable-settlements', 'payable-settlements']) {
expect(api).toContain(`${basePathMarker(endpoint)}`)
}
expect(api).not.toContain('function listFinanceBills')
@@ -70,4 +70,11 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain('@media(max-width:720px)')
expect(source).toContain('@media(max-width:420px)')
})
it('carries the existing read-only CarrierScheduleQuery on the booking detail page', () => {
expect(source).toContain('CarrierScheduleQueryPanel')
expect(source).toContain("from '@/modules/port-schedules'")
expect(source).toContain(':ocean-booking-id="detail.id"')
expect(source).not.toContain('createCarrierScheduleQuery')
})
})
@@ -18,6 +18,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import {
createOceanBooking,
deleteOceanBookingDraft,
@@ -377,6 +378,8 @@ onMounted(() => { void load(); void loadOptions() })
</div>
</section>
<div v-if="detail" class="surface-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.id" /></div>
<section v-if="detail" class="surface-panel history-panel">
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
<el-timeline><el-timeline-item v-for="event in detail.histories" :key="event.id" :timestamp="event.occurredAt"><strong>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</strong><p>{{ event.operatorName || '系统' }}<span v-if="event.reason"> · {{ event.reason }}</span></p></el-timeline-item></el-timeline>
@@ -0,0 +1,14 @@
# 海外业务待补契约
当前仓库尚未提供海外区域 API Base、海外 Token 选择器或海外采购/本土运输正式端点。模块因此只冻结 TypeScript 请求/响应模型和页面结构,不创建临时 URL,不使用示例数据,也不模拟提交成功。
后端接入前需冻结:
- 海外 Gateway Base 与统一认证层的海外 Token 选择方式;
- 采购订单、卡车订单、散板订单的列表、详情、创建与更新端点;
- 分页信封、长整型 ID 字符串透传、金额/币种精度和时间格式;
- 状态机、允许编辑状态、乐观锁版本、幂等键及错误码;
- 客户、采购仓库等只读候选接口与权限;
- 对应 `list/create/edit` 权限资源在海外服务端的鉴权落点。
接入时应在统一双区域 HTTP/认证层完成 Base 与 Token 选择,业务页面不得自行读写 Token 或创建第二套登录。
@@ -0,0 +1,20 @@
# 四期海外业务原型核对矩阵
原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/07-overseas-business.html`;按钮函数来源:同原型目录 `prototype-src/app.js`。
| 页面 | 操作 | 原型证据 | 原型承载方式 | 冻结结论 |
|---|---|---|---|---|
| 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列 |
| 海外采购订单 | 详情 | `page-overseas-purchase-detail` 204–206 行;`showOverseasPurchaseDetail()` 13538–13540 行 | 独立详情页,商品/库存/订单/快递/进销存五分区 | 建立正式详情路由,详情复用 `purchase:list` |
| 海外采购订单 | 编辑 | `page-overseas-purchase-edit` 143–150 行;`editOverseasPurchase()`、`saveOverseasPurchaseEdit()` 13536–13537 行 | 独立编辑页 | 建立正式编辑路由,使用独立 `purchase:edit` 权限 |
| 海外采购订单 | 新增 | 14–15 行明确“历史新增表单仅保留为不可达模板” | 不可达 | 不建立新增入口或路由 |
| 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;端点未冻结前提交禁用 |
| 本土卡车 | 编辑 | `page-overseas-truck-edit` 313–362 行;`editTruckOrder()`、`saveTruckOrderEdit()` 13630–13674 行 | 独立页面 | 建立正式编辑路由;端点未冻结前提交禁用 |
| 本土卡车 | 详情 | 列表渲染函数 13563 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
| 本土散板 | 列表 | `page-overseas-looseboard` 222–244 行;`renderLooseboardList()` 13589–13605 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
| 本土散板 | 新增 | `page-overseas-looseboard-add` 247–310 行;`saveLooseboardOrder()` 13606–13622 行 | 独立页面 | 建立正式新增路由;端点未冻结前提交禁用 |
| 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;端点未冻结前提交禁用 |
| 本土散板 | 详情 | 列表渲染函数 13602 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
删除、审核/确认和文件预览在三类正式页面中均未定义,本批不自行补充承载方式。
@@ -0,0 +1,31 @@
import type {
LocalLooseboardOrder,
LocalTransportOrder,
LocalTransportQuery,
OverseasBusinessPage,
OverseasPurchaseOrder,
OverseasPurchaseQuery,
SaveLocalLooseboardOrderRequest,
SaveLocalTransportOrderRequest,
UpdateOverseasPurchaseRequest,
} from '../types'
/**
* 海外区域 API 的前端边界。
*
* 当前仓库尚未冻结海外 API Base、Token 选择和端点路径,因此这里只保存
* 请求/响应形状,不创建 Axios 实例、不拼接临时 URL,也不提供模拟实现。
*/
export interface OverseasBusinessApi {
listPurchases(query: OverseasPurchaseQuery): Promise<OverseasBusinessPage<OverseasPurchaseOrder>>
getPurchase(purchaseId: string): Promise<OverseasPurchaseOrder>
updatePurchase(purchaseId: string, payload: UpdateOverseasPurchaseRequest): Promise<OverseasPurchaseOrder>
listTrucks(query: LocalTransportQuery): Promise<OverseasBusinessPage<LocalTransportOrder>>
getTruck(orderId: string): Promise<LocalTransportOrder>
createTruck(payload: SaveLocalTransportOrderRequest): Promise<LocalTransportOrder>
updateTruck(orderId: string, payload: SaveLocalTransportOrderRequest): Promise<LocalTransportOrder>
listLooseboards(query: LocalTransportQuery): Promise<OverseasBusinessPage<LocalLooseboardOrder>>
getLooseboard(orderId: string): Promise<LocalLooseboardOrder>
createLooseboard(payload: SaveLocalLooseboardOrderRequest): Promise<LocalLooseboardOrder>
updateLooseboard(orderId: string, payload: SaveLocalLooseboardOrderRequest): Promise<LocalLooseboardOrder>
}
@@ -0,0 +1,14 @@
<script setup lang="ts">
withDefaults(defineProps<{ domain: string; compact?: boolean }>(), { compact: false })
</script>
<template>
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
<template #title>{{ domain }}海外区域接口待接入</template>
<p v-if="!compact">页面已按四期原型冻结字段与承载方式;海外 API Base、Token 选择和正式端点补齐前,不展示演示数据,也不会模拟查询或写入成功。</p>
</el-alert>
</template>
<style scoped>
.contract-pending p { margin: 5px 0 0; color: var(--el-text-color-regular); font-size: 13px; line-height: 1.55; }
</style>
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const read = (path: string) => readFileSync(resolve(process.cwd(), `src/modules/overseas-business/${path}`), 'utf8')
describe('overseas business pending contract pages', () => {
it('keeps operation columns first and renders no sample records', () => {
const purchase = read('pages/PurchaseOrdersView.vue')
const transport = read('pages/TransportOrdersView.vue')
const purchaseColumns = purchase.slice(purchase.indexOf('const columns'))
const transportColumns = transport.slice(transport.indexOf('const baseColumns'))
expect(purchaseColumns.indexOf("key: 'actions'")).toBeLessThan(purchaseColumns.indexOf("key: 'orderNo'"))
expect(transportColumns.indexOf("key: 'actions'")).toBeLessThan(transportColumns.indexOf("key: 'orderNo'"))
expect(purchase).toContain('const rows = ref<OverseasPurchaseOrder[]>([])')
expect(transport).toContain('const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([])')
expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
})
it('gives every formal list a stable query preference key without sharing transport preferences', () => {
const purchase = read('pages/PurchaseOrdersView.vue')
const transport = read('pages/TransportOrdersView.vue')
const purchaseToolbar = purchase.match(/<QueryToolbar[^>]*\/>/)?.[0]
const transportToolbar = transport.match(/<QueryToolbar[^>]*\/>/)?.[0]
expect(purchaseToolbar).toContain('preference-key="overseas-business-purchases-query"')
expect(transportToolbar).toContain(':preference-key="queryPreferenceKey"')
expect(transport).toContain("'overseas-business-truck-query'")
expect(transport).toContain("'overseas-business-looseboard-query'")
})
it('does not invent an overseas endpoint or success fallback', () => {
const apiContract = read('api/contracts.ts')
const allPages = ['PurchaseOrdersView.vue', 'PurchaseOrderDetailView.vue', 'PurchaseOrderEditView.vue', 'TransportOrdersView.vue', 'TransportOrderFormView.vue']
.map((file) => read(`pages/${file}`)).join('\n')
expect(apiContract).not.toContain("from '@/shared/api/http'")
expect(apiContract).not.toMatch(/\/platform\/v1|\/overseas\/v1|axios\.create/)
expect(allPages).not.toMatch(/ElMessage\.success|模拟成功|提交成功/)
expect(allPages).toContain('海外区域写端点待冻结')
expect(allPages).toContain('海外区域更新端点待冻结')
})
it('keeps prototype-backed forms as routes with validation and unsaved-work registration', () => {
const purchaseEdit = read('pages/PurchaseOrderEditView.vue')
const transportForm = read('pages/TransportOrderFormView.vue')
expect(purchaseEdit).toContain('useUnsavedWorkRegistration')
expect(purchaseEdit).toContain('出库量不能大于入库量')
expect(transportForm).toContain('useUnsavedWorkRegistration')
expect(transportForm).toContain("message: '请输入起运地'")
expect(transportForm).toContain("message: '请输入目的地'")
expect(transportForm).toContain('disabled><Save')
})
})
+3
View File
@@ -0,0 +1,3 @@
export { overseasBusinessPermissions } from './permissions'
export { overseasBusinessRoutes } from './routes'
export type * from './types'
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { InventoryTransaction } from '../types'
const route = useRoute()
const router = useRouter()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const fields = ['客户名称', '采购仓库', '采购品类', '商品名称', '商品规格', '商品链接']
const stockFields = ['入库量', '出库量', '库存量', '库存状态']
const orderFields = ['订单编号', '采购数量', '预算单价', '预算总价', '订单状态', '联系人', '联系电话', '收货地址', '订单备注']
const courierFields = ['国内承运商', '国内运单号', '海外承运商', '海外运单号', '预计到达时间']
const transactionColumns: DataTableColumn[] = [
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 155 },
{ key: 'documentType', prop: 'documentType', label: '单据类型', minWidth: 120 },
{ key: 'documentNo', prop: 'documentNo', label: '关联单号', minWidth: 160 },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
{ key: 'balanceQuantity', prop: 'balanceQuantity', label: '结存量', width: 95, align: 'right' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
]
const transactions: InventoryTransaction[] = []
</script>
<template>
<AdminPage><DetailPageLayout aria-label="海外采购订单详情">
<template #header><div class="detail-header"><div><h2>海外采购订单详情</h2><span>订单标识:{{ purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回</el-button></div></template>
<ContractPendingNotice domain="海外采购订单详情" />
<div class="detail-surface surface-panel">
<div class="detail-grid">
<section><h3>商品信息</h3><dl><div v-for="label in fields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
<section><h3>库存信息</h3><dl><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
<section><h3>订单信息</h3><dl><div v-for="label in orderFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
<section><h3>快递信息</h3><dl><div v-for="label in courierFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
<section class="full"><h3>进销存明细</h3><DataTable :data="transactions" :columns="transactionColumns" row-key="id" empty-text="进销存明细接口待接入" /></section>
</div>
</div>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.detail-surface { padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-last-child(-n+2) { border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; color:var(--cc-text); font-size:13px; }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
import type { FormInstance, FormRules } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseStatus, UpdateOverseasPurchaseRequest } from '../types'
interface PurchaseEditModel extends Omit<UpdateOverseasPurchaseRequest, 'expectedVersion'> { orderNo: string }
const route = useRoute()
const router = useRouter()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const formRef = ref<FormInstance>()
const form = reactive<PurchaseEditModel>({ orderNo: '', category: '', productName: '', specification: null, productUrl: null, inboundQuantity: 0, outboundQuantity: 0, status: 'PENDING_REVIEW', domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null })
const validateOutbound = (_rule: unknown, value: number, callback: (error?: Error) => void) => value > form.inboundQuantity ? callback(new Error('出库量不能大于入库量')) : callback()
const rules: FormRules<PurchaseEditModel> = {
category: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
inboundQuantity: [{ required: true, type: 'number', min: 0, message: '入库量不能小于 0', trigger: 'change' }],
outboundQuantity: [{ required: true, type: 'number', min: 0, message: '出库量不能小于 0', trigger: 'change' }, { validator: validateOutbound, trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
const statusOptions: Array<{ value: OverseasPurchaseStatus; label: string }> = [
{ value: 'PENDING_REVIEW', label: '待审核' }, { value: 'CONFIRMED', label: '已确认' }, { value: 'PURCHASING', label: '采购中' }, { value: 'COMPLETED', label: '已完成' },
]
useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
</script>
<template>
<AdminPage width="comfortable"><DetailPageLayout aria-label="编辑海外采购订单">
<template #header><div class="form-header"><div><h2>编辑海外采购订单</h2><span>订单标识:{{ purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId } })"><ArrowLeft :size="16" />返回</el-button></div></template>
<ContractPendingNotice domain="海外采购订单编辑" />
<section class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<FormSection title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled placeholder="接口接入后回填" /></el-form-item><el-form-item label="采购品类" prop="category"><el-input v-model="form.category" maxlength="100" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="160" /></el-form-item><el-form-item class="span-2" label="商品规格" prop="specification"><el-input v-model="form.specification" maxlength="255" /></el-form-item><el-form-item class="span-3" label="商品链接" prop="productUrl"><el-input v-model="form.productUrl" maxlength="1000" /></el-form-item></div></FormSection>
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="0" controls-position="right" /></el-form-item><el-form-item label="出库量" prop="outboundQuantity"><el-input-number v-model="form.outboundQuantity" :min="0" controls-position="right" /></el-form-item><el-form-item label="状态" prop="status"><el-select v-model="form.status"><el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="120" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="120" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="120" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="120" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></div></FormSection>
</el-form><footer class="form-actions"><el-tooltip content="海外区域更新端点待冻结"><span><el-button type="primary" disabled><Save :size="16" />保存</el-button></span></el-tooltip><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId } })">取消</el-button></footer></section>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; gap:0 20px; }.form-grid.three { grid-template-columns:repeat(3,minmax(0,1fr)); }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
</style>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Eye, Pencil } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { OverseasPurchaseOrder } from '../types'
const rows = ref<OverseasPurchaseOrder[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ orderNo: '', category: '', productName: '' })
const fields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
{ key: 'category', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180 },
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160 },
{ key: 'category', prop: 'category', label: '采购品类', minWidth: 130 },
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 160 },
{ key: 'specification', prop: 'specification', label: '商品规格', minWidth: 180 },
{ key: 'unitPrice', prop: 'unitPrice', label: '采购单价', width: 120, align: 'right' },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
{ key: 'stockQuantity', prop: 'stockQuantity', label: '库存量', width: 95, align: 'right' },
]
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外采购订单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="海外采购订单" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-business-purchases" row-key="id" empty-text="海外采购订单查询契约待接入">
<template #actions><div class="table-actions"><el-button size="small" type="primary" plain disabled><Eye :size="15" />详情</el-button><el-button size="small" disabled><Pencil :size="15" />编辑</el-button></div></template>
<template #pagination-left>共 0 条海外采购订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.table-panel { overflow: hidden; }.table-actions { display: flex; gap: 6px; }.table-actions :deep(.el-button) { margin-left: 0; }
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
import type { FormInstance, FormRules } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { overseasBusinessPermissions } from '../permissions'
import type { LocalTransportStatus, SaveLocalLooseboardOrderRequest } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const route = useRoute()
const router = useRouter()
const formRef = ref<FormInstance>()
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const editing = computed(() => Boolean(orderId.value))
const isLooseboard = computed(() => props.domain === 'looseboard')
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
const requiredPermission = computed(() => {
if (isLooseboard.value) return editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate
return editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate
})
interface TransportFormModel extends SaveLocalLooseboardOrderRequest { status: LocalTransportStatus }
const form = reactive<TransportFormModel>({ transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactPhone: null, containerType: '20GP', pieces: 0, grossWeightKg: null, volumeCbm: null, budgetAmount: null, currency: 'CNY', requiredDate: null, status: 'PENDING_ACCEPTANCE' })
const rules: FormRules<TransportFormModel> = {
transportMode: [{ validator: (_rule, value, callback) => !isLooseboard.value || value ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
palletRequired: [{ validator: (_rule, value, callback) => !isLooseboard.value || value ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
transportType: [{ required: true, message: '请选择运输类型', trigger: 'change' }],
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
pieces: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }],
grossWeightKg: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总重量最多保留 3 位小数', trigger: 'blur' }],
volumeCbm: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总体积最多保留 3 位小数', trigger: 'blur' }],
budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
}
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
const statusOptions: Array<{ value: LocalTransportStatus; label: string }> = [{ value: 'PENDING_ACCEPTANCE', label: '待接单' }, { value: 'DISPATCHED', label: '已派车' }, { value: 'IN_TRANSIT', label: '运输中' }, { value: 'COMPLETED', label: '已完成' }]
useUnsavedWorkRegistration({ key: computed(() => `overseas-${props.domain}-editor`).value, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
function back() { void router.push({ name: listRouteName.value }) }
</script>
<template>
<AdminPage width="comfortable"><DetailPageLayout :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<template #header><div class="form-header"><div><h2>{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h2><span v-if="editing">订单标识:{{ orderId }}</span></div><el-button @click="back"><ArrowLeft :size="16" />返回列表</el-button></div></template>
<ContractPendingNotice :domain="`${domainLabel}订单${editing ? '编辑' : '新增'}`" />
<section class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<FormSection v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
<FormSection title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="160" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="160" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="255" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="255" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="80" placeholder="联系人姓名" /></el-form-item><el-form-item label="联系方式"><el-input v-model="form.contactPhone" maxlength="40" placeholder="联系电话" /></el-form-item></div></FormSection>
<FormSection title="📦 货物信息"><div class="form-grid four"><el-form-item label="柜型"><el-select v-model="form.containerType"><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="件数" prop="pieces"><el-input-number v-model="form.pieces" :min="0" controls-position="right" /></el-form-item><el-form-item label="总重量 (kg)" prop="grossWeightKg"><el-input v-model="form.grossWeightKg" inputmode="decimal" /></el-form-item><el-form-item label="总体积 (m³)" prop="volumeCbm"><el-input v-model="form.volumeCbm" inputmode="decimal" /></el-form-item></div></FormSection>
<FormSection title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价(¥)" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" /></el-form-item><el-form-item label="要求时间"><el-date-picker v-model="form.requiredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item v-if="editing" label="状态"><el-select v-model="form.status"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></div></FormSection>
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-tooltip content="海外区域写端点待冻结"><span><el-button type="primary" disabled><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></span></el-tooltip></footer></section>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { max-width:760px; padding:20px 24px; }.form-grid { display:grid; gap:0 24px; }.form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }.form-grid.four { grid-template-columns:repeat(4,minmax(0,1fr)); gap:0 16px; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.choice-block { display:grid; grid-template-columns:1fr 1fr; gap:24px; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Pencil, Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { overseasBusinessPermissions } from '../permissions'
import type { LocalLooseboardOrder, LocalTransportOrder, LocalTransportStatus } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const router = useRouter()
const access = useAccessPreviewStore()
const isLooseboard = computed(() => props.domain === 'looseboard')
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
const queryPreferenceKey = computed(() => isLooseboard.value
? 'overseas-business-looseboard-query'
: 'overseas-business-truck-query')
const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate)
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ status: '', routeKeyword: '' })
const statusOptions: Array<{ value: LocalTransportStatus; label: string }> = [
{ value: 'PENDING_ACCEPTANCE', label: '待接单' }, { value: 'DISPATCHED', label: '已派车' }, { value: 'IN_TRANSIT', label: '运输中' }, { value: 'COMPLETED', label: '已完成' },
]
const fields: QueryField[] = [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: statusOptions },
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
]
const baseColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportType', prop: 'transportType', label: '运输类型', minWidth: 120 },
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
{ key: 'contactPhone', prop: 'contactPhone', label: '联系方式', minWidth: 145 },
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 },
{ key: 'grossWeightKg', prop: 'grossWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'budgetAmount', prop: 'budgetAmount', label: '预算总价', width: 115, align: 'right' },
{ key: 'requiredDate', prop: 'requiredDate', label: '要求时间', width: 120 },
{ key: 'status', prop: 'status', label: '状态', width: 105 },
]
const columns = computed<DataTableColumn[]>(() => isLooseboard.value ? [
baseColumns[0]!, baseColumns[1]!,
{ key: 'transportMode', prop: 'transportMode', label: '运输方式', minWidth: 120 },
{ key: 'palletRequired', prop: 'palletRequired', label: '是否打盘', width: 100 },
...baseColumns.slice(2),
] : baseColumns)
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
</script>
<template>
<AdminPage><ListPageLayout :aria-label="`${domainLabel}订单列表`">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="page = 1" /></template>
<template #state><ContractPendingNotice :domain="`${domainLabel}订单`" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`${domainLabel}订单查询契约待接入`">
<template #toolbar-actions><el-tooltip v-if="access.can(createPermission)" content="海外区域创建端点待冻结"><span><el-button type="primary" disabled @click="openCreate"><Plus :size="16" />新增订单</el-button></span></el-tooltip></template>
<template #actions><el-button size="small" disabled><Pencil :size="15" />编辑</el-button></template>
<template #pagination-left>共 0 条{{ domainLabel }}订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.table-panel { overflow:hidden; }
</style>
@@ -0,0 +1,10 @@
export const overseasBusinessPermissions = {
purchaseList: 'platform:overseas-business:purchase:list',
purchaseEdit: 'platform:overseas-business:purchase:edit',
truckList: 'platform:overseas-business:truck:list',
truckCreate: 'platform:overseas-business:truck:create',
truckEdit: 'platform:overseas-business:truck:edit',
looseboardList: 'platform:overseas-business:looseboard:list',
looseboardCreate: 'platform:overseas-business:looseboard:create',
looseboardEdit: 'platform:overseas-business:looseboard:edit',
} as const
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { overseasBusinessPermissions as permission } from './permissions'
import { overseasBusinessRoutes } from './routes'
describe('overseas business routes', () => {
it('keeps the frozen paths and permissions', () => {
expect(overseasBusinessRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
['overseas-business/purchases', permission.purchaseList],
['overseas-business/purchases/:purchaseId', permission.purchaseList],
['overseas-business/purchases/:purchaseId/edit', permission.purchaseEdit],
['overseas-business/trucks', permission.truckList],
['overseas-business/trucks/new', permission.truckCreate],
['overseas-business/trucks/:orderId/edit', permission.truckEdit],
['overseas-business/looseboards', permission.looseboardList],
['overseas-business/looseboards/new', permission.looseboardCreate],
['overseas-business/looseboards/:orderId/edit', permission.looseboardEdit],
])
})
it('does not invent excluded create or detail routes', () => {
const paths = overseasBusinessRoutes.map((route) => route.path)
expect(paths).not.toContain('overseas-business/purchases/new')
expect(paths).not.toContain('overseas-business/trucks/:orderId')
expect(paths).not.toContain('overseas-business/looseboards/:orderId')
expect(JSON.stringify(overseasBusinessRoutes)).not.toContain('permissionAnyOf')
})
})
+65
View File
@@ -0,0 +1,65 @@
import type { RouteRecordRaw } from 'vue-router'
import { overseasBusinessPermissions as permission } from './permissions'
export const overseasBusinessRoutes = [
{
path: 'overseas-business/purchases',
name: 'overseas-business-purchases',
component: () => import('./pages/PurchaseOrdersView.vue'),
meta: { title: '海外采购订单', description: '查询海外采购订单与库存变化', permission: permission.purchaseList },
},
{
path: 'overseas-business/purchases/:purchaseId',
name: 'overseas-business-purchase-detail',
component: () => import('./pages/PurchaseOrderDetailView.vue'),
meta: { title: '海外采购订单详情', description: '查看商品、库存、订单、快递与进销存明细', permission: permission.purchaseList, showPageHeading: false },
},
{
path: 'overseas-business/purchases/:purchaseId/edit',
name: 'overseas-business-purchase-edit',
component: () => import('./pages/PurchaseOrderEditView.vue'),
meta: { title: '编辑海外采购订单', description: '维护采购商品、库存和快递信息', permission: permission.purchaseEdit, showPageHeading: false },
},
{
path: 'overseas-business/trucks',
name: 'overseas-business-trucks',
component: () => import('./pages/TransportOrdersView.vue'),
props: { domain: 'truck' },
meta: { title: '本土卡车', description: '管理海外本土卡车订单', permission: permission.truckList },
},
{
path: 'overseas-business/trucks/new',
name: 'overseas-business-truck-new',
component: () => import('./pages/TransportOrderFormView.vue'),
props: { domain: 'truck' },
meta: { title: '新增本土卡车订单', description: '创建海外本土卡车订单', permission: permission.truckCreate, showPageHeading: false },
},
{
path: 'overseas-business/trucks/:orderId/edit',
name: 'overseas-business-truck-edit',
component: () => import('./pages/TransportOrderFormView.vue'),
props: { domain: 'truck' },
meta: { title: '编辑本土卡车订单', description: '维护海外本土卡车订单', permission: permission.truckEdit, showPageHeading: false },
},
{
path: 'overseas-business/looseboards',
name: 'overseas-business-looseboards',
component: () => import('./pages/TransportOrdersView.vue'),
props: { domain: 'looseboard' },
meta: { title: '本土散板', description: '管理海外本土散板订单', permission: permission.looseboardList },
},
{
path: 'overseas-business/looseboards/new',
name: 'overseas-business-looseboard-new',
component: () => import('./pages/TransportOrderFormView.vue'),
props: { domain: 'looseboard' },
meta: { title: '新增本土散板订单', description: '创建海外本土散板订单', permission: permission.looseboardCreate, showPageHeading: false },
},
{
path: 'overseas-business/looseboards/:orderId/edit',
name: 'overseas-business-looseboard-edit',
component: () => import('./pages/TransportOrderFormView.vue'),
props: { domain: 'looseboard' },
meta: { title: '编辑本土散板订单', description: '维护海外本土散板订单', permission: permission.looseboardEdit, showPageHeading: false },
},
] satisfies RouteRecordRaw[]
+137
View File
@@ -0,0 +1,137 @@
export interface OverseasBusinessPage<T> {
records: T[]
page: number
size: number
total: number
pages: number
}
export interface OverseasPurchaseQuery {
page?: number
size?: number
orderNo?: string
category?: string
productName?: string
}
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'CONFIRMED' | 'PURCHASING' | 'COMPLETED'
export interface InventoryTransaction {
id: string
occurredAt: string
documentType: string
documentNo: string
inboundQuantity: number
outboundQuantity: number
balanceQuantity: number
remark: string | null
}
export interface OverseasPurchaseOrder {
id: string
orderNo: string
customerId: string
customerName: string
purchaseWarehouseId: string
purchaseWarehouseName: string
category: string
productName: string
specification: string | null
productUrl: string | null
purchaseQuantity: number
unitPrice: string | null
currency: string
inboundQuantity: number
outboundQuantity: number
stockQuantity: number
receivingAddress: string | null
domesticCarrier: string | null
domesticTrackingNo: string | null
overseasCarrier: string | null
overseasTrackingNo: string | null
estimatedArrivalAt: string | null
contactName: string | null
contactPhone: string | null
status: OverseasPurchaseStatus
remark: string | null
inventoryTransactions: InventoryTransaction[]
version: number
}
export interface UpdateOverseasPurchaseRequest {
category: string
productName: string
specification: string | null
productUrl: string | null
inboundQuantity: number
outboundQuantity: number
status: OverseasPurchaseStatus
domesticCarrier: string | null
domesticTrackingNo: string | null
overseasCarrier: string | null
overseasTrackingNo: string | null
estimatedArrivalAt: string | null
remark: string | null
expectedVersion: number
}
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
export type LocalTransportStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED'
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
export type PalletRequired = 'YES' | 'NO'
export interface LocalTransportOrder {
id: string
orderNo: string
transportType: LocalTransportType
origin: string
destination: string
originAddress: string | null
destinationAddress: string | null
contactName: string | null
contactPhone: string | null
containerType: string | null
pieces: number
grossWeightKg: string | null
volumeCbm: string | null
budgetAmount: string | null
currency: string
requiredDate: string | null
status: LocalTransportStatus
version: number
}
export interface LocalLooseboardOrder extends LocalTransportOrder {
transportMode: LooseboardTransportMode
palletRequired: PalletRequired
}
export interface LocalTransportQuery {
page?: number
size?: number
status?: LocalTransportStatus | ''
routeKeyword?: string
}
export interface SaveLocalTransportOrderRequest {
transportType: LocalTransportType | ''
origin: string
destination: string
originAddress: string | null
destinationAddress: string | null
contactName: string | null
contactPhone: string | null
containerType: string | null
pieces: number
grossWeightKg: string | null
volumeCbm: string | null
budgetAmount: string | null
currency: string
requiredDate: string | null
expectedVersion?: number
}
export interface SaveLocalLooseboardOrderRequest extends SaveLocalTransportOrderRequest {
transportMode: LooseboardTransportMode | ''
palletRequired: PalletRequired | ''
}
@@ -0,0 +1,12 @@
# 海外通用资料接口缺口
当前仓库没有海外仓报价、汇率管理和车辆管理的已确认海外区域 API、请求/响应 DTO、错误码或写操作权限资源。
因此本模块当前只冻结正式路由、页面结构、字段、承载方式和只读权限,不包含内存示例数据或模拟成功:
- 海外仓报价:`platform:overseas-master-data:quote:list`
- 汇率管理:`platform:overseas-master-data:exchange-rate:list`
- 车辆列表/详情:`platform:overseas-master-data:vehicle:list`
新增、编辑、启停、删除、导出等写操作继续按动作使用独立权限,但编码与接口需等海外区域契约确认后再接入。列表权限只包含对应列表、详情和只读辅助数据访问。
@@ -0,0 +1,20 @@
# 四期海外通用资料原型核对矩阵
事实来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/10-general.html`、`prototype-src/overlays.html`、`prototype-src/app.js`。
| 页面 | 操作 | 原型证据 | 原型承载方式 | 原型尺寸/布局 | 当前实现 | 结论 |
|---|---|---|---|---|---|---|
| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类页签 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 不新增重复页面 | 建议类型码 `OVERSEAS_PARTNER` 未生效,不得冒充 `OVERSEAS` |
| 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 现有仓库页暂不开放该类型 | 后端正式接受合作仓类型后复用现有仓库弹框和写权限 |
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14007-14039` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 正式模块列表页、结构化空态 | 保留原型字段;等待海外查询接口 |
| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 写入口禁用,详情无记录时不生成示例对象 | 接口接入后使用居中弹框,不改抽屉或独立页 |
| 海外仓报价 | 新增/编辑 | `10-general.html:171-199`、`app.js:14135-14202` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 工具栏写按钮禁用 | 等写接口与动作权限确认后接入,不模拟保存成功 |
| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:14203-14218` | 列表行操作/工具栏 | 操作列第一列 | 无记录;写入口禁用 | 等独立动作权限与接口 |
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3373-3397` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 正式模块列表页、结构化空态 | 统一展示 `1 USD = X CNY`,等待查询接口 |
| 汇率管理 | 更新/编辑 | `overlays.html:1648-1658`、`app.js:3399-3434` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 更新按钮禁用 | 等写接口与动作权限,不模拟覆盖成功 |
| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:9719-9806`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 正式模块将区域降为查询下拉框,仅保留自有/外派归属页签;归属页签直接沿用订单列表的查询面板、底部指示条和键盘切换结构;操作列按全局约定移到第一列 | 当前对话确认高于原型;保留会改变表格列结构的归属页签,区域作为普通筛选;等待查询接口 |
| 车辆 | 详情 | `10-general.html:421-448`、`app.js:9871-9947` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 正式详情路由;无记录时显示契约待接入态 | 不改弹框或抽屉,不生成示例车辆 |
| 车辆 | 新增/编辑 | `10-general.html:353-383`、`app.js:9808-9869` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 写按钮禁用 | 等写接口与动作权限,不模拟保存成功 |
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:9759-9806` | 列表行操作/工具栏 | 原型列表操作 | 无记录;写入口禁用 | 等独立动作权限与接口 |
原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。
@@ -0,0 +1,15 @@
<script setup lang="ts">
withDefaults(defineProps<{ domain: string; detail?: boolean }>(), { detail: false })
</script>
<template>
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
<template #title>{{ domain }}海外区域接口待接入</template>
<p>{{ detail ? '当前详情没有可查询的真实记录。' : '页面已按四期原型冻结字段与布局。' }}在海外 API、Token 与权限资源补齐前,不展示演示数据,也不会模拟查询或写操作成功。</p>
</el-alert>
</template>
<style scoped>
.contract-pending p { margin: 5px 0 0; color: var(--el-text-color-regular); font-size: 13px; line-height: 1.55; }
</style>
@@ -0,0 +1,3 @@
export { overseasMasterDataRoutes } from './routes'
export { overseasMasterDataPermissions } from './permissions'
export type * from './types'
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { MonthlyExchangeRate } from '../types'
const rows = ref<MonthlyExchangeRate[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ month: '' })
const fields: QueryField[] = [
{ key: 'month', type: 'date', label: '查询月份', placeholder: '请选择月份', width: 200, props: { type: 'month', 'value-format': 'YYYY-MM' } },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ key: 'month', prop: 'month', label: '汇率月份', minWidth: 120 },
{ key: 'usdToCnyAverage', prop: 'usdToCnyAverage', label: '平均汇率', minWidth: 120 },
{ key: 'conversion', label: '换算说明', minWidth: 190, slotName: 'conversion' },
{ key: 'recordStatus', label: '状态', minWidth: 110 },
{ key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
]
const summaries = [
{ label: '最新维护汇率', value: '—' },
{ label: '汇率月份', value: '—' },
{ label: '最后更新人', value: '—' },
{ label: '最后更新时间', value: '—' },
]
</script>
<template>
<AdminPage>
<ListPageLayout aria-label="汇率管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" aria-label="汇率月份筛选" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" @search="page = 1" /></template>
<template #metrics>
<section class="rate-summary" aria-label="汇率摘要">
<article v-for="item in summaries" :key="item.label" class="surface-panel rate-summary__item"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></article>
</section>
</template>
<template #state><ContractPendingNotice domain="汇率管理" /></template>
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-exchange-rates-table" row-key="month" empty-text="月度汇率查询契约待接入">
<template #toolbar-start><div><h2>美元兑人民币月平均汇率</h2><p>统一口径:1 USD = X CNY</p></div></template>
<template #toolbar-actions><el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />更新月度汇率</el-button></span></el-tooltip></template>
<template #actions><span class="muted-value">—</span></template>
<template #conversion>—</template>
<template #pagination-left>共 0 条月度汇率</template>
</DataTable>
</section>
</ListPageLayout>
</AdminPage>
</template>
<style scoped>
.rate-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.rate-summary__item { position: relative; min-width: 0; padding: 16px 18px; overflow: hidden; }
.rate-summary__item::before { position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--cc-primary); content: ''; }
.rate-summary__item span { display: block; color: var(--cc-muted); font-size: 12px; }
.rate-summary__item strong { display: block; margin-top: 8px; color: var(--cc-text); font-size: 22px; }
.table-panel { min-width: 0; overflow: hidden; }
.muted-value { color: var(--cc-muted); }
@media (max-width: 720px) { .rate-summary { grid-template-columns: minmax(0, 1fr); } }
</style>
@@ -0,0 +1,76 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
function source(name: string) {
return readFileSync(resolve(process.cwd(), `src/modules/overseas-master-data/pages/${name}.vue`), 'utf8')
}
describe('overseas master data pending-contract pages', () => {
it('gives every list query toolbar a unique stable preference key', () => {
const expectedKeys = new Map([
['OverseasQuotesView', 'overseas-quotes-query'],
['ExchangeRatesView', 'overseas-exchange-rates-query'],
['VehiclesView', 'overseas-vehicles-query'],
])
const actualKeys: string[] = []
for (const [name, expectedKey] of expectedKeys) {
const view = source(name)
const toolbar = view.match(/<QueryToolbar\b[^>]*\/>/s)?.[0]
expect(toolbar, `${name} should render QueryToolbar`).toBeDefined()
expect(toolbar).toContain(`preference-key="${expectedKey}"`)
const actualKey = toolbar?.match(/preference-key="([^"]+)"/)?.[1]
expect(actualKey).toBe(expectedKey)
actualKeys.push(actualKey!)
}
expect(new Set(actualKeys).size).toBe(actualKeys.length)
})
it('keeps operation columns first and preserves the prototype fields', () => {
const quotes = source('OverseasQuotesView')
const exchangeRates = source('ExchangeRatesView')
const vehicles = source('VehiclesView')
expect(quotes.indexOf("key: 'actions'")).toBeLessThan(quotes.indexOf("key: 'name'"))
for (const label of ['报价名称', '海外仓', '服务商', '币种', '生效日期', '失效日期', '报价版本', '状态', '更新时间', '备注']) {
expect(quotes).toContain(`label: '${label}'`)
}
const exchangeColumns = exchangeRates.slice(exchangeRates.indexOf('const columns'))
expect(exchangeColumns.indexOf("key: 'actions'")).toBeLessThan(exchangeColumns.indexOf("key: 'month'"))
expect(vehicles.indexOf("key: 'actions'")).toBeLessThan(vehicles.indexOf("key: 'plateNumber'"))
for (const label of ['自有车辆', '外派车辆', '当前派送单', '最近更换原因']) expect(vehicles).toContain(label)
expect(vehicles).toContain("key: 'region'")
expect(vehicles).toContain("placeholder: '全部区域'")
expect(vehicles).toContain('class="status-tabs"')
expect(vehicles).toContain("'status-tab', { 'is-active': ownership === tab.value }")
expect(vehicles).not.toContain('<el-segmented')
expect(vehicles).not.toContain('aria-label="车辆区域"')
})
it('keeps all write and export entry points disabled with an explicit contract message', () => {
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
const view = source(name)
expect(view).toContain('海外区域')
expect(view).toMatch(/<el-button[^>]*disabled/)
expect(view).not.toContain('ElMessage.success')
}
})
it('uses an independent vehicle detail page without inventing a record', () => {
const detail = source('VehicleDetailView')
expect(detail).toContain('车辆信息')
expect(detail).toContain('派送订单明细')
expect(detail).toContain('派送更换记录')
expect(detail).toContain('<ContractPendingNotice domain="车辆详情" detail />')
expect(detail).not.toContain('AppDrawer')
expect(detail).not.toContain('const vehicle =')
})
it('records the prototype evidence before implementation', () => {
const matrix = readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/PROTOTYPE_MATRIX.md'), 'utf8')
for (const id of ['gen-partner-warehouse', 'gen-overseas-quote', 'gen-exchange-rate', 'vehicle-detail']) expect(matrix).toContain(id)
for (const modal of ['modal-gen-overseas-quote-detail', 'modal-gen-overseas-quote', 'modal-exchange-rate-edit', 'modal-vehicle-edit']) expect(matrix).toContain(modal)
expect(matrix).toContain('原型未定义车辆文件预览、审核/确认流程')
})
})
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Download, Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { OverseasWarehouseQuote } from '../types'
const rows = ref<OverseasWarehouseQuote[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ keyword: '', warehouseId: '', provider: '', currency: '', status: '' })
const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '报价名称 / 版本', placeholder: '报价名称 / 版本', width: 180 },
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [], width: 180 },
{ key: 'provider', type: 'input', label: '服务商', placeholder: '服务商', width: 170 },
{ key: 'currency', type: 'select', label: '币种', placeholder: '全部币种', options: [{ label: 'USD', value: 'USD' }, { label: 'CNY', value: 'CNY' }], width: 120 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '启用', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }], width: 120 },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 248, fixed: 'left', slotName: 'actions' },
{ key: 'name', prop: 'name', label: '报价名称', minWidth: 190 },
{ key: 'warehouseName', prop: 'warehouseName', label: '海外仓', minWidth: 170 },
{ key: 'providerName', prop: 'providerName', label: '服务商', minWidth: 180 },
{ key: 'currency', prop: 'currency', label: '币种', width: 74 },
{ key: 'effectiveStart', prop: 'effectiveStart', label: '生效日期', width: 112 },
{ key: 'effectiveEnd', prop: 'effectiveEnd', label: '失效日期', width: 112 },
{ key: 'version', prop: 'version', label: '报价版本', width: 92 },
{ key: 'status', prop: 'status', label: '状态', width: 76 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 142 },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
]
</script>
<template>
<AdminPage>
<ListPageLayout aria-label="海外仓报价列表">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" :fields="fields" aria-label="海外仓报价筛选" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" @search="page = 1" />
</template>
<template #state><ContractPendingNotice domain="海外仓报价" /></template>
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-quotes-table" row-key="id" empty-text="海外仓报价查询契约待接入">
<template #toolbar-actions>
<el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />新增报价</el-button></span></el-tooltip>
<el-tooltip content="海外区域导出接口待接入"><span><el-button disabled><Download :size="16" />导出Excel</el-button></span></el-tooltip>
</template>
<template #actions><span class="muted-value">—</span></template>
<template #pagination-left>共 0 条海外仓报价</template>
</DataTable>
</section>
</ListPageLayout>
</AdminPage>
</template>
<style scoped>
.table-panel { min-width: 0; overflow: hidden; }
.muted-value { color: var(--cc-muted); }
@media (max-width: 720px) { .table-panel :deep(.data-table__business-actions) { width: 100%; } }
</style>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ArrowLeft } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.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 ContractPendingNotice from '../components/ContractPendingNotice.vue'
const route = useRoute()
const router = useRouter()
const deliveryColumns: DataTableColumn[] = [
{ key: 'orderNo', prop: 'orderNo', label: '派送单号', minWidth: 160 },
{ key: 'sourceOrderNo', prop: 'sourceOrderNo', label: 'BOL / 来源单号', minWidth: 170 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 160 },
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 140 },
{ key: 'appointmentDate', prop: 'appointmentDate', label: '预约日期', minWidth: 130 },
{ key: 'status', prop: 'status', label: '派送状态', minWidth: 110 },
{ key: 'relation', prop: 'relation', label: '关联关系', minWidth: 120 },
]
const historyColumns: DataTableColumn[] = [
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 150 },
{ key: 'orderNo', prop: 'orderNo', label: '派送单号', minWidth: 160 },
{ key: 'action', prop: 'action', label: '动作', minWidth: 110 },
{ key: 'relatedVehicle', prop: 'relatedVehicle', label: '关联车辆', minWidth: 140 },
{ key: 'reason', prop: 'reason', label: '原因', minWidth: 220 },
{ key: 'operatorName', prop: 'operatorName', label: '操作人', minWidth: 120 },
]
function backToList() {
void router.push({ name: 'overseas-master-data-vehicles' })
}
</script>
<template>
<AdminPage width="comfortable">
<DetailPageLayout aria-label="车辆详情">
<template #header>
<div class="detail-heading">
<div><h1>车辆详情</h1><p>车辆 ID:{{ String(route.params.vehicleId || '—') }}</p></div>
<el-button @click="backToList"><ArrowLeft :size="16" />返回列表</el-button>
</div>
</template>
<ContractPendingNotice domain="车辆详情" detail />
<section class="surface-panel detail-surface">
<FormSection title="车辆信息"><div class="detail-empty">没有可展示的真实车辆档案</div></FormSection>
<FormSection title="派送订单明细"><DataTable :data="[]" :columns="deliveryColumns" empty-text="车辆详情接口待接入" /></FormSection>
<FormSection title="派送更换记录" :bordered="false"><DataTable :data="[]" :columns="historyColumns" empty-text="车辆详情接口待接入" /></FormSection>
</section>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.detail-heading h1 { margin: 0; color: var(--cc-text); font-size: 20px; }
.detail-heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-surface { min-width: 0; overflow: hidden; }
.detail-empty { padding: 18px; color: var(--cc-muted); text-align: center; }
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-heading :deep(.el-button) { min-height: 44px; } }
</style>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Download, Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { VehicleOwnership, VehicleSummary } from '../types'
const rows = ref<VehicleSummary[]>([])
const page = ref(1)
const pageSize = ref(20)
const ownership = ref<VehicleOwnership>('OWNED')
const ownershipTabs: Array<{ label: string; value: VehicleOwnership }> = [
{ label: '自有车辆', value: 'OWNED' },
{ label: '外派车辆', value: 'DISPATCHED' },
]
const filters = ref<QueryModel>({ keyword: '', region: '', status: '' })
const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '车辆关键词', placeholder: '车牌 / 司机 / 车型', width: 220 },
{ key: 'region', type: 'select', label: '区域', placeholder: '全部区域', width: 140, options: [
{ label: '国内', value: 'DOMESTIC' },
{ label: '海外', value: 'OVERSEAS' },
] },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: [
['在役', 'IN_SERVICE'], ['外派中', 'DISPATCHED'], ['维修中', 'MAINTENANCE'], ['停用', 'INACTIVE'], ['已归还', 'RETURNED'],
].map(([label, value]) => ({ label, value })) },
]
const ownedColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 160, fixed: 'left', slotName: 'actions' },
{ key: 'plateNumber', prop: 'plateNumber', label: '车牌', minWidth: 130 },
{ key: 'model', prop: 'model', label: '车型', minWidth: 180 },
{ key: 'fuelConsumptionPer100Km', prop: 'fuelConsumptionPer100Km', label: '油耗(L/100km)', minWidth: 130, align: 'right' },
{ key: 'mileageKm', prop: 'mileageKm', label: '当前公里码(km)', minWidth: 140, align: 'right' },
{ key: 'driverOrDispatchCompany', prop: 'driverOrDispatchCompany', label: '司机', minWidth: 130 },
{ key: 'phone', prop: 'phone', label: '联系电话', minWidth: 145 },
{ key: 'status', prop: 'status', label: '状态', width: 100 },
{ key: 'region', prop: 'region', label: '区域', width: 100 },
]
const dispatchedColumns: DataTableColumn[] = [
...ownedColumns.slice(0, 5),
{ key: 'driverOrDispatchCompany', prop: 'driverOrDispatchCompany', label: '外派单位', minWidth: 160 },
{ key: 'phone', prop: 'phone', label: '联系电话', minWidth: 145 },
{ key: 'currentDeliveryOrderNo', prop: 'currentDeliveryOrderNo', label: '当前派送单', minWidth: 160 },
{ key: 'latestReplacementReason', prop: 'latestReplacementReason', label: '最近更换原因', minWidth: 180 },
{ key: 'status', prop: 'status', label: '状态', width: 100 },
{ key: 'region', prop: 'region', label: '区域', width: 100 },
]
const columns = computed(() => ownership.value === 'OWNED' ? ownedColumns : dispatchedColumns)
const regionLabel = computed(() => ({ DOMESTIC: '国内', OVERSEAS: '海外' }[String(filters.value.region)] ?? '全部区域'))
function selectOwnership(value: VehicleOwnership) {
if (ownership.value === value) return
ownership.value = value
page.value = 1
}
function handleOwnershipTabKeydown(event: KeyboardEvent, index: number) {
let nextIndex = index
if (event.key === 'ArrowRight') nextIndex = (index + 1) % ownershipTabs.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + ownershipTabs.length) % ownershipTabs.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = ownershipTabs.length - 1
else return
event.preventDefault()
const tab = ownershipTabs[nextIndex]
if (!tab) return
selectOwnership(tab.value)
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
</script>
<template>
<AdminPage>
<ListPageLayout aria-label="车辆列表">
<template #toolbar>
<div class="vehicles-query-panel">
<div class="status-tabs" role="tablist" aria-label="车辆归属">
<button
v-for="(tab, index) in ownershipTabs"
:key="tab.value"
type="button"
role="tab"
:aria-selected="ownership === tab.value"
aria-controls="vehicle-results"
:tabindex="ownership === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': ownership === tab.value }]"
@click="selectOwnership(tab.value)"
@keydown="handleOwnershipTabKeydown($event, index)"
>{{ tab.label }}</button>
</div>
<QueryToolbar v-model="filters" class="vehicles-query-toolbar" :fields="fields" aria-label="车辆筛选" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" @search="page = 1" />
</div>
</template>
<template #state><ContractPendingNotice domain="车辆管理" /></template>
<section id="vehicle-results" class="surface-panel vehicle-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-vehicles-table" row-key="id" empty-text="车辆查询契约待接入">
<template #toolbar-actions>
<el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />添加车辆</el-button></span></el-tooltip>
<el-tooltip content="海外区域导出接口待接入"><span><el-button disabled><Download :size="16" />导出Excel</el-button></span></el-tooltip>
</template>
<template #actions><span class="muted-value">—</span></template>
<template #pagination-left>共 0 条{{ regionLabel }}{{ ownership === 'OWNED' ? '自有' : '外派' }}车辆</template>
</DataTable>
</section>
</ListPageLayout>
</AdminPage>
</template>
<style scoped>
.vehicle-panel { min-width: 0; overflow: hidden; }
.vehicles-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
.status-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
.status-tab.is-active::after { background: var(--cc-primary); }
.vehicles-query-toolbar { border: 0; }
.muted-value { color: var(--cc-muted); }
@media (max-width: 720px) { .status-tab { min-height: 46px; padding: 0 14px; } }
</style>
@@ -0,0 +1,6 @@
export const overseasMasterDataPermissions = {
quoteList: 'platform:overseas-master-data:quote:list',
exchangeRateList: 'platform:overseas-master-data:exchange-rate:list',
vehicleList: 'platform:overseas-master-data:vehicle:list',
} as const
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { overseasMasterDataPermissions } from './permissions'
import { overseasMasterDataRoutes } from './routes'
describe('overseas master data routes', () => {
it('uses the frozen stable paths and list permissions', () => {
expect(overseasMasterDataRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
['overseas-master-data/quotes', overseasMasterDataPermissions.quoteList],
['overseas-master-data/exchange-rates', overseasMasterDataPermissions.exchangeRateList],
['overseas-master-data/vehicles', overseasMasterDataPermissions.vehicleList],
['overseas-master-data/vehicles/:vehicleId', overseasMasterDataPermissions.vehicleList],
])
})
it('keeps vehicle detail as a route page and does not add a duplicate partner warehouse route', () => {
expect(overseasMasterDataRoutes.at(-1)?.meta?.showPageHeading).toBe(false)
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
})
it('documents but does not activate the suggested partner warehouse type', () => {
const apiSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouses/api/platformWarehouses.ts'), 'utf8')
const gap = readFileSync(resolve(process.cwd(), 'src/modules/warehouses/PARTNER_WAREHOUSE_CONTRACT_GAP.md'), 'utf8')
expect(gap).toContain('OVERSEAS_PARTNER')
expect(apiSource).not.toContain("'OVERSEAS_PARTNER'")
})
})
@@ -0,0 +1,30 @@
import type { RouteRecordRaw } from 'vue-router'
import { overseasMasterDataPermissions as permission } from './permissions'
export const overseasMasterDataRoutes = [
{
path: 'overseas-master-data/quotes',
name: 'overseas-master-data-quotes',
component: () => import('./pages/OverseasQuotesView.vue'),
meta: { title: '海外仓报价', description: '维护海外合作仓仓储作业与中转派送报价', permission: permission.quoteList },
},
{
path: 'overseas-master-data/exchange-rates',
name: 'overseas-master-data-exchange-rates',
component: () => import('./pages/ExchangeRatesView.vue'),
meta: { title: '汇率管理', description: '维护美元兑人民币月平均汇率', permission: permission.exchangeRateList },
},
{
path: 'overseas-master-data/vehicles',
name: 'overseas-master-data-vehicles',
component: () => import('./pages/VehiclesView.vue'),
meta: { title: '车辆列表', description: '查询国内与海外自有、外派车辆档案', permission: permission.vehicleList },
},
{
path: 'overseas-master-data/vehicles/:vehicleId',
name: 'overseas-master-data-vehicle-detail',
component: () => import('./pages/VehicleDetailView.vue'),
meta: { title: '车辆详情', description: '查看车辆档案、派送订单与更换记录', permission: permission.vehicleList, showPageHeading: false },
},
] satisfies RouteRecordRaw[]
+89
View File
@@ -0,0 +1,89 @@
export type MasterDataStatus = 'ACTIVE' | 'INACTIVE'
export type QuoteCurrency = 'USD' | 'CNY'
export type VehicleRegion = 'DOMESTIC' | 'OVERSEAS'
export type VehicleOwnership = 'OWNED' | 'DISPATCHED'
export type VehicleStatus = 'IN_SERVICE' | 'DISPATCHED' | 'MAINTENANCE' | 'INACTIVE' | 'RETURNED'
export interface OverseasWarehouseFee {
item: string
billingUnit: string
unitPrice: number
minimumCharge: number | null
remark: string | null
}
export interface OverseasTransitPrice {
warehouseId: string
fbaWarehouseCode: string
deliveryMethod: string
estimatedMiles: number
price: number
minimumCharge: number | null
remark: string | null
}
export interface OverseasWarehouseQuote {
id: string
name: string
warehouseId: string
warehouseName: string
providerName: string
currency: QuoteCurrency
effectiveStart: string
effectiveEnd: string | null
version: string
status: MasterDataStatus
updatedAt: string
remark: string | null
warehouseFees: OverseasWarehouseFee[]
transitPrices: OverseasTransitPrice[]
}
export interface MonthlyExchangeRate {
month: string
usdToCnyAverage: number
updatedBy: string
updatedAt: string
remark: string | null
}
export interface VehicleSummary {
id: string
plateNumber: string
model: string
fuelConsumptionPer100Km: number
mileageKm: number
driverOrDispatchCompany: string | null
phone: string | null
region: VehicleRegion
ownership: VehicleOwnership
status: VehicleStatus
currentDeliveryOrderNo: string | null
latestReplacementReason: string | null
}
export interface VehicleDeliveryOrder {
orderNo: string
sourceOrderNo: string | null
customerName: string
destinationWarehouse: string
appointmentDate: string | null
status: string
relation: string
}
export interface VehicleReplacementHistory {
occurredAt: string
orderNo: string
action: string
relatedVehicle: string
reason: string
operatorName: string
}
export interface VehicleDetail extends VehicleSummary {
remark: string | null
deliveryOrders: VehicleDeliveryOrder[]
replacementHistory: VehicleReplacementHistory[]
}
@@ -0,0 +1,18 @@
# 四期海外仓前端待补契约
第一批正式页面只冻结路由、权限、字段与操作承载方式。以下后端/基础设施契约缺失前,页面不得用演示数据或本地成功提示替代真实调用:
- 海外区域 API Base、海外 Access Token 的存储/刷新/选择机制,以及与现有国内 `http` 客户端的统一错误处理。
- 尾程订单分页查询、详情、导入模板、批量导入、异步导出、批量转仓和入库计划接口;分页查询需明确“全部 / 待分配”页签的请求映射,以及出库单号、派送单号只读关联字段。
- 入库单分页查询、详情、创建、编辑、预约、智能排车接口,以及状态机、幂等键和操作权限。
- 库存汇总、分页查询、详情、库位库存、托盘箱号和进销存记录接口。
- 换标单分页、详情、创建、编辑/审核、文件上传与费用明细接口,以及状态机和幂等约定。
- 托盘分页、详情、创建、编辑接口,以及海外仓/FBA 仓候选、编码唯一性与占用状态约束。
- 箱号分页、详情、创建、编辑接口,以及产品/托盘/入库单关联校验;“已出库”只作为只读库存事实,本模块不承接出库动作。
- 产品分页、详情、创建、编辑接口,以及 SKU 唯一性、品类和包装尺寸精度约定。
- 四期权限资源在 IAM 初始化数据中的正式登记。前端暂按本模块 `permissions.ts` 的稳定编码装配。
- 所有分页响应、错误码、`requestId`/`traceId`、时间/金额精度和长整型 ID 字符串传输约定。
范围排除:海外仓出库、退货出库、海外派送和其他库存离仓接口。
当前 `types.ts` 只冻结原型派生的页面展示字段,不是海外 API DTO;不得据此推导分页包络、URL、请求参数、时间/金额精度或写接口。待上述契约冻结后再建立实际 API 适配层,并逐页替换空态数据源。
@@ -0,0 +1,57 @@
# 四期海外仓原型核对矩阵
原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/03-overseas-warehouse.html`。
| 页面 | 操作 | 原型证据 | 原型承载方式 | 当前实现(开工前) | 冻结结论 |
|---|---|---|---|---|---|
| 尾程订单 | 列表/筛选 | `page-last-mile-orders`,868–897 行;`lastMileSearch()`、`lastMileReset()` | 独立列表页,状态标签、查询区、横向宽表 | `/prototype/overseas-warehouse-orders/*` 占位 | 建立正式列表路由;操作列第一列,保留原型字段顺序,接口未接时显示明确契约态 |
| 尾程订单 | 详情 | `page-last-mile-order-detail`,899–909 行;`openLastMileOrderDetail()`、`renderLastMileOrderDetail()` | 独立详情页,紧凑页头;订单信息、订单明细、计划信息、关联单据、货物信息、操作记录两列分区 | 正式参数路由与 `LastMileOrderDetailView.vue`;接口待接入时保留字段、空表与明确契约态 | 保持独立页面,不改为抽屉或弹框;原型中的出库计划和派送字段因本期边界排除,不形成操作入口 |
| 尾程订单 | 导入 | `modal-lmo-import`,933 行 | 居中弹框,约 980px | 无正式实现 | 后端导入契约未定义,本批不提供可点击成功入口 |
| 尾程订单 | 导出字段 | `modal-lmo-field-selector`,935 行 | 居中弹框,约 760px | 无正式实现 | 后端异步导出契约未定义,本批不模拟导出 |
| 尾程订单 | 批量转仓 | `modal-lmo-batch-transfer`,921 行 | 居中弹框,约 720px | 无正式实现 | 属库内操作,但写接口/幂等契约未定义;暂不提供写入口 |
| 尾程订单 | 入库计划 | `modal-lmo-inbound-plan`,912 行 | 居中弹框,约 720px | 无正式实现 | 属本期入仓链路;待写接口与状态机冻结后按原型实现 |
| 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 |
| 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 建立正式列表路由;保留筛选与列契约,接口未接时不填充演示数据 |
| 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue`;字段和费用空态已落位 | 保持独立页面,不改成抽屉;真实查询与费用数据等待海外区域接口 |
| 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 |
| 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 |
| 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 |
| 库存管理 | 列表/筛选 | `page-inventory-overview`,51–89 行;`unifiedInventorySearch()`、`unifiedInventoryReset()` | 独立列表页,六项紧凑汇总、查询区、横向宽表 | 无正式页面 | 建立正式列表路由;汇总与列表在无接口时保持零/空且明确说明未接契约 |
| 库存管理 | 详情 | `page-warehouse-stock-detail`,938–983 行;`renderWarehouseStockDetail()` 6624–6646 行 | 独立详情页;商品/库存汇总左右并列,库位库存、托盘箱号、进销存明细连续分区 | 正式参数路由与 `InventoryDetailView.vue`;字段和三类明细空态已落位 | 保持独立页面;进销存仅为只读库存事实,不提供出库或派送操作 |
| 库存管理 | 导出 | 列表页“导出Excel”按钮 | 页面工具栏动作,具体浮层未定义 | 无正式实现 | 待 File 导出类型与海外 API 契约补齐,不模拟成功 |
第一批明确不实现:`overseas-outbound`、`fba-outbound-order*`、`fba-return-outbound`、尾程订单的出库计划及任何海外派送/库存离仓操作。
尾程列表按 `page-last-mile-orders` 保留“全部 / 待分配”状态页签,并保留入库单号、出库单号、派送单号、换标单号的原型列顺序;其中出库单号和派送单号仅为只读关联事实,不提供链接、详情跳转或操作入口。箱号列表遵循 `renderFbaCarton()`:仅“在库”记录显示编辑入口,“已出库”记录只读。
第一批桌面布局按 `1920×1080` 验收:列表使用紧凑查询区与横向宽表;详情使用连续白底、双列或三列紧凑字段分区,长表保持横向滚动。当前缺少人工页面截图,视觉结果待总控按真实页面截图确认。
## 第二批:换标单、托盘、箱号、产品库
| 页面 | 操作 | 原型证据 | 原型承载方式与尺寸 | 实施结论 |
|---|---|---|---|---|
| 换标单 | 列表/筛选 | `page-fba-relabel-order`,531–555 行;`renderFbaRelabelOrder()` 8042–8058 行 | 独立列表页;SKU、状态筛选;换标单号、SKU、中文品名、箱数、预算总价、贴标要求、文件、状态 | 建立正式列表;操作列按仓库规则移到第一列;无查询接口时保持空表和契约态 |
| 换标单 | 详情 | `page-fba-relabel-order-detail`,696–699 行;`openFbaRelabelOrderDetail()` 8605–8613 行 | 独立详情页;单据信息、商品信息、作业要求、费用明细 | 建立正式参数路由,详情复用 `list` 权限;不改为弹框或抽屉 |
| 换标单 | 新增 | `modal-fba-relabel-form`,556–566 行;`openFbaRelabelCreate()` 7994–8001 行 | 居中弹框,最大宽度约 760px;订单信息与作业要求分组 | 按原型建立 `create` 权限入口与弹框;写接口未冻结,保存禁用且不模拟成功 |
| 换标单 | 编辑/审核 | 同一 `modal-fba-relabel-form`;`openFbaRelabelEdit()` 8003–8022 行 | 居中弹框,约 760px;显示状态和预算报价,提交文案为“提交审核” | 使用独立 `edit` 权限;仅保留承载和字段契约,端点/状态机未冻结前不提交 |
| 托盘管理 | 列表/筛选 | `page-fba-pallet`,626–652 行;`renderFbaPallet()` 8319–8335 行 | 独立列表页;编码、类型、仓库、状态筛选;横向表格 | 建立正式列表;操作列第一列;仓库选项等待海外基础资料接口 |
| 托盘管理 | 详情 | `page-fba-pallet-detail`,702–705 行;`openFbaPalletDetail()` 8615–8621 行 | 独立详情页;基础信息、使用信息 | 建立正式参数路由,详情复用 `list` 权限 |
| 托盘管理 | 新增/编辑 | `modal-fba-pallet-form`,654 行;`openFbaPalletForm()` 8358–8375 行 | 同一居中弹框,最大宽度约 760px;两列字段 | 分别使用 `create`、`edit` 权限;保存禁用,不发明 URL 或本地写入 |
| 箱号管理 | 列表/筛选 | `page-fba-carton`,657–681 行;`renderFbaCarton()` 8428–8448 行 | 独立列表页;箱号、FBA 仓、状态筛选 | 建立正式列表;操作列第一列;“已出库”仅作为只读库存事实,不提供离仓动作 |
| 箱号管理 | 详情 | `page-fba-carton-detail`,708–711 行;`openFbaCartonDetail()` 8623–8630 行 | 独立详情页;基础信息、商品信息 | 建立正式参数路由,详情复用 `list` 权限;关联出库单只读展示不链接出库流程 |
| 箱号管理 | 新增/编辑 | `overlays.html` 的 `modal-fba-carton-form`,1528–1541 行;`openFbaCartonForm()` 8460–8481 行 | 同一居中弹框,最大宽度约 860px;两列字段;已出库不可编辑 | 分别使用 `create`、`edit` 权限;不实现出库状态变更,保存禁用 |
| 产品库 | 列表/筛选 | `page-fba-product`,720–745 行;`renderFbaProduct()` 8695–8711 行 | 独立列表页;SKU、中文品名、品类筛选 | 建立正式列表;操作列第一列 |
| 产品库 | 详情 | `page-fba-product-detail`,714–717 行;`openFbaProductDetail()` 8683–8690 行 | 独立详情页;基础信息、包装信息、其他信息 | 建立正式参数路由,详情复用 `list` 权限 |
| 产品库 | 新增/编辑 | `modal-fbap-add`,748–771 行;`openFbapModal()`、`openFbapEditModal()` 8753–8767 行 | 同一居中弹框,最大宽度约 560px;单列标签/控件布局 | 分别使用 `create`、`edit` 权限;保存禁用,不在前端生成产品数据 |
第二批所有新增/编辑弹框按原型尺寸和字段顺序承载;接口未冻结时只用于展示已确认的交互容器,不提供可成功提交路径。导出接口同样未定义,本批不显示可用导出动作。
## 第三批:库存只读支撑页
| 页面 | 操作 | 原型证据 | 原型承载方式与布局 | 实施结论 |
|---|---|---|---|---|
| 进销存记录 | 列表/筛选 | `page-inventory-transactions`,986–1007 行;`renderInventoryTransactions()` 6573–6582 行;`inventoryTransactionSearch()`、`inventoryTransactionReset()` 6584–6599 行 | 从库存管理进入的独立列表页;日期、客户/货主、海外仓、SKU、业务类型及业务/入库/出库单号筛选;约 2050px 横向表格 | 建立无独立菜单的正式路由,复用库存 `list` 权限;出库单号与出库数量仅作为只读库存流水事实,不提供出库详情跳转或离仓操作 |
| 进销存记录 | 返回 | `backToInventoryManagement()` 6606–6609 行 | 返回库存管理列表 | 页面工具栏提供明确返回按钮 |
| 进销存记录 | 导出 | 页面 989 行“导出Excel”按钮 | 工具栏动作,原型未定义导出任务载体 | File 导出类型与海外接口未冻结,本批不显示可成功导出入口 |
`overseas-inbound`、`overseas-relabel` 等原型库存视图用于联查入仓/换标库存事实,不新增重复菜单页;当前正式入库单、换标单、库存列表与详情已经分别承载对应入口。`purchase-stock*` 和 `fba-return-stock` 是否作为独立正式页面尚无本轮导航与接口契约,不在本批自行扩展。
@@ -0,0 +1,29 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
withDefaults(defineProps<{
title: string
width: string
domain: string
confirmText?: string
}>(), {
confirmText: '保存',
})
const open = defineModel<boolean>({ default: false })
</script>
<template>
<AppDialog v-model="open" :title="title" :width="width" :confirm-text="confirmText" confirm-disabled>
<el-alert type="warning" :closable="false" show-icon :title="`${domain}写接口待接入`">
<p>弹框按原型保留字段与尺寸;端点、状态机和幂等契约冻结前不可提交,也不会在本地模拟保存成功。</p>
</el-alert>
<div class="pending-form"><slot /></div>
</AppDialog>
</template>
<style scoped>
.pending-form { margin-top: 16px; }
.pending-form :deep(.el-select), .pending-form :deep(.el-input-number) { width: 100%; }
.pending-form :deep(.el-alert p) { margin: 0; }
</style>
@@ -0,0 +1,14 @@
<script setup lang="ts">
defineProps<{ domain: string }>()
</script>
<template>
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
<template #title>{{ domain }}海外区域接口待接入</template>
<p>页面已按四期原型冻结字段与布局;在海外 API、Token 与权限资源补齐前,不展示演示数据,也不会模拟查询或写操作成功。</p>
</el-alert>
</template>
<style scoped>
.contract-pending p { margin: 5px 0 0; color: var(--el-text-color-regular); font-size: 13px; line-height: 1.55; }
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ContractPendingNotice from './ContractPendingNotice.vue'
defineProps<{
title: string
referenceLabel: string
referenceValue: string
backLabel: string
domain: string
}>()
defineEmits<{ back: [] }>()
</script>
<template>
<AdminPage as="div" class="overseas-detail-page">
<header class="overseas-detail-head">
<el-button :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回</el-button>
<div class="overseas-detail-title">
<h1>{{ title }}</h1>
<p>{{ referenceLabel }} <strong>{{ referenceValue }}</strong></p>
</div>
<span class="overseas-detail-status" role="status">接口待接入</span>
</header>
<ContractPendingNotice :domain="domain" />
<slot />
</AdminPage>
</template>
<style scoped>
.overseas-detail-page { min-width: 0; }
.overseas-detail-head { display: flex; min-height: 44px; align-items: center; gap: 12px; }
.overseas-detail-title { min-width: 0; flex: 1; }
.overseas-detail-title h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; }
.overseas-detail-title p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.overseas-detail-title strong { color: var(--cc-text); overflow-wrap: anywhere; }
.overseas-detail-status { display: inline-flex; min-height: 26px; align-items: center; padding: 3px 10px; border: 1px solid #f3d19e; border-radius: 999px; background: #fdf6ec; color: #b88230; font-size: 12px; font-weight: 600; white-space: nowrap; }
</style>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import OverseasDetailShell from './OverseasDetailShell.vue'
interface DetailGroup {
title: string
fields: string[]
}
defineProps<{
title: string
referenceLabel: string
referenceValue: string
backLabel: string
domain: string
groups: DetailGroup[]
}>()
defineEmits<{ back: [] }>()
</script>
<template>
<OverseasDetailShell
:title="title"
:reference-label="referenceLabel"
:reference-value="referenceValue"
:back-label="backLabel"
:domain="domain"
@back="$emit('back')"
>
<section class="read-only-detail" :aria-label="title">
<section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title">
<h2>{{ group.title }}</h2>
<dl class="detail-fields"><div v-for="field in group.fields" :key="field"><dt>{{ field }}</dt><dd>—</dd></div></dl>
</section>
</section>
</OverseasDetailShell>
</template>
<style scoped>
.read-only-detail { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-group:nth-child(even) { border-right: 0; }
.detail-group h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 126px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
.detail-fields dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute()
const router = useRouter()
const cartonId = computed(() => String(route.params.cartonId || '—'))
const groups = [
{ title: '基础信息', fields: ['箱号', '所属FBA仓', '状态', '关联托盘', '关联入库单', '关联出库单'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '数量', '重量(kg)', '创建时间', '更新时间', '备注'] },
]
function back() { void router.push({ name: 'overseas-cartons' }) }
</script>
<template><OverseasReadOnlyDetail title="箱号详情" reference-label="箱号" :reference-value="cartonId" back-label="返回箱号管理" domain="箱号详情" :groups="groups" @back="back" /></template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import ContractPendingFormDialog from '../components/ContractPendingFormDialog.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { CartonListItem } from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const rows = ref<CartonListItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ cartonNo: '', fbaWarehouse: '', status: '' })
const fields: QueryField[] = [
{ key: 'cartonNo', type: 'input', label: '箱号', placeholder: '请输入箱号', width: 190 },
{ key: 'fbaWarehouse', type: 'select', label: '所属FBA仓', placeholder: '全部FBA仓', width: 180, options: [] },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: ['在库', '已出库'].map((value) => ({ label: value, value })) },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'cartonNo', label: '箱号', minWidth: 180 }, { key: 'fbaWarehouse', label: '所属FBA仓', minWidth: 150 },
{ key: 'sku', label: '商品SKU', minWidth: 150 }, { key: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'quantity', label: '数量', width: 100, align: 'right' }, { key: 'weight', label: '重量(kg)', width: 110, align: 'right' },
{ key: 'status', label: '状态', width: 100 },
]
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = reactive({ cartonNo: '', fbaWarehouse: '', sku: '', productName: '', quantity: undefined as number | undefined, weight: undefined as number | undefined, palletNo: '', inboundOrderNo: '', outboundOrderNo: '', status: '在库', remark: '' })
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
function openForm(mode: 'create' | 'edit', source?: object) { const row = source as CartonListItem | undefined; dialogMode.value = mode; Object.assign(form, { cartonNo: '', fbaWarehouse: '', sku: '', productName: '', quantity: undefined, weight: undefined, palletNo: '', inboundOrderNo: '', outboundOrderNo: '', status: '在库', remark: '' }); if (mode === 'edit' && row) Object.assign(form, { cartonNo: String(row.cartonNo || ''), fbaWarehouse: String(row.fbaWarehouse || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), quantity: optionalNumber(row.quantity), weight: optionalNumber(row.weight), palletNo: String(row.palletNo || ''), inboundOrderNo: String(row.inboundOrderNo || ''), outboundOrderNo: String(row.outboundOrderNo || ''), status: String(row.status || '在库'), remark: String(row.remark || '') }); dialogOpen.value = true }
function canEditCarton(source: object) { return String((source as CartonListItem).status) === '在库' }
function openDetail(source: object) { const row = source as CartonListItem; void router.push({ name: 'overseas-carton-detail', params: { cartonId: String(row.cartonNo) } }) }
</script>
<template>
<AdminPage><ListPageLayout aria-label="箱号管理列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="箱号筛选" preference-key="overseas-cartons-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="箱号管理" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-cartons" row-key="cartonNo" empty-text="海外区域箱号查询契约待接入">
<template #toolbar-actions><el-button v-if="access.can(permission.cartonCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增箱号</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.cartonEdit) && canEditCarton(row)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />编辑</el-button></template>
<template #pagination-left>共 0 条箱号记录</template>
</DataTable></section>
</ListPageLayout>
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增箱号' : '编辑箱号'" width="min(860px, calc(100vw - 28px))" domain="箱号">
<el-form label-position="top"><el-row :gutter="16">
<el-col :span="12"><el-form-item label="箱号" required><el-input v-model="form.cartonNo" :readonly="dialogMode === 'edit'" placeholder="请输入箱号" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouse" placeholder="FBA仓候选接口待接入" disabled /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品 SKU" required><el-input v-model="form.sku" placeholder="请输入商品 SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="数量" required><el-input-number v-model="form.quantity" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="重量(kg)" required><el-input-number v-model="form.weight" :min="0.01" :precision="2" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="关联托盘"><el-input v-model="form.palletNo" placeholder="请输入关联托盘号" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="关联入库单"><el-input v-model="form.inboundOrderNo" placeholder="请输入关联入库单号" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="关联出库单"><el-input v-model="form.outboundOrderNo" readonly /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="状态"><el-input v-model="form.status" readonly /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-col>
</el-row></el-form>
</ContractPendingFormDialog>
</AdminPage>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
const route = useRoute()
const router = useRouter()
const orderId = computed(() => String(route.params.orderId || '—'))
const groups = [
{ title: '单据信息', fields: ['入库单号', '入仓码', '类型', '货主', '关联订单'] },
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '计费体积(m³)', '总托盘数'] },
{ title: '预约与排车', fields: ['状态', '预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] },
]
const feeColumns: DataTableColumn[] = [
{ key: 'index', label: '序号', width: 70, align: 'center' },
{ key: 'name', label: '费用项目', minWidth: 180 },
{ key: 'currency', label: '币种', width: 100 },
{ key: 'amount', label: '金额', width: 140, align: 'right' },
{ key: 'remark', label: '备注', minWidth: 200 },
]
function back() {
void router.push({ name: 'overseas-inbound-orders' })
}
</script>
<template>
<OverseasDetailShell title="入库单详情" reference-label="入库单号" :reference-value="orderId" back-label="返回入库单" domain="入库单详情" @back="back">
<section class="detail-section" aria-label="入库单基本信息">
<div class="detail-groups">
<section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title">
<h2>{{ group.title }}</h2>
<dl class="detail-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
</div>
</section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading">
<h2 id="inbound-fees-heading">应收费用明细</h2>
<DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="海外区域应收费用契约待接入" />
<footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer>
</section>
</OverseasDetailShell>
</template>
<style scoped>
.detail-section { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }
.detail-groups { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }
.detail-group:last-child { border-right: 0; }
.detail-group h2, .fee-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
.detail-fields dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
.fee-section { overflow: hidden; padding-top: 16px; }
.fee-section h2 { margin-right: 18px; margin-left: 18px; }
.fee-total { display: flex; min-height: 48px; align-items: center; justify-content: flex-end; gap: 40px; padding: 10px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-text); }
.fee-total strong { min-width: 120px; font-size: 16px; }
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Eye } from '@lucide/vue'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { InboundOrderListItem } from '../types'
const rows = ref<InboundOrderListItem[]>([])
const router = useRouter()
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', status: '', warehouse: '', fbaWarehouse: '', appointmentStatus: '', dispatchStatus: '', inboundDate: [] })
const fields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: ['待审核', '已审核', '已入库'].map((value) => ({ label: value, value })) },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [] },
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '全部FBA仓库', options: [] },
{ key: 'appointmentStatus', type: 'select', label: '预约状态', placeholder: '全部预约状态', options: ['未预约', '预约中', '预约成功'].map((value) => ({ label: value, value })) },
{ key: 'dispatchStatus', type: 'select', label: '排车状态', placeholder: '全部排车状态', options: ['未排车', '已排车'].map((value) => ({ label: value, value })) },
{ key: 'inboundDate', type: 'daterange', label: '入库时间' },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 120, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '入库单号', minWidth: 170 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
{ key: 'type', prop: 'type', label: '类型', width: 100 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 },
{ key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
{ key: 'cartons', prop: 'cartons', label: '总箱数', width: 90, align: 'right' },
{ key: 'volume', prop: 'volume', label: '计费体积(m³)', width: 125, align: 'right' },
{ key: 'pallets', prop: 'pallets', label: '总托盘数', width: 100, align: 'right' },
{ key: 'status', prop: 'status', label: '状态', width: 100 },
{ key: 'appointmentStatus', prop: 'appointmentStatus', label: 'FBA预约', width: 110 },
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
]
function openDetail(source: object) {
const row = source as InboundOrderListItem
void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo } })
}
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外仓入库单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="入库单" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-inbound-orders" row-key="orderNo" empty-text="海外区域入库单查询契约待接入">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 0 条入库单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
const route = useRoute()
const router = useRouter()
const inventoryId = computed(() => String(route.params.inventoryId || '—'))
const productFields = ['客户', '海外仓', 'SKU编码', '中文品名', '英文品名', '更新时间']
const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '预计总量']
const locationColumns: DataTableColumn[] = [
{ key: 'location', label: '库位', minWidth: 130 },
{ key: 'status', label: '库存状态', width: 120 },
{ key: 'palletNo', label: '托盘号', minWidth: 150 },
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
{ key: 'quantity', label: '库存数量', width: 110, align: 'right' },
{ key: 'available', label: '可用数量', width: 110, align: 'right' },
{ key: 'inboundDate', label: '入库日期', width: 130 },
{ key: 'ageDays', label: '库龄(天)', width: 100, align: 'right' },
]
const palletColumns: DataTableColumn[] = [
{ key: 'palletNo', label: '托盘号', minWidth: 150 },
{ key: 'cartons', label: '关联箱数', width: 110, align: 'right' },
{ key: 'cartonRange', label: '箱号范围', minWidth: 200 },
{ key: 'location', label: '库位', minWidth: 130 },
{ key: 'quantity', label: '库存数量', width: 110, align: 'right' },
]
const ledgerColumns: DataTableColumn[] = [
{ key: 'occurredAt', label: '发生时间', minWidth: 160 },
{ key: 'businessType', label: '业务类型', width: 120 },
{ key: 'businessOrderNo', label: '业务订单号', minWidth: 170 },
{ key: 'inboundOrderNo', label: '入库单号', minWidth: 160 },
{ key: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
{ key: 'location', label: '库位', minWidth: 120 },
{ key: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' },
{ key: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' },
{ key: 'balance', label: '结存', width: 100, align: 'right' },
{ key: 'operator', label: '操作人', minWidth: 120 },
{ key: 'remark', label: '备注', minWidth: 180 },
]
function back() {
void router.push({ name: 'overseas-inventory' })
}
</script>
<template>
<OverseasDetailShell title="库存详情" reference-label="库存记录" :reference-value="inventoryId" back-label="返回库存管理" domain="库存详情" @back="back">
<section class="overview-panel" aria-label="库存概览">
<section aria-labelledby="inventory-product-heading">
<h2 id="inventory-product-heading">商品信息</h2>
<dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
<section aria-labelledby="inventory-summary-heading">
<h2 id="inventory-summary-heading">库存状态汇总</h2>
<dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
</section>
<section class="ledger-section" aria-labelledby="inventory-location-heading">
<h2 id="inventory-location-heading">库位库存</h2>
<DataTable :data="[]" :columns="locationColumns" column-settings-mode="hide" empty-text="海外区域库位库存契约待接入" />
</section>
<section class="ledger-section" aria-labelledby="inventory-pallet-heading">
<h2 id="inventory-pallet-heading">托盘箱号信息</h2>
<DataTable :data="[]" :columns="palletColumns" column-settings-mode="hide" empty-text="海外区域托盘箱号契约待接入" />
</section>
<section class="ledger-section" aria-labelledby="inventory-ledger-heading">
<h2 id="inventory-ledger-heading">进销存单据明细</h2>
<DataTable :data="[]" :columns="ledgerColumns" column-settings-mode="hide" empty-text="海外区域进销存契约待接入" />
</section>
</OverseasDetailShell>
</template>
<style scoped>
.overview-panel { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
.overview-panel > section { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }
.overview-panel > section:last-child { border-right: 0; }
.overview-panel h2, .ledger-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
.detail-fields dd { margin: 0; color: var(--cc-text); }
.ledger-section { min-width: 0; overflow: hidden; padding-top: 16px; border: 1px solid var(--cc-border); background: #fff; }
.ledger-section h2 { margin-right: 18px; margin-left: 18px; }
</style>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ArrowLeft } from '@lucide/vue'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { InventoryTransactionListItem } from '../types'
const router = useRouter()
const rows = ref<InventoryTransactionListItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ occurredDate: [], owner: '', warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
const fields: QueryField[] = [
{ key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 },
{ key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主', width: 190 },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', width: 190, options: [] },
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码', width: 170 },
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '全部业务类型', width: 170, options: ['采购库存', '头程入库', '尾程入库', '退货入库', '暂扣库存', '一件代发', '头程出库', '派送出库', '调拨转仓', '库存调整'].map((value) => ({ label: value, value })) },
{ key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 },
{ key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 },
{ key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 180 },
]
const columns: DataTableColumn[] = [
{ key: 'occurredAt', label: '发生时间', minWidth: 160 },
{ key: 'owner', label: '客户', minWidth: 160 },
{ key: 'warehouse', label: '海外仓', minWidth: 180 },
{ key: 'sku', label: 'SKU编码', minWidth: 150 },
{ key: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'businessType', label: '业务类型', width: 120 },
{ key: 'businessOrderNo', label: '业务订单号', minWidth: 170 },
{ key: 'inboundOrderNo', label: '入库单号', minWidth: 160 },
{ key: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
{ key: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' },
{ key: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' },
{ key: 'balance', label: '结存', width: 100, align: 'right' },
{ key: 'operator', label: '操作人', minWidth: 120 },
{ key: 'remark', label: '备注', minWidth: 180 },
]
function back() { void router.push({ name: 'overseas-inventory' }) }
</script>
<template>
<AdminPage><ListPageLayout aria-label="进销存记录列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="进销存记录" /><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。</p></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-inventory-transactions" row-key="id" empty-text="海外区域进销存查询契约待接入">
<template #toolbar-actions><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template>
<template #pagination-left>共 0 条进销存记录</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.read-only-boundary { margin: 8px 2px 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from 'vue'
import { BookOpen, Eye } from '@lucide/vue'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { InventoryListItem } from '../types'
const rows = ref<InventoryListItem[]>([])
const router = useRouter()
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ warehouse: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
const fields: QueryField[] = [
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [] },
{ key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主' },
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码' },
{ key: 'productName', type: 'input', label: '中文品名', placeholder: '请输入中文品名' },
{ key: 'batchNo', type: 'input', label: '批次号', placeholder: '请输入批次号' },
{ key: 'location', type: 'input', label: '库位', placeholder: '请输入库位' },
{ key: 'status', type: 'select', label: '库存状态', placeholder: '全部库存状态', options: ['正常', '库存不足', '无可用库存', '存在暂扣', '存在美本土散版'].map((value) => ({ label: value, value })) },
]
const metrics = [
['在库总量', 0, 'primary'], ['可用库存', 0, 'success'], ['暂扣库存', 0, 'danger'],
['美本土散版', 0, 'neutral'], ['在途库存', 0, 'warning'], ['采购库存', 0, 'neutral'],
] as const
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'owner', prop: 'owner', label: '客户', minWidth: 160 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 },
{ key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' },
{ key: 'available', prop: 'available', label: '可用库存', width: 105, align: 'right' },
{ key: 'held', prop: 'held', label: '暂扣库存', width: 105, align: 'right' },
{ key: 'looseboard', prop: 'looseboard', label: '美本土散版', width: 115, align: 'right' },
{ key: 'inTransit', prop: 'inTransit', label: '在途库存', width: 105, align: 'right' },
{ key: 'purchase', prop: 'purchase', label: '采购库存', width: 105, align: 'right' },
{ key: 'status', prop: 'status', label: '库存状态', width: 110 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 },
]
function openDetail(source: object) {
const row = source as InventoryListItem
void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id } })
}
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外仓库存管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="page = 1" /></template>
<template #metrics><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template #state><ContractPendingNotice domain="库存管理" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-inventory" row-key="id" empty-text="海外区域库存查询契约待接入">
<template #toolbar-actions><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 0 条库存记录</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.inventory-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.inventory-metric { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 13px 16px; border-right: 1px solid var(--cc-border); }
.inventory-metric:last-child { border-right: 0; }
.inventory-metric span { color: #64748b; font-size: 12px; }
.inventory-metric strong { color: #334155; font-size: 22px; line-height: 1; }
.inventory-metric.is-primary strong { color: var(--cc-primary); }
.inventory-metric.is-success strong { color: #16a34a; }
.inventory-metric.is-danger strong { color: #dc2626; }
.inventory-metric.is-warning strong { color: #d97706; }
</style>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
const route = useRoute()
const router = useRouter()
const orderId = computed(() => String(route.params.orderId || '—'))
const orderFields = ['订单类型', '拒收时间', '实际入库时间', '头程单号', '货主', '海外仓', '转仓目的仓', 'FBA仓库', '集装箱号', '货柜异常', '异常原因', '运输方式', '预计到仓', '清关状态']
const detailFields = ['订单类型', '入仓码', '目的仓', '托数', '箱数', '数量', '体积重量', '预计到港时间', '实际到港时间', '预计到仓', '是否暂扣', '备注(仅自己)', '备注(客户)', '备注(仓库)']
const planFields = ['入仓码', '计划入库仓', '合托批次', '合托后托数', 'FBA预约号', '预约状态', '换标状态']
const documentColumns: DataTableColumn[] = [
{ key: 'type', label: '单据类型', minWidth: 120 },
{ key: 'number', label: '单据编号', minWidth: 180 },
{ key: 'createdAt', label: '生成时间', minWidth: 160 },
{ key: 'quote', label: '报价', minWidth: 140 },
]
const cargoColumns: DataTableColumn[] = [
{ key: 'sku', label: 'SKU', minWidth: 150 },
{ key: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'pallets', label: '托数', width: 90, align: 'right' },
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
{ key: 'quantity', label: '件数', width: 90, align: 'right' },
{ key: 'weight', label: '重量(kg)', width: 110, align: 'right' },
{ key: 'volume', label: '体积(m³)', width: 110, align: 'right' },
]
const logColumns: DataTableColumn[] = [
{ key: 'occurredAt', label: '时间', minWidth: 160 },
{ key: 'node', label: '操作节点', minWidth: 140 },
{ key: 'operator', label: '操作人', minWidth: 130 },
{ key: 'result', label: '处理结果', minWidth: 220 },
]
function back() {
void router.push({ name: 'overseas-last-mile-orders' })
}
</script>
<template>
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="orderId" back-label="返回尾程订单" domain="尾程订单详情" @back="back">
<div class="detail-grid">
<section class="detail-section" aria-labelledby="last-mile-order-heading">
<h2 id="last-mile-order-heading">订单信息</h2>
<dl class="detail-fields"><div v-for="label in orderFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
<section class="detail-section" aria-labelledby="last-mile-lines-heading">
<h2 id="last-mile-lines-heading">订单明细</h2>
<dl class="detail-fields"><div v-for="label in detailFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
<section class="detail-section" aria-labelledby="last-mile-plan-heading">
<h2 id="last-mile-plan-heading">计划信息</h2>
<dl class="detail-fields"><div v-for="label in planFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
<section class="detail-section table-section" aria-labelledby="last-mile-documents-heading">
<h2 id="last-mile-documents-heading">关联单据</h2>
<DataTable :data="[]" :columns="documentColumns" column-settings-mode="hide" empty-text="海外区域关联单据契约待接入" />
</section>
<section class="detail-section table-section is-wide" aria-labelledby="last-mile-cargo-heading">
<h2 id="last-mile-cargo-heading">货物信息</h2>
<DataTable :data="[]" :columns="cargoColumns" column-settings-mode="hide" empty-text="海外区域货物明细契约待接入" />
</section>
<section class="detail-section table-section" aria-labelledby="last-mile-logs-heading">
<h2 id="last-mile-logs-heading">操作记录</h2>
<DataTable :data="[]" :columns="logColumns" column-settings-mode="hide" empty-text="海外区域操作记录契约待接入" />
</section>
</div>
</OverseasDetailShell>
</template>
<style scoped>
.detail-grid { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
.detail-section { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-section:nth-child(even) { border-right: 0; }
.detail-section.is-wide { grid-column: 1 / -1; border-right: 0; }
.detail-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.detail-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.detail-fields > div { min-width: 0; min-height: 58px; padding: 9px 11px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt { color: var(--cc-muted); font-size: 12px; }
.detail-fields dd { margin: 4px 0 0; color: var(--cc-text); overflow-wrap: anywhere; }
.table-section { padding-right: 0; padding-bottom: 0; padding-left: 0; overflow: hidden; }
.table-section h2 { margin-right: 18px; margin-left: 18px; }
</style>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Eye } from '@lucide/vue'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { LastMileOrderListItem } from '../types'
const rows = ref<LastMileOrderListItem[]>([])
const router = useRouter()
const page = ref(1)
const pageSize = ref(20)
const statusTab = ref<'全部' | '待分配'>('全部')
const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', warehouse: '', fbaWarehouse: '', orderType: '', owner: '', containerNo: '', status: '', consolidated: '', portEta: [] })
const fields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [] },
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '全部FBA仓库', options: [] },
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部订单类型', options: ['一件代发', '头程', '拒收订单', '快递订单'].map((value) => ({ label: value, value })) },
{ key: 'owner', type: 'input', label: '货主', placeholder: '请输入货主' },
{ key: 'containerNo', type: 'input', label: '集装箱号', placeholder: '请输入集装箱号' },
{ key: 'status', type: 'select', label: '当前状态', placeholder: '全部状态', options: ['待入库计划', '待清关', '清关完成', '已合托', '待预约', '预约中', '预约成功'].map((value) => ({ label: value, value })) },
{ key: 'consolidated', type: 'select', label: '是否合托', placeholder: '全部', options: [{ label: '是', value: 'yes' }, { label: '否', value: 'no' }] },
{ key: 'portEta', type: 'daterange', label: '预计到港时间' },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
{ key: 'orderType', prop: 'orderType', label: '订单类型', minWidth: 110 },
{ key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
{ key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'transferWarehouse', prop: 'transferWarehouse', label: '转仓', minWidth: 140 },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150 },
{ key: 'containerStatus', prop: 'containerStatus', label: '货柜状态', width: 110 },
{ key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100 },
{ key: 'held', prop: 'held', label: '是否暂扣', width: 100 },
{ key: 'pallets', prop: 'pallets', label: '托数', width: 80, align: 'right' },
{ key: 'cartons', prop: 'cartons', label: '箱数', width: 80, align: 'right' },
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 100, align: 'right' },
{ key: 'quantity', prop: 'quantity', label: '数量', width: 80, align: 'right' },
{ key: 'volumeWeight', prop: 'volumeWeight', label: '体积重量', width: 100, align: 'right' },
{ key: 'portEta', prop: 'portEta', label: '预计到港时间', minWidth: 150 },
{ key: 'portAta', prop: 'portAta', label: '实际到港时间', minWidth: 150 },
{ key: 'warehouseEta', prop: 'warehouseEta', label: '预计到仓', minWidth: 140 },
{ key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 150 },
{ key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 150 },
{ key: 'dispatchOrderNo', prop: 'dispatchOrderNo', label: '派送单号', minWidth: 150 },
{ key: 'relabelOrderNo', prop: 'relabelOrderNo', label: '换标单号', minWidth: 150 },
{ key: 'relabelStatus', prop: 'relabelStatus', label: '换标状态', width: 110 },
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100 },
{ key: 'status', prop: 'status', label: '当前状态', width: 110 },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(仅自己)', minWidth: 160 },
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
]
function openDetail(source: object) {
const row = source as LastMileOrderListItem
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderNo } })
}
</script>
<template>
<AdminPage><ListPageLayout aria-label="尾程订单列表">
<template #toolbar>
<section class="surface-panel last-mile-toolbar" aria-label="尾程订单状态与筛选">
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="page = 1">
<el-radio-button value="全部">全部</el-radio-button>
<el-radio-button value="待分配">待分配</el-radio-button>
</el-radio-group>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="page = 1" />
</section>
</template>
<template #state><ContractPendingNotice domain="尾程订单" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-last-mile-orders" row-key="orderNo" empty-text="海外区域尾程订单查询契约待接入">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 0 条尾程订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.last-mile-toolbar { padding: 14px 16px 0; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
</style>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute()
const router = useRouter()
const palletId = computed(() => String(route.params.palletId || '—'))
const groups = [
{ title: '基础信息', fields: ['托盘编码', '托盘类型', '仓库', '容积(m³)', '采购价格', '所属FBA仓', '状态'] },
{ title: '使用信息', fields: ['关联入库单', '关联箱数', '创建时间', '更新时间', '备注'] },
]
function back() { void router.push({ name: 'overseas-pallets' }) }
</script>
<template><OverseasReadOnlyDetail title="托盘详情" reference-label="托盘编码" :reference-value="palletId" back-label="返回托盘管理" domain="托盘详情" :groups="groups" @back="back" /></template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import ContractPendingFormDialog from '../components/ContractPendingFormDialog.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { PalletListItem } from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const rows = ref<PalletListItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ code: '', type: '', warehouse: '', status: '' })
const fields: QueryField[] = [
{ key: 'code', type: 'input', label: '托盘编码', placeholder: '请输入托盘编码', width: 180 },
{ key: 'type', type: 'select', label: '托盘类型', placeholder: '全部类型', width: 160, options: ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸'].map((value) => ({ label: value, value })) },
{ key: 'warehouse', type: 'select', label: '仓库', placeholder: '全部仓库', width: 190, options: [] },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: ['闲置', '占用'].map((value) => ({ label: value, value })) },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'code', label: '托盘编码', minWidth: 160 }, { key: 'type', label: '托盘类型', minWidth: 130 },
{ key: 'warehouse', label: '仓库', minWidth: 180 }, { key: 'volume', label: '容积(m³)', width: 110, align: 'right' },
{ key: 'purchasePrice', label: '采购价格', width: 120, align: 'right' }, { key: 'fbaWarehouse', label: '所属FBA仓', minWidth: 140 },
{ key: 'status', label: '状态', width: 100 }, { key: 'remark', label: '备注', minWidth: 180 },
]
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = reactive({ code: '', type: '木托盘', volume: undefined as number | undefined, purchasePrice: undefined as number | undefined, warehouse: '', fbaWarehouse: '', status: '闲置', remark: '' })
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
function openForm(mode: 'create' | 'edit', source?: object) { const row = source as PalletListItem | undefined; dialogMode.value = mode; Object.assign(form, { code: '', type: '木托盘', volume: undefined, purchasePrice: undefined, warehouse: '', fbaWarehouse: '', status: '闲置', remark: '' }); if (mode === 'edit' && row) Object.assign(form, { code: String(row.code || ''), type: String(row.type || '木托盘'), volume: optionalNumber(row.volume), purchasePrice: optionalNumber(row.purchasePrice), warehouse: String(row.warehouse || ''), fbaWarehouse: String(row.fbaWarehouse || ''), status: String(row.status || '闲置'), remark: String(row.remark || '') }); dialogOpen.value = true }
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: String(row.code) } }) }
</script>
<template>
<AdminPage><ListPageLayout aria-label="托盘管理列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="托盘管理" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-pallets" row-key="code" empty-text="海外区域托盘查询契约待接入">
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />编辑</el-button></template>
<template #pagination-left>共 0 条托盘</template>
</DataTable></section>
</ListPageLayout>
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" domain="托盘">
<el-form label-position="top"><el-row :gutter="16">
<el-col :span="12"><el-form-item label="托盘编码" required><el-input v-model="form.code" :readonly="dialogMode === 'edit'" placeholder="请输入托盘编码" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="托盘类型" required><el-select v-model="form.type"><el-option v-for="value in ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.01" :precision="2" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="仓库" required><el-select v-model="form.warehouse" placeholder="海外仓候选接口待接入" disabled /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouse" placeholder="FBA仓候选接口待接入" disabled /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="状态" required><el-select v-model="form.status"><el-option label="闲置" value="闲置" /><el-option label="占用" value="占用" /></el-select></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item></el-col>
</el-row></el-form>
</ContractPendingFormDialog>
</AdminPage>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute()
const router = useRouter()
const productId = computed(() => String(route.params.productId || '—'))
const groups = [
{ title: '基础信息', fields: ['商品SKU', '中文品名', '英文品名', '产品分类'] },
{ title: '包装信息', fields: ['长(cm)', '宽(cm)', '高(cm)', '重量(kg)', '包装体积(m³)'] },
{ title: '其他信息', fields: ['创建时间', '更新时间', '备注'] },
]
function back() { void router.push({ name: 'overseas-products' }) }
</script>
<template><OverseasReadOnlyDetail title="产品详情" reference-label="商品SKU" :reference-value="productId" back-label="返回产品库" domain="产品详情" :groups="groups" @back="back" /></template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import ContractPendingFormDialog from '../components/ContractPendingFormDialog.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { ProductListItem } from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const rows = ref<ProductListItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ sku: '', productName: '', category: '' })
const fields: QueryField[] = [
{ key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
{ key: 'productName', type: 'input', label: '中文品名', placeholder: '请输入中文品名', width: 190 },
{ key: 'category', type: 'select', label: '品类', placeholder: '全部品类', width: 150, options: ['电子', '家居', '服饰', '户外'].map((value) => ({ label: value, value })) },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'sku', label: '商品SKU', minWidth: 150 }, { key: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'englishName', label: '英文品名', minWidth: 180 }, { key: 'category', label: '品类', width: 120 },
{ key: 'length', label: '长(cm)', width: 100, align: 'right' }, { key: 'width', label: '宽(cm)', width: 100, align: 'right' },
{ key: 'height', label: '高(cm)', width: 100, align: 'right' }, { key: 'weight', label: '重量(kg)', width: 110, align: 'right' },
]
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = reactive({ sku: '', productName: '', englishName: '', category: '', length: undefined as number | undefined, width: undefined as number | undefined, height: undefined as number | undefined, weight: undefined as number | undefined })
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
function openForm(mode: 'create' | 'edit', source?: object) { const row = source as ProductListItem | undefined; dialogMode.value = mode; Object.assign(form, { sku: '', productName: '', englishName: '', category: '', length: undefined, width: undefined, height: undefined, weight: undefined }); if (mode === 'edit' && row) Object.assign(form, { sku: String(row.sku || ''), productName: String(row.productName || ''), englishName: String(row.englishName || ''), category: String(row.category || ''), length: optionalNumber(row.length), width: optionalNumber(row.width), height: optionalNumber(row.height), weight: optionalNumber(row.weight) }); dialogOpen.value = true }
function openDetail(source: object) { const row = source as ProductListItem; void router.push({ name: 'overseas-product-detail', params: { productId: String(row.sku) } }) }
</script>
<template>
<AdminPage><ListPageLayout aria-label="产品库列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="产品筛选" preference-key="overseas-products-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="产品库" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-products" row-key="sku" empty-text="海外区域产品查询契约待接入">
<template #toolbar-actions><el-button v-if="access.can(permission.productCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增产品</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.productEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />编辑</el-button></template>
<template #pagination-left>共 0 条产品</template>
</DataTable></section>
</ListPageLayout>
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增产品' : '编辑产品'" width="min(560px, calc(100vw - 28px))" domain="产品" confirm-text="确定">
<el-form label-width="110px" label-position="right">
<el-form-item label="商品SKU" required><el-input v-model="form.sku" placeholder="如 SKU-A1001" /></el-form-item>
<el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item>
<el-form-item label="英文品名"><el-input v-model="form.englishName" placeholder="请输入英文品名" /></el-form-item>
<el-form-item label="品类" required><el-select v-model="form.category" placeholder="请选择品类"><el-option v-for="value in ['电子', '家居', '服饰', '户外']" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="长(cm)"><el-input-number v-model="form.length" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="宽(cm)"><el-input-number v-model="form.width" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="高(cm)"><el-input-number v-model="form.height" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="重量(kg)"><el-input-number v-model="form.weight" :min="0" :precision="3" controls-position="right" /></el-form-item>
</el-form>
</ContractPendingFormDialog>
</AdminPage>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute()
const router = useRouter()
const orderId = computed(() => String(route.params.orderId || '—'))
const groups = [
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '创建时间', '更新时间'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
{ title: '费用明细', fields: ['开箱费', '挑货费', '换标费', '换箱唛标费', '换标费总计'] },
]
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
</script>
<template><OverseasReadOnlyDetail title="换标单详情" reference-label="换标单号" :reference-value="orderId" back-label="返回换标单" domain="换标单详情" :groups="groups" @back="back" /></template>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Eye, Pencil, Plus, Upload } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import ContractPendingFormDialog from '../components/ContractPendingFormDialog.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { RelabelOrderListItem } from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const rows = ref<RelabelOrderListItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ sku: '', status: '' })
const fields: QueryField[] = [
{ key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: ['待处理', '处理中', '已完成'].map((value) => ({ label: value, value })) },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', label: '换标单号', minWidth: 170 },
{ key: 'sku', label: '商品SKU', minWidth: 150 },
{ key: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
{ key: 'budgetTotal', label: '预算总价', width: 120, align: 'right' },
{ key: 'labelRequirement', label: '贴标要求', minWidth: 150 },
{ key: 'fileName', label: '上传文件', minWidth: 160 },
{ key: 'status', label: '状态', width: 110 },
]
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = reactive({ orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: '待处理', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '' })
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
function openForm(mode: 'create' | 'edit', source?: object) {
const row = source as RelabelOrderListItem | undefined
dialogMode.value = mode
Object.assign(form, { orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: '待处理', labelRequirement: '', relabelCost: undefined, remark: '' })
if (mode === 'edit' && row) Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: String(row.status || '待处理'), labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') })
dialogOpen.value = true
}
function openDetail(source: object) {
const row = source as RelabelOrderListItem
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) } })
}
</script>
<template>
<AdminPage><ListPageLayout aria-label="换标单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="换标单" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="海外区域换标单查询契约待接入">
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
<template #pagination-left>共 0 条换标单</template>
</DataTable></section>
</ListPageLayout>
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" domain="换标单" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'">
<el-form label-position="top"><h3>订单信息</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" placeholder="请输入商品SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="预算总价(USD)" required><el-input-number v-model="form.budgetTotal" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="状态"><el-select v-model="form.status" :disabled="dialogMode === 'create'"><el-option v-for="value in ['待处理', '处理中', '已完成']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
</el-row><h3>作业要求</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" placeholder="请选择贴标要求"><el-option v-for="value in ['全覆盖换标', '加贴新标', '移除原标+换标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="上传文件"><el-button disabled><Upload :size="16" />选择文件</el-button></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="预算报价(USD)"><el-input-number v-model="form.relabelCost" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-col>
</el-row></el-form>
</ContractPendingFormDialog>
</AdminPage>
</template>
<style scoped>
h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 14px; }
h3:not(:first-child) { margin-top: 4px; padding-top: 14px; border-top: 1px solid var(--cc-border); }
</style>
@@ -0,0 +1,209 @@
import { readFileSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
function source(fileName: string) {
return readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages', fileName), 'utf8')
}
function columnKeys(fileName: string) {
const value = source(fileName)
const start = value.indexOf('const columns: DataTableColumn[]')
const end = value.indexOf('\n]', start)
return [...value.slice(start, end).matchAll(/\{ key: '([^']+)'/g)].map((match) => match[1])
}
describe('overseas warehouse first-batch prototype contract', () => {
it('gives every formal list query toolbar a unique stable preference key', () => {
const expected = new Map([
['LastMileOrdersView.vue', 'overseas-last-mile-orders-query'],
['InboundOrdersView.vue', 'overseas-inbound-orders-query'],
['InventoryView.vue', 'overseas-inventory-query'],
['InventoryTransactionsView.vue', 'overseas-inventory-transactions-query'],
['RelabelOrdersView.vue', 'overseas-relabel-orders-query'],
['PalletsView.vue', 'overseas-pallets-query'],
['CartonsView.vue', 'overseas-cartons-query'],
['ProductsView.vue', 'overseas-products-query'],
])
const keys = [...expected.entries()].map(([fileName, preferenceKey]) => {
const value = source(fileName)
const toolbar = value.match(/<QueryToolbar\b[^>]*\/>/)?.[0]
expect(toolbar, `${fileName} should render a QueryToolbar`).toBeDefined()
expect(toolbar).toContain(`preference-key="${preferenceKey}"`)
return preferenceKey
})
const queryToolbarPages = readdirSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages'))
.filter((fileName) => fileName.endsWith('.vue') && source(fileName).includes('<QueryToolbar'))
expect(queryToolbarPages.sort()).toEqual([...expected.keys()].sort())
expect(new Set(keys).size).toBe(expected.size)
})
it('keeps the operation column first on every business list', () => {
for (const fileName of ['LastMileOrdersView.vue', 'InboundOrdersView.vue', 'InventoryView.vue', 'RelabelOrdersView.vue', 'PalletsView.vue', 'CartonsView.vue', 'ProductsView.vue']) {
const value = source(fileName)
const columns = value.slice(value.indexOf('const columns: DataTableColumn[]'))
expect(columns.match(/\{\s*key: '([^']+)'/)?.[1]).toBe('actions')
}
})
it('preserves the prototype dialog carriers and desktop widths', () => {
const expected = [
['RelabelOrdersView.vue', '760px', '新增换标单', '审核换标单'],
['PalletsView.vue', '760px', '新增托盘', '编辑托盘'],
['CartonsView.vue', '860px', '新增箱号', '编辑箱号'],
['ProductsView.vue', '560px', '新增产品', '编辑产品'],
]
for (const [fileName, width, createTitle, editTitle] of expected) {
const value = source(fileName)
expect(value).toContain('<ContractPendingFormDialog')
expect(value).toContain(width)
expect(value).toContain(createTitle)
expect(value).toContain(editTitle)
expect(value).not.toContain('AppDrawer')
}
})
it('implements all second-batch details as formal parameterized pages', () => {
for (const fileName of ['RelabelOrderDetailView.vue', 'PalletDetailView.vue', 'CartonDetailView.vue', 'ProductDetailView.vue']) {
const value = source(fileName)
expect(value).toContain('<OverseasReadOnlyDetail')
expect(value).toContain('@back="back"')
expect(value).not.toContain('AppDialog')
expect(value).not.toContain('AppDrawer')
}
})
it('keeps second-batch forms non-submitting until overseas write contracts exist', () => {
const dialog = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/components/ContractPendingFormDialog.vue'), 'utf8')
expect(dialog).toContain('confirm-disabled')
expect(dialog).toContain('不可提交')
expect(dialog).not.toContain('@confirm=')
for (const fileName of ['RelabelOrdersView.vue', 'PalletsView.vue', 'CartonsView.vue', 'ProductsView.vue']) {
const value = source(fileName)
expect(value).not.toContain('ElMessage.success')
expect(value).not.toMatch(/https?:\/\//)
}
})
it('implements three prototype-defined details as formal pages', () => {
for (const fileName of ['LastMileOrderDetailView.vue', 'InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
const value = source(fileName)
expect(value).toContain('<OverseasDetailShell')
expect(value).toContain('@back="back"')
expect(value).not.toContain('AppDrawer')
expect(value).not.toContain('AppDialog')
}
})
it('does not expose outbound planning or delivery actions from the last-mile detail', () => {
const value = source('LastMileOrderDetailView.vue')
expect(value).not.toContain('计划出库日期')
expect(value).not.toContain('派送方式')
expect(value).not.toContain('保存并生成出库单')
expect(value).not.toContain('海外派送')
})
it('shows contract-pending empty states instead of demo data or fake success actions', () => {
for (const fileName of ['LastMileOrderDetailView.vue', 'InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
const value = source(fileName)
expect(value).toContain(':data="[]"')
expect(value).toContain('契约待接入')
expect(value).not.toContain('ElMessage.success')
}
})
it('adds the prototype inventory transaction page as a read-only inventory route', () => {
const inventory = source('InventoryView.vue')
const transactions = source('InventoryTransactionsView.vue')
expect(inventory).toContain("name: 'overseas-inventory-transactions'")
expect(transactions).toContain('进销存记录筛选')
expect(transactions).toContain('业务订单号')
expect(transactions).toContain('入库单号')
expect(transactions).toContain('出库单号')
expect(transactions).toContain('出库相关字段仅展示既有库存流水事实')
expect(transactions).toContain("router.push({ name: 'overseas-inventory' })")
expect(transactions).not.toContain('保存')
expect(transactions).not.toContain('ElMessage.success')
expect(transactions).not.toMatch(/router\.push\([^)]*outbound/)
})
it('uses module presentation models instead of untyped records', () => {
for (const fileName of ['LastMileOrdersView.vue', 'InboundOrdersView.vue', 'InventoryView.vue', 'RelabelOrdersView.vue', 'PalletsView.vue', 'CartonsView.vue', 'ProductsView.vue', 'InventoryTransactionsView.vue']) {
const value = source(fileName)
expect(value).toContain("from '../types'")
expect(value).not.toContain('ref<Record<string, unknown>[]>')
}
})
it('keeps every formal list connected to its read-only detail route', () => {
const expected = [
['LastMileOrdersView.vue', 'overseas-last-mile-order-detail'],
['InboundOrdersView.vue', 'overseas-inbound-order-detail'],
['InventoryView.vue', 'overseas-inventory-detail'],
['RelabelOrdersView.vue', 'overseas-relabel-order-detail'],
['PalletsView.vue', 'overseas-pallet-detail'],
['CartonsView.vue', 'overseas-carton-detail'],
['ProductsView.vue', 'overseas-product-detail'],
]
for (const [fileName, routeName] of expected) {
const value = source(fileName)
expect(value).toContain(routeName)
expect(value).toContain('<Eye :size="15" />查看')
}
})
it('preserves last-mile status tabs and read-only outbound association columns without links', () => {
const value = source('LastMileOrdersView.vue')
expect(value).toContain('aria-label="尾程订单状态"')
expect(value).toContain('<el-radio-button value="全部">全部</el-radio-button>')
expect(value).toContain('<el-radio-button value="待分配">待分配</el-radio-button>')
expect(value.indexOf("label: '入库单号'")).toBeLessThan(value.indexOf("label: '出库单号'"))
expect(value.indexOf("label: '出库单号'")).toBeLessThan(value.indexOf("label: '派送单号'"))
expect(value.indexOf("label: '派送单号'")).toBeLessThan(value.indexOf("label: '换标单号'"))
expect(value).not.toMatch(/router\.push\([^)]*(outbound|dispatch)/)
})
it('keeps outbound cartons read-only and uses the prototype product confirmation copy', () => {
const cartons = source('CartonsView.vue')
const products = source('ProductsView.vue')
expect(cartons).toContain("function canEditCarton(source: object)")
expect(cartons).toContain("=== '在库'")
expect(cartons).toContain('access.can(permission.cartonEdit) && canEditCarton(row)')
expect(products).toContain('confirm-text="确定"')
})
it('keeps every prototype list column in its frozen order', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
'actions', 'orderNo', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'containerStatus', 'abnormal', 'held', 'pallets',
'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'consolidated', 'status',
'internalRemark', 'customerRemark', 'warehouseRemark',
])
expect(columnKeys('InboundOrdersView.vue')).toEqual([
'actions', 'orderNo', 'inboundCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'volume',
'pallets', 'status', 'appointmentStatus', 'dispatchStatus',
])
expect(columnKeys('InventoryView.vue')).toEqual([
'actions', 'owner', 'warehouse', 'sku', 'productName', 'onHand', 'available', 'held', 'looseboard',
'inTransit', 'purchase', 'status', 'updatedAt',
])
expect(columnKeys('RelabelOrdersView.vue')).toEqual([
'actions', 'orderNo', 'sku', 'productName', 'cartons', 'budgetTotal', 'labelRequirement', 'fileName', 'status',
])
expect(columnKeys('PalletsView.vue')).toEqual([
'actions', 'code', 'type', 'warehouse', 'volume', 'purchasePrice', 'fbaWarehouse', 'status', 'remark',
])
expect(columnKeys('CartonsView.vue')).toEqual([
'actions', 'cartonNo', 'fbaWarehouse', 'sku', 'productName', 'quantity', 'weight', 'status',
])
expect(columnKeys('ProductsView.vue')).toEqual([
'actions', 'sku', 'productName', 'englishName', 'category', 'length', 'width', 'height', 'weight',
])
expect(columnKeys('InventoryTransactionsView.vue')).toEqual([
'occurredAt', 'owner', 'warehouse', 'sku', 'productName', 'businessType', 'businessOrderNo',
'inboundOrderNo', 'outboundOrderNo', 'inboundQuantity', 'outboundQuantity', 'balance', 'operator', 'remark',
])
})
})
@@ -0,0 +1,17 @@
export const overseasWarehousePermissions = {
lastMileOrderList: 'platform:overseas-warehouse:last-mile-order:list',
inboundOrderList: 'platform:overseas-warehouse:inbound-order:list',
inventoryList: 'platform:overseas-warehouse:inventory:list',
relabelOrderList: 'platform:overseas-warehouse:relabel-order:list',
relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create',
relabelOrderEdit: 'platform:overseas-warehouse:relabel-order:edit',
palletList: 'platform:overseas-warehouse:pallet:list',
palletCreate: 'platform:overseas-warehouse:pallet:create',
palletEdit: 'platform:overseas-warehouse:pallet:edit',
cartonList: 'platform:overseas-warehouse:carton:list',
cartonCreate: 'platform:overseas-warehouse:carton:create',
cartonEdit: 'platform:overseas-warehouse:carton:edit',
productList: 'platform:overseas-warehouse:product:list',
productCreate: 'platform:overseas-warehouse:product:create',
productEdit: 'platform:overseas-warehouse:product:edit',
} as const
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { overseasWarehouseRoutes } from './routes'
import { overseasWarehousePermissions as permission } from './permissions'
describe('overseas warehouse routes', () => {
it('uses list permission for each list and read-only detail route', () => {
expect(overseasWarehouseRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
['overseas-warehouse/last-mile-orders', permission.lastMileOrderList],
['overseas-warehouse/last-mile-orders/:orderId', permission.lastMileOrderList],
['overseas-warehouse/inbound-orders', permission.inboundOrderList],
['overseas-warehouse/inbound-orders/:orderId', permission.inboundOrderList],
['overseas-warehouse/inventory', permission.inventoryList],
['overseas-warehouse/inventory-transactions', permission.inventoryList],
['overseas-warehouse/inventory/:inventoryId', permission.inventoryList],
['overseas-warehouse/relabel-orders', permission.relabelOrderList],
['overseas-warehouse/relabel-orders/:orderId', permission.relabelOrderList],
['overseas-warehouse/pallets', permission.palletList],
['overseas-warehouse/pallets/:palletId', permission.palletList],
['overseas-warehouse/cartons', permission.cartonList],
['overseas-warehouse/cartons/:cartonId', permission.cartonList],
['overseas-warehouse/products', permission.productList],
['overseas-warehouse/products/:productId', permission.productList],
])
})
it('keeps all details as formal parameterized pages', () => {
const details = overseasWarehouseRoutes.filter((route) => route.path.includes('/:'))
expect(details).toHaveLength(7)
expect(details.every((route) => route.meta.showPageHeading === false)).toBe(true)
})
it('keeps create and edit permissions independent for second-batch resources', () => {
expect(new Set([
permission.relabelOrderCreate, permission.relabelOrderEdit,
permission.palletCreate, permission.palletEdit,
permission.cartonCreate, permission.cartonEdit,
permission.productCreate, permission.productEdit,
]).size).toBe(8)
})
})
+95
View File
@@ -0,0 +1,95 @@
import type { RouteRecordRaw } from 'vue-router'
import { overseasWarehousePermissions as permission } from './permissions'
export const overseasWarehouseRoutes = [
{
path: 'overseas-warehouse/last-mile-orders',
name: 'overseas-last-mile-orders',
component: () => import('./pages/LastMileOrdersView.vue'),
meta: { title: '尾程订单', description: '管理海外接柜后的尾程订单与入仓衔接', permission: permission.lastMileOrderList },
},
{
path: 'overseas-warehouse/last-mile-orders/:orderId',
name: 'overseas-last-mile-order-detail',
component: () => import('./pages/LastMileOrderDetailView.vue'),
meta: { title: '尾程订单详情', description: '查看尾程订单、计划、货物和操作记录', permission: permission.lastMileOrderList, showPageHeading: false },
},
{
path: 'overseas-warehouse/inbound-orders',
name: 'overseas-inbound-orders',
component: () => import('./pages/InboundOrdersView.vue'),
meta: { title: '入库单', description: '管理海外合作仓入库、预约与排车衔接', permission: permission.inboundOrderList },
},
{
path: 'overseas-warehouse/inbound-orders/:orderId',
name: 'overseas-inbound-order-detail',
component: () => import('./pages/InboundOrderDetailView.vue'),
meta: { title: '入库单详情', description: '查看海外仓入库单与应收费用', permission: permission.inboundOrderList, showPageHeading: false },
},
{
path: 'overseas-warehouse/inventory',
name: 'overseas-inventory',
component: () => import('./pages/InventoryView.vue'),
meta: { title: '库存管理', description: '查看海外仓库存汇总、可用量和库内分布', permission: permission.inventoryList },
},
{
path: 'overseas-warehouse/inventory-transactions',
name: 'overseas-inventory-transactions',
component: () => import('./pages/InventoryTransactionsView.vue'),
meta: { title: '进销存记录', description: '查看海外仓库存的只读变动流水', permission: permission.inventoryList },
},
{
path: 'overseas-warehouse/inventory/:inventoryId',
name: 'overseas-inventory-detail',
component: () => import('./pages/InventoryDetailView.vue'),
meta: { title: '库存详情', description: '查看商品、库位、托盘箱号和进销存明细', permission: permission.inventoryList, showPageHeading: false },
},
{
path: 'overseas-warehouse/relabel-orders',
name: 'overseas-relabel-orders',
component: () => import('./pages/RelabelOrdersView.vue'),
meta: { title: '换标单', description: '管理海外仓换标作业要求与费用信息', permission: permission.relabelOrderList },
},
{
path: 'overseas-warehouse/relabel-orders/:orderId',
name: 'overseas-relabel-order-detail',
component: () => import('./pages/RelabelOrderDetailView.vue'),
meta: { title: '换标单详情', description: '查看换标单、作业要求与费用明细', permission: permission.relabelOrderList, showPageHeading: false },
},
{
path: 'overseas-warehouse/pallets',
name: 'overseas-pallets',
component: () => import('./pages/PalletsView.vue'),
meta: { title: '托盘管理', description: '管理海外仓托盘主数据与使用状态', permission: permission.palletList },
},
{
path: 'overseas-warehouse/pallets/:palletId',
name: 'overseas-pallet-detail',
component: () => import('./pages/PalletDetailView.vue'),
meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
},
{
path: 'overseas-warehouse/cartons',
name: 'overseas-cartons',
component: () => import('./pages/CartonsView.vue'),
meta: { title: '箱号管理', description: '管理海外仓箱号、商品与托盘关联', permission: permission.cartonList },
},
{
path: 'overseas-warehouse/cartons/:cartonId',
name: 'overseas-carton-detail',
component: () => import('./pages/CartonDetailView.vue'),
meta: { title: '箱号详情', description: '查看箱号基础信息与商品信息', permission: permission.cartonList, showPageHeading: false },
},
{
path: 'overseas-warehouse/products',
name: 'overseas-products',
component: () => import('./pages/ProductsView.vue'),
meta: { title: '产品库', description: '管理海外仓商品与包装信息', permission: permission.productList },
},
{
path: 'overseas-warehouse/products/:productId',
name: 'overseas-product-detail',
component: () => import('./pages/ProductDetailView.vue'),
meta: { title: '产品详情', description: '查看商品基础信息与包装信息', permission: permission.productList, showPageHeading: false },
},
] satisfies RouteRecordRaw[]
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/types.ts'), 'utf8')
describe('overseas warehouse presentation contracts', () => {
it('covers every formal list without freezing an API transport', () => {
for (const name of [
'LastMileOrderListItem', 'InboundOrderListItem', 'InventoryListItem',
'RelabelOrderListItem', 'PalletListItem', 'CartonListItem',
'ProductListItem', 'InventoryTransactionListItem',
]) expect(source).toContain(`interface ${name}`)
expect(source).not.toContain('axios')
expect(source).not.toContain("from '@/shared/api/http'")
expect(source).not.toMatch(/https?:\/\//)
expect(source).not.toContain('tenantId')
})
it('marks outbound references as read-only inventory facts', () => {
expect(source.match(/Read-only inventory fact/g)).toHaveLength(2)
expect(source).not.toContain('createOutbound')
expect(source).not.toContain('updateOutbound')
})
})
+141
View File
@@ -0,0 +1,141 @@
/**
* Prototype-derived presentation models for the Admin pages in this module.
* These types intentionally do not freeze overseas API URLs, pagination envelopes,
* identifiers, timestamps, money precision, or mutation request contracts.
*/
export interface LastMileOrderListItem {
orderNo: string
warning: string
orderType: string
rejectedAt: string
inboundCode: string
owner: string
warehouse: string
transferWarehouse: string
fbaWarehouse: string
containerNo: string
containerStatus: string
abnormal: string
held: string
pallets: number
cartons: number
volume: number
quantity: number
volumeWeight: number
portEta: string
portAta: string
warehouseEta: string
inboundOrderNo: string
/** Read-only association fact; this module does not implement the outbound workflow. */
outboundOrderNo: string
/** Read-only association fact; this module does not implement overseas delivery. */
dispatchOrderNo: string
relabelOrderNo: string
relabelStatus: string
consolidated: string
status: string
internalRemark: string
customerRemark: string
warehouseRemark: string
}
export interface InboundOrderListItem {
orderNo: string
inboundCode: string
type: string
warehouse: string
fbaWarehouse: string
owner: string
cartons: number
volume: number
pallets: number
status: string
appointmentStatus: string
dispatchStatus: string
}
export interface InventoryListItem {
id: string
owner: string
warehouse: string
sku: string
productName: string
onHand: number
available: number
held: number
looseboard: number
inTransit: number
purchase: number
status: string
updatedAt: string
}
export interface RelabelOrderListItem {
orderNo: string
relatedOrderNo: string
sku: string
productName: string
cartons: number
budgetTotal: number
labelRequirement: string
relabelCost?: number
fileName: string
status: string
remark: string
}
export interface PalletListItem {
code: string
type: string
warehouse: string
volume: number
purchasePrice: number
fbaWarehouse: string
status: string
remark: string
}
export interface CartonListItem {
cartonNo: string
fbaWarehouse: string
sku: string
productName: string
quantity: number
weight: number
status: string
palletNo: string
inboundOrderNo: string
/** Read-only inventory fact; this module does not implement the outbound workflow. */
outboundOrderNo: string
remark: string
}
export interface ProductListItem {
sku: string
productName: string
englishName: string
category: string
length: number
width: number
height: number
weight: number
}
export interface InventoryTransactionListItem {
id: string
occurredAt: string
owner: string
warehouse: string
sku: string
productName: string
businessType: string
businessOrderNo: string
inboundOrderNo: string
/** Read-only inventory fact; it is not an outbound-workflow link. */
outboundOrderNo: string
inboundQuantity: number
outboundQuantity: number
balance: number
operator: string
remark: string
}
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries'
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() } }
})
const query = {
id: 9007199254740993n,
queryNo: 'CSQ-1',
customerId: 101,
oceanBookingId: 202,
containerBookingId: 303,
carrierCode: 'COSCO',
trackingType: 'CONTAINER_NO',
trackingNo: 'CSNU1234567',
status: 'MANUAL_REQUIRED',
attemptCount: '2',
resultPortScheduleId: 404,
version: '3',
}
describe('platformCarrierScheduleQueries', () => {
beforeEach(() => vi.clearAllMocks())
it('filters by real booking/container relations and preserves long ids as strings', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ query, expired: true, maxAttempts: '3', nextAllowedAt: null }], page: '1', size: '200', total: '1', pages: '1' } })
const result = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' })
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries', { params: { page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' } })
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', attemptCount: 2, version: 3 }, expired: true, maxAttempts: 3 })
})
it('normalizes attempts and immutable history without creating query operations', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query, expired: false, maxAttempts: 3 }, attempts: [{ id: 1, queryId: query.id, windowAttemptNo: '2', carrierCode: 'COSCO', attemptStatus: 'FAILED', coverageGap: true }], histories: [{ id: 2, queryId: query.id, action: 'ATTEMPT', fromVersion: '1', toVersion: '2' }] } })
const result = await getCarrierScheduleQuery('9007199254740993')
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/9007199254740993')
expect(result.attempts[0]).toMatchObject({ id: '1', queryId: '9007199254740993', windowAttemptNo: 2, attemptStatus: 'FAILED' })
expect(result.histories[0]).toMatchObject({ id: '2', fromVersion: 1, toVersion: 2 })
})
})
@@ -0,0 +1,52 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
CarrierScheduleQueryAttempt,
CarrierScheduleQueryDetail,
CarrierScheduleQueryFilter,
CarrierScheduleQueryHistory,
CarrierScheduleQueryPage,
CarrierScheduleQueryResource,
CarrierScheduleQueryView,
} from '../types'
const basePath = '/platform/v1/carrier-schedule-queries'
function optionalId(value: unknown): string | null { return value == null ? null : String(value) }
function numberValue(value: unknown, fallback = 0): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback }
function resource(raw: CarrierScheduleQueryResource): CarrierScheduleQueryResource {
return {
...raw,
id: String(raw.id),
customerId: String(raw.customerId),
oceanBookingId: String(raw.oceanBookingId),
containerBookingId: optionalId(raw.containerBookingId),
resultPortScheduleId: optionalId(raw.resultPortScheduleId),
attemptCount: numberValue(raw.attemptCount),
version: numberValue(raw.version),
}
}
function view(raw: CarrierScheduleQueryView): CarrierScheduleQueryView {
return { ...raw, query: resource(raw.query), maxAttempts: numberValue(raw.maxAttempts), nextAllowedAt: raw.nextAllowedAt ?? raw.query.nextAllowedAt ?? null }
}
function attempt(raw: CarrierScheduleQueryAttempt): CarrierScheduleQueryAttempt {
return { ...raw, id: String(raw.id), queryId: String(raw.queryId), windowAttemptNo: numberValue(raw.windowAttemptNo), coverageGap: Boolean(raw.coverageGap) }
}
function history(raw: CarrierScheduleQueryHistory): CarrierScheduleQueryHistory {
return { ...raw, id: String(raw.id), queryId: String(raw.queryId), fromVersion: raw.fromVersion == null ? null : numberValue(raw.fromVersion), toVersion: raw.toVersion == null ? null : numberValue(raw.toVersion) }
}
export async function listCarrierScheduleQueries(filter: CarrierScheduleQueryFilter): Promise<CarrierScheduleQueryPage> {
const response = await http.get<ApiEnvelope<CarrierScheduleQueryPage>, ApiEnvelope<CarrierScheduleQueryPage>>(basePath, { params: filter })
const result = unwrapApiResponse(response)
return { ...result, records: (result.records || []).map(view), page: numberValue(result.page, filter.page), size: numberValue(result.size, filter.size), total: numberValue(result.total), pages: numberValue(result.pages) }
}
export async function getCarrierScheduleQuery(queryId: string): Promise<CarrierScheduleQueryDetail> {
const response = await http.get<ApiEnvelope<CarrierScheduleQueryDetail>, ApiEnvelope<CarrierScheduleQueryDetail>>(`${basePath}/${encodeURIComponent(queryId)}`)
const result = unwrapApiResponse(response)
return { ...result, query: view(result.query), attempts: (result.attempts || []).map(attempt), histories: (result.histories || []).map(history) }
}
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue'), 'utf8')
describe('CarrierScheduleQueryPanel', () => {
it('uses the existing relation filters and never creates or executes polling tasks', () => {
expect(source).toContain('listCarrierScheduleQueries({ page: 1, size: 200')
expect(source).toContain('oceanBookingId: props.oceanBookingId')
expect(source).toContain('containerBookingId: props.containerBookingId')
expect(source).not.toContain('createCarrierSchedule')
expect(source).not.toContain('attemptCarrierSchedule')
})
it('shows source, observation/update times, history, failures and expiry truthfully', () => {
expect(source).toContain('最新来源')
expect(source).toContain('观测时间')
expect(source).toContain('来源更新时间')
expect(source).toContain('任务更新时间')
expect(source).toContain('查询尝试')
expect(source).toContain('变更历史')
expect(source).toContain('query.lastErrorMessage')
expect(source).toContain("return '结果已过期'")
})
it('keeps the missing node-event contract explicit instead of fabricating data', () => {
expect(source).toContain('待接口提供独立节点事实')
expect(source).toContain('不会模拟节点或自动创建任务')
})
})
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { getPortSchedule } from '../api/platformPortSchedules'
import type { CarrierScheduleQueryDetail, CarrierScheduleQueryView, PortScheduleView } from '../types'
const props = defineProps<{ oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null }>()
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const detailError = ref<ApiErrorDetails | null>(null)
const selected = ref<CarrierScheduleQueryView | null>(null)
const detail = ref<CarrierScheduleQueryDetail | null>(null)
const latestSchedule = ref<PortScheduleView | null>(null)
const query = computed(() => detail.value?.query.query || selected.value?.query || null)
const queryView = computed(() => detail.value?.query || selected.value)
const statusText = computed(() => {
if (!query.value) return '未建立'
if (queryView.value?.expired) return '结果已过期'
return ({ READY: '待查询', MANUAL_REQUIRED: '需人工补充', SUCCESS: '最近查询成功' } as Record<string, string>)[query.value.status] || query.value.status
})
const statusType = computed<'success' | 'warning' | 'danger'>(() => queryView.value?.expired || query.value?.status === 'MANUAL_REQUIRED' ? 'danger' : query.value?.status === 'SUCCESS' ? 'success' : 'warning')
const latestSource = computed(() => latestSchedule.value?.schedule.sourceName || query.value?.carrierCode || '—')
const sourceReference = computed(() => latestSchedule.value?.schedule.sourceRef || query.value?.officialUrl || '—')
const latestObservation = computed(() => latestSchedule.value?.schedule.observedAt || query.value?.sourceUpdatedAt || query.value?.lastSuccessAt || null)
const histories = computed(() => [...(detail.value?.histories || [])].reverse())
const attempts = computed(() => [...(detail.value?.attempts || [])].reverse())
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function attemptText(status: string) { return ({ SUCCESS: '成功', FAILED: '失败', MANUAL_REQUIRED: '需人工补充' } as Record<string, string>)[status] || status }
function historyText(action: string) { return ({ CREATE: '建立查询任务', ATTEMPT: '执行查询', MANUAL_RESULT: '关联人工船期结果' } as Record<string, string>)[action] || action }
function latestTimestamp(item: CarrierScheduleQueryView) { return Date.parse(item.query.updatedAt || item.query.createdAt || '') || 0 }
async function load() {
selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; detailError.value = null
if (!props.oceanBookingId && !props.containerBookingId) return
loading.value = true
try {
const page = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: props.oceanBookingId || undefined, containerBookingId: props.containerBookingId || undefined })
const matching = props.portScheduleId ? page.records.filter((item) => item.query.resultPortScheduleId === String(props.portScheduleId)) : page.records
selected.value = [...matching].sort((left, right) => latestTimestamp(right) - latestTimestamp(left))[0] || null
if (!selected.value) return
const tasks: [Promise<CarrierScheduleQueryDetail>, Promise<{ portSchedule: PortScheduleView } | null>] = [
getCarrierScheduleQuery(selected.value.query.id),
selected.value.query.resultPortScheduleId ? getPortSchedule(selected.value.query.resultPortScheduleId) : Promise.resolve(null),
]
const [queryResult, scheduleResult] = await Promise.allSettled(tasks)
if (queryResult.status === 'fulfilled') detail.value = queryResult.value
else detailError.value = getApiErrorDetails(queryResult.reason, '查询历史加载失败')
if (scheduleResult.status === 'fulfilled') latestSchedule.value = scheduleResult.value?.portSchedule || null
else detailError.value = getApiErrorDetails(scheduleResult.reason, '最新船期结果加载失败')
} catch (cause) { error.value = getApiErrorDetails(cause, '船节点查询状态加载失败') }
finally { loading.value = false }
}
watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleId], () => void load(), { immediate: true })
</script>
<template>
<section class="carrier-query" aria-label="国内出仓后船节点">
<header><div><h2>国内出仓后船节点</h2><p>沿用 CarrierScheduleQuery,跟踪至目的港到达;本页面不会新建轮询任务。</p></div><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></header>
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="2" animated /></div>
<el-empty v-else-if="!query" :image-size="52" description="尚未建立关联的船节点查询任务;当前页面不会模拟节点或自动创建任务。" />
<template v-else>
<el-alert v-if="query.lastErrorMessage || query.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="query.lastErrorMessage || query.manualReason || '自动查询暂不可用,需要人工补充可靠结果。'" />
<el-alert v-if="detailError" type="warning" :closable="false" show-icon title="部分查询详情暂不可用"><template #default><ApiErrorMeta :details="detailError" /></template></el-alert>
<dl class="query-facts">
<div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ query.trackingNo || '—' }}</dd></div>
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div>
<div><dt>最新节点</dt><dd>{{ latestSchedule ? '船期快照已更新' : '待接口提供独立节点事实' }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div>
<div><dt>来源更新时间</dt><dd>{{ formatTime(query.sourceUpdatedAt) }}</dd></div><div><dt>任务更新时间</dt><dd>{{ formatTime(query.updatedAt) }}</dd></div>
<div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>最近尝试</dt><dd>{{ formatTime(query.lastAttemptAt) }}</dd></div>
<div><dt>结果过期时间</dt><dd>{{ formatTime(query.expiresAt) }}</dd></div><div><dt>下次允许查询</dt><dd>{{ formatTime(queryView?.nextAllowedAt) }}</dd></div>
</dl>
<div v-if="latestSchedule" class="latest-result"><strong>{{ latestSchedule.schedule.vesselName }} / {{ latestSchedule.schedule.voyageNo }}</strong><span>{{ latestSchedule.schedule.departurePortName || latestSchedule.schedule.departurePortCode }} → {{ latestSchedule.schedule.destinationPortName || latestSchedule.schedule.destinationPortCode }}</span><span>预计开船 {{ formatTime(latestSchedule.schedule.etdAt) }} · 预计到港 {{ formatTime(latestSchedule.schedule.etaAt) }}</span></div>
<div class="history-grid">
<section><h3>查询尝试</h3><p v-if="!attempts.length" class="empty-line">暂无查询尝试</p><ol v-else><li v-for="item in attempts" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ attemptText(item.attemptStatus) }}</strong><small>{{ item.errorMessage || item.manualReason || item.operatorName || item.carrierCode }}</small></li></ol></section>
<section><h3>变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.reason || item.operatorName || '系统记录' }}</small></li></ol></section>
</div>
</template>
</section>
</template>
<style scoped>
.carrier-query{display:grid;gap:12px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.carrier-query h2{margin:0;font-size:15px}.carrier-query header p{margin:4px 0 0;color:var(--cc-muted);font-size:12px}.loading-state{padding:8px 0}.query-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 18px;margin:0}.query-facts>div{min-width:0}.query-facts dt{color:var(--cc-muted);font-size:12px}.query-facts dd{margin:4px 0 0;font-size:13px}.break-value{overflow-wrap:anywhere}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:8px 18px;padding:10px 12px;border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:13px}.history-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.history-grid section{min-width:0}.history-grid h3{margin:0 0 8px;font-size:13px}.history-grid ol{display:grid;gap:0;margin:0;padding:0;list-style:none}.history-grid li{display:grid;grid-template-columns:128px 100px minmax(0,1fr);gap:8px;padding:7px 0;border-top:1px solid var(--cc-border);font-size:12px}.history-grid small,.empty-line{color:var(--cc-muted);font-size:12px}.empty-line{margin:0}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.query-facts,.history-grid{grid-template-columns:1fr}.history-grid li{grid-template-columns:1fr}}
</style>
+1
View File
@@ -1,2 +1,3 @@
export { portScheduleRoutes } from './routes'
export { default as CarrierScheduleQueryPanel } from './components/CarrierScheduleQueryPanel.vue'
export * from './types'
@@ -84,4 +84,9 @@ describe('PortSchedulesView', () => {
expect(source).toContain('<el-button link type="primary" @click="loadCustomers"')
expect(source).toContain(':total="total" stripe row-key="schedule.id"')
})
it('shows query facts only inside the existing read-only schedule dialog', () => {
expect(source).toContain('CarrierScheduleQueryPanel')
expect(source).toContain('v-if="dialogReadonly && form.oceanBookingId"')
expect(source).toContain(':port-schedule-id="editingId"')
})
})
@@ -13,6 +13,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
import CarrierScheduleQueryPanel from '../components/CarrierScheduleQueryPanel.vue'
import { createPortSchedule, getPortSchedule, listPortSchedules, updatePortSchedule } from '../api/platformPortSchedules'
import type { PortScheduleData, PortScheduleView } from '../types'
@@ -63,7 +64,7 @@ watch([page, pageSize], () => void load()); onMounted(() => { void load(); void
<template #vessel="{ row }">{{ row.schedule.vesselName }} / {{ row.schedule.voyageNo }}</template><template #observedAt="{ row }">{{ formatDateTime(row.schedule.observedAt) }}</template><template #updatedAt="{ row }">{{ formatDateTime(row.schedule.updatedAt) }}</template><template #expired="{ row }"><el-tag :type="row.expired ? 'danger' : 'success'">{{ row.expired ? '已过期' : '有效' }}</el-tag></template>
</DataTable></section>
</ListPageLayout>
<AppDialog v-model="dialogOpen" :title="dialogTitle" width="820px" :confirm-loading="saving" :show-confirm="!dialogReadonly" :cancel-text="dialogReadonly ? '关闭' : '取消'" @confirm="save"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><div v-if="customersError" class="selector-error"><span>{{ customersError.message }}</span><el-button link type="primary" @click="loadCustomers">重新加载客户</el-button></div><div v-if="bookingsError" class="selector-error"><span>{{ bookingsError.message }}</span><el-button link type="primary" @click="loadBookings()">重新加载订舱</el-button></div><el-form ref="formRef" :model="form" :rules="rules" :disabled="dialogReadonly" label-position="top" class="schedule-form"><el-row :gutter="16">
<AppDialog v-model="dialogOpen" :title="dialogTitle" width="820px" :confirm-loading="saving" :show-confirm="!dialogReadonly" :cancel-text="dialogReadonly ? '关闭' : '取消'" @confirm="save"><div class="schedule-dialog-body"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><div v-if="customersError" class="selector-error"><span>{{ customersError.message }}</span><el-button link type="primary" @click="loadCustomers">重新加载客户</el-button></div><div v-if="bookingsError" class="selector-error"><span>{{ bookingsError.message }}</span><el-button link type="primary" @click="loadBookings()">重新加载订舱</el-button></div><el-form ref="formRef" :model="form" :rules="rules" :disabled="dialogReadonly" label-position="top" class="schedule-form"><el-row :gutter="16">
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="form.customerId" filterable placeholder="请选择客户" :loading="customersLoading" no-data-text="暂无可用客户" style="width:100%" @update:model-value="handleCustomerChange(String($event || ''))"><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customerLabel(customer)" :value="String(customer.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="订舱记录" prop="oceanBookingId"><el-select v-model="form.oceanBookingId" filterable placeholder="请先选择客户,再选择订舱记录" :loading="bookingsLoading" :disabled="!form.customerId" no-data-text="该客户暂无订舱记录" style="width:100%"><el-option v-for="booking in bookings" :key="booking.id" :label="bookingLabel(booking)" :value="booking.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="信息来源" prop="sourceName"><el-input v-model="form.sourceName" placeholder="如美森官网" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="来源参考" prop="sourceRef"><el-input v-model="form.sourceRef" placeholder="查询链接或参考号" /></el-form-item></el-col>
@@ -72,6 +73,6 @@ watch([page, pageSize], () => void load()); onMounted(() => { void load(); void
<el-col :xs="24" :sm="12"><el-form-item label="预计开船"><el-date-picker v-model="form.etdAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="预计到港"><el-date-picker v-model="form.etaAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="查询时间" prop="observedAt"><el-date-picker v-model="form.observedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择本次查询时间" style="width:100%" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="过期时间" prop="expiresAt"><el-date-picker v-model="form.expiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择结果过期时间" style="width:100%" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入船期补充说明(可选)" /></el-form-item></el-col>
</el-row></el-form></AppDialog></AdminPage>
</el-row></el-form><CarrierScheduleQueryPanel v-if="dialogReadonly && form.oceanBookingId" class="schedule-query-panel" :ocean-booking-id="form.oceanBookingId" :container-booking-id="form.containerBookingId" :port-schedule-id="editingId" /></div></AppDialog></AdminPage>
</template>
<style scoped>.schedule-form{max-height:65vh;overflow:auto;padding-inline:2px}.selector-error{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;padding:6px 10px;border-radius:var(--cc-radius-sm);background:var(--cc-danger-soft);color:var(--el-color-danger);font-size:12px;line-height:20px}.selector-error span{overflow-wrap:anywhere}:global(.el-dialog:has(.schedule-form)){width:min(820px,calc(100vw - 28px))!important;max-height:90vh;overflow:hidden}@media(max-width:720px){.schedule-form{max-height:68vh}}</style>
<style scoped>.schedule-dialog-body{max-height:65vh;overflow:auto;padding-inline:2px}.schedule-query-panel{margin-top:4px;padding-top:16px;border-top:1px solid var(--cc-border)}.selector-error{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;padding:6px 10px;border-radius:var(--cc-radius-sm);background:var(--cc-danger-soft);color:var(--el-color-danger);font-size:12px;line-height:20px}.selector-error span{overflow-wrap:anywhere}:global(.el-dialog:has(.schedule-form)){width:min(820px,calc(100vw - 28px))!important;max-height:90vh;overflow:hidden}@media(max-width:720px){.schedule-dialog-body{max-height:68vh}}</style>
+88
View File
@@ -32,3 +32,91 @@ export interface PortSchedulePage { records: PortScheduleView[]; page: number; s
export interface PortScheduleQuery { page: number; size: number; customerId?: string; keyword?: string; expired?: boolean }
export type PortScheduleData = Omit<PortScheduleResource, 'id' | 'scheduleNo' | 'version' | 'updatedAt'>
export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null }
export type CarrierScheduleQueryStatus = 'READY' | 'MANUAL_REQUIRED' | 'SUCCESS'
export interface CarrierScheduleQueryResource {
id: string
queryNo: string
customerId: string
oceanBookingId: string
containerBookingId?: string | null
carrierCode: string
trackingType: string
trackingNo: string
officialUrl?: string | null
accessMode?: string | null
status: CarrierScheduleQueryStatus
windowStartedAt?: string | null
attemptCount: number
nextAllowedAt?: string | null
lastAttemptAt?: string | null
lastSuccessAt?: string | null
sourceUpdatedAt?: string | null
expiresAt?: string | null
resultPortScheduleId?: string | null
lastAttemptStatus?: string | null
manualReason?: string | null
lastErrorCode?: string | null
lastErrorMessage?: string | null
remark?: string | null
createdAt?: string | null
updatedAt?: string | null
version: number
}
export interface CarrierScheduleQueryView {
query: CarrierScheduleQueryResource
expired: boolean
maxAttempts: number
nextAllowedAt?: string | null
}
export interface CarrierScheduleQueryAttempt {
id: string
queryId: string
windowAttemptNo: number
carrierCode: string
accessMode?: string | null
officialUrl?: string | null
attemptStatus: string
coverageGap?: boolean | null
coverageGapCode?: string | null
manualReason?: string | null
errorCode?: string | null
errorMessage?: string | null
operatorName?: string | null
createdAt?: string | null
}
export interface CarrierScheduleQueryHistory {
id: string
queryId: string
action: string
fromVersion?: number | null
toVersion?: number | null
reason?: string | null
operatorName?: string | null
createdAt?: string | null
}
export interface CarrierScheduleQueryDetail {
query: CarrierScheduleQueryView
attempts: CarrierScheduleQueryAttempt[]
histories: CarrierScheduleQueryHistory[]
}
export interface CarrierScheduleQueryPage {
records: CarrierScheduleQueryView[]
page: number
size: number
total: number
pages: number
}
export interface CarrierScheduleQueryFilter {
page: number
size: number
oceanBookingId?: string
containerBookingId?: string
}
@@ -0,0 +1,11 @@
# 海外合作仓类型契约缺口
四期原型把海外合作仓作为现有仓库主数据中的一种仓库类型,而不是独立库存领域。
- 建议类型码:`OVERSEAS_PARTNER`。
- 当前状态:仅为前端与后端对齐用的建议值,尚未生效。
- 当前 Warehouse Service 的 DTO、校验与 OpenAPI 只接受 `DOMESTIC`、`FBA`、`OVERSEAS`。
- 在服务端正式接受并返回合作仓类型前,Admin 不得把合作仓按 `OVERSEAS` 创建或更新,也不新增一套重复的合作仓列表。
- 合作仓列表和详情继续复用 `platform:warehouse:list`;未来创建、编辑、启停分别复用现有 `platform:warehouse:create`、`platform:warehouse:edit`、`platform:warehouse:status`。
- 服务端契约补齐时需同步核对仓库 DTO、Controller、OpenAPI、初始化 SQL、导入模板及全部消费者。