Files
admin-web/src/components/PlatformFirstLegOrdersPanel.vue
T

355 lines
22 KiB
Vue
Raw Normal View History

2026-07-31 13:43:16 +08:00
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Eye, FilePenLine, PackageCheck, RotateCcw, Search } from '@lucide/vue'
import {
acceptPlatformFirstLegOrder,
addPlatformFirstLegOrderNote,
approvePlatformFirstLegOrder,
cancelPlatformFirstLegOrder,
completePlatformFirstLegOrder,
getPlatformFirstLegOrder,
listPlatformFirstLegOrders,
startPlatformFirstLegOrder,
updatePlatformFirstLegOrderExpress,
type PlatformFirstLegOrderDetailResource,
type PlatformFirstLegOrderResource,
type PlatformFirstLegOrderStatus,
} from '@/api/platformFirstLegOrders'
import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import MetricStrip from '@/components/MetricStrip.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { useAccessPreviewStore } from '@/stores/accessPreview'
const TENANT_SESSION_KEY = 'cc-admin-first-leg-order-tenant-id'
const access = useAccessPreviewStore()
const tenantId = ref(sessionStorage.getItem(TENANT_SESSION_KEY) || '')
const loadedTenantId = ref('')
const orders = ref<PlatformFirstLegOrderResource[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const loading = ref(false)
const actionLoadingId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref<PlatformFirstLegOrderDetailResource | null>(null)
const noteDialogOpen = ref(false)
const expressDialogOpen = ref(false)
const selectedOrder = ref<PlatformFirstLegOrderResource | null>(null)
const noteContent = ref('')
const noteSaving = ref(false)
const expressSaving = ref(false)
const expressForm = reactive({ expressCompany: '', expressTrackingNo: '' })
const filters = reactive<{ keyword: string; status: PlatformFirstLegOrderStatus | ''; dateRange: [string, string] | [] }>({
keyword: '',
status: '',
dateRange: [],
})
const tenantReady = computed(() => Boolean(loadedTenantId.value) && tenantId.value.trim() === loadedTenantId.value)
const metrics = computed(() => [
{ label: '订单总数', value: total.value, note: loadedTenantId.value ? `租户 ${loadedTenantId.value}` : '等待选择租户' },
{ label: '待审核', value: orders.value.filter((item) => item.status === 'SUBMITTED').length, note: '当前页数据' },
{ label: '履约处理中', value: orders.value.filter((item) => ['APPROVED', 'ACCEPTED', 'PROCESSING'].includes(item.status)).length, note: '当前页数据' },
{ label: '已完成', value: orders.value.filter((item) => item.status === 'COMPLETED').length, note: '当前页数据' },
])
const statusOptions: Array<{ value: PlatformFirstLegOrderStatus; label: string }> = [
{ value: 'DRAFT', label: '草稿' },
{ value: 'SUBMITTED', label: '待审核' },
{ value: 'APPROVED', label: '已审核' },
{ value: 'ACCEPTED', label: '已受理' },
{ value: 'PROCESSING', label: '处理中' },
{ value: 'COMPLETED', label: '已完成' },
{ value: 'CANCELLED', label: '已取消' },
{ value: 'REJECTED', label: '已驳回(旧契约)' },
]
function statusLabel(status: PlatformFirstLegOrderStatus) {
return statusOptions.find((item) => item.value === status)?.label || status
}
function transportLabel(order: PlatformFirstLegOrderResource) {
if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? '海运整箱 FCL' : '海运拼箱 LCL'
if (order.transportMode === 'AIR') return '空运'
if (order.transportMode === 'RAIL') return '铁路运输'
return '陆运'
}
function formattedDate(value: string | null | undefined) {
return value ? value.replace('T', ' ') : '-'
}
function can(action: string) {
return access.can(action)
}
function canCancel(order: PlatformFirstLegOrderResource) {
return can('platform:order:cancel') && ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED'].includes(order.status)
}
function actionLabel(order: PlatformFirstLegOrderResource) {
if (order.status === 'SUBMITTED' && can('platform:order:approve')) return '审核通过'
if (order.status === 'APPROVED' && can('platform:order:accept')) return '受理订单'
if (order.status === 'ACCEPTED' && can('platform:order:process')) return '开始处理'
if (order.status === 'PROCESSING' && can('platform:order:complete')) return '完成订单'
return ''
}
async function loadOrders(resetPage = false) {
const targetTenantId = tenantId.value.trim()
loadError.value = null
if (!/^\d+$/.test(targetTenantId) || targetTenantId === '0') {
loadError.value = { message: '请输入有效的目标租户 ID 后查询订单' }
return
}
if (resetPage) page.value = 1
loading.value = true
try {
const result = await listPlatformFirstLegOrders(targetTenantId, {
keyword: filters.keyword.trim() || undefined,
status: filters.status || undefined,
createdFrom: filters.dateRange.length === 2 ? `${filters.dateRange[0]}T00:00:00` : undefined,
createdTo: filters.dateRange.length === 2 ? `${filters.dateRange[1]}T23:59:59` : undefined,
page: page.value,
size: pageSize,
})
orders.value = result.records
total.value = result.total
loadedTenantId.value = targetTenantId
sessionStorage.setItem(TENANT_SESSION_KEY, targetTenantId)
} catch (error) {
orders.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '订单列表加载失败,请检查 Gateway、租户范围和订单权限')
} finally {
loading.value = false
}
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.dateRange = []
void loadOrders(true)
}
function handlePageChange(nextPage: number) {
page.value = nextPage
void loadOrders()
}
async function openDetail(order: PlatformFirstLegOrderResource) {
if (!tenantReady.value) return
selectedOrder.value = order
detail.value = null
detailOpen.value = true
detailLoading.value = true
try {
detail.value = await getPlatformFirstLegOrder(loadedTenantId.value, String(order.id))
} catch (error) {
const details = getApiErrorDetails(error, '订单详情加载失败')
ElMessage.error(details.message)
} finally {
detailLoading.value = false
}
}
async function refreshAfterAction(orderId: string) {
await loadOrders()
if (detailOpen.value && selectedOrder.value?.id === orderId) {
detail.value = await getPlatformFirstLegOrder(loadedTenantId.value, orderId)
}
}
async function runLifecycleAction(order: PlatformFirstLegOrderResource) {
if (!tenantReady.value) return
const label = actionLabel(order)
if (!label) return
try {
await ElMessageBox.confirm(`确认${label}“${order.orderNo || order.id}”吗?该操作会写入订单状态历史。`, label, {
confirmButtonText: `确认${label}`,
cancelButtonText: '取消',
type: 'warning',
})
actionLoadingId.value = String(order.id)
if (order.status === 'SUBMITTED') await approvePlatformFirstLegOrder(loadedTenantId.value, String(order.id))
if (order.status === 'APPROVED') await acceptPlatformFirstLegOrder(loadedTenantId.value, String(order.id))
if (order.status === 'ACCEPTED') await startPlatformFirstLegOrder(loadedTenantId.value, String(order.id))
if (order.status === 'PROCESSING') await completePlatformFirstLegOrder(loadedTenantId.value, String(order.id))
await refreshAfterAction(String(order.id))
ElMessage.success(`${label}已完成`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorDetails(error, `${label}失败,请稍后重试`).message)
} finally {
actionLoadingId.value = ''
}
}
async function cancelOrder(order: PlatformFirstLegOrderResource) {
if (!tenantReady.value) return
try {
const result = await ElMessageBox.prompt('请填写取消原因。后端要求该字段必填,并将写入订单状态历史。', '取消订单', {
confirmButtonText: '确认取消',
cancelButtonText: '返回',
inputPattern: /\S+/,
inputErrorMessage: '请填写取消原因',
inputPlaceholder: '例如:客户取消本次运输安排',
})
actionLoadingId.value = String(order.id)
await cancelPlatformFirstLegOrder(loadedTenantId.value, String(order.id), result.value.trim())
await refreshAfterAction(String(order.id))
ElMessage.success('订单已取消')
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorDetails(error, '取消订单失败,请稍后重试').message)
} finally {
actionLoadingId.value = ''
}
}
function openExpress(order: PlatformFirstLegOrderResource) {
selectedOrder.value = order
expressForm.expressCompany = order.expressCompany || ''
expressForm.expressTrackingNo = order.expressTrackingNo || ''
expressDialogOpen.value = true
}
async function saveExpress() {
if (!selectedOrder.value || !tenantReady.value) return
if (!expressForm.expressCompany.trim() || !expressForm.expressTrackingNo.trim()) {
ElMessage.warning('请填写快递公司和快递单号')
return
}
expressSaving.value = true
try {
await updatePlatformFirstLegOrderExpress(loadedTenantId.value, String(selectedOrder.value.id), {
expressCompany: expressForm.expressCompany.trim(),
expressTrackingNo: expressForm.expressTrackingNo.trim(),
})
expressDialogOpen.value = false
await refreshAfterAction(String(selectedOrder.value.id))
ElMessage.success('履约快递信息已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '履约快递信息保存失败').message)
} finally {
expressSaving.value = false
}
}
function openNote(order: PlatformFirstLegOrderResource) {
selectedOrder.value = order
noteContent.value = ''
noteDialogOpen.value = true
}
async function saveNote() {
if (!selectedOrder.value || !tenantReady.value) return
if (!noteContent.value.trim()) {
ElMessage.warning('请填写内部备注')
return
}
noteSaving.value = true
try {
await addPlatformFirstLegOrderNote(loadedTenantId.value, String(selectedOrder.value.id), noteContent.value.trim())
noteDialogOpen.value = false
await refreshAfterAction(String(selectedOrder.value.id))
ElMessage.success('内部备注已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '内部备注保存失败').message)
} finally {
noteSaving.value = false
}
}
onMounted(() => {
if (tenantId.value) void loadOrders()
})
</script>
<template>
<div class="business-page">
<MetricStrip :items="metrics" />
<section class="surface-panel filter-panel" aria-label="头程订单筛选">
<div class="filter-fields server-order-filter-fields">
<el-input v-model.trim="tenantId" clearable placeholder="目标租户 ID" aria-label="目标租户 ID" @keyup.enter="loadOrders(true)" />
<el-input v-model.trim="filters.keyword" clearable placeholder="订单号或客户名称" aria-label="订单关键词" @keyup.enter="loadOrders(true)">
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="订单状态">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="创建开始" end-placeholder="创建结束" aria-label="订单创建日期" />
</div>
<div class="filter-actions">
<el-button type="primary" plain :loading="loading" @click="loadOrders(true)"><Search :size="16" />查询租户</el-button>
<el-button :disabled="loading" @click="resetFilters"><RotateCcw :size="16" />重置筛选</el-button>
</div>
</section>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button v-if="tenantId" link type="danger" @click="loadOrders(true)">重新加载</el-button>
</el-alert>
<el-alert v-else type="info" :closable="false" show-icon>
2026-07-31 15:34:35 +08:00
当前页面已直连 Order 服务。后端尚未提供平台代客创建、平台编辑及履约任务查询接口,因此相关操作暂不可用。
2026-07-31 13:43:16 +08:00
</el-alert>
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>头程订单</h2><p>{{ loadedTenantId ? `当前数据范围:租户 ${loadedTenantId}` : '输入目标租户 ID 后读取 Order 服务真实数据' }}</p></div></header>
<el-table v-if="loading || orders.length" v-loading="loading" :data="orders" row-key="id">
<el-table-column label="操作" min-width="200" fixed="left">
<template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button><el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button><el-button v-if="canCancel(row as PlatformFirstLegOrderResource)" link type="danger" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)">取消</el-button><el-button v-if="can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes((row as PlatformFirstLegOrderResource).status)" link type="primary" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />快递</el-button><el-button v-if="can('platform:order:note')" link type="primary" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" />备注</el-button></div></template>
</el-table-column>
<el-table-column label="订单信息" min-width="210"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template></el-table-column>
<el-table-column label="客户" min-width="190" show-overflow-tooltip><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).customerName }}</strong><span>租户 {{ (row as PlatformFirstLegOrderResource).tenantId }}</span></div></template></el-table-column>
<el-table-column label="运输方案" min-width="180"><template #default="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template></el-table-column>
<el-table-column label="目的地" min-width="150"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template></el-table-column>
<el-table-column label="平台收货仓" min-width="180"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template></el-table-column>
<el-table-column label="履约快递" min-width="160"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="120"><template #default="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template></el-table-column>
</el-table>
<el-empty v-else :description="loadedTenantId ? '当前租户没有符合条件的头程订单' : '请先输入目标租户 ID 并查询'" />
<footer class="table-footer"><span>共 {{ total }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="handlePageChange" /></footer>
</section>
<el-drawer v-model="detailOpen" title="订单详情" size="760px" class="business-drawer">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="order-detail-head"><div><span>{{ transportLabel(detail.order) }}</span><h2>{{ detail.order.orderNo || detail.order.id }}</h2><p>{{ detail.order.customerName }} · {{ detail.order.referenceId || '无客户参考号' }}</p></div><StatusBadge :status="statusLabel(detail.order.status)" /></div>
<div class="detail-section"><h3>订单概要</h3><el-descriptions :column="2" border><el-descriptions-item label="目标仓 / 国家">{{ detail.order.destinationWarehouseName || '-' }} · {{ detail.order.destinationCountryCode || '-' }}</el-descriptions-item><el-descriptions-item label="平台收货仓">{{ detail.order.receivingWarehouseName || '-' }}</el-descriptions-item><el-descriptions-item label="收货地址" :span="2">{{ detail.order.receivingWarehouseAddress || '-' }}</el-descriptions-item><el-descriptions-item label="渠道">{{ detail.order.channelName || '-' }}({{ detail.order.channelCode || '-' }})</el-descriptions-item><el-descriptions-item label="预计出运">{{ detail.order.expectedShipDate || '-' }}</el-descriptions-item><el-descriptions-item label="客户备注" :span="2">{{ detail.order.customerRemark || '-' }}</el-descriptions-item></el-descriptions></div>
<div class="detail-section"><h3>货物明细</h3><el-table :data="detail.cargoes" size="small" empty-text="暂无货物明细"><el-table-column prop="itemNo" label="#" width="52" /><el-table-column prop="cargoName" label="品名" min-width="150" /><el-table-column prop="packageType" label="包装" min-width="100" /><el-table-column prop="packageCount" label="件/箱" width="86" /><el-table-column label="毛重 / 体积" min-width="150"><template #default="{ row }">{{ row.grossWeightKg }} kg / {{ row.volumeCbm }} m³</template></el-table-column></el-table></div>
<div class="detail-section"><h3>收发货方</h3><div class="party-grid"><div v-for="party in detail.parties" :key="party.id"><span>{{ party.partyType === 'SENDER' ? '发货方' : '收货方' }}</span><strong>{{ party.companyName }}</strong><p>{{ party.contactName }} · {{ party.phone }}</p><p>{{ [party.province, party.city, party.district, party.addressLine].filter(Boolean).join(' ') }}</p></div></div></div>
<div class="detail-section"><h3>履约快递</h3><el-descriptions :column="2" border><el-descriptions-item label="快递公司">{{ detail.order.expressCompany || '-' }}</el-descriptions-item><el-descriptions-item label="快递单号">{{ detail.order.expressTrackingNo || '-' }}</el-descriptions-item></el-descriptions></div>
<div class="detail-section"><h3>内部备注</h3><el-empty v-if="!detail.internalNotes.length" description="暂无内部备注" :image-size="52" /><el-timeline v-else><el-timeline-item v-for="note in detail.internalNotes" :key="note.id" :timestamp="formattedDate(note.createdAt)" placement="top"><p>{{ note.content }}</p></el-timeline-item></el-timeline></div>
<div class="detail-section"><h3>状态历史</h3><el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || item.action || '状态已更新。' }}</p></el-timeline-item></el-timeline></div>
</template>
<el-empty v-else-if="!detailLoading" description="订单详情暂不可用" />
</div>
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button></div></template>
</el-drawer>
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="520px">
<el-alert title="当前后端契约只接收快递公司名称和快递单号;快递主数据关联待后端补充。" type="info" :closable="false" show-icon />
<el-form label-position="top" class="delivery-form"><el-form-item label="快递公司" required><el-input v-model.trim="expressForm.expressCompany" maxlength="64" placeholder="例如 顺丰速运" /></el-form-item><el-form-item label="快递单号" required><el-input v-model.trim="expressForm.expressTrackingNo" maxlength="128" placeholder="请输入完整快递单号" /></el-form-item></el-form>
<template #footer><el-button :disabled="expressSaving" @click="expressDialogOpen = false">取消</el-button><el-button type="primary" :loading="expressSaving" @click="saveExpress">保存快递信息</el-button></template>
</el-dialog>
<el-dialog v-model="noteDialogOpen" title="添加内部备注" width="520px">
<el-form label-position="top"><el-form-item label="内部备注" required><el-input v-model.trim="noteContent" maxlength="2000" type="textarea" :rows="4" show-word-limit placeholder="仅平台内部可见" /></el-form-item></el-form>
<template #footer><el-button :disabled="noteSaving" @click="noteDialogOpen = false">取消</el-button><el-button type="primary" :loading="noteSaving" @click="saveNote">保存备注</el-button></template>
</el-dialog>
</div>
</template>
<style scoped>
.server-order-filter-fields { grid-template-columns: minmax(150px, .65fr) minmax(220px, 1.2fr) minmax(150px, .75fr) minmax(260px, 1.1fr); }
.party-grid p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
@media (max-width: 720px) { .server-order-filter-fields { grid-template-columns: 1fr; } }
</style>