fix(F006): 完善企业等级/评级/评分 + 行业页 UI 标准化 - 4页加顶部统计卡 + 批量栏 + TableActions + st-pill,消除宽度割裂感

This commit is contained in:
zou 2026-08-22 15:41:29 +08:00
parent cbb98742ff
commit 512111af3e
4 changed files with 524 additions and 176 deletions

View File

@ -72,6 +72,28 @@ function filterTree(nodes: Category[], kw: string): Category[] {
}
const displayList = computed(() => filterTree(LIST.value, search.name.trim()))
// 统计: 顶数 / 1级分类数 / 最深层级 / 已停用数
const stats = computed(() => {
const total = LIST.value.length
const roots = LIST.value.length
let maxDepth = 1
const flatten = (ns: Category[], d: number) => {
for (const n of ns) { if (d > maxDepth) maxDepth = d; if (n.children?.length) flatten(n.children, d + 1) }
}
flatten(LIST.value, 1)
let disabled = 0
const count = (ns: Category[]) => {
for (const n of ns) { if (n.status === 1) disabled++; if (n.children?.length) count(n.children) }
}
count(LIST.value)
return [
{ label: '分类总数', value: total, icon: 'Files', color: 'var(--brand)' },
{ label: '一级分类', value: roots, icon: 'FolderOpened', color: 'var(--green)' },
{ label: '最大层级', value: 'Lv' + maxDepth, icon: 'Histogram', color: 'var(--amber)' },
{ label: '已停用', value: disabled, icon: 'CircleClose', color: 'var(--red)' }
]
})
async function load() {
loading.value = true
try {
@ -189,6 +211,19 @@ onMounted(load)
</el-button>
</PageHeader>
<!-- 顶部统计卡 -->
<div class="stats-grid">
<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="search"
:fields="[{ key: 'name', label: '分类名称', type: 'text', placeholder: '按名称搜索' }]"
@ -273,6 +308,28 @@ onMounted(load)
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
/* 顶部统计卡 */
.stats-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 16px 18px;
display: flex; align-items: center; gap: 12px;
transition: all .15s;
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow); }
}
.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; }
.rr-list-card {
background: var(--surface); border-radius: 12px; overflow: hidden;
}

View File

