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

175 lines
12 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, onActivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Eye, FileText, Pencil, Plus, Power } 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 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 { 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 | '' }
const filters = ref<Filters>({ status: '', region: '', keyword: '', carrierCode: '' })
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 }] : []),
{ 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: 230, slotName: 'name' },
{ key: 'category', label: '类别', width: 90, slotName: 'category' },
{ key: 'carrierCodes', label: '可代理船公司', minWidth: 240, slotName: 'carrierCodes' },
{ key: 'region', label: '地区', minWidth: 110 },
{ key: 'legalRepresentative', label: '法人', minWidth: 120 },
{ key: 'contactName', label: '联系人', minWidth: 110 },
{ key: 'phone', label: '联系电话', minWidth: 150 },
{ key: 'businessLicenseNo', label: '营业执照号', minWidth: 190 },
{ 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, 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}供应商`, { 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>
<ListPageLayout aria-label="供应商列表">
<template #toolbar>
<div class="supplier-list-top">
<div class="supplier-metrics" aria-label="供应商分类统计">
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">
<span>{{ item.label }}</span><strong>{{ item.value }}<small>家</small></strong>
</button>
</div>
<div class="supplier-query-panel">
<nav class="supplier-tabs" aria-label="供应商分类">
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">{{ categoryLabels[item.category] }}</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">
<el-button size="small" plain type="primary" @click="router.push({ name: 'supplier-detail', params: { id: row.id } })"><Eye :size="15" />详情</el-button>
<el-button v-if="access.can('platform:supplier:edit')" size="small" plain type="primary" @click="router.push({ name: 'supplier-edit', params: { id: row.id } })"><Pencil :size="15" />编辑</el-button>
<el-button v-if="access.can('platform:supplier:status')" size="small" plain :type="row.status === 'ACTIVE' ? 'danger' : 'success'" :loading="actionId === row.id" @click="toggleStatus(supplierRow(row))"><Power :size="15" />{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
</template>
<template #name="{ row }"><strong class="supplier-name">{{ row.name }}</strong></template>
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
<template #carrierCodes="{ row }">{{ supplierRow(row).category === 'BOOKING' ? formatCarrierCodes(supplierRow(row).carrierCodes) : '—' }}</template>
<template #license="{ row }"><el-button size="small" plain type="primary" @click="showLicense(supplierRow(row))"><FileText :size="15" />查看</el-button></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[row.status as SupplierStatus]" /></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: 16px; }.supplier-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }.supplier-metrics button { min-width: 0; padding: 12px 14px; border: 1px solid #e7edf5; border-radius: 8px; background: #f8fafc; color: var(--cc-text); text-align: left; cursor: pointer; }.supplier-metrics button.active { border-color: var(--cc-primary); background: #f7f8ff; box-shadow: inset 0 3px 0 var(--cc-primary); }.supplier-metrics span { display: block; margin-bottom: 6px; color: #64748b; font-size: 12px; }.supplier-metrics strong { color: #1e293b; font-size: 20px; }.supplier-metrics small { margin-left: 3px; color: #64748b; font-size: 12px; }.supplier-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.supplier-query-toolbar { border: 0; }.supplier-tabs { display: flex; gap: 6px; padding: 10px 14px; overflow-x: auto; border-bottom: 1px solid var(--cc-border); background: #fafbfc; }.supplier-tabs button { min-width: 76px; padding: 7px 20px; border: 0; border-radius: 6px; background: #f1f3f5; color: #555; cursor: pointer; }.supplier-tabs button.active { background: var(--cc-primary); color: #fff; font-weight: 650; }
.row-actions { display: flex; align-items: center; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 8px; }.supplier-name { overflow-wrap: anywhere; }
@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>