diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index fa414f4..8ae9c56 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -149,6 +149,29 @@ export interface FirstLegOrderSaveRequest { referenceId?: string } +export interface MerchantBatchDraftItem { + clientRowId: string + draft: FirstLegOrderSaveRequest +} + +export interface MerchantBatchDraftResult { + index: number + clientRowId: string + success: boolean + orderId: number | string | null + orderNo: string | null + inboundCode: string | null + errorCode: string | null + errorMessage: string | null +} + +export interface MerchantBatchDraftResponse { + total: number + succeeded: number + failed: number + items: MerchantBatchDraftResult[] +} + export interface MerchantFirstLegOrder { id?: number | string orderId?: number | string @@ -323,6 +346,15 @@ export function createMerchantFirstLegOrder(payload: FirstLegOrderSaveRequest) { }) } +export function createMerchantFirstLegOrderBatch(items: MerchantBatchDraftItem[], idempotencyKey: string) { + return apiRequest({ + method: 'POST', + url: '/merchant/v1/orders/first-leg/batch', + data: items, + headers: { 'Idempotency-Key': idempotencyKey }, + }) +} + export function updateMerchantFirstLegOrder(id: number | string, payload: FirstLegOrderSaveRequest) { return apiRequest({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload }) } diff --git a/src/modules/first-leg/routes.ts b/src/modules/first-leg/routes.ts index 3149881..96ff88f 100644 --- a/src/modules/first-leg/routes.ts +++ b/src/modules/first-leg/routes.ts @@ -22,6 +22,14 @@ export const firstLegModule = { release: 'active', meta: { title: '创建订单', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' }, }, + { + path: 'orders/first-leg/:draftId/edit', + name: 'first-leg-order-edit', + component: () => import('./views/FirstLegOrdersView.vue'), + props: { createPage: true }, + release: 'active', + meta: { title: '编辑订单草稿', description: '继续完善快速下单生成的草稿明细', permission: 'order:first-leg:edit' }, + }, { path: 'first-leg/import', name: 'first-leg-import', @@ -34,9 +42,9 @@ export const firstLegModule = { path: 'first-leg/quick-order', name: 'first-leg-quick-order', component: () => import('./views/QuickOrderView.vue'), - release: 'prototype', - meta: { title: '快速下单', description: '快速创建头程订单', permission: 'merchant:first-leg:quick-create' }, - navigation: { key: 'first-leg-quick-order', icon: Zap }, + release: 'active', + meta: { title: '快速下单', description: '按渠道、目的仓和产品 SKU 快速生成订单草稿', permission: 'order:first-leg:quick-create' }, + navigation: { key: 'first-leg-quick-order', title: '快速下单', icon: Zap }, }, ], } satisfies ClientModuleManifest diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index ab16531..958b58d 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -9,6 +9,7 @@ const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import. test('declares the documented merchant warehouse and first-leg endpoints', () => { assert.match(apiSource, /url: '\/merchant\/v1\/warehouses'/) assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg'/) + assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/batch'/) assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}`/) assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}\/submit`/) assert.match(apiSource, /`\/merchant\/v1\/warehouses\/\$\{id\}`/) @@ -125,6 +126,9 @@ test('does not expose the out-of-scope cancellation write', () => { test('sends an idempotency key for create and submit writes', () => { assert.match(apiSource, /'Idempotency-Key': createIdempotencyKey\('first-leg-create'\)/) assert.match(apiSource, /'Idempotency-Key': createIdempotencyKey\(`first-leg-submit-\$\{id\}`\)/) + assert.match(apiSource, /createMerchantFirstLegOrderBatch\(items: MerchantBatchDraftItem\[\], idempotencyKey: string\)/) + assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/) + assert.match(storeSource, /createMerchantFirstLegOrderBatch\(items, idempotencyKey\)/) }) test('does not expose the contradictory backend rejected state in the client model', () => { diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index 66561bc..41ec905 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -1,6 +1,7 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import { + createMerchantFirstLegOrderBatch, createMerchantFirstLegOrder, getMerchantFirstLegOrder, getMerchantFirstLegOrders, @@ -13,6 +14,7 @@ import { type FirstLegOrderSaveRequest, type MerchantFirstLegOrder, type MerchantFirstLegOrderDetail, + type MerchantBatchDraftResponse, type MerchantWarehouse, } from '@/modules/first-leg/api/firstLeg' import { @@ -152,6 +154,21 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust } } +function prepareDraftForSave(draft: FirstLegOrderDraft, warehouses: ClientWarehouse[]) { + const warehouse = warehouses.find((item) => item.id === draft.receivingWarehouseId) + if (!warehouse || warehouse.warehouseType !== 'DOMESTIC') throw new Error('所选国内收货仓当前不可用') + if (draft.destinationType !== 'PRIVATE') { + const destinationWarehouse = warehouses.find((item) => item.id === draft.destinationWarehouseId && item.warehouseType === draft.destinationType) + if (!destinationWarehouse) throw new Error('所选目的仓当前不可用,请重新选择') + draft.destinationWarehouseName = destinationWarehouse.name + draft.destinationCountryCode = destinationWarehouse.countryCode + draft.amazonWarehouseCode = draft.destinationType === 'FBA' ? destinationWarehouse.code : '' + } else { + draft.destinationWarehouseId = '' + } + return warehouse +} + function warehouseSnapshot(warehouse?: PlatformReceivingWarehouse): PlatformReceivingWarehouseSnapshot | null { return warehouse ? { id: warehouse.id, code: warehouse.code, name: warehouse.name, province: warehouse.province, city: warehouse.city, address: warehouse.address } : null } @@ -524,17 +541,7 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () } async function saveDraft(draft: FirstLegOrderDraft, customerName: string, id?: string) { - const warehouse = warehouses.value.find((item) => item.id === draft.receivingWarehouseId) - if (!warehouse || warehouse.warehouseType !== 'DOMESTIC') throw new Error('所选国内收货仓当前不可用') - if (draft.destinationType !== 'PRIVATE') { - const destinationWarehouse = warehouses.value.find((item) => item.id === draft.destinationWarehouseId && item.warehouseType === draft.destinationType) - if (!destinationWarehouse) throw new Error('所选目的仓当前不可用,请重新选择') - draft.destinationWarehouseName = destinationWarehouse.name - draft.destinationCountryCode = destinationWarehouse.countryCode - draft.amazonWarehouseCode = draft.destinationType === 'FBA' ? destinationWarehouse.code : '' - } else { - draft.destinationWarehouseId = '' - } + const warehouse = prepareDraftForSave(draft, warehouses.value) if (id) { const order = getById(id) if (!order || !canClientEditFirstLegOrder(order.status)) throw new Error('当前订单状态不允许编辑') @@ -545,6 +552,18 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () return loadLegacyDetail(String(createdId)) } + async function saveDraftBatch( + drafts: Array<{ clientRowId: string; draft: FirstLegOrderDraft }>, + customerName: string, + idempotencyKey: string, + ): Promise { + const items = drafts.map(({ clientRowId, draft }) => { + const warehouse = prepareDraftForSave(draft, warehouses.value) + return { clientRowId, draft: savePayload(draft, warehouse, customerName) } + }) + return createMerchantFirstLegOrderBatch(items, idempotencyKey) + } + async function submit(draft: FirstLegOrderDraft, customerName: string, id?: string, companyAuditStatus?: MerchantTenantStatus) { if (companyAuditStatus && companyAuditStatus !== 'ACTIVE') throw new Error('企业资料尚未审核通过,请先完善企业资料并等待平台审核') const order = await saveDraft(draft, customerName, id) @@ -552,5 +571,9 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () return loadUnifiedDetail(order.id) } - return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadWarehouseDetail, loadWarehouseType, saveDraft, submit } + async function loadDraftDetail(id: string) { + return loadLegacyDetail(id) + } + + return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, saveDraft, saveDraftBatch, submit } }) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 37b0bf7..2f446ae 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -1,6 +1,6 @@ - + + + diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index f0c2d0c..23eeb37 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -36,6 +36,7 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ items: [ { code: 'order:first-leg:list', label: '查看订单', description: '查看本企业头程订单列表和详情' }, { code: 'order:first-leg:create', label: '创建订单', description: '新建并保存订单草稿' }, + { code: 'order:first-leg:quick-create', label: '快速下单', description: '使用快速下单页面生成订单草稿,不包含提交订单权限' }, { code: 'order:first-leg:edit', label: '编辑订单', description: '编辑本企业尚未提交的订单草稿' }, { code: 'order:first-leg:submit', label: '提交订单', description: '提交草稿进入平台审核' }, { code: 'order:first-leg:cancel', label: '取消订单', description: '在允许的状态下取消订单' }, @@ -74,6 +75,7 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly> = { 'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'], 'order:first-leg:create': ['merchant:order:create'], + 'order:first-leg:quick-create': ['merchant:order:quick-create', 'merchant:order:create'], 'order:first-leg:edit': ['merchant:order:edit'], 'order:first-leg:submit': ['merchant:order:submit'], 'order:first-leg:cancel': ['merchant:order:cancel'], @@ -92,6 +94,7 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly([ 'finance:bill:view', + 'order:first-leg:quick-create', ]) export function hasMappedMerchantPermission(frontendCode: string, grantedBackendCodes: ReadonlySet) { diff --git a/src/navigation/clientNavigation.test.mjs b/src/navigation/clientNavigation.test.mjs index 2611688..2c9babd 100644 --- a/src/navigation/clientNavigation.test.mjs +++ b/src/navigation/clientNavigation.test.mjs @@ -51,7 +51,7 @@ test('keeps only implemented navigation groups visible', () => { { title: '业务总览', children: undefined }, { title: '头程物流', - children: ['订单列表'], + children: ['订单列表', '快速下单'], }, { title: '财务结算', @@ -86,6 +86,7 @@ test('flattens visible leaves with unique paths', () => { assert.deepEqual(paths, [ '/', '/orders/first-leg', + '/first-leg/quick-order', '/finance/bills', '/company/profile', '/organization/members', @@ -303,7 +304,7 @@ test('keeps the dashboard route permission synchronized with navigation', () => assert.match(dashboardRoute, /permission: 'dashboard:view'/) }) -test('keeps every prototype view cataloged with permission metadata but unreleased', () => { +test('keeps prototype views cataloged while allowing the implemented quick-order page', () => { const routerSource = [ 'first-leg', 'cross-border', @@ -326,6 +327,11 @@ test('keeps every prototype view cataloged with permission metadata but unreleas ] for (const [path, view] of routes) { + if (path === 'first-leg/quick-order') { + assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'active'[\\s\\S]*?permission:`)) + assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), true) + continue + } assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'prototype'[\\s\\S]*?permission:`)) assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), false) } diff --git a/src/styles/shell.css b/src/styles/shell.css index 9d8c92f..f7ca0bf 100644 --- a/src/styles/shell.css +++ b/src/styles/shell.css @@ -12,7 +12,7 @@ .client-user-copy { display: flex; flex-direction: column; } .client-user-copy strong { color: #25324a; font-size: 12px; } .client-user-copy small { color: #8893a3; font-size: 10px; } -.client-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 21; display: flex; width: 250px; flex-direction: column; overflow: hidden; padding: 0 14px 16px; background: linear-gradient(180deg, #0b1f3a 0%, #0c2a52 100%); box-shadow: 4px 0 24px rgba(8, 25, 48, .14); color: #fff; } +.client-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 21; display: flex; width: 250px; flex-direction: column; overflow: hidden; padding: 0 14px 16px; background: linear-gradient(180deg, #0b1f3a 0%, #0c2a52 100%); box-shadow: 4px 0 24px rgba(8, 25, 48, .14); color: #fff; font-family: "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif; font-synthesis: none; } .client-sidebar-brand { display: flex; flex: 0 0 82px; min-height: 82px; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; padding: 10px 4px 12px; border-bottom: 1px solid rgba(255, 255, 255, .09); } .client-sidebar-brand-link { display: flex; min-width: 0; align-items: center; gap: 10px; color: #fff; text-decoration: none; } .client-sidebar-brand-logo { display: block; width: 142px; height: 54px; object-fit: contain; object-position: left center; filter: brightness(0) invert(1); } @@ -32,16 +32,16 @@ .client-nav-node { min-width: 0; } .client-nav-node.has-divider-before { margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, .09); } .client-sidebar-group-trigger, -.client-sidebar-link { display: flex; width: 100%; height: 46px; min-width: 0; align-items: center; gap: 12px; margin: 2px 0; padding: 0 14px; border-radius: 10px; color: rgba(255, 255, 255, .72); font-size: 14px; font-weight: 600; line-height: 1.35; transition: color .2s ease, background .2s ease, box-shadow .2s ease; } +.client-sidebar-link { display: flex; width: 100%; height: 46px; min-width: 0; align-items: center; gap: 12px; margin: 2px 0; padding: 0 14px; border-radius: 10px; color: rgba(255, 255, 255, .82); font-size: 14px; font-weight: 500; line-height: 1.35; transition: color .2s ease, background .2s ease, box-shadow .2s ease; } .client-sidebar-group-trigger { border: 0; background: transparent; cursor: pointer; text-align: left; } .client-sidebar-link { text-decoration: none; } .client-sidebar-group-trigger:hover, .client-sidebar-link:hover { background: rgba(255, 255, 255, .08); color: #fff; } -.client-nav-menu > .client-nav-node > .client-sidebar-link[aria-current="page"] { background: linear-gradient(90deg, #1565c0, #1e88e5); box-shadow: 0 6px 16px rgba(3, 15, 35, .28); color: #fff; font-weight: 650; } -.client-nav-submenu .client-sidebar-link { height: 42px; margin: 1px 0; padding: 0 14px 0 22px; border-radius: 9px; color: rgba(255, 255, 255, .62); font-size: 13px; font-weight: 550; } +.client-nav-menu > .client-nav-node > .client-sidebar-link[aria-current="page"] { background: linear-gradient(90deg, #1565c0, #1e88e5); box-shadow: 0 6px 16px rgba(3, 15, 35, .28); color: #fff; font-weight: 600; } +.client-nav-submenu .client-sidebar-link { height: 42px; margin: 1px 0; padding: 0 14px 0 22px; border-radius: 9px; color: rgba(255, 255, 255, .74); font-size: 13px; font-weight: 500; } .client-sidebar .client-nav-submenu .client-sidebar-node-icon { width: 16px; height: 16px; flex: 0 0 16px; opacity: .86; } .client-nav-submenu .client-sidebar-link:hover { background: rgba(255, 255, 255, .07); color: #fff; } -.client-nav-submenu .client-sidebar-link[aria-current="page"] { background: rgba(30, 136, 229, .25); box-shadow: inset 0 0 0 1px rgba(109, 185, 255, .14); color: #fff; font-weight: 650; } +.client-nav-submenu .client-sidebar-link[aria-current="page"] { background: rgba(30, 136, 229, .25); box-shadow: inset 0 0 0 1px rgba(109, 185, 255, .14); color: #fff; font-weight: 600; } .client-nav-node.is-active-parent > .client-sidebar-group-trigger { color: #fff; font-weight: 600; } .client-sidebar .client-sidebar-group-trigger svg, .client-sidebar .client-sidebar-link svg { flex: 0 0 18px; }