@ -1,8 +1,6 @@
<script setup lang="ts">
/**
* F006 企业等级管理 - W16
*
* 沿用老 vue user/enterprise/enterprise-level.vue 字段
* F006 企业等级管理 - W16 + UI 审计补完 (2026-08-22)
*
* 端点: 4 URL 探测 (enterpriselevel/page, /list, /search, /queryPage); 404 → FALLBACK seed
*/
@ -10,36 +8,34 @@ import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import SearchBar, { type SearchField } from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface LevelRow { id:string; enterpriseLevel:string; scoreFrom:number; scoreTo:number; createTime:string }
interface LevelRow { id:string; enterpriseLevel:string; levelCode:string; scoreFrom:number; scoreTo:number; createTime:string }
const ENDPOINTS = [
'/user/enterpriseLevel/page',
'/user/enterpriseLevel/list'
]
const FALLBACK: LevelRow[] = [
{ id:'L001', enterpriseLevel:'A (战略级)', scoreFrom:90, scoreTo:100, createTime:'2026-01-05 10:00:00' },
{ id:'L002', enterpriseLevel:'B (核心级)', scoreFrom:80, scoreTo:89, createTime:'2026-01-05 10:05:00' },
{ id:'L003', enterpriseLevel:'C (成长级)', scoreFrom:70, scoreTo:79, createTime:'2026-01-05 10:10:00' },
{ id:'L004', enterpriseLevel:'D (入门级)', scoreFrom:60, scoreTo:69, createTime:'2026-01-05 10:15:00' },
{ id:'L005', enterpriseLevel:'E (观察级)', scoreFrom:0, scoreTo:59, createTime:'2026-01-05 10:20:00' }
{ id:'L001', enterpriseLevel:'A 战略级', levelCode:'A', scoreFrom:90, scoreTo:100, createTime:'2026-01-05 10:00:00' },
{ id:'L002', enterpriseLevel:'B 核心级', levelCode:'B', scoreFrom:80, scoreTo:89, createTime:'2026-01-05 10:05:00' },
{ id:'L003', enterpriseLevel:'C 成长级', levelCode:'C', scoreFrom:70, scoreTo:79, createTime:'2026-01-05 10:10:00' },
{ id:'L004', enterpriseLevel:'D 入门级', levelCode:'D', scoreFrom:60, scoreTo:69, createTime:'2026-01-05 10:15:00' },
{ id:'L005', enterpriseLevel:'E 观察级', levelCode:'E', scoreFrom:0, scoreTo:59, createTime:'2026-01-05 10:20:00' }
]
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
type Col = { prop?: string; label: string; minWidth?: number; width?: number; slot?: string; fixed?: 'right'; align?: 'center' | 'left' | 'right' }
const columns: Col[] = [
{ type:'selection', label:'', width:50 },
{ prop:'id', label:'ID', width:80, fixed:true },
{ prop:'enterpriseLevel', label:'等级', width:140 },
{ prop:'scoreFrom', label:'起始分', width:100 },
{ prop:'scoreTo', label:'截止分', width:100 },
{ prop:'actions', label:'操作', width:140, slot:'actions', fixed:'right' }
{ prop: 'enterpriseLevel', label: '等级', minWidth: 160 },
{ prop: 'scoreFrom', label: '起始分', minWidth: 110, align: 'center' },
{ prop: 'scoreTo', label: '截止分', minWidth: 110, align: 'center' },
{ prop: 'createTime', label: '创建时间', minWidth: 180 },
{ label: '操作', width: 180, slot: 'actions', fixed: 'right', align: 'center' }
]
const STATUS_DEFAULTS = [
{ value:'', label:'全部' }
]
const list = ref<LevelRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
const list = ref<LevelRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const selection = ref<LevelRow[]>([])
const filterKw = ref(''); const filterLevel = ref('')
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
@ -51,56 +47,176 @@ async function load() {
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (kw) rs = rs.filter(r => String(r.id).includes(kw) || String(r.enterpriseLevel).includes(kw))
if (filterLevel.value) rs = rs.filter(r => r.levelCode === filterLevel.value)
if (kw) rs = rs.filter(r => String(r.id).toLowerCase().includes(kw) || String(r.enterpriseLevel).toLowerCase().includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
{ key:'id', label:'ID', type:'text' },
{ key:'enterpriseLevel', label:'等级', type:'text' }
// 顶部统计卡
const stats = computed(() => {
const rs = filtered.value
const codeCount = new Set(rs.map(r => r.levelCode)).size
const maxFrom = rs.length ? Math.max(...rs.map(r => r.scoreFrom)) : 0
const minFrom = rs.length ? Math.min(...rs.map(r => r.scoreFrom)) : 0
return [
{ label: '等级总数', value: codeCount, icon: 'Medal', color: 'var(--brand)' },
{ label: '最高分数段', value: maxFrom + ' 分起', icon: 'Top', color: 'var(--green)' },
{ label: '最低分数段', value: minFrom + ' 分起', icon: 'Bottom', color: 'var(--ink-3)' },
{ label: '分数总区间', value: minFrom + ' ~ ' + (rs.length ? Math.max(...rs.map(r => r.scoreTo)) : 0), icon: 'Histogram', color: 'var(--amber)' }
]
})
const searchFields: SearchField[] = [
{ key:'enterpriseLevel', label:'等级名称', type:'text', placeholder:'如 A 战略级' },
{ key:'id', label:'ID', type:'text', placeholder:'如 L001' }
]
const filters = reactive<Record<string, any>>({})
function onSearch() { filterKw.value = String(filters.id || filters.enterpriseLevel || ''); page.value = 1 }
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
function onView(row: LevelRow) { ElMessage.info(`查看 ${row.id}`) }
async function onEdit(row: LevelRow) {
ElMessage.success('编辑成功 (前端示例)')
const filters = reactive<Record<string, any>>({ id: '', enterpriseLevel: '' })
function onSearch() {
filterKw.value = `${filters.id || ''}|${filters.enterpriseLevel || ''}`
page.value = 1
}
async function onDelete(row: LevelRow) {
try { await ElMessageBox.confirm(`确认删除等级「${row.enterpriseLevel}」?`, '提示', { type: 'warning' }) } catch { return }
function onReset() {
filters.id = ''; filters.enterpriseLevel = ''
filterKw.value = ''; page.value = 1
}
function onSelectionChange(rows: LevelRow[]) { selection.value = rows }
function onView(row: LevelRow) { ElMessage.info(`查看等级 ${row.enterpriseLevel}`) }
function onEdit(row: LevelRow) { editVisible.value = true; editForm.id = row.id; editForm.enterpriseLevel = row.enterpriseLevel; editForm.scoreFrom = row.scoreFrom; editForm.scoreTo = row.scoreTo }
function onDelete(row: LevelRow) {
ElMessageBox.confirm(`确认删除等级「${row.enterpriseLevel}」?`, '删除确认', { type: 'warning' }).then(() => {
ElMessage.success('已删除 (前端示例)')
load()
}).catch(() => {})
}
async function onBatchDelete() {
if (!selection.value.length) return
await ElMessageBox.confirm(`确认批量删除选中的 ${selection.value.length} 个等级?`, '批量删除', { type: 'warning' }).catch(() => {})
ElMessage.success(`已批量删除 ${selection.value.length} 条 (前端示例)`)
selection.value = []
}
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
// 新增/编辑 弹窗
const editVisible = ref(false); const editMode = ref<'add'|'edit'>('add')
const editForm = reactive({ id: '', enterpriseLevel: '', levelCode: 'A', scoreFrom: 90, scoreTo: 100 })
function onAdd() { editMode.value = 'add'; editForm.id = ''; editForm.enterpriseLevel = ''; editForm.levelCode = 'A'; editForm.scoreFrom = 90; editForm.scoreTo = 100; editVisible.value = true }
async function onSubmitEdit() {
if (!editForm.enterpriseLevel.trim()) { ElMessage.warning('请填写等级名称'); return }
if (editForm.scoreFrom > editForm.scoreTo) { ElMessage.warning('起始分不能大于截止分'); return }
ElMessage.success((editMode.value === 'add' ? '新增' : '修改') + '成功 (前端示例)')
editVisible.value = false
load()
}
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="企业等级管理" sub="供应商等级与对应分数段配置" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
<el-button type="primary" @click="onAdd">
<el-icon style="margin-right:4px"><Plus /></el-icon>新增等级
</el-button>
<el-button @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<!-- 顶部统计卡 -->
<div class="stats-grid">
<div v-for="(s, i) in stats" :key="i" class="stat-card">
<div class="stat-icon" :style="{ color: s.color }">
<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="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
<span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 个等级</span>
<span v-else class="batch-hint">勾选后可批量删除</span>
<div class="batch-actions">
<el-button size="small" type="danger" plain :disabled="!selection.length" @click="onBatchDelete">批量删除</el-button>
</div>
</div>
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
@selection-change="onSelectionChange"
@page-change="(p: number) => { page = p; load() }"
@limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #actions="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '查看', type: 'primary', onClick: onView },
{ label: '编辑', type: 'primary', onClick: onEdit },
{ label: '删除', type: 'danger', onClick: onDelete }
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
]"
/>
</template>
</DataTable>
</div>
<!-- 新增/编辑 弹窗 -->
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增等级' : '修改等级'" width="500px" :close-on-click-modal="false" destroy-on-close>
<el-form label-width="100px" label-position="right">
<el-form-item label="等级名称" required>
<el-input v-model="editForm.enterpriseLevel" placeholder="如 A 战略级" maxlength="30" show-word-limit clearable />
</el-form-item>
<el-form-item label="等级代码" required>
<el-select v-model="editForm.levelCode" style="width:100%">
<el-option v-for="c in ['A','B','C','D','E']" :key="c" :value="c" :label="`${c} 级`" />
</el-select>
</el-form-item>
<el-form-item label="起始分" required>
<el-input-number v-model="editForm.scoreFrom" :min="0" :max="100" :step="1" style="width:100%" />
</el-form-item>
<el-form-item label="截止分" required>
<el-input-number v-model="editForm.scoreTo" :min="0" :max="100" :step="1" style="width:100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmitEdit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
<style scoped lang="scss">
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
/* 顶部统计卡 */
.stats-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 16px 18px;
display: flex; align-items: center; gap: 12px;
transition: all .15s;
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow); }
}
.stat-icon {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 20px;
background: var(--brand-soft);
color: var(--brand);
}
.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; }
</style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts">
/**
* F006 供应商评级管理 - W16
*
* 沿用老 vue user/enterprise/enterprise-rating.vue 字段
* F006 供应商评级管理 - W16 + UI 审计补完 (2026-08-22)
*
* 端点: 4 URL 探测 (enterpriserating/page, /list, /search, /queryPage); 404 → FALLBACK seed
*/
@ -10,8 +8,9 @@ import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import SearchBar, { type SearchField } from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.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 }
const ENDPOINTS = [
@ -24,32 +23,27 @@ const FALLBACK: RatingRow[] = [
{ id:'R003', enterpriseId:'s20017', enterpriseName:'融鑫管业', 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:'s20020', enterpriseName:'万家灯具', supplierLevel:'B 核心级', clientScore:86, performanceScore:82, awardScore:2, finalScore:170, 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:'s20023', 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' },
{ id:'R011', enterpriseId:'s20025', enterpriseName:'融鑫管业', supplierLevel:'C 成长级', clientScore:74, performanceScore:78, awardScore:0, finalScore:152, deadline:'2026-06-30 23:59:59' },
{ id:'R012', enterpriseId:'s20026', enterpriseName:'美洁涂料', supplierLevel:'D 入门级', clientScore:62, performanceScore:65, awardScore:-1, finalScore:126, 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 = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
type Col = { prop?: string; label: string; minWidth?: number; width?: number; slot?: string; fixed?: 'right'; align?: 'center' | 'left' | 'right' }
const columns: Col[] = [
{ type:'selection', label:'', width:50 },
{ prop:'id', label:'评级ID', width:90, fixed:true },
{ prop:'enterpriseName', label:'供应商', width:200 },
{ prop:'supplierLevel', label:'等级', width:120, slot:'level' },
{ prop:'clientScore', label:'客户评价', width:100 },
{ prop:'performanceScore', label:'履约评价', width:100 },
{ prop:'awardScore', label:'奖惩分', width:90, slot:'award' },
{ prop:'finalScore', label:'最终得分', width:100, slot:'final' },
{ prop:'deadline', label:'评级时间', width:170, slot:'deadline' },
{ prop:'actions', label:'操作', width:180, slot:'actions', fixed:'right' }
{ prop: 'enterpriseName', label: '供应商', minWidth: 180 },
{ prop: 'supplierLevel', label: '等级', minWidth: 110, slot: 'level', align: 'center' },
{ prop: 'clientScore', label: '客户评价', minWidth: 110, align: 'center' },
{ prop: 'performanceScore', label: '履约评价', minWidth: 110, align: 'center' },
{ prop: 'awardScore', label: '奖惩分', minWidth: 100, slot: 'award', align: 'center' },
{ prop: 'finalScore', label: '最终得分', minWidth: 110, slot: 'final', align: 'center' },
{ prop: 'deadline', label: '评级时间', minWidth: 170 },
{ label: '操作', width: 180, slot: 'actions', fixed: 'right', align: 'center' }
]
const STATUS_DEFAULTS = [
{ value:'', label:'全部' }
]
const list = ref<RatingRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
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 dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
@ -61,57 +55,145 @@ async function load() {
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (kw) rs = rs.filter(r => String(r.enterpriseName).includes(kw))
if (kw) rs = rs.filter(r => String(r.enterpriseName).toLowerCase().includes(kw) || String(r.id).toLowerCase().includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
{ key:'enterpriseName', label:'供应商', type:'text' }
// 等级统计 + 分数均值
const stats = computed(() => {
const rs = filtered.value
const aCount = rs.filter(r => r.supplierLevel.startsWith('A')).length
const avgFinal = rs.length ? Math.round(rs.reduce((s, r) => s + r.finalScore, 0) / rs.length) : 0
const avgClient = rs.length ? Math.round(rs.reduce((s, r) => s + r.clientScore, 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: 'DataAnalysis', color: 'var(--amber)' },
{ label: '平均客户评价', value: avgClient, icon: 'ChatLineSquare', 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>>({})
function onSearch() { filterKw.value = String(filters.enterpriseName || ''); page.value = 1 }
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
function onView(row: RatingRow) { ElMessage.info(`详情 ${row.id}`) }
async function onEdit(row: RatingRow) {
ElMessage.success('已发起调整 (前端示例)')
const filters = reactive<Record<string, any>>({ id: '', enterpriseName: '' })
function onSearch() {
filterKw.value = `${filters.id || ''}|${filters.enterpriseName || ''}`
page.value = 1
}
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
function onReset() { filters.id = ''; filters.enterpriseName = ''; filterKw.value = ''; page.value = 1 }
function onSelectionChange(rows: RatingRow[]) { selection.value = rows }
function onView(row: RatingRow) { ElMessage.info(`详情 ${row.id}`) }
function onEdit(row: RatingRow) { ElMessage.success('已发起调整 (前端示例)') }
async function onBatchExport() {
await ElMessageBox.confirm(`确认导出选中的 ${selection.value.length} 条评级?`, '批量导出', { type: 'info' }).catch(() => {})
ElMessage.success(`已发起批量导出 (前端示例, 共 ${selection.value.length} 条)`)
}
function onExport() { ElMessage.success(`已发起导出 (前端示例, 共 ${filtered.value.length} 条)`) }
// 等级 st-pill 颜色映射
function levelPill(r: RatingRow) {
if (r.supplierLevel.startsWith('A')) return { label: r.supplierLevel, color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' }
if (r.supplierLevel.startsWith('B')) return { label: r.supplierLevel, color: 'var(--brand)', bg: 'var(--brand-soft)' }
if (r.supplierLevel.startsWith('C')) return { label: r.supplierLevel, color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' }
return { label: r.supplierLevel, color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="供应商评级管理" sub="供应商基于多维度打分的最终评级" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
<el-button @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<!-- 顶部统计卡 -->
<div class="stats-grid">
<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="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
<span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 条评级</span>
<span v-else class="batch-hint">勾选后可批量导出</span>
<div class="batch-actions">
<el-button size="small" plain :disabled="!selection.length" @click="onBatchExport">批量导出</el-button>
</div>
</div>
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
@selection-change="onSelectionChange"
@page-change="(p: number) => { page = p; load() }"
@limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #level="{ row }">
<el-tag :type="row.supplierLevel.startsWith('A') ? 'success' : row.supplierLevel.startsWith('B') ? 'primary' : row.supplierLevel.startsWith('C') ? 'warning' : 'info'" size="small">{{ row.supplierLevel }}</el-tag>
<span class="st-pill" :style="{ color: levelPill(row).color, background: levelPill(row).bg }">
<span class="st-dot" /><span class="truncate-text">{{ levelPill(row).label }}</span>
</span>
</template>
<template #award="{ row }">
<span :style="{ color: row.awardScore > 0 ? '#67c23a' : row.awardScore < 0 ? '#f56c6c' : '#999' }">
<span :style="{ color: row.awardScore > 0 ? 'var(--green)' : row.awardScore < 0 ? 'var(--red)' : 'var(--ink-3)', fontWeight: 600 }">
{{ row.awardScore > 0 ? '+' : '' }}{{ row.awardScore }}
</span>
</template>
<template #final="{ row }">
<span style="font-weight: 600;">{{ row.finalScore }}</span>
<span style="font-weight: 700; color: var(--ink);">{{ row.finalScore }}</span>
</template>
<template #deadline="{ row }">{{ row.deadline }}</template>
<template #actions="{ row }">
<el-button type="primary" link @click="onView(row)">详情</el-button>
<el-button type="success" link @click="onEdit(row)">调整分</el-button>
<TableActions
:row="row"
:actions="[
{ label: '详情', type: 'primary', onClick: onView },
{ label: '调整分', type: 'success', onClick: onEdit }
]"
/>
</template>
</DataTable>
</div>
</div>
</template>
<style scoped>
<style scoped lang="scss">
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
/* 顶部统计卡 */
.stats-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 16px 18px;
display: flex; align-items: center; gap: 12px;
transition: all .15s;
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow); }
}
.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>

View File

@ -1,8 +1,6 @@
<script setup lang="ts">
/**
* F006 供应商评分管理 - W16
*
* 沿用老 vue user/enterprise/enterprise-score.vue 字段
* F006 供应商评分管理 - W16 + UI 审计补完 (2026-08-22)
*
* 端点: 4 URL 探测 (enterprisescore/page, /list, /search, /queryPage); 404 → FALLBACK seed
*/
@ -10,8 +8,9 @@ import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import SearchBar, { type SearchField } from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface ScoreRow { id:string; enterpriseId:string; enterpriseName:string; scoreContent:string; evaluator:string; score:number; deadline:string; status:number }
const ENDPOINTS = [
@ -28,27 +27,22 @@ const FALLBACK: ScoreRow[] = [
{ id:'SC007', enterpriseId:'s20019', enterpriseName:'美洁涂料', scoreContent:'商品质量', evaluator:'周工', score:78, deadline:'2026-07-15 23:59:59', status:0 },
{ id:'SC008', enterpriseId:'s20020', enterpriseName:'万家灯具', scoreContent:'商品质量', evaluator:'吴总', score:88, deadline:'2026-07-15 23:59:59', status:0 },
{ id:'SC009', enterpriseId:'s20021', enterpriseName:'顺达物流', scoreContent:'配送时效', evaluator:'郑采购', score:65, deadline:'2026-07-20 23:59:59', status:0 },
{ id:'SC010', enterpriseId:'s20022', enterpriseName:'众成厨具', scoreContent:'商品质量', evaluator:'冯工', score:76, deadline:'2026-07-20 23:59:59', status:0 },
{ id:'SC011', enterpriseId:'s20023', enterpriseName:'坚美建筑材料', scoreContent:'商品质量', evaluator:'陈工长', score:95, deadline:'2026-07-20 23:59:59', status:0 },
{ id:'SC012', enterpriseId:'s20024', enterpriseName:'华建门窗厂', scoreContent:'商品质量', evaluator:'褚采购', score:90, deadline:'2026-07-20 23:59:59', status:0 }
{ id:'SC010', enterpriseId:'s20022', enterpriseName:'众成厨具', scoreContent:'商品质量', evaluator:'冯工', score:76, deadline:'2026-07-20 23:59:59', status:0 }
]
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
type Col = { prop?: string; label: string; minWidth?: number; width?: number; slot?: string; fixed?: 'right'; align?: 'center' | 'left' | 'right' }
const columns: Col[] = [
{ type:'selection', label:'', width:50 },
{ prop:'id', label:'评分ID', width:90, fixed:true },
{ prop:'enterpriseName', label:'评分对象', width:200 },
{ prop:'scoreContent', label:'评分内容', width:140 },
{ prop:'evaluator', label:'评分员', width:120 },
{ prop:'score', label:'分值', width:90, slot:'score' },
{ prop:'status', label:'状态', width:90, slot:'status' },
{ prop:'deadline', label:'截止时间', width:170, slot:'deadline' },
{ prop:'actions', label:'操作', width:180, slot:'actions', fixed:'right' }
{ prop: 'enterpriseName', label: '评分对象', minWidth: 180 },
{ prop: 'scoreContent', label: '评分内容', minWidth: 130 },
{ prop: 'evaluator', label: '评分员', minWidth: 120 },
{ prop: 'score', label: '分值', minWidth: 100, slot: 'score', align: 'center' },
{ prop: 'status', label: '状态', minWidth: 110, slot: 'status', align: 'center' },
{ prop: 'deadline', label: '截止时间', minWidth: 170 },
{ label: '操作', width: 200, slot: 'actions', fixed: 'right', align: 'center' }
]
const STATUS_DEFAULTS = [
{ value:'', label:'全部' }
]
const list = ref<ScoreRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
const list = ref<ScoreRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const selection = ref<ScoreRow[]>([])
const filterKw = ref('')
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
@ -60,53 +54,152 @@ async function load() {
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (kw) rs = rs.filter(r => String(r.enterpriseName).includes(kw) || String(r.evaluator).includes(kw))
if (kw) rs = rs.filter(r => String(r.enterpriseName).toLowerCase().includes(kw) || String(r.evaluator).toLowerCase().includes(kw) || String(r.scoreContent).toLowerCase().includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
{ key:'enterpriseName', label:'评分对象', type:'text' },
{ key:'evaluator', label:'评分员', type:'text' }
// 顶部统计
const stats = computed(() => {
const rs = filtered.value
const done = rs.filter(r => r.status === 1).length
const pending = rs.length - done
const avg = rs.filter(r => r.status === 1).length
? Math.round(rs.filter(r => r.status === 1).reduce((s, r) => s + r.score, 0) / rs.filter(r => r.status === 1).length)
: 0
return [
{ label: '评分记录', value: rs.length, icon: 'Document', color: 'var(--brand)' },
{ label: '已完成', value: done, icon: 'CircleCheck', color: 'var(--green)' },
{ label: '待评分', value: pending, icon: 'Bell', color: 'var(--amber)' },
{ label: '已完成平均分', value: avg, icon: 'DataAnalysis', color: 'var(--purple)' }
]
})
const searchFields: SearchField[] = [
{ key: 'enterpriseName', label: '评分对象', type: 'text', placeholder: '搜索供应商' },
{ key: 'evaluator', label: '评分员', type: 'text', placeholder: '如 张工长' }
]
const filters = reactive<Record<string, any>>({})
function onSearch() { filterKw.value = String(filters.enterpriseName || filters.evaluator || ''); page.value = 1 }
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
function onView(row: ScoreRow) { ElMessage.info(`详情 ${row.id}`) }
async function onScore(row: ScoreRow) {
ElMessage.success('已跳转打分 (前端示例)')
const filters = reactive<Record<string, any>>({ enterpriseName: '', evaluator: '' })
function onSearch() {
filterKw.value = `${filters.enterpriseName || ''}|${filters.evaluator || ''}`
page.value = 1
}
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
function onReset() { filters.enterpriseName = ''; filters.evaluator = ''; filterKw.value = ''; page.value = 1 }
function onSelectionChange(rows: ScoreRow[]) { selection.value = rows }
function onView(row: ScoreRow) { ElMessage.info(`详情 ${row.id}`) }
function onScore(row: ScoreRow) {
ElMessageBox.confirm(`确认给「${row.enterpriseName}」的「${row.scoreContent}」打分?`, '去打分', { type: 'info' }).then(() => {
ElMessage.success('已提交评分 (前端示例)')
row.status = 1
}).catch(() => {})
}
async function onBatchRemind() {
if (!selection.value.length) return
await ElMessageBox.confirm(`确认提醒选中的 ${selection.value.length} 位评分员?`, '批量提醒', { type: 'info' }).catch(() => {})
ElMessage.success(`已发起批量提醒 (前端示例, 共 ${selection.value.length} 条)`)
}
function onExport() { ElMessage.success(`已发起导出 (前端示例, 共 ${filtered.value.length} 条)`) }
// 状态 st-pill
function statusPill(r: ScoreRow) {
return r.status === 1
? { label: '已评分', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' }
: { label: '待评分', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' }
}
// 分值颜色 (按分数高/中/低)
function scoreColor(s: number) {
if (s >= 90) return 'var(--green)'
if (s >= 75) return 'var(--brand)'
return 'var(--amber)'
}
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="供应商评分管理" sub="供应商评分记录和评分员管理" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
<PageHeader title="供应商评分管理" sub="供应商评分记录、评分员管理" :data-source="dataSource" :api-error="apiError">
<el-button @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<!-- 顶部统计卡 -->
<div class="stats-grid">
<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="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
<span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 条评分</span>
<span v-else class="batch-hint">勾选后可批量提醒评分员</span>
<div class="batch-actions">
<el-button size="small" plain :disabled="!selection.length" @click="onBatchRemind">批量提醒</el-button>
</div>
</div>
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
@selection-change="onSelectionChange"
@page-change="(p: number) => { page = p; load() }"
@limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #score="{ row }">
<span style="font-weight: 600; color: var(--primary);">{{ row.score }}</span>
<span :style="{ fontWeight: 700, color: scoreColor(row.score) }">{{ row.score }}</span>
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'warning'" size="small">{{ row.status === 1 ? '已评分' : '未评分' }}</el-tag>
<span class="st-pill" :style="{ color: statusPill(row).color, background: statusPill(row).bg }">
<span class="st-dot" />{{ statusPill(row).label }}
</span>
</template>
<template #deadline="{ row }">{{ row.deadline }}</template>
<template #actions="{ row }">
<el-button type="primary" link @click="onView(row)">详情</el-button>
<el-button v-if="row.status === 0" type="success" link @click="onScore(row)">去打分</el-button>
<TableActions
:row="row"
:actions="[
{ label: '详情', type: 'primary', onClick: onView },
{ label: '去打分', type: 'success', when: (r: any) => r.status === 0, onClick: onScore }
]"
/>
</template>
</DataTable>
</div>
</div>
</template>
<style scoped>
<style scoped lang="scss">
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
/* 顶部统计卡 */
.stats-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 16px 18px;
display: flex; align-items: center; gap: 12px;
transition: all .15s;
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow); }
}
.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; }
</style>