fix(F006): rating page UI fixes (menu highlight strict match / no stats / inline search labels / real API + industry column)

This commit is contained in:
zou 2026-08-22 21:16:56 +08:00
parent fd0d43be7e
commit 2cdf5b2ee5
2 changed files with 139 additions and 97 deletions

View File

@ -57,7 +57,10 @@ function logout() {
router.push('/login') router.push('/login')
} }
function isActive(path: string): boolean { function isActive(path: string): boolean {
return path === '/' ? route.path === '/' : route.path.startsWith(path) if (path === '/') return route.path === '/'
// 严格匹配: route.path 完全等于 path, 或 route.path 是 path 的子路径 (path 后必须是 '/')
// 避免 '/user/enterprise/rating' 同时匹配 '/user/enterprise' (前缀误激活)
return route.path === path || route.path.startsWith(path + '/')
} }
/** /**

View File

@ -1,108 +1,122 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 供应商评级管理 - W16 + UI 审计补完 (2026-08-22) * F006 供应商评级管理 - 2026-08-22 UI 整改
* *
* 端点: 4 URL 探测 (enterpriserating/page, /list, /search, /queryPage); 404 → FALLBACK seed * 后端端点: GET /user/enterprise/getRatingPage (RatingVO 返回)
* 参考老后台: /vue/statistics/index-town#/user/enterprise/enterprise-rating
*
* 老后台搜索字段: 企业id / 企业名称 / 手机号 / 行业类型 / 账号状态 / 供应商等级
* 老后台列: 供应商id / 供应商信息 / 等级 / 客户评价 / 履约评价 / 奖惩 / 最终 / 评级时间
*/ */
import { ref, reactive, onMounted, computed } from 'vue' import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue' import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar, { type SearchField } from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue' import TableActions from '@/components/TableActions/TableActions.vue'
interface RatingRow { id:string; enterpriseId:string; enterpriseName:string; supplierLevel:string; clientScore:number; performanceScore:number; awardScore:number; finalScore:number; deadline:string } interface RatingRow {
id: string
enterpriseName: string
industry?: string
supplierLevel: string
clientScore: number
performanceScore: number
awardScore: number
finalScore: number
deadline: string
}
const ENDPOINTS = [ const ENDPOINTS = [
'/user/performancescorerecord/page', '/user/enterprise/getRatingPage',
'/user/performancescorerecord/list' '/user/enterprise/ratingExport'
] ]
const FALLBACK: RatingRow[] = [ const FALLBACK: RatingRow[] = [
{ id:'R001', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', supplierLevel:'A 战略级', clientScore:96, performanceScore:92, awardScore:5, finalScore:193, deadline:'2026-06-30 23:59:59' }, { id:'R001', enterpriseName:'坚美建筑材料有限公司', industry:'建材', supplierLevel:'A 战略级', clientScore:96, performanceScore:92, awardScore:5, finalScore:193, deadline:'2026-06-30 23:59:59' },
{ id:'R002', enterpriseId:'s20016', enterpriseName:'华建门窗厂', supplierLevel:'B 核心级', clientScore:88, performanceScore:85, awardScore:3, finalScore:176, deadline:'2026-06-30 23:59:59' }, { id:'R002', enterpriseName:'华建门窗厂', industry:'门窗', supplierLevel:'B 核心级', clientScore:88, performanceScore:85, awardScore:3, finalScore:176, deadline:'2026-06-30 23:59:59' },
{ id:'R003', enterpriseId:'s20017', enterpriseName:'融鑫管业', supplierLevel:'B 核心级', clientScore:84, performanceScore:88, awardScore:0, finalScore:172, deadline:'2026-06-30 23:59:59' }, { id:'R003', enterpriseName:'融鑫管业', industry:'管业', supplierLevel:'B 核心级', clientScore:84, performanceScore:88, awardScore:0, finalScore:172, deadline:'2026-06-30 23:59:59' }
{ id:'R004', enterpriseId:'s20018', enterpriseName:'东方五金机械', supplierLevel:'A 战略级', clientScore:92, performanceScore:90, awardScore:8, finalScore:190, deadline:'2026-06-30 23:59:59' },
{ id:'R005', enterpriseId:'s20019', enterpriseName:'美洁涂料', supplierLevel:'C 成长级', clientScore:78, performanceScore:76, awardScore:-2, finalScore:152, deadline:'2026-06-30 23:59:59' },
{ id:'R006', enterpriseId:'s20018', enterpriseName:'东方五金机械', supplierLevel:'B 核心级', clientScore:86, performanceScore:82, awardScore:2, finalScore:170, deadline:'2026-06-30 23:59:59' },
{ id:'R007', enterpriseId:'s20021', enterpriseName:'顺达物流', supplierLevel:'D 入门级', clientScore:65, performanceScore:68, awardScore:0, finalScore:133, deadline:'2026-06-30 23:59:59' },
{ id:'R008', enterpriseId:'s20022', enterpriseName:'众成厨具', supplierLevel:'C 成长级', clientScore:75, performanceScore:72, awardScore:-3, finalScore:144, deadline:'2026-06-30 23:59:59' },
{ id:'R009', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', supplierLevel:'A 战略级', clientScore:94, performanceScore:91, awardScore:6, finalScore:191, deadline:'2026-06-30 23:59:59' },
{ id:'R010', enterpriseId:'s20024', enterpriseName:'华建门窗厂', supplierLevel:'A 战略级', clientScore:90, performanceScore:89, awardScore:4, finalScore:183, deadline:'2026-06-30 23:59:59' }
] ]
type Col = { prop?: string; label: string; minWidth?: number; width?: number; slot?: string; fixed?: 'right'; align?: 'center' | 'left' | 'right' } type Col = { prop?: string; label: string; minWidth?: number; width?: number; slot?: string; fixed?: 'right'; align?: 'center' | 'left' | 'right' }
const columns: Col[] = [ const columns: Col[] = [
{ prop: 'enterpriseName', label: '供应商', minWidth: 180 }, { prop: 'enterpriseName', label: '供应商', minWidth: 180 },
{ prop: 'supplierLevel', label: '等级', minWidth: 110, slot: 'level', align: 'center' }, { prop: 'supplierLevel', label: '供应商等级', minWidth: 110, slot: 'level', align: 'center' },
{ prop: 'clientScore', label: '客户评价', minWidth: 110, align: 'center' }, { prop: 'industry', label: '所属行业', minWidth: 120, align: 'center' },
{ prop: 'performanceScore', label: '履约评价', minWidth: 110, align: 'center' }, { prop: 'clientScore', label: '客户评价', minWidth: 100, align: 'center' },
{ prop: 'awardScore', label: '奖惩分', minWidth: 100, slot: 'award', align: 'center' }, { prop: 'performanceScore', label: '履约评价', minWidth: 100, align: 'center' },
{ prop: 'awardScore', label: '奖惩加减分', minWidth: 110, slot: 'award', align: 'center' },
{ prop: 'finalScore', label: '最终得分', minWidth: 110, slot: 'final', align: 'center' }, { prop: 'finalScore', label: '最终得分', minWidth: 110, slot: 'final', align: 'center' },
{ prop: 'deadline', label: '评级时间', minWidth: 170 }, { prop: 'deadline', label: '评级时间', minWidth: 170 },
{ label: '操作', width: 180, slot: 'actions', fixed: 'right', align: 'center' } { label: '操作', width: 200, slot: 'actions', fixed: 'right', align: 'center' }
] ]
const list = ref<RatingRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const selection = ref<RatingRow[]>([]) const list = ref<RatingRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const selection = ref<RatingRow[]>([])
const filterKw = ref('') const filterKw = ref('')
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('') const dataSource = ref<'api' | 'mock' | 'loading'>('loading'); const apiError = ref<string>('')
// inline 表单搜索字段 (label 在输入框左侧)
const searchFields = reactive({
enterpriseName: '',
linkMobile: '',
supplierLevel: ''
})
async function load() { async function load() {
loading.value = true; dataSource.value = 'loading' loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) { for (const ep of ENDPOINTS) {
try { try {
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value }) const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value, ...searchFields })
const rows = (data?.list || []).map((it: any) => ({ ...it, raw: it })) const rows = (data?.list || data?.data?.list || []).map((it: any) => ({
list.value = rows; total.value = Number(data?.total || rows.length); dataSource.value = 'api'; apiError.value = ''; loading.value = false; return id: String(it.id ?? ''),
enterpriseName: it.enterpriseName || '-',
industry: it.industry || '-',
supplierLevel: it.supplierLevel || '-',
clientScore: Number(it.clientScore) || 0,
performanceScore: Number(it.performanceScore) || 0,
awardScore: Number(it.awardScore) || 0,
finalScore: Number(it.finalScore) || 0,
deadline: it.deadline || ''
}))
list.value = rows
total.value = Number(data?.total || data?.data?.total || rows.length)
dataSource.value = 'api'; apiError.value = ''
loading.value = false; return
} catch {} } catch {}
} }
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false // 所有端点失败, fallback
list.value = FALLBACK
total.value = FALLBACK.length
dataSource.value = 'mock'
apiError.value = '后端 API 暂未实装, 已加载前端示例数据'
loading.value = false
} }
const filtered = computed(() => { const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value const kw = filterKw.value.trim().toLowerCase()
if (kw) rs = rs.filter(r => String(r.enterpriseName).toLowerCase().includes(kw) || String(r.id).toLowerCase().includes(kw)) let rs = list.value
if (kw) rs = rs.filter(r => String(r.enterpriseName).toLowerCase().includes(kw))
return rs return rs
}) })
// 统计 + 平均分 (用可选链防御 API 字段缺失)
const stats = computed(() => {
const rs = filtered.value
const aCount = rs.filter(r => r.supplierLevel?.startsWith('A') ?? false).length
const avgFinal = rs.length ? Math.round(rs.reduce((s, r) => s + (r.finalScore || 0), 0) / rs.length) : 0
const avgClient = rs.length ? Math.round(rs.reduce((s, r) => s + (r.clientScore || 0), 0) / rs.length) : 0
return [
{ label: '评级总数', value: rs.length, icon: 'Medal', color: 'var(--brand)' },
{ label: 'A 战略级', value: aCount, icon: 'Top', color: 'var(--green)' },
{ label: '平均最终得分', value: avgFinal, icon: 'Trophy', color: 'var(--amber)' },
{ label: '平均客户评价', value: avgClient, icon: 'Star', color: 'var(--purple)' }
]
})
const searchFields: SearchField[] = [
{ key: 'enterpriseName', label: '供应商', type: 'text', placeholder: '搜索供应商名称' },
{ key: 'id', label: '评级 ID', type: 'text', placeholder: '如 R001' }
]
const filters = reactive<Record<string, any>>({ id: '', enterpriseName: '' })
function onSearch() {
filterKw.value = `${filters.id || ''}|${filters.enterpriseName || ''}`
page.value = 1
}
function onReset() { filters.id = ''; filters.enterpriseName = ''; filterKw.value = ''; page.value = 1 }
function onSelectionChange(rows: RatingRow[]) { selection.value = rows } function onSelectionChange(rows: RatingRow[]) { selection.value = rows }
function onSearch() { page.value = 1; load() }
function onReset() { searchFields.enterpriseName = ''; searchFields.linkMobile = ''; searchFields.supplierLevel = ''; filterKw.value = ''; page.value = 1 }
function onView(row: RatingRow) { ElMessage.info(`详情 ${row.id}`) } function onView(row: RatingRow) { ElMessage.info(`详情 ${row.enterpriseName}`) }
function onEdit(row: RatingRow) { ElMessage.success('已发起调整 (前端示例)') } function onEdit(row: RatingRow) { ElMessage.success('已发起调整 (前端示例)') }
async function onBatchExport() { async function onBatchExport() {
await ElMessageBox.confirm(`确认导出选中的 ${selection.value.length} 条评级?`, '批量导出', { type: 'info' }).catch(() => {}) await ElMessageBox.confirm(`确认导出选中的 ${selection.value.length} 条评级?`, '批量导出', { type: 'info' }).catch(() => {})
ElMessage.success(`已发起批量导出 (前端示例, 共 ${selection.value.length} 条)`) ElMessage.success(`已发起批量导出 (前端示例, 共 ${selection.value.length} 条)`)
} }
function onExport() { ElMessage.success(`已发起导出 (前端示例, 共 ${filtered.value.length} 条)`) } function onExport() { ElMessage.success(`已发起导出 (前端示例, 共 ${list.value.length} 条)`) }
function levelPill(r: RatingRow) { function levelPill(r: RatingRow) {
if (r.supplierLevel?.startsWith('A')) return { label: r.supplierLevel, color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' } const lvl = r.supplierLevel || ''
if (r.supplierLevel?.startsWith('B')) return { label: r.supplierLevel, color: 'var(--brand)', bg: 'var(--brand-soft)' } if (lvl.startsWith('A')) return { color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' }
if (r.supplierLevel?.startsWith('C')) return { label: r.supplierLevel, color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' } if (lvl.startsWith('B')) return { color: 'var(--brand)', bg: 'var(--brand-soft)' }
return { label: r.supplierLevel ?? '-', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' } if (lvl.startsWith('C')) return { color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' }
return { color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
} }
onMounted(load) onMounted(load)
@ -114,21 +128,25 @@ onMounted(load)
<el-button @click="onExport">导出明细</el-button> <el-button @click="onExport">导出明细</el-button>
</PageHeader> </PageHeader>
<div class="stats-grid"> <!-- 内联搜索栏 (label 在输入框左侧) -->
<div v-for="(s, i) in stats" :key="i" class="stat-card">
<div class="stat-icon" :style="{ color: s.color, background: 'var(--brand-soft)' }">
<el-icon><component :is="s.icon" /></el-icon>
</div>
<div class="stat-info">
<div class="stat-label">{{ s.label }}</div>
<div class="stat-value">{{ s.value }}</div>
</div>
</div>
</div>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" @reset="onReset" />
<div class="rr-list-card"> <div class="rr-list-card">
<el-form :inline="true" class="rr-search-inline">
<el-form-item label="供应商名称">
<el-input v-model="searchFields.enterpriseName" placeholder="请输入供应商名称" clearable style="width:200px" @keyup.enter="onSearch" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="searchFields.linkMobile" placeholder="请输入手机号" clearable style="width:160px" @keyup.enter="onSearch" />
</el-form-item>
<el-form-item label="供应商等级">
<el-input v-model="searchFields.supplierLevel" placeholder="如 A 战略级" clearable style="width:140px" @keyup.enter="onSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 批量操作栏 -->
<div class="batch-bar" :class="{ 'has-selection': selection.length > 0 }"> <div class="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
<span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 条评级</span> <span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 条评级</span>
<span v-else class="batch-hint">勾选后可批量导出</span> <span v-else class="batch-hint">勾选后可批量导出</span>
@ -137,14 +155,25 @@ onMounted(load)
</div> </div>
</div> </div>
<DataTable :columns="columns" :data="filtered" <DataTable
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id" :columns="columns"
:data="list"
:loading="loading"
row-key="id"
borderless
empty-text="暂无评级"
:show-pagination="true"
:page="page"
:limit="limit"
:total="total"
:page-sizes="[10, 20, 50, 100]"
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
@page-change="(p: number) => { page = p; load() }" @page-change="(p: number) => { page = p; load() }"
@limit-change="(l: number) => { limit = l; page = 1; load() }"> @limit-change="(l: number) => { limit = l; page = 1; load() }"
>
<template #level="{ row }"> <template #level="{ row }">
<span class="st-pill" :style="{ color: levelPill(row).color, background: levelPill(row).bg }"> <span class="st-pill" :style="{ color: levelPill(row).color, background: levelPill(row).bg }">
<span class="st-dot"></span><span class="truncate-text">{{ levelPill(row).label }}</span> <span class="st-dot"></span><span class="truncate-text">{{ row.supplierLevel }}</span>
</span> </span>
</template> </template>
<template #award="{ row }"> <template #award="{ row }">
@ -171,24 +200,34 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { padding: 16px; } .rr-page { padding: 16px; }
.stats-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; /* inline 搜索栏: label 在输入框左侧, 与查询按钮同行 */
margin-bottom: 16px; .rr-search-inline {
padding: 12px 16px 0;
background: var(--bg-soft);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
:deep(.el-form-item) {
margin-bottom: 12px;
margin-right: 16px;
}
:deep(.el-form-item__label) {
font-weight: 500;
color: var(--ink-2);
padding-right: 8px;
}
} }
.stat-card {
background: var(--surface); border: 1px solid var(--border); /* 长文本截断 (level pill 文本过长) */
border-radius: var(--radius-lg); padding: 16px 18px; .truncate-text {
display: flex; align-items: center; gap: 12px; display: inline-block;
transition: all .15s; max-width: 90px;
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow); } overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
} }
.stat-icon {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 20px;
}
.stat-info { flex: 1; min-width: 0; }
.stat-label { font-size: 12px; color: var(--ink-3); margin-bottom: 4px; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }
.truncate-text { display: inline-block; max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style> </style>