This commit is contained in:
2026-08-13 09:48:51 +08:00
parent 3366c58a5e
commit d765bd4340
10 changed files with 799 additions and 27 deletions
+32
View File
@@ -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<MerchantBatchDraftResponse>({
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<void>({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload })
}
+11 -3
View File
@@ -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
@@ -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', () => {
+35 -12
View File
@@ -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<MerchantBatchDraftResponse> {
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 }
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules, type UploadRequestOptions } from 'element-plus'
import { AlertTriangle, Box, Download, Eye, FilePlus2, ImagePlus, MapPin, PackageCheck, Pencil, Plus, Send, Trash2, Upload } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES, type FirstLegOrderStatus } from '@chongchong/shared'
@@ -42,9 +42,11 @@ import { validateProductImageFile } from '@/modules/first-leg/utils/productImage
const store = useFirstLegOrdersStore()
const tenantStore = useTenantAdministrationStore()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const props = withDefaults(defineProps<{ createPage?: boolean }>(), { createPage: false })
const isCreatePage = computed(() => props.createPage)
const routeDraftId = computed(() => typeof route.params.draftId === 'string' ? route.params.draftId : '')
const canViewCompanyProfile = computed(() => auth.hasPermission('company:profile:view'))
const companyAuditLoaded = ref(false)
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
@@ -277,7 +279,57 @@ function emptyDraft(): FirstLegOrderDraft {
}
}
const form = reactive<FirstLegOrderDraft>(emptyDraft())
function quickOrderDraft() {
const draft = emptyDraft()
if (!isCreatePage.value || route.query.quick !== '1') return draft
const destinationType = String(route.query.destinationType ?? '')
if (destinationType === 'FBA' || destinationType === 'OVERSEAS' || destinationType === 'PRIVATE') draft.destinationType = destinationType
draft.receivingWarehouseId = String(route.query.receivingWarehouseId ?? '')
draft.channelCode = String(route.query.channelCode ?? '')
draft.channelName = String(route.query.channelName ?? '')
draft.destinationWarehouseId = String(route.query.destinationWarehouseId ?? '')
draft.destinationWarehouseName = String(route.query.destinationWarehouseName ?? '')
draft.destinationCountryCode = String(route.query.destinationCountryCode ?? draft.destinationCountryCode).toUpperCase()
draft.amazonWarehouseCode = String(route.query.amazonWarehouseCode ?? '')
if (draft.destinationType === 'PRIVATE') {
try {
const consignee = JSON.parse(String(route.query.consignee ?? '{}')) as Record<string, unknown>
draft.consigneePartySnapshot = {
...createEmptyPartySnapshot('CONSIGNEE'),
name: draft.destinationWarehouseName,
countryRegion: draft.destinationCountryCode,
contactName: typeof consignee.contactName === 'string' ? consignee.contactName : '',
phone: typeof consignee.phone === 'string' ? consignee.phone : '',
city: typeof consignee.city === 'string' ? consignee.city : '',
address: typeof consignee.address === 'string' ? consignee.address : '',
postalCode: typeof consignee.postalCode === 'string' ? consignee.postalCode : '',
}
draft.consignee = draft.destinationWarehouseName
draft.destination = draft.destinationWarehouseName
} catch {
draft.consigneePartySnapshot = createEmptyPartySnapshot('CONSIGNEE')
}
} else {
draft.destination = draft.destinationWarehouseName
}
try {
const cargo = JSON.parse(String(route.query.cargo ?? '[]')) as Array<{ sku?: unknown; packageCount?: unknown }>
if (Array.isArray(cargo) && cargo.length) {
draft.items = cargo.map((item) => ({
...createEmptyCargoItem(`quick-cargo-${++cargoSequence}`),
sku: typeof item.sku === 'string' ? item.sku.slice(0, 64) : '',
packageCount: Number.isInteger(item.packageCount) && Number(item.packageCount) > 0 ? Number(item.packageCount) : 1,
}))
}
} catch {
// Keep the standard empty row when quick-order data is malformed.
}
return draft
}
const form = reactive<FirstLegOrderDraft>(quickOrderDraft())
const serializedDraft = computed(() => JSON.stringify(form))
const editorDirty = computed(() => (editorOpen.value || isCreatePage.value) && serializedDraft.value !== editorBaseline.value)
const unsavedRouteKey = props.createPage ? 'first-leg-order-create' : 'first-leg-orders'
@@ -381,9 +433,23 @@ async function loadPageData() {
if (!isCreatePage.value && store.draftLoadError) supportingDataErrors.value.push(`订单草稿加载失败:${store.draftLoadError}`)
if (destinationResult.status === 'rejected') supportingDataErrors.value.push(`目的仓加载失败:${errorMessage(destinationResult.reason)}`)
await autofillWarehouseSelections()
if (isCreatePage.value && routeDraftId.value) await loadRouteDraft(routeDraftId.value)
supportingDataLoading.value = false
}
async function loadRouteDraft(id: string) {
const detailedOrder = await store.loadDraftDetail(id)
if (!canClientEditFirstLegOrder(detailedOrder.status)) throw new Error('当前订单已不是草稿状态,无法继续编辑')
const draft = draftFromOrder(detailedOrder)
if (draft.destinationType !== 'PRIVATE') await loadDestinationWarehouses(draft.destinationType)
editingId.value = detailedOrder.id
replaceForm(draft)
await Promise.all(form.items.map(ensureProductImagePreview))
if (form.receivingWarehouseId) await loadSelectedWarehouse(form.receivingWarehouseId)
resetEditorValidation()
markEditorClean()
}
async function loadDestinationWarehouses(type: 'FBA' | 'OVERSEAS' | 'PRIVATE') {
if (type === 'PRIVATE') return
destinationWarehousesLoading.value = true
@@ -411,8 +477,9 @@ onMounted(() => {
watch(isCreatePage, (isCreate, wasCreate) => {
if (!isCreate || wasCreate) return
if (routeDraftId.value) return
editingId.value = undefined
replaceForm(emptyDraft())
replaceForm(quickOrderDraft())
resetEditorValidation()
}, { immediate: true })
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./QuickOrderView.vue', import.meta.url), 'utf8')
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const contractsSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
const orderViewSource = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
test('exposes quick order as an active first-leg route with its dedicated permission', () => {
assert.match(routesSource, /path: 'first-leg\/quick-order'[\s\S]*?release: 'active'[\s\S]*?permission: 'order:first-leg:quick-create'/)
assert.match(contractsSource, /code: 'order:first-leg:quick-create'[\s\S]*?label: '快速下单'/)
assert.match(contractsSource, /'order:first-leg:quick-create': \['merchant:order:quick-create', 'merchant:order:create'\]/)
assert.match(contractsSource, /FRONTEND_PERMISSIONS_REQUIRING_ALL_BACKEND_CODES[\s\S]*?'order:first-leg:quick-create'/)
assert.match(routesSource, /path: 'orders\/first-leg\/:draftId\/edit'[\s\S]*?name: 'first-leg-order-edit'[\s\S]*?permission: 'order:first-leg:edit'/)
assert.match(orderViewSource, /store\.loadDraftDetail\(id\)/)
})
test('keeps the primary quick-order table limited to channel, destination, SKU and package count', () => {
for (const label of ['选择渠道', '目的仓', '产品信息:产品 SKU / 箱数', '新增一行', '生成草稿单']) {
assert.match(source, new RegExp(label))
}
assert.match(source, /getMerchantPricingChannels/)
assert.match(source, /destinationType: 'FBA'/)
assert.match(source, /function createProduct\(\): QuickOrderProduct/)
assert.match(source, /products: \[createProduct\(\)\]/)
})
test('creates one real backend draft per order row and keeps its products together', () => {
assert.match(source, /function buildOrderDraft\(row: QuickOrderRow, channel: QuickChannelOption\)/)
assert.match(source, /items: row\.products\.map/)
assert.match(source, /const drafts = rows\.flatMap\(\(row\) =>/)
assert.match(source, /clientRowId: row\.id, draft: buildOrderDraft\(row, channel\)/)
assert.match(source, /store\.saveDraftBatch\(drafts, customerName, activeBatchKey\.value\)/)
assert.match(source, /response\.items\.flatMap/)
assert.match(source, /item\.inboundCode\?\.trim\(\)/)
assert.match(source, /已生成 \$\{successes\.length\} 张草稿单/)
assert.match(source, /successfulIds\.has\(rows\[index\]\.id\)/)
assert.match(source, /response\.items\.filter/)
assert.match(source, /activeBatchKey\.value/)
assert.match(source, /name: 'first-leg-order-edit'/)
assert.match(source, /params: \{ draftId: result\.draftId \}/)
assert.doesNotMatch(source, /getMerchantUnifiedOrder/)
assert.doesNotMatch(source, /store\.saveDraft\(/)
assert.doesNotMatch(source, /store\.submit\(|store\.submitSavedDraft\(|submitMerchantFirstLegOrder/)
assert.doesNotMatch(source, /name: 'first-leg-order-create'/)
})
test('requires only channel, destination, SKU and package count before directly creating drafts', () => {
assert.match(source, /!row\.channelKey \|\| !row\.destinationWarehouseName/)
assert.match(source, /!product\.sku\.trim\(\) \|\| product\.packageCount < 1/)
assert.match(source, /渠道、目的仓、产品 SKU 和箱数/)
assert.match(source, /createEmptyCargoItem/)
assert.match(source, /packageCount: product\.packageCount/)
assert.match(source, /sku: product\.sku\.trim\(\)/)
assert.match(source, /destinationWarehouseId: row\.destinationWarehouseId/)
assert.match(source, /destinationWarehouseName: row\.destinationWarehouseName/)
assert.match(source, /destinationCountryCode: row\.destinationCountryCode/)
assert.doesNotMatch(source, /mixedChannel/)
assert.doesNotMatch(source, /confirmationDialogOpen|QuickCargoConfirmation|confirmAndCreateQuickDraft/)
assert.doesNotMatch(source, /确认草稿资料|确认并生成草稿/)
assert.doesNotMatch(source, /validateFirstLegOrderDraft/)
assert.match(source, /@click="submitQuickOrder"/)
assert.ok(
source.indexOf('async function submitQuickOrder()') < source.indexOf('store.saveDraftBatch(drafts'),
'the primary quick-order action must save drafts directly',
)
})
test('shows every created draft and keeps failed rows available for retry', () => {
assert.match(source, /v-for="result in draftResults"/)
assert.match(source, /入仓码 \{\{ result\.inboundCode \}\}/)
assert.match(source, /@click="editDraft\(result\)"/)
assert.match(source, /if \(rows\.length === 0\) rows\.push\(createRow\(\)\)/)
assert.match(source, /resultDialogOpen\.value = successes\.length > 0/)
})
test('adds products inside one order row and only the footer adds a new draft row', () => {
assert.match(source, /function addProduct\(row: QuickOrderRow\)[\s\S]*?row\.products\.push\(createProduct\(\)\)/)
assert.match(source, /function addOrderRow\(\)[\s\S]*?rows\.push\(createRow\(\)\)/)
assert.match(source, /@click="addProduct\(row\)"[^>]*><Plus[^>]*\/>添加产品行/)
assert.match(source, /@click="addOrderRow"[^>]*><Plus[^>]*\/>新增一行/)
assert.match(source, /v-for="\(product, productIndex\) in row\.products"/)
assert.match(source, /@click="removeProduct\(row, productIndex\)"/)
})
test('binds exactly one destination warehouse through a destination dialog', () => {
assert.match(source, /<el-dialog v-model="destinationDialogOpen"/)
assert.match(source, /function bindDestination\(\)/)
assert.match(source, /availableDestinationWarehouses\.value\.find/)
assert.match(source, /destinationWarehouseId: warehouse\.id/)
assert.match(source, /destinationWarehouseId: ''/)
assert.match(source, /const required = \[destinationForm\.privateName, destinationForm\.countryCode\]/)
assert.match(source, /hasCompletePrivateContact\(row\)/)
assert.match(source, /确认绑定/)
})
+535 -2
View File
@@ -1,5 +1,538 @@
<script setup lang="ts">
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { MapPin, Plus, Send, Trash2 } from '@lucide/vue'
import { useAuthStore } from '@/modules/auth/store/auth'
import { getMerchantPricingChannels, type MerchantPricingChannel } from '@/modules/first-leg/api/firstLeg'
import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders'
import type { FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder'
import { createEmptyCargoItem } from '@/modules/first-leg/utils/firstLegOrder'
type DestinationType = 'FBA' | 'OVERSEAS' | 'PRIVATE'
interface QuickOrderProduct {
id: string
sku: string
packageCount: number
}
interface QuickOrderRow {
id: string
channelKey: string
destinationType: DestinationType
destinationWarehouseId: string
destinationWarehouseName: string
destinationCountryCode: string
amazonWarehouseCode: string
consigneeContactName: string
consigneePhone: string
consigneeCity: string
consigneeAddress: string
consigneePostalCode: string
products: QuickOrderProduct[]
}
interface QuickChannelOption extends MerchantPricingChannel {
warehouseId: string
warehouseName: string
}
interface QuickDraftResult {
rowId: string
sku: string
draftId: string
orderNo: string
inboundCode: string
}
const router = useRouter()
const auth = useAuthStore()
const store = useFirstLegOrdersStore()
const loading = ref(false)
const submitting = ref(false)
const loadError = ref('')
const channelOptions = ref<QuickChannelOption[]>([])
const destinationDialogOpen = ref(false)
const destinationLoading = ref(false)
const editingDestinationRow = ref<QuickOrderRow>()
const draftResults = reactive<QuickDraftResult[]>([])
const resultDialogOpen = ref(false)
const activeBatchKey = ref('')
const destinationForm = reactive({
type: 'FBA' as DestinationType,
warehouseId: '',
privateName: '',
countryCode: 'US',
contactName: '',
phone: '',
city: '',
address: '',
postalCode: '',
})
let rowSequence = 0
let productSequence = 0
function createBatchKey() {
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
return `first-leg-quick-batch-${randomPart}`
}
function createProduct(): QuickOrderProduct {
return reactive({ id: `quick-product-${++productSequence}`, sku: '', packageCount: 1 })
}
function createRow(): QuickOrderRow {
return reactive({
id: `quick-order-${++rowSequence}`,
channelKey: '',
destinationType: 'FBA',
destinationWarehouseId: '',
destinationWarehouseName: '',
destinationCountryCode: '',
amazonWarehouseCode: '',
consigneeContactName: '',
consigneePhone: '',
consigneeCity: '',
consigneeAddress: '',
consigneePostalCode: '',
products: [createProduct()],
})
}
const rows = reactive<QuickOrderRow[]>([createRow()])
const hasChannels = computed(() => channelOptions.value.length > 0)
const availableDestinationWarehouses = computed(() => destinationForm.type === 'PRIVATE'
? []
: store.warehouses.filter((warehouse) => warehouse.warehouseType === destinationForm.type))
function addOrderRow() {
rows.push(createRow())
}
function addProduct(row: QuickOrderRow) {
row.products.push(createProduct())
}
function removeProduct(row: QuickOrderRow, productIndex: number) {
if (row.products.length === 1) {
row.products[0] = createProduct()
return
}
row.products.splice(productIndex, 1)
}
function removeRow(rowIndex: number) {
if (rows.length === 1) {
rows[0] = createRow()
return
}
rows.splice(rowIndex, 1)
}
function channelKey(warehouseId: string, channelCode: string) {
return `${warehouseId}:${channelCode}`
}
function selectedChannel(row: QuickOrderRow) {
return channelOptions.value.find((channel) => channelKey(channel.warehouseId, channel.channelCode) === row.channelKey)
}
function createConsigneeSnapshot(row: QuickOrderRow) {
return {
id: '',
code: '',
name: row.destinationWarehouseName,
role: 'CONSIGNEE' as const,
contactName: row.consigneeContactName,
phone: row.consigneePhone,
email: '',
countryRegion: row.destinationCountryCode,
province: '',
city: row.consigneeCity,
postalCode: row.consigneePostalCode,
address: row.consigneeAddress,
}
}
function hasCompletePrivateContact(row: QuickOrderRow) {
return [row.consigneeContactName, row.consigneePhone, row.consigneeCity, row.consigneeAddress]
.every((value) => value.trim())
}
function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): FirstLegOrderDraft {
const pricingTaxMode = channel.pricingTaxModes[0] ?? 'TAX_INCLUDED'
return {
referenceNo: '',
transportMode: 'FCL',
businessType: 'EXPORT',
origin: '',
destination: row.destinationWarehouseName,
receivingWarehouseId: channel.warehouseId,
expectedDepartureDate: '',
shipperPartyId: '',
shipperPartySnapshot: null,
shipper: '',
consigneePartyId: '',
consigneePartySnapshot: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
? createConsigneeSnapshot(row) : null,
consignee: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
? row.destinationWarehouseName : '',
priority: 'NORMAL',
customerRemark: '',
tradeTerm: 'FOB',
destinationCountryCode: row.destinationCountryCode,
destinationWarehouseId: row.destinationWarehouseId,
destinationWarehouseName: row.destinationWarehouseName,
pickupMethod: 'SELF_EXPRESS',
channelCode: channel.channelCode,
channelName: channel.channelName,
pricingTaxMode,
taxMode: 'BUY_DECLARATION',
customsInspection: false,
destinationType: row.destinationType,
amazonWarehouseCode: row.amazonWarehouseCode,
shipmentId: '',
items: row.products.map((product) => {
return {
...createEmptyCargoItem(`quick-cargo-${product.id}`),
sku: product.sku.trim(),
packageCount: product.packageCount,
pieces: product.packageCount,
}
}),
}
}
function destinationLabel(row: QuickOrderRow) {
if (!row.destinationWarehouseName) return '尚未绑定目的仓'
const code = row.amazonWarehouseCode || row.destinationCountryCode
return [row.destinationWarehouseName, code].filter(Boolean).join(' · ')
}
async function openDestinationDialog(row: QuickOrderRow, typeValue: string | number | boolean | undefined) {
const type = String(typeValue) as DestinationType
if (!['FBA', 'OVERSEAS', 'PRIVATE'].includes(type)) return
editingDestinationRow.value = row
Object.assign(destinationForm, {
type,
warehouseId: row.destinationType === type ? row.destinationWarehouseId : '',
privateName: row.destinationType === type ? row.destinationWarehouseName : '',
countryCode: row.destinationType === type ? row.destinationCountryCode || 'US' : 'US',
contactName: row.destinationType === type ? row.consigneeContactName : '',
phone: row.destinationType === type ? row.consigneePhone : '',
city: row.destinationType === type ? row.consigneeCity : '',
address: row.destinationType === type ? row.consigneeAddress : '',
postalCode: row.destinationType === type ? row.consigneePostalCode : '',
})
destinationDialogOpen.value = true
if (type === 'PRIVATE') return
destinationLoading.value = true
try {
await store.loadWarehouseType(type)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '目的仓加载失败,请稍后重试')
} finally {
destinationLoading.value = false
}
}
function bindDestination() {
const row = editingDestinationRow.value
if (!row) return
if (destinationForm.type !== 'PRIVATE') {
const warehouse = availableDestinationWarehouses.value.find((item) => item.id === destinationForm.warehouseId)
if (!warehouse) {
ElMessage.warning('请选择一个目的仓')
return
}
Object.assign(row, {
destinationType: destinationForm.type,
destinationWarehouseId: warehouse.id,
destinationWarehouseName: warehouse.name,
destinationCountryCode: warehouse.countryCode,
amazonWarehouseCode: destinationForm.type === 'FBA' ? warehouse.code : '',
consigneeContactName: '',
consigneePhone: '',
consigneeCity: '',
consigneeAddress: '',
consigneePostalCode: '',
})
} else {
const required = [destinationForm.privateName, destinationForm.countryCode]
if (required.some((value) => !value.trim())) {
ElMessage.warning('请填写私人仓名称和目的国家')
return
}
if (!/^[A-Za-z]{2}$/.test(destinationForm.countryCode.trim())) {
ElMessage.warning('目的国家请输入两位英文代码,例如 US')
return
}
Object.assign(row, {
destinationType: 'PRIVATE',
destinationWarehouseId: '',
destinationWarehouseName: destinationForm.privateName.trim(),
destinationCountryCode: destinationForm.countryCode.trim().toUpperCase(),
amazonWarehouseCode: '',
consigneeContactName: destinationForm.contactName.trim(),
consigneePhone: destinationForm.phone.trim(),
consigneeCity: destinationForm.city.trim(),
consigneeAddress: destinationForm.address.trim(),
consigneePostalCode: destinationForm.postalCode.trim(),
})
}
destinationDialogOpen.value = false
}
async function loadChannels() {
loading.value = true
loadError.value = ''
try {
await store.loadWarehouseType('DOMESTIC', true)
const results = await Promise.allSettled(store.warehouses
.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
.map(async (warehouse) => {
const channels = await getMerchantPricingChannels(warehouse.id)
return channels.map((channel) => ({ ...channel, warehouseId: warehouse.id, warehouseName: warehouse.name }))
}))
channelOptions.value = results.flatMap((result) => result.status === 'fulfilled' ? result.value : [])
if (!channelOptions.value.length) loadError.value = '当前国内收货仓暂无可用渠道,请联系平台管理员维护渠道。'
} catch (error) {
loadError.value = error instanceof Error ? error.message : '渠道加载失败,请稍后重试。'
} finally {
loading.value = false
}
}
async function submitQuickOrder() {
const incompleteIndex = rows.findIndex((row) => !row.channelKey || !row.destinationWarehouseName
|| row.products.some((product) => !product.sku.trim() || product.packageCount < 1))
if (incompleteIndex >= 0) {
ElMessage.warning(`请完善第 ${incompleteIndex + 1} 行的渠道、目的仓、产品 SKU 和箱数`)
return
}
const unavailableIndex = rows.findIndex((row) => !selectedChannel(row))
if (unavailableIndex >= 0) {
ElMessage.warning(`第 ${unavailableIndex + 1} 行所选渠道已不可用,请重新选择`)
return
}
const drafts = rows.flatMap((row) => {
const channel = selectedChannel(row)
return channel ? [{ clientRowId: row.id, draft: buildOrderDraft(row, channel) }] : []
})
submitting.value = true
try {
const customerName = auth.user?.tenantName?.trim() || auth.profile.tenantName.trim()
if (!customerName) throw new Error('当前登录会话缺少企业名称,请重新登录后重试')
if (!activeBatchKey.value) activeBatchKey.value = createBatchKey()
const response = await store.saveDraftBatch(drafts, customerName, activeBatchKey.value)
const successes = response.items.flatMap((item): QuickDraftResult[] => {
if (!item.success || item.orderId == null || !item.inboundCode?.trim()) return []
const row = rows.find((candidate) => candidate.id === item.clientRowId)
if (!row) return []
return [{
rowId: row.id,
sku: row.products.map((product) => product.sku.trim()).join('、'),
draftId: String(item.orderId),
orderNo: item.orderNo ?? '',
inboundCode: item.inboundCode.trim(),
}]
})
const failures = response.items.filter((item) => !item.success || item.orderId == null || !item.inboundCode?.trim())
draftResults.splice(0, draftResults.length, ...successes)
const successfulIds = new Set(successes.map((item) => item.rowId))
for (let index = rows.length - 1; index >= 0; index -= 1) {
if (successfulIds.has(rows[index].id)) rows.splice(index, 1)
}
if (rows.length === 0) rows.push(createRow())
resultDialogOpen.value = successes.length > 0
if (failures.length) ElMessage.error(`${failures.length} 张草稿生成失败:${failures[0].errorMessage || '草稿单生成失败'}`)
else {
activeBatchKey.value = ''
ElMessage.success(`已生成 ${successes.length} 张草稿单`)
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '批量草稿生成失败')
} finally {
submitting.value = false
}
}
async function editDraft(result: QuickDraftResult) {
resultDialogOpen.value = false
await router.push({ name: 'first-leg-order-edit', params: { draftId: result.draftId } })
}
onMounted(loadChannels)
</script>
<template><PrototypeFeatureView title="快速下单" description="快速创建头程订单" /></template>
<template>
<section class="quick-order-page" aria-labelledby="quick-order-heading">
<header class="quick-order-intro">
<h2 id="quick-order-heading">客户下单</h2>
<p>只需输入渠道、目的仓、产品 SKU 和箱数,即可快速填写订单。</p>
<small>每一行会直接生成一张独立草稿单,其他明细可进入草稿后继续编辑并提交。</small>
</header>
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false">
<template #default><el-button text type="primary" @click="loadChannels">重新加载</el-button></template>
</el-alert>
<div v-loading="loading" class="quick-order-table-wrap">
<table class="quick-order-table">
<thead>
<tr>
<th>选择渠道</th>
<th>目的仓</th>
<th>操作</th>
<th>产品信息:产品 SKU / 箱数</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in rows" :key="row.id">
<td class="channel-cell">
<el-select v-model="row.channelKey" :disabled="!hasChannels" placeholder="请选择">
<el-option
v-for="channel in channelOptions"
:key="channelKey(channel.warehouseId, channel.channelCode)"
:label="channel.channelName"
:value="channelKey(channel.warehouseId, channel.channelCode)"
>
<span>{{ channel.channelName }}</span>
<small>{{ channel.warehouseName }}</small>
</el-option>
</el-select>
</td>
<td>
<el-radio-group :model-value="row.destinationType" class="destination-options" @change="(value) => openDestinationDialog(row, value)">
<el-radio value="FBA">FBA</el-radio>
<el-radio value="OVERSEAS">海外仓</el-radio>
<el-radio value="PRIVATE">私人仓</el-radio>
</el-radio-group>
<button type="button" class="destination-binding" @click="openDestinationDialog(row, row.destinationType)">
<MapPin :size="14" />
<span>{{ destinationLabel(row) }}</span>
</button>
</td>
<td class="operation-cell">
<el-button text type="primary" @click="addProduct(row)"><Plus :size="15" />添加产品行</el-button>
<el-button text type="danger" @click="removeRow(rowIndex)"><Trash2 :size="15" />删除本行</el-button>
</td>
<td class="products-cell">
<div v-for="(product, productIndex) in row.products" :key="product.id" class="product-fields">
<el-input v-model="product.sku" maxlength="64" placeholder="产品 SKU" />
<el-input-number v-model="product.packageCount" :min="1" :max="99999" :precision="0" controls-position="right" aria-label="箱数" />
<el-button v-if="row.products.length > 1" text type="danger" aria-label="删除产品" @click="removeProduct(row, productIndex)"><Trash2 :size="15" /></el-button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="quick-order-actions">
<el-button plain @click="addOrderRow"><Plus :size="16" />新增一行</el-button>
<el-button type="primary" :loading="submitting" :disabled="loading || !hasChannels" @click="submitQuickOrder">
<Send :size="16" />生成草稿单
</el-button>
</div>
<el-dialog v-model="destinationDialogOpen" class="destination-dialog" :title="`绑定${destinationForm.type === 'FBA' ? ' FBA 仓' : destinationForm.type === 'OVERSEAS' ? '海外仓' : '私人仓'}`" width="min(560px, 92vw)">
<el-form label-position="top" class="destination-dialog-form">
<el-form-item label="目的仓类型" required>
<el-radio-group v-model="destinationForm.type" class="destination-type-switch" @change="(value) => openDestinationDialog(editingDestinationRow!, value)">
<el-radio-button value="FBA">FBA 仓</el-radio-button>
<el-radio-button value="OVERSEAS">海外仓</el-radio-button>
<el-radio-button value="PRIVATE">私人仓</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="destinationForm.type !== 'PRIVATE'" :label="destinationForm.type === 'FBA' ? 'FBA 仓' : '海外仓'" required>
<el-select v-model="destinationForm.warehouseId" filterable :loading="destinationLoading" placeholder="请选择当前可送达的目的仓">
<el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" />
</el-select>
<small v-if="!destinationLoading && availableDestinationWarehouses.length === 0">平台暂未维护可用目的仓,请联系平台管理员。</small>
</el-form-item>
<template v-else>
<el-form-item label="私人地址名称" required><el-input v-model="destinationForm.privateName" maxlength="128" placeholder="例如:洛杉矶客户自有仓" /></el-form-item>
<div class="destination-private-grid">
<el-form-item label="目的国家" required><el-input v-model="destinationForm.countryCode" maxlength="2" placeholder="US" /></el-form-item>
<el-form-item label="联系人" required><el-input v-model="destinationForm.contactName" maxlength="64" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="destinationForm.phone" maxlength="32" /></el-form-item>
<el-form-item label="城市" required><el-input v-model="destinationForm.city" maxlength="64" /></el-form-item>
</div>
<el-form-item label="详细地址" required><el-input v-model="destinationForm.address" maxlength="256" /></el-form-item>
<el-form-item label="邮编"><el-input v-model="destinationForm.postalCode" maxlength="20" /></el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="destinationDialogOpen = false">取消</el-button>
<el-button type="primary" @click="bindDestination">确认绑定</el-button>
</template>
</el-dialog>
<el-dialog v-model="resultDialogOpen" class="quick-result-dialog" title="草稿单生成结果" width="min(700px, 94vw)">
<div class="quick-draft-results">
<article v-for="result in draftResults" :key="result.rowId">
<div><strong>{{ result.orderNo || `草稿 ${result.draftId}` }}</strong><span>SKU {{ result.sku }}</span><span>入仓码 {{ result.inboundCode }}</span></div>
<el-button type="primary" plain @click="editDraft(result)">编辑明细</el-button>
</article>
</div>
<template #footer><el-button type="primary" @click="resultDialogOpen = false">完成</el-button></template>
</el-dialog>
</section>
</template>
<style scoped>
.quick-order-page { min-width: 0; min-height: 330px; padding: 18px 16px 24px; border: 1px solid #e8edf4; border-radius: 8px; background: #fff; box-shadow: 0 10px 28px rgba(15, 31, 58, .05); }
.quick-order-intro { margin-bottom: 16px; }
.quick-order-intro h2 { margin: 0 0 6px; color: #1d2939; font-size: 16px; }
.quick-order-intro p, .quick-order-intro small { display: block; margin: 0; color: #8a94a6; font-size: 12px; line-height: 1.65; }
.quick-order-page > :deep(.el-alert) { margin-bottom: 14px; }
.quick-order-table-wrap { min-height: 126px; overflow-x: auto; border: 1px solid #e5eaf2; border-radius: 8px; }
.quick-order-table { width: 100%; min-width: 900px; border-collapse: collapse; table-layout: fixed; }
.quick-order-table th { height: 42px; padding: 0 14px; background: #f4f5fb; color: #637fec; font-size: 12px; font-weight: 650; text-align: left; }
.quick-order-table th:nth-child(1) { width: 220px; }
.quick-order-table th:nth-child(2) { width: 145px; }
.quick-order-table th:nth-child(3) { width: 210px; }
.quick-order-table td { padding: 12px 14px; border-top: 1px solid #edf0f5; vertical-align: middle; }
.channel-cell :deep(.el-select) { width: 100%; }
.destination-options { display: flex; align-items: flex-start; flex-direction: column; gap: 4px; }
.destination-options :deep(.el-radio) { height: 20px; margin-right: 0; }
.destination-binding { display: flex; width: 100%; min-width: 0; align-items: center; gap: 5px; margin-top: 7px; padding: 0; border: 0; background: transparent; color: #637fec; cursor: pointer; font-size: 11px; text-align: left; }
.destination-binding span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.destination-binding:focus-visible { outline: 2px solid #637fec; outline-offset: 3px; }
.operation-cell { white-space: normal; }
.operation-cell :deep(.el-button) { margin: 0 8px 4px 0; padding: 4px 0; }
.products-cell { padding-top: 7px !important; padding-bottom: 7px !important; }
.product-fields { display: grid; grid-template-columns: minmax(160px, 260px) 112px 32px; align-items: center; gap: 10px; min-height: 44px; }
.product-fields + .product-fields { padding-top: 7px; border-top: 1px dashed #e5eaf2; }
.product-fields :deep(.el-input-number) { width: 112px; }
.product-fields :deep(.el-button) { width: 30px; height: 30px; margin: 0; padding: 0; }
.quick-order-actions { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 12px; padding-bottom: 44px; }
.quick-order-actions > :deep(.el-button:last-child) { position: absolute; left: 50%; bottom: 0; min-width: 112px; transform: translateX(-50%); }
.quick-order-actions :deep(.el-button + .el-button) { margin-left: 0; }
:deep(.el-select-dropdown__item small) { float: right; margin-left: 12px; color: #98a2b3; }
:deep(.destination-dialog .el-dialog__body) { padding-top: 12px; }
.destination-dialog-form :deep(.el-select) { width: 100%; }
.destination-dialog-form :deep(.el-form-item) { margin-bottom: 16px; }
.destination-dialog-form small { display: block; margin-top: 6px; color: #8a94a6; font-size: 11px; }
.destination-type-switch { display: flex; width: 100%; }
.destination-type-switch :deep(.el-radio-button) { flex: 1; }
.destination-type-switch :deep(.el-radio-button__inner) { width: 100%; }
.destination-private-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
.quick-draft-results { display: grid; gap: 10px; }
.quick-draft-results article { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 14px; border: 1px solid #e5eaf2; border-radius: 6px; }
.quick-draft-results article div { display: grid; min-width: 0; gap: 3px; }
.quick-draft-results article strong { color: #1d2939; font-size: 14px; }
.quick-draft-results article span { color: #667085; font-size: 11px; }
@media (max-width: 720px) {
.quick-order-page { padding: 14px 12px 20px; }
.quick-order-table { min-width: 760px; }
.quick-order-actions { align-items: stretch; flex-direction: column; padding-bottom: 0; }
.quick-order-actions > :deep(.el-button), .quick-order-actions > :deep(.el-button:last-child) { position: static; width: 100%; min-height: 42px; transform: none; }
.destination-private-grid { grid-template-columns: 1fr; }
}
</style>
@@ -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<Record<string, readonly string[]>> = {
'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<Record<string, read
export const FRONTEND_PERMISSIONS_REQUIRING_ALL_BACKEND_CODES = new Set<string>([
'finance:bill:view',
'order:first-leg:quick-create',
])
export function hasMappedMerchantPermission(frontendCode: string, grantedBackendCodes: ReadonlySet<string>) {
+8 -2
View File
@@ -51,7 +51,7 @@ test('keeps only implemented navigation groups visible', () => {
{ title: '业务总览', children: undefined },
{
title: '头程物流',
children: ['订单列表'],
children: ['订单列表', '快速下单'],
},
{
title: '财务结算',
@@ -82,6 +82,7 @@ test('flattens visible leaves with unique paths', () => {
assert.deepEqual(paths, [
'/',
'/orders/first-leg',
'/first-leg/quick-order',
'/finance/bills',
'/company/profile',
'/organization/members',
@@ -298,7 +299,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',
@@ -321,6 +322,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)
}
+5 -5
View File
@@ -9,7 +9,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); }
@@ -29,16 +29,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; }