Files
admin-web/src/modules/warehouses/pages/WarehousesView.vue
T
2026-08-04 09:05:19 +08:00

459 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MapPin, Pencil, Plus, Power, Warehouse as WarehouseIcon } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
changePlatformWarehouseStatus,
createPlatformWarehouse,
getPlatformWarehouse,
listPlatformWarehouses,
updatePlatformWarehouse,
type PlatformWarehouseDetailResource,
type PlatformWarehouseResource,
type SavePlatformWarehouseRequest,
} from '@/modules/warehouses/api/platformWarehouses'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { PlatformTenantSelector } from '@/modules/iam'
import type { Warehouse } from '@/modules/warehouses/types'
const access = useAccessPreviewStore()
const editorOpen = ref(false)
const saving = ref(false)
const loading = ref(false)
const editLoading = ref(false)
const page = ref(1)
const pageSize = ref(10)
const selectedTenantId = ref('')
const loadedTenantId = ref<string | null>(null)
const loadError = ref<ApiErrorDetails | null>(null)
const detailWarningCount = ref(0)
const statusLoadingIds = ref(new Set<string>())
type WarehouseFilters = QueryModel & { keyword: string; status: Warehouse['status'] | '' }
const filters = ref<WarehouseFilters>({ keyword: '', status: '' })
const appliedFilters = ref<WarehouseFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '仓库名称、编码、城市或地址' },
{
key: 'status', type: 'select', placeholder: '全部状态', options: [
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'INACTIVE' },
],
},
]
let loadSequence = 0
interface WarehouseRow extends Warehouse {
detail: PlatformWarehouseDetailResource | null
}
const rows = ref<WarehouseRow[]>([])
const warehouseColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'warehouse', label: '仓库', minWidth: 200, slotName: 'warehouse' },
{ key: 'city', label: '所在城市', minWidth: 130, slotName: 'city' },
{ key: 'address', label: '收货地址', minWidth: 260, slotName: 'address' },
{ key: 'contact', label: '联系人', minWidth: 150, slotName: 'contact' },
{ prop: 'businessHours', label: '收货时间', minWidth: 190, formatter: (row: WarehouseRow) => row.businessHours || '-' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
const emptyForm = (): Omit<Warehouse, 'updatedAt'> => ({
id: '', code: '', name: '', province: '', city: '', address: '', contactName: '', contactPhone: '',
businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '',
})
const form = reactive(emptyForm())
type WarehouseRequiredField = 'code' | 'name' | 'city' | 'address'
const requiredFieldMessages: Record<WarehouseRequiredField, string> = {
code: '请输入仓库编码',
name: '请输入仓库名称',
city: '请输入城市',
address: '请输入详细地址',
}
const formErrors = reactive<Record<WarehouseRequiredField, string>>({ code: '', name: '', city: '', address: '' })
const editorLabel = computed(() => form.id ? '编辑收货仓' : '新增收货仓')
const editorPermission = computed(() => form.id ? 'platform:warehouse:edit' : 'platform:warehouse:create')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'warehouse-editor',
label: editorLabel,
isOpen: editorOpen,
model: form,
requiredPermission: editorPermission,
})
const warehouseReady = computed(() => Boolean(selectedTenantId.value)
&& loadedTenantId.value === selectedTenantId.value
&& !loading.value)
const tenantSwitchDisabled = computed(() => editorOpen.value
|| editLoading.value
|| saving.value
|| statusLoadingIds.value.size > 0)
const filteredRows = computed(() => rows.value.filter((warehouse) => {
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
const matchesKeyword = !keyword || [warehouse.code, warehouse.name, warehouse.province, warehouse.city, warehouse.address]
.some((value) => value.toLowerCase().includes(keyword))
return matchesKeyword && (!appliedFilters.value.status || warehouse.status === appliedFilters.value.status)
}))
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const loadErrorDescription = computed(() => {
if (!loadError.value) return ''
const references = [
loadError.value.code ? `错误码 ${loadError.value.code}` : '',
loadError.value.requestId ? `requestId ${loadError.value.requestId}` : '',
loadError.value.traceId ? `traceId ${loadError.value.traceId}` : '',
].filter(Boolean)
const retained = loadedTenantId.value ? `当前仍显示租户 ${loadedTenantId.value} 最近一次成功加载的数据。` : ''
return [loadError.value.message, references.join(' · '), retained].filter(Boolean).join(' ')
})
watch(
() => access.can(editorPermission.value),
(canWrite) => {
if (canWrite || !editorOpen.value) return
editorOpen.value = false
Object.assign(form, emptyForm())
},
{ flush: 'sync' },
)
function toRow(warehouse: PlatformWarehouseResource, detail: PlatformWarehouseDetailResource | null): WarehouseRow {
const address = detail?.addresses[0]
const receiving = detail?.capabilities.find((item) => item.capabilityCode === 'RECEIVING')
return {
id: String(warehouse.id),
code: warehouse.warehouseCode,
name: warehouse.warehouseName,
province: address?.stateProvince || '',
city: address?.city || '',
address: [address?.district, address?.addressLine1, address?.addressLine2].filter(Boolean).join(' '),
contactName: warehouse.contactName || '',
contactPhone: warehouse.contactPhone || '',
businessHours: receiving?.description || (receiving?.cutoffTime ? `每日截单 ${receiving.cutoffTime}` : ''),
status: warehouse.status,
remark: warehouse.remark || '',
updatedAt: warehouse.updatedAt?.replace('T', ' ').slice(0, 16) || '',
detail,
}
}
function queryWarehouses(model: QueryModel) {
appliedFilters.value = {
keyword: String(model.keyword || ''),
status: (model.status || '') as Warehouse['status'] | '',
}
page.value = 1
void loadWarehouses()
}
function clearWarehouseFormErrors() {
for (const field of Object.keys(formErrors) as WarehouseRequiredField[]) formErrors[field] = ''
}
function clearFieldError(field: WarehouseRequiredField) {
if (formErrors[field]) formErrors[field] = ''
}
function validateRequiredField(field: WarehouseRequiredField) {
formErrors[field] = form[field].trim() ? '' : requiredFieldMessages[field]
return !formErrors[field]
}
function validateWarehouseForm() {
return (Object.keys(requiredFieldMessages) as WarehouseRequiredField[])
.map(validateRequiredField)
.every(Boolean)
}
function apiTenantId(value: string) {
const numeric = Number(value)
return Number.isSafeInteger(numeric) ? numeric : value
}
async function loadWarehouses(tenantId = selectedTenantId.value) {
if (!tenantId) return
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const requestTenantId = apiTenantId(tenantId)
const result = await listPlatformWarehouses(requestTenantId, 1, 200)
const details = await Promise.allSettled(
result.records.map((warehouse) => getPlatformWarehouse(requestTenantId, warehouse.id)),
)
if (sequence !== loadSequence) return
rows.value = result.records.map((warehouse, index) => {
const detailResult = details[index]
return toRow(warehouse, detailResult?.status === 'fulfilled' ? detailResult.value : null)
})
detailWarningCount.value = details.filter((item) => item.status === 'rejected').length
loadedTenantId.value = tenantId
page.value = 1
} catch (error) {
if (sequence !== loadSequence) return
loadError.value = getApiErrorDetails(error, '仓库数据加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
function requireCurrentTenant() {
if (!warehouseReady.value) {
ElMessage.warning('仓库数据尚未加载完成,请稍后重试')
return null
}
return apiTenantId(selectedTenantId.value)
}
function openCreate() {
if (!requireCurrentTenant()) return
Object.assign(form, emptyForm())
clearWarehouseFormErrors()
captureBaseline()
editorOpen.value = true
}
async function ensureDetail(warehouse: WarehouseRow) {
if (warehouse.detail) return warehouse.detail
const tenantId = requireCurrentTenant()
if (!tenantId) return null
try {
const detail = await getPlatformWarehouse(tenantId, warehouse.id)
Object.assign(warehouse, toRow(detail.warehouse, detail))
detailWarningCount.value = rows.value.filter((item) => !item.detail).length
return detail
} catch (error) {
const details = getApiErrorDetails(error, '仓库详情加载失败,请重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
return null
}
}
async function openEdit(warehouse: WarehouseRow) {
const tenantAtStart = selectedTenantId.value
editLoading.value = true
try {
const detail = await ensureDetail(warehouse)
if (!detail || selectedTenantId.value !== tenantAtStart || loadedTenantId.value !== tenantAtStart) return
const address = detail.addresses[0]
Object.assign(form, warehouse, {
province: address?.stateProvince || '',
city: address?.city || '',
address: address?.addressLine1 || '',
})
clearWarehouseFormErrors()
captureBaseline()
editorOpen.value = true
} finally {
editLoading.value = false
}
}
function nullable(value: string) {
return value.trim() || null
}
function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseRequest {
const existing = form.id ? rows.value.find((item) => item.id === form.id)?.detail : null
const warehouse = existing?.warehouse
const currentAddress = existing?.addresses[0]
const capabilities = (existing?.capabilities || []).map((capability) => ({
capabilityCode: capability.capabilityCode,
capabilityName: capability.capabilityName,
enabled: capability.enabled ?? true,
cutoffTime: capability.cutoffTime,
description: capability.capabilityCode === 'RECEIVING' ? nullable(form.businessHours) : capability.description,
}))
if (!capabilities.some((item) => item.capabilityCode === 'RECEIVING')) {
capabilities.push({
capabilityCode: 'RECEIVING',
capabilityName: '收货服务',
enabled: true,
cutoffTime: null,
description: nullable(form.businessHours),
})
}
return {
tenantId,
warehouseCode: form.code.trim().toUpperCase(),
warehouseName: form.name.trim(),
warehouseType: warehouse?.warehouseType || 'DOMESTIC',
countryCode: warehouse?.countryCode || 'CN',
timezone: warehouse?.timezone || 'Asia/Shanghai',
contactName: nullable(form.contactName),
contactPhone: nullable(form.contactPhone),
contactEmail: warehouse?.contactEmail || null,
status: form.status,
remark: nullable(form.remark),
address: {
addressType: currentAddress?.addressType || 'OPERATING',
countryCode: currentAddress?.countryCode || warehouse?.countryCode || 'CN',
stateProvince: nullable(form.province),
city: form.city.trim(),
district: currentAddress?.district || null,
postalCode: currentAddress?.postalCode || null,
addressLine1: form.address.trim(),
addressLine2: currentAddress?.addressLine2 || null,
latitude: currentAddress?.latitude || null,
longitude: currentAddress?.longitude || null,
},
capabilities,
}
}
async function saveWarehouse() {
const tenantId = requireCurrentTenant()
if (!tenantId) return
if (!validateWarehouseForm()) return
saving.value = true
try {
const payload = buildSaveRequest(tenantId)
if (form.id) await updatePlatformWarehouse(tenantId, form.id, payload)
else await createPlatformWarehouse(payload)
const successMessage = form.id ? '仓库资料已更新' : '收货仓已创建'
editorOpen.value = false
ElMessage.success(successMessage)
await loadWarehouses()
} catch (error) {
const details = getApiErrorDetails(error, '仓库保存失败,请检查后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
saving.value = false
}
}
async function toggleStatus(warehouse: WarehouseRow) {
const tenantId = requireCurrentTenant()
if (!tenantId) return
const target = warehouse.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const targetText = target === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(
target === 'INACTIVE'
? `停用“${warehouse.name}”后,客户新建头程订单时将无法选择该仓库。`
: `确认重新启用“${warehouse.name}”供客户下单选择吗?`,
`${targetText}收货仓`,
{ confirmButtonText: `确认${targetText}`, cancelButtonText: '取消', type: target === 'INACTIVE' ? 'warning' : 'info' },
)
statusLoadingIds.value = new Set(statusLoadingIds.value).add(warehouse.id)
await changePlatformWarehouseStatus(tenantId, warehouse.id, target)
warehouse.status = target
if (warehouse.detail) warehouse.detail.warehouse.status = target
ElMessage.success(`仓库已${targetText}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
const details = getApiErrorDetails(error, `仓库${targetText}失败,请重试`)
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
const next = new Set(statusLoadingIds.value)
next.delete(warehouse.id)
statusLoadingIds.value = next
}
}
watch(selectedTenantId, (tenantId) => {
editorOpen.value = false
rows.value = []
loadedTenantId.value = null
detailWarningCount.value = 0
page.value = 1
void loadWarehouses(tenantId)
})
</script>
<template>
<div class="business-page">
<PlatformTenantSelector v-model="selectedTenantId" :disabled="tenantSwitchDisabled" />
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
<template #title>仓库数据加载失败</template>
<p>{{ loadErrorDescription }}</p>
<el-button size="small" @click="loadWarehouses()">重新查询</el-button>
</el-alert>
<el-alert v-else-if="detailWarningCount" type="warning" :closable="false" show-icon class="state-alert">
<template #title>部分仓库详情暂未加载</template>
<p>{{ detailWarningCount }} 个仓库缺少地址或服务能力详情;编辑时会再次尝试加载,不会用模拟数据补齐。</p>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" @search="queryWarehouses" />
<section class="surface-panel table-panel" aria-live="polite">
<div class="panel-heading">
<div>
<h2>平台收货仓</h2>
<p>租户 {{ selectedTenantId || '—' }} · 共 {{ filteredRows.length }} 个收货仓</p>
</div>
<el-button
v-if="access.can('platform:warehouse:create')"
type="primary"
:disabled="!warehouseReady"
@click="openCreate"
><Plus :size="16" />新增仓库</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="pagedRows"
:columns="warehouseColumns"
:loading="loading"
:total="filteredRows.length"
:page-sizes="[10, 20, 50]"
row-key="id"
aria-label="平台收货仓列表"
:empty-text="loadedTenantId ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
>
<template #actions="{ row }">
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!warehouseReady" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:warehouse:status')"
link
:type="(row as WarehouseRow).status === 'ACTIVE' ? 'danger' : 'success'"
:disabled="!warehouseReady"
:loading="statusLoadingIds.has((row as WarehouseRow).id)"
@click="toggleStatus(row as WarehouseRow)"
><Power :size="15" />{{ (row as WarehouseRow).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
<span v-else class="warehouse-subline">仅查看</span>
</template>
<template #warehouse="{ row }"><div class="warehouse-cell"><span><WarehouseIcon :size="18" /></span><div><strong>{{ (row as WarehouseRow).name }}</strong><small>{{ (row as WarehouseRow).code }}</small></div></div></template>
<template #city="{ row }">{{ [(row as WarehouseRow).province, (row as WarehouseRow).city].filter(Boolean).join(' · ') || '-' }}</template>
<template #address="{ row }"><div class="warehouse-address"><MapPin :size="15" />{{ (row as WarehouseRow).address || '详情暂未加载' }}</div></template>
<template #contact="{ row }"><strong>{{ (row as WarehouseRow).contactName || '-' }}</strong><span class="warehouse-subline">{{ (row as WarehouseRow).contactPhone || '-' }}</span></template>
<template #status="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #pagination-left>共 {{ filteredRows.length }} 个收货仓</template>
</DataTable>
</section>
<el-drawer v-model="editorOpen" :title="editorLabel" size="620px" class="business-drawer editor-drawer" :close-on-click-modal="false">
<el-form :model="form" label-position="top" class="drawer-form" :disabled="saving">
<div class="form-section"><h3>仓库标识</h3><div class="form-grid two-cols"><el-form-item label="仓库编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 CN-NB-01" :aria-invalid="Boolean(formErrors.code)" :aria-describedby="formErrors.code ? 'warehouse-code-error' : undefined" @input="clearFieldError('code')" @blur="validateRequiredField('code')" /><p v-if="formErrors.code" id="warehouse-code-error" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item><el-form-item label="仓库名称" required :class="{ 'has-field-error': formErrors.name }"><el-input v-model="form.name" maxlength="120" placeholder="请输入客户下单时显示的名称" :aria-invalid="Boolean(formErrors.name)" :aria-describedby="formErrors.name ? 'warehouse-name-error' : undefined" @input="clearFieldError('name')" @blur="validateRequiredField('name')" /><p v-if="formErrors.name" id="warehouse-name-error" class="field-error" role="alert">{{ formErrors.name }}</p></el-form-item><el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item></div></div>
<div class="form-section"><h3>地址与收货</h3><div class="form-grid two-cols"><el-form-item label="省份"><el-input v-model="form.province" maxlength="100" placeholder="例如 广东省" /></el-form-item><el-form-item label="城市" required :class="{ 'has-field-error': formErrors.city }"><el-input v-model="form.city" maxlength="100" placeholder="例如 深圳市" :aria-invalid="Boolean(formErrors.city)" :aria-describedby="formErrors.city ? 'warehouse-city-error' : undefined" @input="clearFieldError('city')" @blur="validateRequiredField('city')" /><p v-if="formErrors.city" id="warehouse-city-error" class="field-error" role="alert">{{ formErrors.city }}</p></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="请输入仓库联系人姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" placeholder="请输入手机号或固定电话" /></el-form-item><el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" placeholder="例如 周一至周六 08:30-18:00" /></el-form-item></div><el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" placeholder="请输入街道、园区、楼栋及库位信息" :aria-invalid="Boolean(formErrors.address)" :aria-describedby="formErrors.address ? 'warehouse-address-error' : undefined" @input="clearFieldError('address')" @blur="validateRequiredField('address')" /><p v-if="formErrors.address" id="warehouse-address-error" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item></div>
<div class="form-section"><h3>备注</h3><el-form-item label="客户可理解的收货说明"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="例如 预约要求、进仓指引或其他注意事项" /></el-form-item></div>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse">保存仓库</el-button></div></template>
</el-drawer>
</div>
</template>
<style scoped>
.state-alert p { margin: 6px 0 10px; line-height: 1.55; }
.warehouse-table-state { min-height: 180px; }
.warehouse-cell { display: flex; align-items: center; gap: 10px; }
.warehouse-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
.warehouse-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.warehouse-cell strong { overflow-wrap: anywhere; }
.warehouse-cell small, .warehouse-subline { display: block; color: var(--cc-muted); font-size: 11px; }
.warehouse-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
.warehouse-address svg { flex: 0 0 15px; margin-top: 2px; color: var(--cc-muted); }
.field-error { width: 100%; margin: 6px 0 0; color: var(--el-color-danger); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; }
:deep(.has-field-error .el-input__wrapper) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
:deep(.has-field-error .el-textarea__inner) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
@media (max-width: 720px) {
.panel-heading .el-button { width: 100%; }
}
</style>