Files
admin-web/src/modules/suppliers/pages/SuppliersView.vue
T
2026-09-16 11:06:36 +08:00

174 lines
11 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 '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
import { changeSupplierStatus, getSupplierStats, listSuppliers } from '@/modules/suppliers/api/platformSuppliers'
import { truckingScopeOptions, formatTruckingScope, type TruckingScope, carrierCodeOptions, categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type CarrierCode, type Supplier, type SupplierCategory, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
const router = useRouter()
const access = useAccessPreviewStore()
const canView = computed(() => access.can('platform:supplier:list'))
const loading = ref(false)
const rows = ref<Supplier[]>([])
const stats = ref<SupplierStats>({ booking: 0, customs: 0, trucking: 0, certificate: 0 })
const activeCategory = ref<SupplierCategory>('BOOKING')
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loadError = ref<ApiErrorDetails | null>(null)
const actionId = ref('')
const licenseOpen = ref(false)
const licenseSupplier = ref<Supplier | null>(null)
type Filters = QueryModel & { status: SupplierStatus | ''; region: string; keyword: string; carrierCode: CarrierCode | ''; truckingScope: TruckingScope | '' }
const filters = ref<Filters>({ status: '', region: '', keyword: '', carrierCode: '', truckingScope: '' })
const applied = ref<Filters>({ ...filters.value })
let sequence = 0
const queryFields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: [{ label: '合作中', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }] },
...(activeCategory.value === 'BOOKING' ? [{ key: 'carrierCode', type: 'select' as const, label: '可代理船公司', placeholder: '全部船公司', width: 190, options: carrierCodeOptions }] : []),
...(activeCategory.value === 'TRUCKING' ? [{ key: 'truckingScope', type: 'select' as const, label: '拖车类型', placeholder: '全部拖车类型', width: 170, options: truckingScopeOptions }] : []),
{ key: 'region', type: 'input', label: '地区', placeholder: '请输入地区', width: 170, props: { maxlength: 80 } },
{ key: 'keyword', type: 'input', label: '名称或联系人', placeholder: '供应商名称/联系人', width: 220, props: { maxlength: 160 } },
])
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ key: 'name', label: '供应商名称', minWidth: 200, slotName: 'name' },
{ key: 'category', label: '类别', width: 70, slotName: 'category' },
...(activeCategory.value === 'BOOKING' ? [{ key: 'carrierCodes', label: '可代理船公司', minWidth: 200, slotName: 'carrierCodes' }] : []),
...(activeCategory.value === 'TRUCKING' ? [{ key: 'truckingScope', label: '拖车类型', width: 110, slotName: 'truckingScope' }] : []),
{ key: 'region', label: '地区', minWidth: 100 },
{ key: 'legalRepresentative', label: '法人', minWidth: 100 },
{ key: 'contactName', label: '联系人', minWidth: 120 },
{ key: 'phone', label: '联系电话', minWidth: 120 },
{ key: 'businessLicenseNo', label: '营业执照号', minWidth: 120 },
{ key: 'license', label: '营业执照', width: 125, slotName: 'license' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
{ key: 'settlementPeriod', label: '结算周期', width: 120, slotName: 'settlement' },
])
const metricItems = computed(() => [
{ category: 'BOOKING' as const, label: '订舱供应商', value: stats.value.booking },
{ category: 'CUSTOMS' as const, label: '报关供应商', value: stats.value.customs },
{ category: 'TRUCKING' as const, label: '拖车供应商', value: stats.value.trucking },
{ category: 'CERTIFICATE' as const, label: '证书供应商', value: stats.value.certificate },
])
async function load() {
if (!canView.value) return
const current = ++sequence
loading.value = true
loadError.value = null
try {
const [result, counts] = await Promise.all([
listSuppliers({ page: page.value, size: pageSize.value, category: activeCategory.value, status: applied.value.status, region: applied.value.region, keyword: applied.value.keyword, truckingScope: activeCategory.value === 'TRUCKING' ? applied.value.truckingScope || undefined : undefined, carrierCode: activeCategory.value === 'BOOKING' ? applied.value.carrierCode || undefined : undefined }),
getSupplierStats(),
])
if (current !== sequence) return
rows.value = result.records
total.value = result.total
stats.value = counts
} catch (error) {
if (current !== sequence) return
rows.value = []; total.value = 0
loadError.value = getApiErrorDetails(error, '供应商列表加载失败')
} finally { if (current === sequence) loading.value = false }
}
function search() { applied.value = { ...filters.value }; page.value = 1; void load() }
function switchCategory(category: SupplierCategory) {
activeCategory.value = category
page.value = 1
void load()
}
function showLicense(row: Supplier) { licenseSupplier.value = row; licenseOpen.value = true }
function supplierRow(row: object) { return row as Supplier }
async function toggleStatus(row: Supplier) {
const next: SupplierStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const verb = next === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确认${verb}供应商“${row.name}”吗?`, `${verb}供应商`, { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
actionId.value = row.id
await changeSupplierStatus(row.id, next)
ElMessage.success(`供应商已${verb}`)
await load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
const details = getApiErrorDetails(error, `${verb}供应商失败`)
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally { actionId.value = '' }
}
watch([page, pageSize], ([newPage, newSize], [oldPage, oldSize]) => { if (newPage !== oldPage || newSize !== oldSize) void load() })
let activatedOnce = false
onMounted(load)
onActivated(() => {
if (activatedOnce) void load()
else activatedOnce = true
})
</script>
<template>
<AdminPage class="admin-density">
<ListPageLayout aria-label="供应商列表">
<template #toolbar>
<div class="supplier-list-top">
<div class="supplier-query-panel">
<nav class="supplier-tabs density-tabs" role="tablist" aria-label="供应商分类统计">
<button role="tab" :aria-selected="activeCategory === item.category" v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)"><span class="density-tab-icon" aria-hidden="true">{{ ({ BOOKING: '🚢', CUSTOMS: '📋', TRUCKING: '🚚', CERTIFICATE: '📄' })[item.category] }}</span>{{ categoryLabels[item.category] }}({{ item.value }})</button>
</nav>
<QueryToolbar v-model="filters" class="supplier-query-toolbar" ariaLabel="供应商筛选" :fields="queryFields" @search="search" />
</div>
</div>
</template>
<template #state>
<LoadErrorState v-if="!canView" title="暂无供应商查看权限" message="请联系管理员开通供应商查看权限。" :show-retry="false" />
<LoadErrorState v-else-if="loadError" title="供应商加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section v-if="canView" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" empty-text="暂无供应商数据" preference-key="supplier-list">
<template #toolbar-actions>
<el-button v-if="access.can('platform:supplier:create')" type="primary" @click="router.push({ name: 'supplier-create' })"><Plus :size="16" />新增供应商</el-button>
</template>
<template #actions="{ row }">
<div class="row-actions density-actions">
<el-button data-tone="neutral" class="density-action" size="small" @click="router.push({ name: 'supplier-detail', params: { id: row.id } })">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="access.can('platform:supplier:edit')" size="small" @click="router.push({ name: 'supplier-edit', params: { id: row.id } })">编辑</el-button>
<el-button class="density-action" v-if="access.can('platform:supplier:status')" size="small" :data-tone="row.status === 'ACTIVE' ? 'danger' : 'success'" :loading="actionId === row.id" @click="toggleStatus(supplierRow(row))">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
</template>
<template #name="{ row }"><LineClampText :content="row.name" /></template>
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
<template #truckingScope="{ row }">{{ formatTruckingScope(supplierRow(row).truckingScope) }}</template>
<template #carrierCodes="{ row }"><LineClampText :content="supplierRow(row).category === 'BOOKING' ? formatCarrierCodes(supplierRow(row).carrierCodes) : null" /></template>
<template #license="{ row }"><el-button data-tone="neutral" class="density-action" size="small" @click="showLicense(supplierRow(row))">查看</el-button></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[supplierRow(row).status] ?? '—'" /></template>
<template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template>
</DataTable>
</section>
</ListPageLayout>
<SupplierLicenseDialog v-model="licenseOpen" :supplier="licenseSupplier" />
</AdminPage>
</template>
<style scoped>
.supplier-list-top { display: grid; gap: var(--admin-space-6); }.supplier-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: #fff; }.supplier-query-toolbar { border: 0; }
@media (max-width: 720px) { .supplier-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 380px) { .supplier-metrics { grid-template-columns: 1fr; } }
</style>