优化
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, /确认绑定/)
|
||||
})
|
||||
@@ -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>) {
|
||||
|
||||
Reference in New Issue
Block a user