Files
admin-web/src/modules/suppliers/pages/SuppliersView.vue
T

174 lines
11 KiB
Vue
Raw Normal View History

2026-08-13 11:57:44 +08:00
<script setup lang="ts">
2026-09-15 09:58:15 +08:00
import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, ref, watch } from 'vue'
2026-08-13 11:57:44 +08:00
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
2026-09-15 09:58:15 +08:00
import { Plus } from '@lucide/vue'
2026-08-13 11:57:44 +08:00
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'
2026-09-03 21:25:56 +08:00
import LineClampText from '@/shared/components/LineClampText.vue'
2026-08-13 11:57:44 +08:00
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'
2026-09-16 11:06:36 +08:00
import { truckingScopeOptions, formatTruckingScope, type TruckingScope, carrierCodeOptions, categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type CarrierCode, type Supplier, type SupplierCategory, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
2026-08-13 11:57:44 +08:00
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)
2026-09-16 11:06:36 +08:00
type Filters = QueryModel & { status: SupplierStatus | ''; region: string; keyword: string; carrierCode: CarrierCode | ''; truckingScope: TruckingScope | '' }
const filters = ref<Filters>({ status: '', region: '', keyword: '', carrierCode: '', truckingScope: '' })
2026-08-13 11:57:44 +08:00
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 }] : []),
2026-09-16 11:06:36 +08:00
...(activeCategory.value === 'TRUCKING' ? [{ key: 'truckingScope', type: 'select' as const, label: '拖车类型', placeholder: '全部拖车类型', width: 170, options: truckingScopeOptions }] : []),
2026-08-13 11:57:44 +08:00
{ 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' },
2026-09-03 21:25:56 +08:00
{ key: 'name', label: '供应商名称', minWidth: 200, slotName: 'name' },
{ key: 'category', label: '类别', width: 70, slotName: 'category' },
2026-09-16 11:06:36 +08:00
...(activeCategory.value === 'BOOKING' ? [{ key: 'carrierCodes', label: '可代理船公司', minWidth: 200, slotName: 'carrierCodes' }] : []),
...(activeCategory.value === 'TRUCKING' ? [{ key: 'truckingScope', label: '拖车类型', width: 110, slotName: 'truckingScope' }] : []),
2026-09-03 21:25:56 +08:00
{ 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 },
2026-08-13 11:57:44 +08:00
{ 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([
2026-09-16 11:06:36 +08:00
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 }),
2026-08-13 11:57:44 +08:00
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
2026-08-17 16:45:46 +08:00
page.value = 1
2026-08-13 11:57:44 +08:00
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 {
2026-09-15 09:58:15 +08:00
await ElMessageBox.confirm(`确认${verb}供应商“${row.name}”吗?`, `${verb}供应商`, { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
2026-08-13 11:57:44 +08:00
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
2026-08-13 11:57:44 +08:00
onMounted(load)
onActivated(() => {
if (activatedOnce) void load()
else activatedOnce = true
})
2026-08-13 11:57:44 +08:00
</script>
<template>
2026-09-15 09:58:15 +08:00
<AdminPage class="admin-density">
2026-08-13 11:57:44 +08:00
<ListPageLayout aria-label="供应商列表">
<template #toolbar>
2026-08-14 14:27:03 +08:00
<div class="supplier-list-top">
<div class="supplier-query-panel">
2026-09-15 09:58:15 +08:00
<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>
2026-08-14 14:27:03 +08:00
</nav>
<QueryToolbar v-model="filters" class="supplier-query-toolbar" ariaLabel="供应商筛选" :fields="queryFields" @search="search" />
</div>
</div>
2026-08-13 11:57:44 +08:00
</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 }">
2026-09-15 09:58:15 +08:00
<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>
2026-08-13 11:57:44 +08:00
</div>
</template>
2026-09-03 21:25:56 +08:00
<template #name="{ row }"><LineClampText :content="row.name" /></template>
2026-08-13 11:57:44 +08:00
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
2026-09-16 11:06:36 +08:00
<template #truckingScope="{ row }">{{ formatTruckingScope(supplierRow(row).truckingScope) }}</template>
2026-09-03 21:25:56 +08:00
<template #carrierCodes="{ row }"><LineClampText :content="supplierRow(row).category === 'BOOKING' ? formatCarrierCodes(supplierRow(row).carrierCodes) : null" /></template>
2026-09-15 09:58:15 +08:00
<template #license="{ row }"><el-button data-tone="neutral" class="density-action" size="small" @click="showLicense(supplierRow(row))">查看</el-button></template>
2026-09-03 20:20:21 +08:00
<template #status="{ row }"><StatusBadge :status="statusLabels[supplierRow(row).status] ?? '—'" /></template>
2026-08-13 11:57:44 +08:00
<template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template>
</DataTable>
</section>
</ListPageLayout>
<SupplierLicenseDialog v-model="licenseOpen" :supplier="licenseSupplier" />
</AdminPage>
</template>
<style scoped>
2026-09-15 09:58:15 +08:00
.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; }
2026-08-14 14:27:03 +08:00
@media (max-width: 720px) { .supplier-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 380px) { .supplier-metrics { grid-template-columns: 1fr; } }
2026-08-13 11:57:44 +08:00
</style>