feat: improve client order and member workflows
This commit is contained in:
+1
-21
@@ -54,23 +54,6 @@ export interface MerchantWarehouseDetail {
|
|||||||
capabilities: MerchantWarehouseCapability[]
|
capabilities: MerchantWarehouseCapability[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MerchantPartner {
|
|
||||||
id: number | string
|
|
||||||
partnerCode: string
|
|
||||||
partnerName: string
|
|
||||||
partnerType: string
|
|
||||||
contactName: string | null
|
|
||||||
contactPhone: string | null
|
|
||||||
contactEmail: string | null
|
|
||||||
countryCode: string
|
|
||||||
province: string | null
|
|
||||||
city: string | null
|
|
||||||
district: string | null
|
|
||||||
addressLine: string
|
|
||||||
postalCode: string | null
|
|
||||||
status: 'ACTIVE' | 'INACTIVE'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FirstLegOrderPartyPayload {
|
export interface FirstLegOrderPartyPayload {
|
||||||
companyName: string
|
companyName: string
|
||||||
contactName: string
|
contactName: string
|
||||||
@@ -146,6 +129,7 @@ export interface MerchantFirstLegOrder {
|
|||||||
pickupMethod: ApiPickupMethod
|
pickupMethod: ApiPickupMethod
|
||||||
receivingWarehouseId: number | string
|
receivingWarehouseId: number | string
|
||||||
receivingWarehouseName: string
|
receivingWarehouseName: string
|
||||||
|
receivingWarehouseAddress?: string | null
|
||||||
channelCode: string
|
channelCode: string
|
||||||
channelName: string
|
channelName: string
|
||||||
taxMode: ApiTaxMode
|
taxMode: ApiTaxMode
|
||||||
@@ -209,10 +193,6 @@ export function getMerchantWarehouseDetail(id: number | string) {
|
|||||||
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` })
|
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMerchantPartnerCandidates(type: 'SHIPPER' | 'CONSIGNEE') {
|
|
||||||
return apiRequest<MerchantPartner[]>({ method: 'GET', url: '/merchant/v1/partners/candidates', params: { type } })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMerchantFirstLegOrders(params: FirstLegOrderQuery = {}) {
|
export function getMerchantFirstLegOrders(params: FirstLegOrderQuery = {}) {
|
||||||
return apiRequest<PageResult<MerchantFirstLegOrder>>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } })
|
return apiRequest<PageResult<MerchantFirstLegOrder>>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createMerchantFirstLegOrder,
|
createMerchantFirstLegOrder,
|
||||||
getMerchantFirstLegOrder,
|
getMerchantFirstLegOrder,
|
||||||
getMerchantFirstLegOrders,
|
getMerchantFirstLegOrders,
|
||||||
|
getMerchantWarehouseDetail,
|
||||||
getMerchantWarehouses,
|
getMerchantWarehouses,
|
||||||
submitMerchantFirstLegOrder,
|
submitMerchantFirstLegOrder,
|
||||||
updateMerchantFirstLegOrder,
|
updateMerchantFirstLegOrder,
|
||||||
@@ -105,6 +106,20 @@ function warehouseSnapshot(warehouse?: PlatformReceivingWarehouse): PlatformRece
|
|||||||
return warehouse ? { id: warehouse.id, code: warehouse.code, name: warehouse.name, province: warehouse.province, city: warehouse.city, address: warehouse.address } : null
|
return warehouse ? { id: warehouse.id, code: warehouse.code, name: warehouse.name, province: warehouse.province, city: warehouse.city, address: warehouse.address } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orderWarehouseSnapshot(order: MerchantFirstLegOrder, warehouse?: PlatformReceivingWarehouse): PlatformReceivingWarehouseSnapshot | null {
|
||||||
|
if (order.receivingWarehouseAddress) {
|
||||||
|
return {
|
||||||
|
id: String(order.receivingWarehouseId),
|
||||||
|
code: warehouse?.code ?? '',
|
||||||
|
name: order.receivingWarehouseName,
|
||||||
|
province: warehouse?.province ?? '',
|
||||||
|
city: warehouse?.city ?? '',
|
||||||
|
address: order.receivingWarehouseAddress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return warehouseSnapshot(warehouse)
|
||||||
|
}
|
||||||
|
|
||||||
function partySnapshot(detail: MerchantFirstLegOrderDetail, type: 'SENDER' | 'RECEIVER'): FirstLegPartySnapshot | null {
|
function partySnapshot(detail: MerchantFirstLegOrderDetail, type: 'SENDER' | 'RECEIVER'): FirstLegPartySnapshot | null {
|
||||||
const party = detail.parties.find((item) => item.partyType === type)
|
const party = detail.parties.find((item) => item.partyType === type)
|
||||||
if (!party) return null
|
if (!party) return null
|
||||||
@@ -134,10 +149,10 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
|
|||||||
id: String(order.id), orderNo: order.orderNo, referenceNo: order.referenceId ?? '', transportMode: clientTransport(order),
|
id: String(order.id), orderNo: order.orderNo, referenceNo: order.referenceId ?? '', transportMode: clientTransport(order),
|
||||||
businessType: 'EXPORT', origin: shipper?.city ?? '', destination: order.destinationWarehouseName || order.destinationCountryCode,
|
businessType: 'EXPORT', origin: shipper?.city ?? '', destination: order.destinationWarehouseName || order.destinationCountryCode,
|
||||||
receivingWarehouseId: String(order.receivingWarehouseId), receivingWarehouseName: order.receivingWarehouseName,
|
receivingWarehouseId: String(order.receivingWarehouseId), receivingWarehouseName: order.receivingWarehouseName,
|
||||||
receivingWarehouseSnapshot: warehouseSnapshot(warehouse), expectedDepartureDate: order.expectedShipDate ?? '', etd: '', eta: '',
|
receivingWarehouseSnapshot: orderWarehouseSnapshot(order, warehouse), expectedDepartureDate: order.expectedShipDate ?? '', etd: '', eta: '',
|
||||||
shipperPartyId: shipper?.id ?? '', shipperPartySnapshot: shipper, shipper: shipper?.name ?? '',
|
shipperPartyId: shipper?.id ?? '', shipperPartySnapshot: shipper, shipper: shipper?.name ?? '',
|
||||||
consigneePartyId: consignee?.id ?? '', consigneePartySnapshot: consignee, consignee: consignee?.name ?? '',
|
consigneePartyId: consignee?.id ?? '', consigneePartySnapshot: consignee, consignee: consignee?.name ?? '',
|
||||||
expressCompanyName: '', trackingNumber: '', priority: 'NORMAL', customerRemark: order.customerRemark ?? '', operatorName: '待平台分配',
|
expressCompanyName: order.expressCompany ?? '', trackingNumber: order.expressTrackingNo ?? '', priority: 'NORMAL', customerRemark: order.customerRemark ?? '', operatorName: '待平台分配',
|
||||||
status: clientStatus(order.status), version: order.version ?? 0,
|
status: clientStatus(order.status), version: order.version ?? 0,
|
||||||
items: (detail?.cargoes ?? []).map((item) => ({
|
items: (detail?.cargoes ?? []).map((item) => ({
|
||||||
id: String(item.id), sku: item.skuNo ?? '', cargoNameCn: item.cargoName, cargoNameEn: item.cargoNameEn ?? '',
|
id: String(item.id), sku: item.skuNo ?? '', cargoNameCn: item.cargoName, cargoNameEn: item.cargoNameEn ?? '',
|
||||||
@@ -198,6 +213,22 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
|||||||
return mapped
|
return mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadWarehouseDetail(id: string) {
|
||||||
|
const detail = await getMerchantWarehouseDetail(id)
|
||||||
|
const mapped = mapWarehouse(detail.warehouse)
|
||||||
|
const address = detail.addresses[0]
|
||||||
|
const receivingCapability = detail.capabilities.find((item) => item.enabled && item.cutoffTime)
|
||||||
|
if (address) {
|
||||||
|
mapped.province = address.stateProvince ?? ''
|
||||||
|
mapped.city = address.city ?? ''
|
||||||
|
mapped.address = [address.district, address.addressLine1, address.addressLine2].filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
if (receivingCapability?.cutoffTime) mapped.businessHours = `每日 ${receivingCapability.cutoffTime.slice(0, 5)} 前`
|
||||||
|
const index = warehouses.value.findIndex((item) => item.id === id)
|
||||||
|
if (index >= 0) warehouses.value[index] = mapped
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
async function saveDraft(draft: FirstLegOrderDraft, customerName: string, id?: string) {
|
async function saveDraft(draft: FirstLegOrderDraft, customerName: string, id?: string) {
|
||||||
const warehouse = warehouses.value.find((item) => item.id === draft.receivingWarehouseId)
|
const warehouse = warehouses.value.find((item) => item.id === draft.receivingWarehouseId)
|
||||||
if (!warehouse) throw new Error('所选收货仓当前不可用')
|
if (!warehouse) throw new Error('所选收货仓当前不可用')
|
||||||
@@ -218,5 +249,5 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
|||||||
return loadDetail(order.id)
|
return loadDetail(order.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { orders, warehouses, loading, loadedFromApi, loadError, activeCount, pendingCount, getById, load, loadDetail, saveDraft, submit }
|
return { orders, warehouses, loading, loadedFromApi, loadError, activeCount, pendingCount, getById, load, loadDetail, loadWarehouseDetail, saveDraft, submit }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ function emptyDraft(): FirstLegOrderDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const form = reactive<FirstLegOrderDraft>(emptyDraft())
|
const form = reactive<FirstLegOrderDraft>(emptyDraft())
|
||||||
|
const serializedDraft = computed(() => JSON.stringify(form))
|
||||||
|
const editorDirty = computed(() => editorOpen.value && serializedDraft.value !== editorBaseline.value)
|
||||||
|
|
||||||
const filteredOrders = computed(() => store.orders.filter((order) => {
|
const filteredOrders = computed(() => store.orders.filter((order) => {
|
||||||
const keyword = filters.keyword.trim().toLowerCase()
|
const keyword = filters.keyword.trim().toLowerCase()
|
||||||
@@ -90,8 +92,10 @@ const filteredOrders = computed(() => store.orders.filter((order) => {
|
|||||||
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
|
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
|
||||||
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
|
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
|
||||||
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
|
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
|
||||||
|
const hasActiveFilters = computed(() => Boolean(filters.keyword.trim() || filters.status || filters.transportMode || dateRange.value.length))
|
||||||
|
const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合筛选条件的头程订单' : '还没有头程订单')
|
||||||
|
|
||||||
onMounted(async () => {
|
async function loadPageData() {
|
||||||
try {
|
try {
|
||||||
await Promise.all([store.load(), firstLegPartySource.load()])
|
await Promise.all([store.load(), firstLegPartySource.load()])
|
||||||
availableShippers.value = firstLegPartySource.listAvailable('SHIPPER')
|
availableShippers.value = firstLegPartySource.listAvailable('SHIPPER')
|
||||||
@@ -99,10 +103,33 @@ onMounted(async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '订单与往来单位数据加载失败')
|
ElMessage.error(error instanceof Error ? error.message : '订单与往来单位数据加载失败')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
|
if (!editorDirty.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
void loadPageData()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
|
||||||
|
|
||||||
|
onBeforeRouteLeave(async () => {
|
||||||
|
if (!editorDirty.value) return true
|
||||||
|
return confirmDiscardChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
function markEditorClean() {
|
||||||
|
editorBaseline.value = serializedDraft.value
|
||||||
|
}
|
||||||
|
|
||||||
function replaceForm(draft: FirstLegOrderDraft) {
|
function replaceForm(draft: FirstLegOrderDraft) {
|
||||||
Object.assign(form, structuredClone(draft))
|
Object.assign(form, structuredClone(draft))
|
||||||
|
markEditorClean()
|
||||||
}
|
}
|
||||||
|
|
||||||
function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
|
function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
|
||||||
@@ -180,6 +207,15 @@ function clearConsignee() {
|
|||||||
clearParty('CONSIGNEE')
|
clearParty('CONSIGNEE')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSelectedWarehouse(id: string) {
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
await store.loadWarehouseDetail(id)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '仓库地址与收货时间加载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
if (availableWarehouses.value.length === 0) {
|
if (availableWarehouses.value.length === 0) {
|
||||||
ElMessage.warning('当前没有可用的平台收货仓,请联系平台维护仓库后再创建订单')
|
ElMessage.warning('当前没有可用的平台收货仓,请联系平台维护仓库后再创建订单')
|
||||||
@@ -202,15 +238,47 @@ async function openDetail(order: FirstLegOrder) {
|
|||||||
detailId.value = order.id
|
detailId.value = order.id
|
||||||
detailOpen.value = true
|
detailOpen.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
|
detailError.value = ''
|
||||||
try {
|
try {
|
||||||
await store.loadDetail(order.id)
|
await store.loadDetail(order.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '订单详情加载失败')
|
detailError.value = error instanceof Error ? error.message : '订单详情加载失败'
|
||||||
|
ElMessage.error(detailError.value)
|
||||||
} finally {
|
} finally {
|
||||||
detailLoading.value = false
|
detailLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function retryDetail() {
|
||||||
|
const order = detailId.value ? store.getById(detailId.value) : undefined
|
||||||
|
if (order) await openDetail(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDiscardChanges() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('当前订单还有未保存的修改。离开后这些内容将丢失。', '放弃未保存的修改?', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '放弃修改',
|
||||||
|
cancelButtonText: '继续编辑',
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return false
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestCloseEditor(done: () => void) {
|
||||||
|
if (saving.value) return
|
||||||
|
if (!editorDirty.value || await confirmDiscardChanges()) done()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
void requestCloseEditor(() => {
|
||||||
|
editorOpen.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function addCargoItem() {
|
function addCargoItem() {
|
||||||
form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`))
|
form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,12 +134,12 @@ function clearFilters() {
|
|||||||
|
|
||||||
<div class="client-card member-table-card">
|
<div class="client-card member-table-card">
|
||||||
<el-table v-if="filteredMembers.length" class="desktop-member-table" :data="filteredMembers" row-key="id">
|
<el-table v-if="filteredMembers.length" class="desktop-member-table" :data="filteredMembers" row-key="id">
|
||||||
<el-table-column label="成员" min-width="180"><template #default="{ row }"><div class="member-identity"><span>{{ row.name.slice(0, 1) }}</span><div><strong>{{ row.name }}</strong><small>{{ row.account }}</small></div></div></template></el-table-column>
|
<el-table-column label="成员" min-width="170"><template #default="{ row }"><div class="member-identity"><span>{{ row.name.slice(0, 1) }}</span><div><strong>{{ row.name }}</strong><small>{{ row.account }}</small></div></div></template></el-table-column>
|
||||||
<el-table-column label="联系方式" min-width="190"><template #default="{ row }"><div class="member-contact"><span>{{ row.phone }}</span><small>{{ row.email || '-' }}</small></div></template></el-table-column>
|
<el-table-column label="联系方式" min-width="200"><template #default="{ row }"><div class="member-contact"><span>{{ row.phone }}</span><small>{{ row.email || '-' }}</small></div></template></el-table-column>
|
||||||
<el-table-column label="角色" min-width="180"><template #default="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames(row.roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames(row.roleIds).length === 0" class="readonly-action">未分配</span></div></template></el-table-column>
|
<el-table-column label="角色" min-width="130"><template #default="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames(row.roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames(row.roleIds).length === 0" class="readonly-action">未分配</span></div></template></el-table-column>
|
||||||
<el-table-column prop="lastLoginAt" label="登录记录" min-width="150" />
|
<el-table-column prop="lastLoginAt" label="登录记录" min-width="130" />
|
||||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template></el-table-column>
|
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template></el-table-column>
|
||||||
<el-table-column label="操作" width="250" fixed="right"><template #default="{ row }"><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !row.isAdministrator" link :type="row.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></template></el-table-column>
|
<el-table-column label="操作" width="230"><template #default="{ row }"><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !row.isAdministrator" link :type="row.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<div v-if="filteredMembers.length" class="mobile-member-list">
|
<div v-if="filteredMembers.length" class="mobile-member-list">
|
||||||
@@ -181,6 +181,7 @@ function clearFilters() {
|
|||||||
.member-identity { display: flex; align-items: center; gap: 10px; }
|
.member-identity { display: flex; align-items: center; gap: 10px; }
|
||||||
.member-identity > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 50%; background: #e7f3f1; color: var(--primary); font-weight: 750; }
|
.member-identity > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 50%; background: #e7f3f1; color: var(--primary); font-weight: 750; }
|
||||||
.member-identity > div, .member-contact { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
.member-identity > div, .member-contact { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||||
|
.member-identity strong, .member-identity small, .member-contact small { overflow-wrap: anywhere; }
|
||||||
.member-identity small, .member-contact small { color: var(--muted); font-size: 10px; }
|
.member-identity small, .member-contact small { color: var(--muted); font-size: 10px; }
|
||||||
.role-tags { display: flex; gap: 5px; flex-wrap: wrap; }
|
.role-tags { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||||
.mobile-member-list { display: none; }
|
.mobile-member-list { display: none; }
|
||||||
|
|||||||
Reference in New Issue
Block a user