Compare commits

..

11 Commits

Author SHA1 Message Date
zou
b6033c65d5 feat(F006): 企业列表更换管理员弹窗 - 姓名+手机号表单, 调 /user/enterprise/updateAdministrator 真API (原待实现占位) 2026-08-18 16:22:15 +08:00
zou
c2b3473c5e fix(F006): 用户编辑弹窗先黑一下 - loading提前覆盖全程+企业下拉与详情并行加载+遮罩透明化 2026-08-18 16:00:20 +08:00
zou
30cc69f43b feat(F006): 列表页UI统一 - 8页状态胶囊化(el-tag全局胶囊+st-pill圆点) + 修复拼团/秒杀缺失主表 + 批量栏通用样式 2026-08-18 15:49:50 +08:00
zou
092d1991c7 refactor(F006): 用户列表隐藏用户ID搜索条件与列(用不到) 2026-08-18 15:29:28 +08:00
zou
0519de9428 refactor(F006): 用户列表简化 - 按用户反馈恢复原字段单行无头像, 仅保留UI美化(胶囊/货币化/等宽/批量栏) 2026-08-18 15:24:21 +08:00
zou
4c27e6b4b2 feat(F006): 前台用户列表(/user/user)对齐企业列表UI标准 - 渐变头像单元(header图/首字)+用户ID两行/手机号图标化/企业认证胶囊+企业名两行/余额货币化/状态圆点胶囊(全局st变量暗色自适应)/批量禁用启用删除/保留tab统计与导出 2026-08-18 15:12:34 +08:00
zou
90d6561746 feat(F006): 用户列表按企业列表UI标准升级 - 渐变头像单元/两行信息列(姓名工号/部门角色)/状态圆点胶囊(全局st变量暗色自适应)/用户类型下拉筛选/超管徽章/批量删除/清理老式死样式 2026-08-18 15:04:01 +08:00
zou
99bc522b24 feat(F006): 后台UI统一规范 - DataTable默认borderless/全局表格表头底色圆角统一/页面内边距统一/状态色token化(暗色适配)/标题字号20px统一/统计页echarts异步组件拆分(509KB->6.9KB) 2026-08-18 14:47:39 +08:00
zou
508c84e571 feat(F006): 企业列表页专业UI升级 - 品牌渐变头像/等级彩色徽章/状态圆点标签(待审核脉冲)/标签前2+溢出/批量栏选中高亮/补审核状态筛选/清理死代码详情弹窗 2026-08-18 14:07:07 +08:00
zou
8be7ca9dc2 feat(F006): 企业详情页专业UI重构 + 审核交互全链路 (审核通过/驳回+原因+本会话留痕时间线, 列表页单行/批量审核接通, 修 enabled/showEnterprise query传参bug) 2026-08-18 13:20:34 +08:00
zou
fc7d9f9179 feat(资讯): InformationListView 加发布功能 — 新增/编辑弹窗(标题/一级分类下拉/封面图上传/内容), 走 POST /other/information/save|update + /sys/file/upload; 分类来自 /other/category/list?type=3&level=1(排除无level脏数据9999); 修复 devbox 构建缺 @types/node; 验证: admin 弹窗分类下拉含行业动态, API 创建行业动态新闻后前端 NewsView 行业动态tab显示图片(Playwright 9/9 PASS) 2026-08-14 14:58:03 +08:00
33 changed files with 1877 additions and 655 deletions

18
package-lock.json generated
View File

@ -20,6 +20,7 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
"@types/qs": "^6.15.1",
"@vitejs/plugin-vue": "^5.0.4",
"sass": "^1.71.1",
@ -1280,6 +1281,16 @@
"@types/lodash": "*"
}
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
@ -2913,6 +2924,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unimport": {
"version": "3.14.6",
"resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz",

View File

@ -22,6 +22,7 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
"@types/qs": "^6.15.1",
"@vitejs/plugin-vue": "^5.0.4",
"sass": "^1.71.1",

View File

@ -63,7 +63,8 @@ const props = withDefaults(defineProps<Props>(), {
pageSizes: () => [10, 20, 50, 100],
rowKey: 'id',
stripe: false,
borderless: false,
// 2026-08-18 UI 统一: 默认无边框 (带 border 的老页面由全局样式消除, 见 styles/global-ui.scss)
borderless: true,
emptyText: '暂无数据',
errorText: '',
error: null

View File

@ -10,6 +10,7 @@ import 'element-plus/dist/index.css'
import './theme/tokens.scss'
import './theme/themes.scss'
import './styles/global-popper.scss'
import './styles/global-ui.scss' // 2026-08-18 UI 统一规范 (表格/卡片/内边距/暗色状态色)
import { loadTheme } from './theme/presets'
// 启动时恢复用户主题

View File

@ -414,7 +414,7 @@ window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.res
.rr-dashboard { max-width: 1400px; }
.rr-page-header { margin-bottom: 20px; }
.rr-page-title {
font-size: 22px; font-weight: 600; letter-spacing: -0.02em;
font-size: 20px; font-weight: 700;
color: var(--ink); margin-bottom: 4px;
}
.rr-page-sub { color: var(--ink-3); font-size: 13px; }

View File

@ -192,15 +192,19 @@ onMounted(() => { load() })
/* 状态徽章(4 种) - 浅底色块(套设计规范 §5.4) */
.rr-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11.5px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
line-height: 17.25px;
height: 21.25px;
line-height: 20px;
box-sizing: border-box;
}
.rr-badge::before {
content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor;
}
.rr-badge-pending { background: var(--amber-soft); color: var(--amber); }
.rr-badge-quoting { background: var(--brand-soft); color: var(--brand); }
.rr-badge-done { background: var(--green-soft); color: var(--green); }

View File

@ -18,6 +18,7 @@
import { ref, onMounted, reactive, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock } from '@element-plus/icons-vue'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
@ -46,6 +47,14 @@ interface Order {
const STATUS_LABEL: Record<number, string> = {
1: '待支付', 2: '待发货', 3: '待收货', 4: '待付尾款', 5: '待评价', 6: '已完成', 7: '已取消', 8: '采购单待审核', 9: '付款单待审核'
}
// 状态胶囊配色 (已完成=绿/已取消=灰/其余=品牌蓝, 引用全局 --st-* 变量暗色自适应)
const STATUS_PILL: Record<number, { color: string; bg: string }> = {
6: { color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
7: { color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' }
}
function statusPill(status: number) {
return STATUS_PILL[status] || { color: 'var(--primary)', bg: 'var(--primary-soft, #dbeafe)' }
}
const STATUS_TABS = [
{ value: '', label: '全部' },
@ -259,19 +268,15 @@ onMounted(load)
<span v-else class="rr-muted">--</span>
</template>
<template #time="{ row }">
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
<span class="rr-time"><el-icon style="vertical-align:-2px;margin-right:4px"><Clock /></el-icon>{{ fmtDate(row.createDate) }}</span>
</template>
<template #amount="{ row }">
<span class="rr-amount">¥ {{ row.totalAmount.toLocaleString() }}</span>
</template>
<template #status="{ row }">
<el-tag
:type="(STATUS_LABEL[row.status] === '已完成' ? 'success' : STATUS_LABEL[row.status] === '已取消' ? 'info' : 'primary') as any"
size="small"
effect="light"
>
{{ STATUS_LABEL[row.status] || `状态${row.status}` }}
</el-tag>
<span class="st-pill" :style="{ color: statusPill(row.status).color, background: statusPill(row.status).bg }">
<span class="st-dot"></span>{{ STATUS_LABEL[row.status] || `状态${row.status}` }}
</span>
</template>
<template #action="{ row }">
<TableActions

View File

@ -29,9 +29,9 @@ interface Groupbuy {
raw: any
}
const STATUS_MAP: Record<number, { label: string; type: string }> = {
1: { label: '启用', type: 'success' },
2: { label: '停用', type: 'danger' }
const STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '启用', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
2: { label: '停用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
const FALLBACK: Groupbuy[] = [
@ -116,6 +116,33 @@ onMounted(load)
/>
<div class="rr-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>
<span class="batch-actions">
<el-button size="small" type="danger" plain :disabled="!selection.length" @click="onBatchDelete">批量删除</el-button>
</span>
</div>
<el-table :data="list" v-loading="loading" style="width: 100%" @selection-change="onSelectionChange" row-key="id" empty-text="暂无拼团活动">
<el-table-column type="selection" width="48" align="center" />
<el-table-column prop="name" label="活动名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="enterpriseName" label="活动商家" min-width="160" show-overflow-tooltip />
<el-table-column prop="groupBeginTime" label="开始时间" width="150" />
<el-table-column prop="groupEndTime" label="结束时间" width="150" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<span class="st-pill" :style="{ color: STATUS_MAP[row.status]?.color, background: STATUS_MAP[row.status]?.bg }"><span class="st-dot"></span>{{ STATUS_MAP[row.status]?.label }}</span>
</template>
</el-table-column>
<el-table-column prop="createDate" label="创建时间" width="165" />
<el-table-column label="操作" width="170" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" text @click="onView(row)">详情</el-button>
<el-button :type="row.status === 1 ? 'warning' : 'success'" size="small" text @click="onToggleStatus(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button>
<el-button type="danger" size="small" text @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="rr-pagination">
<el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
</div>
@ -129,7 +156,7 @@ onMounted(load)
<el-descriptions-item label="结束时间">{{ viewData.groupEndTime }}</el-descriptions-item>
<el-descriptions-item label="活动商品" :span="2">{{ viewData.type }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="(STATUS_MAP[viewData.status]?.type as any)" size="small">{{ STATUS_MAP[viewData.status]?.label }}</el-tag>
<span class="st-pill" :style="{ color: STATUS_MAP[viewData.status]?.color, background: STATUS_MAP[viewData.status]?.bg }"><span class="st-dot"></span>{{ STATUS_MAP[viewData.status]?.label }}</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ viewData.createDate }}</el-descriptions-item>
</el-descriptions>

View File

@ -1,13 +1,17 @@
<script setup lang="ts">
/**
* F006 资讯列表 - P1 真 view
* F006 资讯列表 - 真 view + 发布功能 (2026-08-08 增强)
*
* 走 /other/information/page
* 真实接口 (2026-08-07 探测: 200, total=7)
* 字段: id/title/categoryId/categoryName/content/createTime
* 列表: GET /other/information/page
* 新增: POST /other/information/save (title/categoryId/categoryName/content/images)
* 修改: POST /other/information/update
* 删除: POST /other/information/delete (body {id})
* 分类: GET /other/category/list?type=3&level=1 (一级分类, 排除无 level 的脏数据)
* 图片: POST /sys/file/upload (FormData, 返 data.url) — 参考旧系统 information-add-or-update.vue
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import { onMounted, ref, reactive, computed } from 'vue'
import { httpGet, httpPost, httpDelete } from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
@ -16,6 +20,8 @@ interface Info {
title: string
categoryId: string
categoryName: string
content: string
images: string
createTime: string
}
@ -23,39 +29,233 @@ const list = ref<Info[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const search = reactive({ title: '' })
const categories = ref<{ id: string; name: string }[]>([])
const categoryLoading = ref(false)
// === 弹窗状态 ===
const dialogVisible = ref(false)
const editMode = ref<'create' | 'update'>('create')
const saving = ref(false)
const uploadLoading = ref(false)
const editForm = reactive({
id: '' as string,
title: '',
categoryId: '' as string,
categoryName: '',
content: '',
images: '',
})
const columns = [
{ prop: 'id', label: 'ID', width: 90 },
{ prop: 'title', label: '资讯标题', minWidth: 280 },
{ prop: 'categoryName', label: '分类', width: 140, formatter: (r: Info) => r.categoryName || '--' },
{ prop: 'createTime', label: '发布时间', width: 180 }
{ prop: 'title', label: '资讯标题', minWidth: 260 },
{ prop: 'categoryName', label: '分类', width: 120, formatter: (r: Info) => r.categoryName || '--' },
{ prop: 'createTime', label: '发布时间', width: 170 },
]
const imgStyle = computed(() => ({
maxWidth: '140px',
maxHeight: '48px',
objectFit: 'cover' as const,
borderRadius: '4px',
}))
async function load() {
loading.value = true
try {
const resp: any = await httpGet('/other/information/page', { page: query.page, limit: query.limit })
const params: any = { page: query.page, limit: query.limit }
if (search.title.trim()) params.title = search.title.trim()
const resp: any = await httpGet('/other/information/page', params)
list.value = resp?.list || []
total.value = Number(resp?.total || 0)
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
function onSearch() { query.page = 1; load() }
function onReset() { search.title = ''; query.page = 1; load() }
// === 分类 ===
async function loadCategories() {
categoryLoading.value = true
try {
const data: any = await httpGet('/other/category/list', { type: 3, level: 1 })
categories.value = (data || [])
.filter((c: any) => c.level === 1 && c.id)
.map((c: any) => ({ id: String(c.id), name: c.name }))
.sort((a: any, b: any) => (a.name > b.name ? 1 : -1))
} catch { categories.value = [] } finally { categoryLoading.value = false }
}
// === 新增/编辑 ===
function openCreate() {
editMode.value = 'create'
editForm.id = ''; editForm.title = ''; editForm.categoryId = ''; editForm.categoryName = ''; editForm.content = ''; editForm.images = ''
dialogVisible.value = true
loadCategories()
}
function openEdit(row: Info) {
editMode.value = 'update'
editForm.id = String(row.id)
editForm.title = row.title || ''
editForm.categoryId = row.categoryId ? String(row.categoryId) : ''
editForm.categoryName = row.categoryName || ''
editForm.content = row.content || ''
editForm.images = row.images || ''
dialogVisible.value = true
loadCategories()
}
function onCategoryChange(id: string) {
const c = categories.value.find(x => x.id === id)
editForm.categoryName = c?.name || ''
}
async function submit() {
if (!editForm.title.trim()) { ElMessage.warning('请填写标题'); return }
if (!editForm.categoryId) { ElMessage.warning('请选择分类'); return }
saving.value = true
try {
const payload: any = {
title: editForm.title.trim(),
categoryId: editForm.categoryId,
categoryName: editForm.categoryName,
content: editForm.content,
images: editForm.images,
}
if (editMode.value === 'create') {
await httpPost('/other/information/save', payload)
ElMessage.success('新增成功')
} else {
payload.id = editForm.id
await httpPost('/other/information/update', payload)
ElMessage.success('修改成功')
}
dialogVisible.value = false
load()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '保存失败')
} finally { saving.value = false }
}
async function onDelete(row: Info) {
try {
await ElMessageBox.confirm(`确定删除资讯「${row.title}」吗?`, '提示', { type: 'warning' })
await httpDelete('/other/information/delete', { id: row.id })
ElMessage.success('删除成功')
load()
} catch (e: any) {
if (e === 'cancel' || e === 'close') return
ElMessage.error(e?.msg || e?.message || '删除失败')
}
}
// === 图片上传 (FormData + Bearer 头由拦截器自动带) ===
async function customUpload(opt: any) {
const file = opt.file as File
if (file.size > 2 * 1024 * 1024) { ElMessage.error('图片大小不能超过 2MB!'); return }
uploadLoading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const { http } = await import('@/utils/request')
const res: any = await http.post('/sys/file/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const body = res?.data
if (body?.code === 0 && body?.data?.url) {
editForm.images = body.data.url
ElMessage.success('图片上传成功')
} else {
ElMessage.error('图片上传失败: ' + (body?.msg || '未知错误'))
}
} catch (e: any) {
ElMessage.error('图片上传失败: ' + (e?.message || '网络错误'))
} finally { uploadLoading.value = false }
}
</script>
<template>
<div class="rr-page">
<PageHeader title="资讯列表" :sub="`平台资讯 (共 ${total} 条)`" />
<PageHeader title="资讯列表" :sub="`平台资讯 (共 ${total} 条)`">
<el-button type="primary" @click="openCreate()">新增资讯</el-button>
</PageHeader>
<div class="rr-card">
<div class="rr-filters-row" style="padding: 16px 16px 0;">
<el-form inline>
<el-form-item label="标题">
<el-input v-model="search.title" placeholder="搜索标题" clearable style="width: 220px" @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>
<DataTable :columns="columns" :data="list"
:borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无资讯" @page-change="onPageChange"
@limit-change="onLimitChange" />
@limit-change="onLimitChange">
<template #default="{ row }">
<el-table-column label="封面" width="160">
<template #default="{ row: r }">
<img v-if="r.images" :src="r.images" :style="imgStyle" alt="封面" referrerpolicy="no-referrer" />
<span v-else class="rr-muted">无图</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row: r }">
<span class="rr-actions">
<el-link type="primary" @click="openEdit(r)">编辑</el-link>
<span class="rr-actions__sep">|</span>
<el-link type="danger" @click="onDelete(r)">删除</el-link>
</span>
</template>
</el-table-column>
</template>
</DataTable>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editMode === 'create' ? '新增资讯' : '编辑资讯'" width="640px" destroy-on-close>
<el-form label-width="90px" label-position="right">
<el-form-item label="标题" required>
<el-input v-model="editForm.title" placeholder="资讯标题" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="一级分类" required>
<el-select v-model="editForm.categoryId" placeholder="请选择一级分类" :loading="categoryLoading" filterable style="width: 100%" @change="onCategoryChange">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="封面图片">
<div class="flex items-center gap-3">
<el-upload
:show-file-list="false"
accept="image/jpeg,image/png,image/jpg"
:http-request="customUpload"
:disabled="uploadLoading">
<img v-if="editForm.images" :src="editForm.images" alt="封面" class="upload-preview" referrerpolicy="no-referrer" />
<el-button v-else :loading="uploadLoading">上传封面</el-button>
</el-upload>
<span class="rr-muted">支持 JPG/PNG, 不超过 2MB</span>
</div>
</el-form-item>
<el-form-item label="资讯内容">
<el-input v-model="editForm.content" type="textarea" :rows="8" placeholder="资讯内容(支持 HTML)" maxlength="20000" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
.rr-filters-row { display: flex; }
.rr-muted { color: var(--text-muted, #909399); font-size: 12px; }
.rr-actions { display: inline-flex; align-items: center; gap: 4px; }
.rr-actions__sep { color: var(--border, #e4e7ed); }
.upload-preview { max-width: 160px; max-height: 80px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border, #e4e7ed); }
</style>

View File

@ -29,9 +29,9 @@ interface Seckill {
raw: any
}
const STATUS_MAP: Record<number, { label: string; type: string }> = {
0: { label: '启用', type: 'success' },
1: { label: '停用', type: 'danger' }
const STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
0: { label: '启用', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
1: { label: '停用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
const FALLBACK: Seckill[] = [
@ -117,6 +117,33 @@ onMounted(load)
/>
<div class="rr-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>
<span class="batch-actions">
<el-button size="small" type="danger" plain :disabled="!selection.length" @click="onBatchDelete">批量删除</el-button>
</span>
</div>
<el-table :data="list" v-loading="loading" style="width: 100%" @selection-change="onSelectionChange" row-key="id" empty-text="暂无秒杀活动">
<el-table-column type="selection" width="48" align="center" />
<el-table-column prop="name" label="活动名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="enterpriseName" label="活动商家" min-width="160" show-overflow-tooltip />
<el-table-column prop="beginTime" label="开始时间" width="150" />
<el-table-column prop="endTime" label="结束时间" width="150" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<span class="st-pill" :style="{ color: STATUS_MAP[row.status]?.color, background: STATUS_MAP[row.status]?.bg }"><span class="st-dot"></span>{{ STATUS_MAP[row.status]?.label }}</span>
</template>
</el-table-column>
<el-table-column prop="createDate" label="创建时间" width="165" />
<el-table-column label="操作" width="170" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" text @click="onView(row)">详情</el-button>
<el-button :type="row.status === 0 ? 'warning' : 'success'" size="small" text @click="onToggleStatus(row)">{{ row.status === 0 ? '停用' : '启用' }}</el-button>
<el-button type="danger" size="small" text @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="rr-pagination">
<el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
</div>
@ -130,7 +157,7 @@ onMounted(load)
<el-descriptions-item label="结束时间">{{ viewData.endTime }}</el-descriptions-item>
<el-descriptions-item label="活动商品" :span="2">{{ viewData.type }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="(STATUS_MAP[viewData.status]?.type as any)" size="small">{{ STATUS_MAP[viewData.status]?.label }}</el-tag>
<span class="st-pill" :style="{ color: STATUS_MAP[viewData.status]?.color, background: STATUS_MAP[viewData.status]?.bg }"><span class="st-dot"></span>{{ STATUS_MAP[viewData.status]?.label }}</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ viewData.createDate }}</el-descriptions-item>
</el-descriptions>

View File

@ -33,10 +33,10 @@ interface Brand {
raw: any
}
const STATUS_MAP: Record<number, { label: string; type: string }> = {
1: { label: '待审核', type: 'warning' },
2: { label: '审核通过', type: 'success' },
3: { label: '审核拒绝', type: 'danger' }
const STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '待审核', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
2: { label: '审核通过', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
3: { label: '审核拒绝', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
const list = ref<Brand[]>([])
@ -212,7 +212,7 @@ onMounted(load)
</el-table-column>
<el-table-column prop="status" label="审核状态" header-align="center" align="center" width="100">
<template #default="{ row }">
<el-tag :type="STATUS_MAP[row.status]?.type || 'info'" size="small">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
<span class="st-pill" :style="{ color: STATUS_MAP[row.status]?.color || 'var(--st-lv4-fg)', background: STATUS_MAP[row.status]?.bg || 'var(--st-lv4-bg)' }"><span class="st-dot"></span>{{ STATUS_MAP[row.status]?.label || row.status }}</span>
</template>
</el-table-column>
<el-table-column prop="createDate" label="申请时间" header-align="center" align="center" width="170" show-overflow-tooltip />
@ -241,7 +241,7 @@ onMounted(load)
</div>
</div>
<el-dialog v-model="auditVisible" title="品牌审核" width="440px" :close-on-click-modal="false">
<el-dialog v-model="auditVisible" title="品牌审核" width="480px" :close-on-click-modal="false">
<el-form label-width="80px">
<el-form-item label="审核结果">
<el-radio-group v-model="auditForm.status">
@ -265,7 +265,7 @@ onMounted(load)
<el-table-column prop="sourceEnterpriseName" label="品牌来源" header-align="center" align="center" min-width="140" show-overflow-tooltip />
<el-table-column prop="status" label="审核状态" header-align="center" align="center" width="100">
<template #default="{ row }">
<el-tag :type="STATUS_MAP[row.status]?.type || 'info'" size="small">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
<span class="st-pill" :style="{ color: STATUS_MAP[row.status]?.color || 'var(--st-lv4-fg)', background: STATUS_MAP[row.status]?.bg || 'var(--st-lv4-bg)' }"><span class="st-dot"></span>{{ STATUS_MAP[row.status]?.label || row.status }}</span>
</template>
</el-table-column>
<el-table-column prop="createDate" label="申请时间" header-align="center" align="center" width="170" />
@ -279,7 +279,7 @@ onMounted(load)
<style scoped lang="scss">
.rr-page { max-width: 1500px; }
.rr-card { background: var(--el-bg-color); border-radius: 8px; border: 1px solid var(--el-border-color-light); overflow: hidden; }
.rr-card { background: var(--el-bg-color); border-radius: 12px; border: 1px solid var(--el-border-color-light); overflow: hidden; }
.rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--el-border-color-light); }
.brand-auth { width: 50px; height: 50px; border-radius: 4px; border: 1px solid var(--el-border-color-light); cursor: pointer; }
.rr-muted { color: var(--el-text-color-placeholder); }

View File

@ -15,16 +15,13 @@
*
* 失败回退: FALLBACK_*
*/
import { ref, onMounted, watch, nextTick } from 'vue'
import { ref, onMounted, defineAsyncComponent } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import * as echarts from 'echarts/core'
import { LineChart, PieChart } from 'echarts/charts'
import { TooltipComponent, GridComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useThemeStore } from '@/stores/theme'
echarts.use([LineChart, PieChart, TooltipComponent, GridComponent, LegendComponent, CanvasRenderer])
// 图表组件: echarts 在组件内动态 import, 独立 chunk 懒加载 (2026-08-18)
const TrendChart = defineAsyncComponent(() => import('./charts/EchartsTrendChart.vue'))
const StatusPie = defineAsyncComponent(() => import('./charts/EchartsStatusPie.vue'))
interface OrderItem {
orderId: string
@ -51,12 +48,6 @@ const recentOrders = ref<any[]>([])
const trendData = ref<{ dates: string[]; counts: number[]; amounts: number[] }>({ dates: [], counts: [], amounts: [] })
const statusSlices = ref<{ name: string; value: number; color: string }[]>([])
const apiStatus = ref<'loading' | 'live' | 'fallback'>('loading')
const trendChartEl = ref<HTMLDivElement | null>(null)
const statusChartEl = ref<HTMLDivElement | null>(null)
let trendChart: echarts.ECharts | null = null
let statusChart: echarts.ECharts | null = null
const themeStore = useThemeStore()
// 订单状态映射 (老 renren: 1=待付款 2=已付款 3=已发货 4=已完成 5=已取消)
const ORDER_STATUS: Record<number, { label: string; type: string }> = {
1: { label: '待付款', type: 'warning' },
@ -213,73 +204,8 @@ onMounted(async () => {
}
apiStatus.value = isLive ? 'live' : 'fallback'
await nextTick()
renderTrend()
renderStatus()
})
// ECharts 渲染
function getBrand(): string { return getComputedStyle(document.body).getPropertyValue('--brand').trim() || '#2563EB' }
function getInk(): string { return getComputedStyle(document.body).getPropertyValue('--ink-3').trim() || '#64748B' }
function getBorder(): string { return getComputedStyle(document.body).getPropertyValue('--border').trim() || '#E2E8F0' }
function getSurface(): string { return getComputedStyle(document.body).getPropertyValue('--surface').trim() || '#FFFFFF' }
function getChartColors(): string[] {
return ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--red'].map(v =>
getComputedStyle(document.body).getPropertyValue(v).trim()
)
}
function renderTrend() {
if (!trendChartEl.value) return
if (!trendChart) trendChart = echarts.init(trendChartEl.value)
const ink = getInk(), border = getBorder(), surface = getSurface(), brand = getBrand()
const chart2 = getComputedStyle(document.body).getPropertyValue('--chart-2').trim() || '#7C3AED'
trendChart.setOption({
grid: { left: 50, right: 50, top: 40, bottom: 30 },
tooltip: { trigger: 'axis', backgroundColor: surface, borderColor: border, textStyle: { color: ink } },
legend: { top: 0, textStyle: { color: ink } },
xAxis: { type: 'category', data: trendData.value.dates, axisLine: { lineStyle: { color: border } }, axisLabel: { color: ink } },
yAxis: [
{ type: 'value', name: '订单数', position: 'left', splitLine: { lineStyle: { color: border } }, axisLabel: { color: ink } },
{ type: 'value', name: '交易额', position: 'right', splitLine: { show: false }, axisLabel: { color: ink, formatter: (v: number) => v >= 1000 ? `${(v/1000).toFixed(0)}K` : String(v) } }
],
series: [
{ name: '订单数', type: 'line', data: trendData.value.counts, smooth: true, itemStyle: { color: brand }, lineStyle: { color: brand, width: 2 }, areaStyle: { color: brand, opacity: 0.15 } },
{ name: '交易额', type: 'line', yAxisIndex: 1, data: trendData.value.amounts, smooth: true, itemStyle: { color: chart2 }, lineStyle: { color: chart2, width: 2 } }
]
})
}
function renderStatus() {
if (!statusChartEl.value) return
if (!statusChart) statusChart = echarts.init(statusChartEl.value)
const ink = getInk(), border = getBorder(), surface = getSurface()
const colors = getChartColors()
const total = statusSlices.value.reduce((a, b) => a + b.value, 0)
statusChart.setOption({
tooltip: { trigger: 'item', backgroundColor: surface, borderColor: border, textStyle: { color: ink } },
legend: { bottom: 0, textStyle: { color: ink } },
series: [{
type: 'pie', radius: ['58%', '80%'], center: ['50%', '45%'],
avoidLabelOverlap: true,
label: { show: false }, labelLine: { show: false },
data: statusSlices.value.map((s, i) => ({ name: s.name, value: s.value, itemStyle: { color: colors[i] } })),
emphasis: { label: { show: true, fontSize: 14, fontWeight: 600, color: ink } }
}],
graphic: total > 0 ? [
{ type: 'text', left: 'center', top: '38%', style: { text: '总计', fontSize: 10, fill: ink, opacity: 0.5 } },
{ type: 'text', left: 'center', top: '45%', style: { text: String(total), fontSize: 24, fontWeight: 700, fill: ink } }
] : []
})
}
watch([() => trendData.value, () => statusSlices.value, () => themeStore.current], async () => {
await nextTick()
renderTrend()
renderStatus()
})
window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.resize() })
</script>
<template>
@ -315,13 +241,13 @@ window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.res
<div class="rr-card-header">
<h3 class="rr-card-title">订单趋势(近 7 天)</h3>
</div>
<div ref="trendChartEl" class="rr-chart"></div>
<TrendChart :dates="trendData.dates" :counts="trendData.counts" :amounts="trendData.amounts" />
</div>
<div class="rr-card rr-chart-card">
<div class="rr-card-header">
<h3 class="rr-card-title">订单状态分布</h3>
</div>
<div ref="statusChartEl" class="rr-chart"></div>
<StatusPie :slices="statusSlices" />
</div>
</div>
@ -338,7 +264,7 @@ window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.res
<style scoped lang="scss">
.rr-dashboard { max-width: 1400px; }
.rr-page-header { margin-bottom: 20px; }
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
.rr-page-title { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
.rr-api-badge { margin-left: 8px; }
.rr-stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
/**
* 统计页 - 订单状态分布环形图 (2026-08-18 从 StatisticsView 抽出)
* echarts 动态 import → 独立 chunk 懒加载
*/
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useThemeStore } from '@/stores/theme'
defineOptions({ name: 'EchartsStatusPie' })
const props = defineProps<{ slices: { name: string; value: number; color?: string }[] }>()
const el = ref<HTMLDivElement | null>(null)
let chart: any = null
let echartsMod: any = null
const themeStore = useThemeStore()
const total = computed(() => props.slices.reduce((a, b) => a + b.value, 0))
async function ensureEcharts() {
if (echartsMod) return echartsMod
const core = await import('echarts/core')
const { PieChart } = await import('echarts/charts')
const { TooltipComponent, LegendComponent } = await import('echarts/components')
const { CanvasRenderer } = await import('echarts/renderers')
core.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
echartsMod = core
return core
}
function cssVar(name: string, fb: string): string {
return getComputedStyle(document.body).getPropertyValue(name).trim() || fb
}
function getChartColors(): string[] {
return ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--red'].map(v => cssVar(v, '')) || ['#2563EB', '#7C3AED', '#10B981', '#D97706', '#DC2626']
}
function render() {
if (!el.value || !chart) return
const ink = cssVar('--ink-3', '#64748B')
const border = cssVar('--border', '#E2E8F0')
const surface = cssVar('--surface', '#FFFFFF')
const colors = getChartColors()
chart.setOption({
tooltip: { trigger: 'item', backgroundColor: surface, borderColor: border, textStyle: { color: ink } },
legend: { bottom: 0, textStyle: { color: ink } },
series: [{
type: 'pie', radius: ['58%', '80%'], center: ['50%', '45%'],
avoidLabelOverlap: true,
label: { show: false }, labelLine: { show: false },
data: props.slices.map((s, i) => ({ name: s.name, value: s.value, itemStyle: { color: colors[i % colors.length] } })),
emphasis: { label: { show: true, fontSize: 14, fontWeight: 600, color: ink } }
}],
graphic: total.value > 0 ? [
{ type: 'text', left: 'center', top: '38%', style: { text: '总计', fontSize: 10, fill: ink, opacity: 0.5 } },
{ type: 'text', left: 'center', top: '45%', style: { text: String(total.value), fontSize: 24, fontWeight: 700, fill: ink } }
] : []
}, true)
}
function onResize() { chart?.resize() }
onMounted(async () => {
try {
const mod = await ensureEcharts()
if (el.value) {
chart = mod.init(el.value)
render()
}
} catch { /* 懒加载失败静默 */ }
window.addEventListener('resize', onResize)
})
watch(
() => [props.slices, themeStore.current],
() => render(),
{ deep: true }
)
onUnmounted(() => {
window.removeEventListener('resize', onResize)
chart?.dispose()
chart = null
})
</script>
<template>
<div ref="el" class="rr-chart"></div>
</template>
<style scoped>
.rr-chart { width: 100%; height: 300px; }
</style>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
/**
* 统计页 - 订单趋势折线图 (2026-08-18 从 StatisticsView 抽出)
* echarts 动态 import → 独立 chunk 懒加载 (StatisticsView 主 chunk 减重)
* 主题/数据变化自动重绘
*/
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useThemeStore } from '@/stores/theme'
defineOptions({ name: 'EchartsTrendChart' })
const props = defineProps<{ dates: string[]; counts: number[]; amounts: number[] }>()
const el = ref<HTMLDivElement | null>(null)
let chart: any = null
let echartsMod: any = null
const themeStore = useThemeStore()
/** 动态加载 echarts (独立 chunk, 不随主 bundle) */
async function ensureEcharts() {
if (echartsMod) return echartsMod
const core = await import('echarts/core')
const { LineChart } = await import('echarts/charts')
const { TooltipComponent, GridComponent, LegendComponent } = await import('echarts/components')
const { CanvasRenderer } = await import('echarts/renderers')
core.use([LineChart, TooltipComponent, GridComponent, LegendComponent, CanvasRenderer])
echartsMod = core
return core
}
function cssVar(name: string, fb: string): string {
return getComputedStyle(document.body).getPropertyValue(name).trim() || fb
}
function render() {
if (!el.value || !chart) return
const ink = cssVar('--ink-3', '#64748B')
const border = cssVar('--border', '#E2E8F0')
const surface = cssVar('--surface', '#FFFFFF')
const brand = cssVar('--brand', '#2563EB')
const chart2 = cssVar('--chart-2', '#7C3AED')
chart.setOption({
grid: { left: 50, right: 50, top: 40, bottom: 30 },
tooltip: { trigger: 'axis', backgroundColor: surface, borderColor: border, textStyle: { color: ink } },
legend: { top: 0, textStyle: { color: ink } },
xAxis: { type: 'category', data: props.dates, axisLine: { lineStyle: { color: border } }, axisLabel: { color: ink } },
yAxis: [
{ type: 'value', name: '订单数', position: 'left', splitLine: { lineStyle: { color: border } }, axisLabel: { color: ink } },
{ type: 'value', name: '交易额', position: 'right', splitLine: { show: false }, axisLabel: { color: ink, formatter: (v: number) => v >= 1000 ? `${(v / 1000).toFixed(0)}K` : String(v) } }
],
series: [
{ name: '订单数', type: 'line', data: props.counts, smooth: true, itemStyle: { color: brand }, lineStyle: { color: brand, width: 2 }, areaStyle: { color: brand, opacity: 0.15 } },
{ name: '交易额', type: 'line', yAxisIndex: 1, data: props.amounts, smooth: true, itemStyle: { color: chart2 }, lineStyle: { color: chart2, width: 2 } }
]
}, true)
}
function onResize() { chart?.resize() }
onMounted(async () => {
try {
const mod = await ensureEcharts()
if (el.value) {
chart = mod.init(el.value)
render()
}
} catch { /* echarts 懒加载失败时静默 (图表留空) */ }
window.addEventListener('resize', onResize)
})
watch(
() => [props.dates, props.counts, props.amounts, themeStore.current],
() => render()
)
onUnmounted(() => {
window.removeEventListener('resize', onResize)
chart?.dispose()
chart = null
})
</script>
<template>
<div ref="el" class="rr-chart"></div>
</template>
<style scoped>
.rr-chart { width: 100%; height: 300px; }
</style>

View File

@ -168,9 +168,9 @@ onMounted(load)
</el-table-column>
<el-table-column label="满减配置" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-tag :type="(row.type === 1 ? 'warning' : 'success') as any" size="small" effect="light">
{{ fmtConfig(row) }}
</el-tag>
<span class="st-pill" :style="row.type === 1 ? { color: 'var(--st-warn-fg)', background: 'var(--st-warn-bg)' } : { color: 'var(--st-ok-fg)', background: 'var(--st-ok-bg)' }">
<span class="st-dot"></span>{{ fmtConfig(row) }}
</span>
</template>
</el-table-column>
<el-table-column label="适用商品" min-width="180" show-overflow-tooltip>
@ -191,9 +191,9 @@ onMounted(load)
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="(row.onShelf === 1 ? 'success' : 'info') as any" size="small" effect="light">
{{ row.onShelf === 1 ? '启用' : '停用' }}
</el-tag>
<span class="st-pill" :style="row.onShelf === 1 ? { color: 'var(--st-ok-fg)', background: 'var(--st-ok-bg)' } : { color: 'var(--st-lv4-fg)', background: 'var(--st-lv4-bg)' }">
<span class="st-dot"></span>{{ row.onShelf === 1 ? '启用' : '停用' }}
</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
@ -221,9 +221,9 @@ onMounted(load)
<el-table-column prop="userName" label="领取人" align="center" />
<el-table-column label="状态" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 1" size="small" type="info" effect="light">已使用</el-tag>
<el-tag v-else-if="row.status === 2" size="small" type="success" effect="light">未使用</el-tag>
<el-tag v-else size="small" type="warning" effect="light">未知</el-tag>
<span v-if="row.status === 1" class="st-pill" style="color: var(--st-lv4-fg); background: var(--st-lv4-bg)"><span class="st-dot"></span>已使用</span>
<span v-else-if="row.status === 2" class="st-pill" style="color: var(--st-ok-fg); background: var(--st-ok-bg)"><span class="st-dot"></span>未使用</span>
<span v-else class="st-pill" style="color: var(--st-warn-fg); background: var(--st-warn-bg)"><span class="st-dot"></span>未知</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="领取时间" align="center" width="200" />

View File

@ -60,15 +60,15 @@ interface SkuItem {
}
// 状态映射 (沿用老 vue checkStatus 1=待审核 2=已通过 3=已驳回, onShelf 0=下架 1=在售)
const CHECK_STATUS_MAP: Record<number, { label: string; type: string }> = {
1: { label: '待审核', type: 'warning' },
2: { label: '已通过', type: 'success' },
3: { label: '已驳回', type: 'danger' }
const CHECK_STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '待审核', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
2: { label: '已通过', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
3: { label: '已驳回', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
const ONSHELF_STATUS_MAP: Record<number, { label: string; type: string }> = {
0: { label: '已下架', type: 'info' },
1: { label: '已上架', type: 'success' }
const ONSHELF_STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
0: { label: '已下架', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' },
1: { label: '已上架', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' }
}
const STATUS_TABS = [
@ -395,14 +395,10 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
<span>{{ [row.firstCategoryName, row.secondCategoryName, row.thirdCategoryName].filter(Boolean).join(' / ') || '--' }}</span>
</template>
<template #onShelf="{ row }">
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">
{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}
</el-tag>
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[row.onShelf]?.color, background: ONSHELF_STATUS_MAP[row.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</span>
</template>
<template #checkStatus="{ row }">
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">
{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}
</el-tag>
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[row.checkStatus]?.color, background: CHECK_STATUS_MAP[row.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</span>
</template>
<template #action="{ row }">
<TableActions
@ -451,17 +447,13 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
<el-table-column prop="unitName" label="单位" width="80" align="center" />
<el-table-column label="铺货" width="100" align="center">
<template #default="{ row }">
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">
{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}
</el-tag>
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[row.onShelf]?.color, background: ONSHELF_STATUS_MAP[row.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</span>
</template>
</el-table-column>
<el-table-column prop="checkOpinion" label="审核意见" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">
{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}
</el-tag>
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[row.checkStatus]?.color, background: CHECK_STATUS_MAP[row.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</span>
</template>
</el-table-column>
<el-table-column label="申请时间" prop="createDate" width="170" />
@ -491,14 +483,10 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
{{ [detailData.firstCategoryName, detailData.secondCategoryName, detailData.thirdCategoryName].filter(Boolean).join(' / ') }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="(CHECK_STATUS_MAP[detailData.checkStatus]?.type as any)" size="small">
{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}
</el-tag>
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[detailData.checkStatus]?.color, background: CHECK_STATUS_MAP[detailData.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}</span>
</el-descriptions-item>
<el-descriptions-item label="铺货">
<el-tag :type="(ONSHELF_STATUS_MAP[detailData.onShelf]?.type as any)" size="small">
{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}
</el-tag>
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[detailData.onShelf]?.color, background: ONSHELF_STATUS_MAP[detailData.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}</span>
</el-descriptions-item>
<el-descriptions-item label="审核意见" :span="2">{{ detailData.checkOpinion || '--' }}</el-descriptions-item>
</el-descriptions>

View File

@ -36,14 +36,14 @@ interface ServiceGood {
raw: any
}
const CHECK_STATUS_MAP: Record<number, { label: string; type: string }> = {
1: { label: '待审核', type: 'warning' },
2: { label: '已通过', type: 'success' },
3: { label: '已驳回', type: 'danger' }
const CHECK_STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '待审核', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
2: { label: '已通过', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
3: { label: '已驳回', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
const ONSHELF_STATUS_MAP: Record<number, { label: string; type: string }> = {
0: { label: '已下架', type: 'info' },
1: { label: '已上架', type: 'success' }
const ONSHELF_STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
0: { label: '已下架', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' },
1: { label: '已上架', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' }
}
const STATUS_TABS = [
{ value: '', label: '全部' },
@ -309,10 +309,10 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
<span class="rr-price">¥ {{ Number(row.price || 0).toLocaleString() }} / {{ row.unit }}</span>
</template>
<template #onShelf="{ row }">
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</el-tag>
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[row.onShelf]?.color, background: ONSHELF_STATUS_MAP[row.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</span>
</template>
<template #checkStatus="{ row }">
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</el-tag>
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[row.checkStatus]?.color, background: CHECK_STATUS_MAP[row.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</span>
</template>
<template #action="{ row }">
<TableActions
@ -337,10 +337,10 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
<el-descriptions-item label="服务区域">{{ detailData.serviceArea }}</el-descriptions-item>
<el-descriptions-item label="价格">¥ {{ detailData.price.toLocaleString() }} / {{ detailData.unit }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="(CHECK_STATUS_MAP[detailData.checkStatus]?.type as any)" size="small">{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}</el-tag>
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[detailData.checkStatus]?.color, background: CHECK_STATUS_MAP[detailData.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}</span>
</el-descriptions-item>
<el-descriptions-item label="铺货">
<el-tag :type="(ONSHELF_STATUS_MAP[detailData.onShelf]?.type as any)" size="small">{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}</el-tag>
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[detailData.onShelf]?.color, background: ONSHELF_STATUS_MAP[detailData.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}</span>
</el-descriptions-item>
<el-descriptions-item label="审核意见" :span="2">{{ detailData.checkOpinion || '--' }}</el-descriptions-item>
</el-descriptions>

View File

@ -226,7 +226,7 @@ onMounted(load)
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }

View File

@ -229,7 +229,7 @@ onMounted(load)
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }

View File

@ -213,7 +213,7 @@ onMounted(load)
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }

View File

@ -137,7 +137,7 @@ onMounted(load)
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }

View File

@ -275,7 +275,7 @@ onMounted(load)
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }

View File

@ -260,7 +260,7 @@ onMounted(load)
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }

View File

@ -221,7 +221,7 @@ onMounted(load)
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }

View File

@ -254,7 +254,7 @@ onMounted(load)
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }

View File

@ -189,7 +189,7 @@ onMounted(load)
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }

View File

@ -193,7 +193,7 @@ onMounted(load)
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }

View File

@ -1,30 +1,25 @@
<script setup lang="ts">
/**
* F006 用户管理 - 套老 vue user.vue 结构 + v1.8 设计规范
* F006 用户管理 - 2026-08-18 按企业列表 UI 标准升级
*
* 后端端点 (SysUserController):
* GET /sys/user/page 分页列表
* GET /sys/user/{id} 详情
* POST /sys/user 新增
* PUT /sys/user 修改
* GET /sys/user/page 分页列表 (支持 username/userType 过滤)
* PUT /sys/user/updateStatus 启用/停用
* PUT /sys/user/password 修改密码(自己)
* PUT /sys/user/resetPassword 重置密码(管理员)
* DELETE /sys/user 删除
* DELETE /sys/user 删除 (支持批量)
*
* 字段(来自 SysUserDTO):
* id / username / password(WRITE_ONLY) / realName / headUrl
* gender(0=男/1=女/2=保密) / email / mobile / deptId
* superAdmin(0=否/1=是) / status(0=停用/1=正常) / remark
* identifier / employeeId(工号) / userType(1=试用/2=正式/3=外包/99=AI公司)
* joinDate / invitationUrl / invitationCode / xcxQrCode / operationId
* createDate / deptName / roleNames / roleIdList / postIdList
* id / username / realName / headUrl / gender / email / mobile / deptId
* superAdmin(0=否/1=是) / status(0=停用/1=正常) / employeeId(工号)
* userType(1=试用/2=正式/3=外包/99=AI公司) / createDate / deptName / roleNames
*
* 设计规范:§16 列表页 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化
* UI 标准对齐企业列表: 头像渐变单元 / 两行信息列 / 圆点状态胶囊(全局 --st-* 变量暗色自适应) /
* 搜索栏补用户类型下拉 / selection 批量删除 / 清理老式死样式
*/
import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import { User, Phone, Clock } from '@element-plus/icons-vue'
import { httpGet, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
@ -35,42 +30,38 @@ interface SysUser {
username: string
realName: string
headUrl?: string | null
gender?: number | null
email?: string | null
mobile?: string | null
deptId?: string | null
deptName?: string | null
superAdmin?: number
status: 0 | 1
remark?: string | null
identifier?: string | null
employeeId?: string | null
userType?: number
joinDate?: string | number | null
createDate: string | number
roleNames?: string | null
roleIdList?: number[] | null
postIdList?: number[] | null
raw: any
}
const STATUS_MAP: Record<number, { label: string; type: string }> = {
1: { label: '正常', type: 'success' },
0: { label: '停用', type: 'danger' }
const STATUS_META: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '正常', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
0: { label: '停用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
const USER_TYPE_MAP: Record<number, { label: string; type: string }> = {
1: { label: '试用期', type: 'info' },
2: { label: '正式', type: 'success' },
3: { label: '外包', type: 'warning' },
99: { label: 'AI 公司', type: 'info' }
const USER_TYPE_MAP: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '试用期', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' },
2: { label: '正式', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
3: { label: '外包', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
99: { label: 'AI 公司', color: 'var(--st-lv2-fg)', bg: 'var(--st-lv2-bg)' }
}
const GENDER_MAP: Record<number, string> = {
0: '男',
1: '女',
2: '保密'
}
// 头像品牌渐变色组 (无 headUrl 时交替使用, 与企业列表同源风格)
const AVATAR_GRADIENTS = [
'linear-gradient(135deg, #0b5394, #6fb1e8)',
'linear-gradient(135deg, #2563eb, #93c5fd)',
'linear-gradient(135deg, #0e7490, #67e8f9)',
'linear-gradient(135deg, #7c3aed, #c4b5fd)',
'linear-gradient(135deg, #be123c, #fda4af)'
]
const list = ref<SysUser[]>([])
const total = ref(0)
@ -78,6 +69,8 @@ const loading = ref(false)
const page = ref(1)
const limit = ref(10)
const loadError = ref<string | null>(null)
const selectedIds = ref<string[]>([])
const selectedCount = ref(0)
const filters = reactive({
username: '',
@ -88,6 +81,14 @@ const resetPwdVisible = ref(false)
const resetPwdTarget = ref<SysUser | null>(null)
const newPassword = ref('')
function avatarStyle(row: SysUser, index: number) {
return { background: AVATAR_GRADIENTS[index % AVATAR_GRADIENTS.length] }
}
function initial(row: SysUser): string {
const name = row.realName && row.realName !== '--' ? row.realName : row.username
return (name || '用').trim().charAt(0)
}
function fmtDate(ts?: string | number | null) {
if (!ts) return '--'
const n = typeof ts === 'string' ? Date.parse(ts) : ts
@ -113,22 +114,14 @@ async function load() {
username: it.username || '--',
realName: it.realName || '--',
headUrl: it.headUrl,
gender: it.gender,
email: it.email,
mobile: it.mobile,
deptId: it.deptId ? String(it.deptId) : null,
deptName: it.deptName || '--',
superAdmin: it.superAdmin ?? 0,
status: (it.status ?? 0) as 0 | 1,
remark: it.remark,
identifier: it.identifier,
employeeId: it.employeeId || '--',
userType: it.userType,
joinDate: it.joinDate,
createDate: it.createDate,
roleNames: it.roleNames,
roleIdList: it.roleIdList,
postIdList: it.postIdList,
raw: it
}))
total.value = data.total || 0
@ -151,6 +144,7 @@ function onReset() {
}
function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id); selectedCount.value = rows.length }
async function toggleStatus(row: SysUser) {
const newStatus = row.status === 1 ? 0 : 1
@ -165,10 +159,7 @@ async function toggleStatus(row: SysUser) {
return
}
try {
await httpPut('/sys/user/updateStatus', {
id: Number(row.id),
status: newStatus
})
await httpPut('/sys/user/updateStatus', { id: Number(row.id), status: newStatus })
ElMessage.success(`${action}成功`)
load()
} catch (e: any) {
@ -184,14 +175,8 @@ function resetPwdHandle(row: SysUser) {
async function resetPwdConfirm() {
if (!resetPwdTarget.value) return
if (!newPassword.value.trim()) {
ElMessage.warning('请输入新密码')
return
}
if (newPassword.value.length < 6) {
ElMessage.warning('密码至少 6 位')
return
}
if (!newPassword.value.trim()) { ElMessage.warning('请输入新密码'); return }
if (newPassword.value.length < 6) { ElMessage.warning('密码至少 6 位'); return }
try {
await httpPut('/sys/user/resetPassword', {
id: Number(resetPwdTarget.value.id),
@ -208,28 +193,35 @@ async function resetPwdConfirm() {
async function deleteHandle(row: SysUser) {
try {
await ElMessageBox.confirm(
`确认删除用户 ${row.username} 吗?删除后无法恢复。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
return
}
await ElMessageBox.confirm(`确认删除用户 ${row.username} 吗?删除后无法恢复。`, '删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
} catch { return }
try {
await httpDelete(`/sys/user/${row.id}`)
ElMessage.success('删除成功')
load()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '删除失败')
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
}
async function batchDelete() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择用户'); return }
try {
await ElMessageBox.confirm(`确认删除已选 ${selectedIds.value.length} 个用户?删除后无法恢复。`, '批量删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
} catch { return }
let ok = 0
for (const id of selectedIds.value) {
try { await httpDelete(`/sys/user/${id}`); ok++ } catch {/* 单条失败跳过 */}
}
ElMessage.success(`已删除 ${ok} 个用户`)
load()
}
onMounted(() => { load() })
</script>
<template>
<div class="rr-sys-user">
<div class="rr-page">
<PageHeader title="后台用户" :sub="`共 ${total} 个用户`">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</PageHeader>
@ -237,68 +229,116 @@ onMounted(() => { load() })
<SearchBar
v-model:filters="filters"
:fields='[
{ key: "username", label: "用户名", type: "text", placeholder: "用户名" }
{ key: "username", label: "用户名", type: "text", placeholder: "用户名" },
{ key: "userType", label: "用户类型", type: "select", placeholder: "全部类型",
options: [
{ value: 1, label: "试用期" },
{ value: 2, label: "正式" },
{ value: 3, label: "外包" },
{ value: 99, label: "AI 公司" }
] }
]'
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card">
<!-- 批量操作栏 -->
<div class="batch-bar" :class="{ 'has-selection': selectedCount > 0 }">
<span v-if="selectedCount > 0" class="batch-count">
已选 <strong>{{ selectedCount }}</strong> 个用户
</span>
<span v-else class="batch-hint">勾选用户后可批量删除</span>
<span class="batch-actions">
<el-button size="small" type="danger" plain :disabled="!selectedCount" @click="batchDelete">批量删除</el-button>
</span>
</div>
<DataTable
:columns="[
{ prop: 'username', label: '用户名', width: 200, slot: 'username' },
{ prop: 'realName', label: '姓名', width: 140 },
{ prop: 'employeeId', label: '工号', width: 120 },
{ prop: 'mobile', label: '手机号', width: 130 },
{ prop: 'userType', label: '类型', width: 100, slot: 'userType' },
{ prop: 'status', label: '状态', width: 80, slot: 'status' },
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
{ label: '操作', width: 140, slot: 'action' }
{ type: 'selection', width: 48, label: '' },
{ label: '用户', minWidth: 220, slot: 'user' },
{ prop: 'mobile', label: '手机号', width: 130, slot: 'mobile' },
{ label: '部门 / 角色', minWidth: 180, slot: 'deptRole' },
{ prop: 'userType', label: '类型', width: 100, slot: 'userType', align: 'center' },
{ prop: 'status', label: '状态', width: 90, slot: 'status', align: 'center' },
{ label: '创建时间', width: 165, slot: 'createDate' },
{ label: '操作', width: 170, slot: 'action', fixed: 'right', align: 'center' }
]"
:data="list" :borderless="true"
:data="list"
:borderless="true"
:loading="loading"
:total="total"
:page="page"
:limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]"
:show-pagination="true"
:page-sizes="[10, 20, 50, 100]"
:error="loadError"
empty-text="暂无用户"
row-key="id"
@page-change="onPageChange"
@limit-change="onLimitChange"
@selection-change="onSelectionChange"
>
<template #username="{ row }">
<code class="rr-username-code">{{ row.username }}</code>
<!-- 用户单元: 头像 + 用户名 + 姓名/工号 -->
<template #user="{ row, index }">
<div class="user-cell">
<span class="user-avatar" :style="avatarStyle(row, index)">
<el-image v-if="row.headUrl" :src="row.headUrl" fit="cover" class="user-avatar-img" />
<template v-else>{{ initial(row) }}</template>
</span>
<span class="user-text">
<span class="user-name">
{{ row.username }}
<el-tag v-if="row.superAdmin === 1" size="small" type="warning" effect="light" class="super-tag">超管</el-tag>
</span>
<span class="user-sub">{{ row.realName }}{{ row.employeeId && row.employeeId !== '--' ? ` · ${row.employeeId}` : '' }}</span>
</span>
</div>
</template>
<!-- 手机号 -->
<template #mobile="{ row }">
<span class="phone-cell"><el-icon><Phone /></el-icon>{{ row.mobile || '--' }}</span>
</template>
<!-- 部门/角色 -->
<template #deptRole="{ row }">
<div class="two-line">
<span class="line-main">{{ row.deptName }}</span>
<span class="line-sub">{{ row.roleNames || '未分配角色' }}</span>
</div>
</template>
<!-- 类型 -->
<template #userType="{ row }">
<el-tag
v-if="row.userType != null && USER_TYPE_MAP[row.userType]"
:type="USER_TYPE_MAP[row.userType].type as any"
size="small"
effect="light"
>{{ USER_TYPE_MAP[row.userType].label }}</el-tag>
<span v-if="row.userType != null && USER_TYPE_MAP[row.userType]" class="chip"
:style="{ color: USER_TYPE_MAP[row.userType].color, background: USER_TYPE_MAP[row.userType].bg }">
{{ USER_TYPE_MAP[row.userType].label }}
</span>
<span v-else class="rr-muted">--</span>
</template>
<!-- 状态: 圆点胶囊 -->
<template #status="{ row }">
<el-tag
:type="STATUS_MAP[row.status].type as any"
size="small"
effect="light"
>{{ STATUS_MAP[row.status].label }}</el-tag>
<span class="status-tag"
:style="{ color: STATUS_META[row.status].color, background: STATUS_META[row.status].bg }">
<span class="status-dot"></span>{{ STATUS_META[row.status].label }}
</span>
</template>
<!-- 创建时间 -->
<template #createDate="{ row }">
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
<span class="date-cell"><el-icon><Clock /></el-icon>{{ fmtDate(row.createDate) }}</span>
</template>
<!-- 操作 -->
<template #action="{ row }">
<TableActions
:row="row"
:actions="[
{ label: (r: any) => r.status === 1 ? '停用' : '启用', type: (r: any) => r.status === 1 ? 'warning' : 'success', onClick: toggleStatus },
{ label: '重置密码', type: 'primary', onClick: resetPwdHandle },
{ label: '重置密码', type: 'info', onClick: resetPwdHandle },
{ label: '删除', type: 'danger', onClick: deleteHandle }
]"
/>
@ -311,11 +351,10 @@ onMounted(() => { load() })
v-model="resetPwdVisible"
title="重置密码"
width="480"
:style="{ borderRadius: '12px', overflow: 'hidden' }"
align-center
destroy-on-close
>
<p v-if="resetPwdTarget" style="margin-bottom: 16px;">
<p v-if="resetPwdTarget" style="margin: 0 0 16px; color: var(--ink-2)">
用户: <code class="rr-username-code">{{ resetPwdTarget.username }}</code>
({{ resetPwdTarget.realName }})
</p>
@ -330,9 +369,8 @@ onMounted(() => { load() })
/>
</el-form-item>
</el-form>
<el-alert type="warning" :closable="false" style="margin-top: 8px;">
<p>重置后请通过安全渠道告知用户</p>
<p>用户下次登录需使用新密码</p>
<el-alert type="warning" :closable="false" style="margin-top: 8px">
<p style="margin: 0">重置后请通过安全渠道告知用户,下次登录需使用新密码</p>
</el-alert>
<template #footer>
<el-button @click="resetPwdVisible = false">取消</el-button>
@ -342,78 +380,64 @@ onMounted(() => { load() })
</div>
</template>
<style scoped>
.rr-sys-user {
padding: 16px;
<style scoped lang="scss">
.rr-page { padding: 16px; }
/* ===== 批量操作栏 (对齐企业列表) ===== */
.batch-bar {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
transition: background .2s;
}
.batch-bar.has-selection { background: var(--primary-soft, #e0ecff); }
.batch-count { font-size: 13px; color: var(--ink-2); }
.batch-count strong { color: var(--primary); font-size: 15px; }
.batch-hint { font-size: 12px; color: var(--ink-3); }
.batch-actions { margin-left: auto; }
/* ===== 用户单元 ===== */
.user-cell { display: flex; align-items: center; gap: 10px; }
.user-avatar {
flex: 0 0 38px; width: 38px; height: 38px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 16px; font-weight: 700; flex-shrink: 0;
}
.user-avatar-img { width: 100%; height: 100%; border-radius: 10px; }
.user-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.user-name {
display: flex; align-items: center; gap: 6px;
font-size: 14px; font-weight: 600; color: var(--ink);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.super-tag { margin-left: 2px; }
.user-sub {
font-size: 12px; color: var(--ink-3);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.rr-page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
/* ===== 单元格通用 ===== */
.two-line { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.line-main { font-size: 13px; color: var(--ink); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.line-sub { font-size: 12px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.phone-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
.date-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--ink-2); font-family: 'SF Mono', Menlo, Consolas, monospace; }
.rr-page-titles {
display: flex;
flex-direction: column;
gap: 2px;
/* ===== 类型/状态胶囊 (对齐企业列表, 使用全局 --st-* 变量暗色自适应) ===== */
.chip {
display: inline-flex; align-items: center; width: fit-content;
font-size: 12px; font-weight: 500; padding: 2px 12px; border-radius: 20px; white-space: nowrap;
}
.rr-page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.rr-page-sub {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rr-page-actions {
display: flex;
gap: 8px;
}
.rr-filters-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.rr-username-input { width: 240px; }
.rr-type-select { width: 140px; }
.rr-list-card {
background: var(--el-bg-color);
border-radius: 12px;
padding: 4px;
.status-tag {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 500; padding: 3px 12px; border-radius: 20px; white-space: nowrap;
}
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.rr-username-code {
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
padding: 2px 6px;
background: var(--el-fill-color-light);
border-radius: 4px;
color: var(--el-color-primary);
}
.rr-time {
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--el-text-color-regular);
}
.rr-muted {
color: var(--el-text-color-placeholder);
font-size: 12px;
font-size: 12px; padding: 2px 6px;
background: var(--fill-color-light, var(--surface-2));
border-radius: 4px; color: var(--el-color-primary);
}
.rr-muted { color: var(--ink-3); font-size: 12px; }
</style>

View File

@ -1,18 +1,23 @@
<script setup lang="ts">
/**
* W18.2 企业列表 — 按项目 UI 规范 (PageHeader/SearchBar/DataTable) + 老 vue 全功能
* W18.2 + 2026-08-18 企业列表 — 专业 UI 升级版 (按项目 UI 规范 + 老 vue 全功能)
*
* 后端端点: ApiEnterpriseController @ /user/enterprise
* GET /user/enterprise/page 分页 (EnterpriseDTO 全字段, 支持 enterpriseName/industry/supplierLevel/salesperson/supplierLabel/status/homeShow 过滤)
* GET /user/enterprise/checkStatus?id=&status=&failReason= 审核(2通过/3驳回, 支持逗号批量)
* POST /user/enterprise/enabled?id= 禁用/启用切换 (后端 @RequestParam 从 query 读)
* POST /user/enterprise/showEnterprise?id= 显示/隐藏切换 (query)
* GET /user/enterprise/homeShow?id=&homeShow= 首页推荐切换 (query)
*
* EnterpriseDTO 真实字段 (来源: devbox 编译反编译确认, 2026-07-15):
* id/enterpriseName/enterpriseLogo/administrator/administratorMobile/industry/supplierLevel
* supplierLabel/salesperson/salespersonMobile/status/createDate/enabled/showFlag/homeShow
* linkMan/linkMobile/legalMan/email/enterpriseAddress/mainProducts/registerTime/orgNo/failReason
*/
import { onMounted, ref, reactive } from 'vue'
import { onMounted, ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { User, Clock, Upload, Download, MoreFilled } from '@element-plus/icons-vue'
import { httpGet, httpPost, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
@ -58,8 +63,17 @@ const loading = ref(false)
const page = ref(1)
const limit = ref(10)
const selectedIds = ref<string[]>([])
const detailVisible = ref(false)
const detailRow = ref<Enterprise | null>(null)
const selectedRows = ref<Enterprise[]>([])
// ========== 单行审核 ==========
const auditVisible = ref(false)
const auditBusy = ref(false)
const auditForm = reactive({ id: '', result: 2, failReason: '' })
// ========== 批量审核 ==========
const batchAuditVisible = ref(false)
const batchAuditBusy = ref(false)
const batchAuditForm = reactive({ result: 2, failReason: '' })
const filters = reactive({
enterpriseName: '',
@ -72,19 +86,33 @@ const filters = reactive({
})
// status 映射 (老 vue: 1=待审核,2=已认证,3=已驳回)
const STATUS_MAP: Record<string, string> = {
'1': '待审核', '2': '已认证', '3': '已驳回'
}
const STATUS_CLASS: Record<string, string> = {
'1': 'rr-tag-warn', '2': 'rr-tag-ok', '3': 'rr-tag-err'
// 状态/等级色引用全局 token (styles/global-ui.scss), 主题切换自动适配暗色
const STATUS_META: Record<string, { label: string; color: string; bg: string }> = {
'1': { label: '待审核', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
'2': { label: '已认证', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
'3': { label: '已驳回', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
// supplierLevel 映射 (老 vue: 1=优质,2=钻石,3=金牌,4=银牌,5=铜牌)
const LEVEL_MAP: Record<number, string> = {
1: '优质供应商', 2: '钻石供应商', 3: '金牌供应商',
4: '银牌供应商', 5: '铜牌供应商'
const LEVEL_META: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '优质供应商', color: 'var(--st-lv1-fg)', bg: 'var(--st-lv1-bg)' },
2: { label: '钻石供应商', color: 'var(--st-lv2-fg)', bg: 'var(--st-lv2-bg)' },
3: { label: '金牌供应商', color: 'var(--st-lv3-fg)', bg: 'var(--st-lv3-bg)' },
4: { label: '银牌供应商', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' },
5: { label: '铜牌供应商', color: 'var(--st-lv5-fg)', bg: 'var(--st-lv5-bg)' }
}
// 头像品牌渐变色组 (企业无 logo 时交替使用)
const AVATAR_GRADIENTS = [
'linear-gradient(135deg, #0b5394, #6fb1e8)',
'linear-gradient(135deg, #2563eb, #93c5fd)',
'linear-gradient(135deg, #0e7490, #67e8f9)',
'linear-gradient(135deg, #7c3aed, #c4b5fd)',
'linear-gradient(135deg, #be123c, #fda4af)'
]
const selectedCount = computed(() => selectedIds.value.length)
function fmtDate(ts?: string | null) {
if (!ts) return '--'
const d = new Date(ts)
@ -100,6 +128,16 @@ function maskMobile(mobile?: string | null) {
/** 企业名首字 (Avatar 用) */
function initial(name: string) { return name ? name.charAt(0) : '企' }
/** 头像背景 (有 logo 用图片, 否则渐变) */
function avatarStyle(row: Enterprise, index: number) {
return { background: AVATAR_GRADIENTS[index % AVATAR_GRADIENTS.length] }
}
/** 标签前 2 个 + 剩余计数 */
function shownTags(row: Enterprise): { tags: string[]; rest: number } {
const raw = row.supplierLabel || ''
const tags = String(raw).split(/[,,、;;\s]+/).map((s: string) => s.trim()).filter(Boolean)
return { tags: tags.slice(0, 2), rest: Math.max(0, tags.length - 2) }
}
async function load() {
loading.value = true
@ -158,14 +196,62 @@ function onReset() {
}
function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id) }
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id); selectedRows.value = rows }
// ========== 审核交互 (后端 GET /user/enterprise/checkStatus?id=&status=&failReason=, 支持逗号批量) ==========
function onAudit(row: Enterprise) {
auditForm.id = row.id
auditForm.result = 2
auditForm.failReason = ''
auditVisible.value = true
}
async function submitAudit() {
if (auditForm.result === 3 && !auditForm.failReason.trim()) { ElMessage.warning('请填写驳回原因'); return }
auditBusy.value = true
try {
await httpGet('/user/enterprise/checkStatus', {
id: auditForm.id, status: auditForm.result,
failReason: auditForm.result === 3 ? auditForm.failReason.trim() : ''
})
ElMessage.success(auditForm.result === 2 ? '审核通过' : '已驳回')
auditVisible.value = false
load()
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '审核操作失败') }
finally { auditBusy.value = false }
}
async function openBatchAudit() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择企业'); return }
batchAuditForm.result = 2
batchAuditForm.failReason = ''
batchAuditVisible.value = true
}
async function submitBatchAudit() {
if (batchAuditForm.result === 3 && !batchAuditForm.failReason.trim()) { ElMessage.warning('请填写驳回原因'); return }
try {
await ElMessageBox.confirm(
`确认对已选 ${selectedIds.value.length} 家企业执行「${batchAuditForm.result === 2 ? '审核通过' : '驳回'}」?` +
(batchAuditForm.result === 2 ? '通过后将通过站内消息与短信通知企业。' : ''),
'批量审核确认', { type: 'warning' }
)
} catch { return }
batchAuditBusy.value = true
try {
await httpGet('/user/enterprise/checkStatus', {
id: selectedIds.value.join(','), status: batchAuditForm.result,
failReason: batchAuditForm.result === 3 ? batchAuditForm.failReason.trim() : ''
})
ElMessage.success(`已对 ${selectedIds.value.length} 家企业完成审核`)
batchAuditVisible.value = false
load()
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '批量审核失败') }
finally { batchAuditBusy.value = false }
}
// ========== 批量操作 ==========
function importHandle() { ElMessage.info('导入功能待实现') }
function batchAuditHandle() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择企业'); return }
ElMessage.info('批量审核功能待实现')
}
function batchLabelHandle() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择企业'); return }
ElMessage.info('批量设置标签待实现')
@ -188,27 +274,44 @@ async function deleteHandle() {
function exportSupplierDetail() { ElMessage.info('导出待实现') }
// ========== 单行操作 ==========
function showDetail(row: Enterprise) { detailRow.value = row; detailVisible.value = true }
function goDetail(row: Enterprise) { router.push(`/user/enterprise/detail/${row.id}`) }
function changeAdminHandle(_row: Enterprise) { ElMessage.info('更换管理员待实现') }
// ========== 更换管理员 ==========
const adminDialogVisible = ref(false)
const adminForm = reactive({ id: '', administrator: '', administratorMobile: '' })
function changeAdminHandle(row: Enterprise) {
Object.assign(adminForm, { id: row.id, administrator: row.administrator || '', administratorMobile: row.administratorMobile || '' })
adminDialogVisible.value = true
}
async function submitChangeAdmin() {
if (!adminForm.administratorMobile?.trim()) { ElMessage.warning('请输入新管理员手机号'); return }
if (!adminForm.administrator?.trim()) { ElMessage.warning('请输入新管理员姓名'); return }
try {
await httpPost('/user/enterprise/updateAdministrator', { ...adminForm })
ElMessage.success('更换成功')
adminDialogVisible.value = false
load()
} catch (e: any) { ElMessage.error(e?.msg || '更换失败') }
}
async function handleMore(cmd: string, row: Enterprise) {
try {
switch (cmd) {
case 'audit': ElMessage.info('审核待实现'); break
case 'audit': onAudit(row); break
case 'label': ElMessage.info('标签待实现'); break
case 'rating': ElMessage.info('评级待实现'); break
case 'enabled':
await httpPost('/user/enterprise/enabled', { id: row.id, enabled: row.enabled === 1 ? 0 : 1 })
ElMessage.success(row.enabled === 1 ? '已禁用' : '已启用'); load()
// 后端 @RequestParam 从 query 读, body 不绑定 (2026-08-18 实测修正)
await httpPost(`/user/enterprise/enabled?id=${row.id}`)
ElMessage.success(row.enabled === 1 ? '已启用' : '已禁用'); load()
break
case 'showFlag':
await httpPost('/user/enterprise/showEnterprise', { id: row.id, showFlag: row.showFlag === 1 ? 0 : 1 })
await httpPost(`/user/enterprise/showEnterprise?id=${row.id}`)
ElMessage.success(row.showFlag === 1 ? '已显示' : '已隐藏'); load()
break
case 'homeShow':
await httpPost('/user/enterprise/homeShow', { id: row.id, homeShow: row.homeShow === 1 ? 0 : 1 })
ElMessage.success(row.homeShow === 1 ? '已取消推荐' : '已推荐'); load()
await httpGet('/user/enterprise/homeShow', { id: row.id, homeShow: row.homeShow === 1 ? 0 : 1 })
ElMessage.success(row.homeShow === 1 ? '已推荐' : '已取消推荐'); load()
break
case 'assign': ElMessage.info('分配业务员待实现'); break
case 'deleteRow':
@ -232,7 +335,7 @@ onMounted(() => load())
:fields="[
{ key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '企业名称' },
{ key: 'industry', label: '行业类型', type: 'text', placeholder: '行业类型' },
{ key: 'supplierLevel', label: '供应商等级', type: 'select', placeholder: '供应商等级',
{ key: 'supplierLevel', label: '供应商等级', type: 'select', placeholder: '全部等级',
options: [
{ value: 1, label: '优质供应商' },
{ value: 2, label: '钻石供应商' },
@ -240,6 +343,13 @@ onMounted(() => load())
{ value: 4, label: '银牌供应商' },
{ value: 5, label: '铜牌供应商' }
]
},
{ key: 'status', label: '审核状态', type: 'select', placeholder: '全部状态',
options: [
{ value: 1, label: '待审核' },
{ value: 2, label: '已认证' },
{ value: 3, label: '已驳回' }
]
}
]"
@search="onSearch"
@ -247,26 +357,32 @@ onMounted(() => load())
/>
<div class="rr-list-card">
<!-- 批量操作栏 -->
<div class="rr-batch-bar">
<el-button size="small" type="success" @click="importHandle">导入</el-button>
<el-button size="small" type="danger" @click="batchAuditHandle">批量审核</el-button>
<el-button size="small" @click="batchLabelHandle">批量设置标签</el-button>
<el-button size="small" type="warning" @click="batchAssignSalesperson">批量分配业务员</el-button>
<el-button size="small" type="danger" @click="deleteHandle">删除</el-button>
<el-button size="small" type="primary" @click="exportSupplierDetail">导出供应商信息明细</el-button>
<!-- 批量操作栏 (选中时高亮) -->
<div class="rr-batch-bar" :class="{ 'has-selection': selectedCount > 0 }">
<span v-if="selectedCount > 0" class="batch-count">
已选 <strong>{{ selectedCount }}</strong> 家企业
</span>
<span v-else class="batch-hint">勾选企业后可执行批量操作</span>
<span class="batch-actions">
<el-button size="small" type="primary" @click="openBatchAudit">批量审核</el-button>
<el-button size="small" @click="batchLabelHandle">批量设置标签</el-button>
<el-button size="small" type="warning" plain @click="batchAssignSalesperson">批量分配业务员</el-button>
<el-button size="small" @click="importHandle"><el-icon style="margin-right:4px"><Upload /></el-icon>导入</el-button>
<el-button size="small" @click="exportSupplierDetail"><el-icon style="margin-right:4px"><Download /></el-icon>导出明细</el-button>
<el-button size="small" type="danger" plain @click="deleteHandle">删除</el-button>
</span>
</div>
<DataTable
:columns="[
{ type: 'selection', width: 48, label: '' },
{ label: '企业名称', minWidth: 220, slot: 'enterpriseName' },
{ label: '企业管理员/手机号', minWidth: 200, slot: 'adminInfo' },
{ label: '行业类型/供应商等级', minWidth: 200, slot: 'levelInfo' },
{ prop: 'supplierLabel', label: '标签', width: 120, align: 'center' },
{ label: '业务员', minWidth: 160, slot: 'salespersonInfo' },
{ label: '状态', width: 100, slot: 'status' },
{ label: '入驻时间', width: 110, slot: 'createDate' },
{ label: '企业名称', minWidth: 250, slot: 'enterpriseName' },
{ label: '企业管理员', minWidth: 170, slot: 'adminInfo' },
{ label: '行业 / 供应商等级', minWidth: 190, slot: 'levelInfo' },
{ label: '标签', minWidth: 140, slot: 'supplierLabel' },
{ label: '业务员', minWidth: 150, slot: 'salespersonInfo' },
{ label: '状态', width: 100, slot: 'status', align: 'center' },
{ label: '入驻时间', width: 120, slot: 'createDate' },
{ label: '操作', width: 200, slot: 'action', fixed: 'right', align: 'center' }
]"
:data="list"
@ -282,71 +398,91 @@ onMounted(() => load())
@limit-change="onLimitChange"
@selection-change="onSelectionChange"
>
<!-- 企业名称(带头像) -->
<!-- 企业名称 (头像 + 名称 + 法人) -->
<template #enterpriseName="{ row, index }">
<div style="display:flex;align-items:center;gap:8px">
<span :style="{ display:'inline-flex',alignItems:'center',justifyContent:'center',
width:'32px',height:'32px',borderRadius:'50%',flexShrink:0,fontSize:'14px',fontWeight:500,
background:index%2===0?'#fce4ec':'#e3f2fd',color:index%2===0?'#c62828':'#1565c0'
}">{{ initial(row.enterpriseName) }}</span>
<span style="font-weight:500">{{ row.enterpriseName }}</span>
<div class="ent-cell">
<span class="ent-avatar" :style="avatarStyle(row, index)">
<el-image v-if="row.enterpriseLogo" :src="row.enterpriseLogo" fit="cover" class="ent-avatar-img" />
<template v-else>{{ initial(row.enterpriseName) }}</template>
</span>
<span class="ent-text">
<span class="ent-name">{{ row.enterpriseName }}</span>
<span class="ent-sub" v-if="row.legalMan && row.legalMan !== '--'">法人:{{ row.legalMan }}</span>
</span>
</div>
</template>
<!-- 企业管理员/手机号(两行) -->
<!-- 企业管理员/手机号 (两行) -->
<template #adminInfo="{ row }">
<div style="display:flex;flex-direction:column;gap:2px">
<span>{{ row.administrator }}</span>
<span style="font-size:12px;color:var(--ink-3)">{{ maskMobile(row.administratorMobile) }}</span>
<div class="two-line">
<span class="line-main">{{ row.administrator }}</span>
<span class="line-sub"><el-icon><User /></el-icon>{{ maskMobile(row.administratorMobile) }}</span>
</div>
</template>
<!-- 行业类型/供应商等级(两行) -->
<!-- 行业/供应商等级 (两行 + 等级徽章) -->
<template #levelInfo="{ row }">
<div style="display:flex;flex-direction:column;gap:2px">
<span>{{ row.industry || '-' }}</span>
<span style="font-size:12px;color:var(--ink-3)">{{ LEVEL_MAP[row.supplierLevel as number] || '-' }}</span>
<div class="two-line">
<span class="line-main">{{ row.industry || '-' }}</span>
<span v-if="LEVEL_META[row.supplierLevel as number]" class="level-chip"
:style="{ color: LEVEL_META[row.supplierLevel as number].color, background: LEVEL_META[row.supplierLevel as number].bg }">
{{ LEVEL_META[row.supplierLevel as number].label }}
</span>
<span v-else class="line-sub">未评级</span>
</div>
</template>
<!-- 标签 (前 2 个 + 溢出) -->
<template #supplierLabel="{ row }">
<div class="label-tags" v-if="row.supplierLabel">
<span v-for="(t, i) in shownTags(row).tags" :key="i" class="label-tag">{{ t }}</span>
<span v-if="shownTags(row).rest > 0" class="label-more">+{{ shownTags(row).rest }}</span>
</div>
<span v-else class="rr-muted">--</span>
</template>
<!-- 业务员 -->
<template #salespersonInfo="{ row }">
<div v-if="row.salesperson" style="font-size:12px;color:var(--ink-3)">
{{ row.salesperson }}({{ maskMobile(row.salespersonMobile) }})
<div v-if="row.salesperson" class="two-line">
<span class="line-main">{{ row.salesperson }}</span>
<span class="line-sub">{{ maskMobile(row.salespersonMobile) }}</span>
</div>
<div v-else style="font-size:12px;color:var(--ink-3);font-style:italic">暂未分配</div>
<span v-else class="unassigned">待分配</span>
</template>
<!-- 状态标签 -->
<!-- 状态标签 (圆点 + 脉冲待审核) -->
<template #status="{ row }">
<span v-if="STATUS_MAP[String(row.status)]" :class="['rr-status-tag', STATUS_CLASS[String(row.status)] || '']">
{{ STATUS_MAP[String(row.status)] }}
<span v-if="STATUS_META[String(row.status)]" class="status-tag" :class="{ pulsing: row.status === 1 }"
:style="{ color: STATUS_META[String(row.status)].color, background: STATUS_META[String(row.status)].bg }">
<span class="status-dot"></span>{{ STATUS_META[String(row.status)].label }}
</span>
<span v-else class="rr-muted">--</span>
</template>
<!-- 入驻时间 -->
<template #createDate="{ row }">
<span style="font-family:'SF Mono',Menlo,monospace;font-size:12px">{{ fmtDate(row.createDate) }}</span>
<span class="date-cell"><el-icon><Clock /></el-icon>{{ fmtDate(row.createDate) }}</span>
</template>
<!-- 操作列 -->
<template #action="{ row }">
<div style="display:flex;align-items:center;gap:2px">
<div class="action-cell">
<el-button type="primary" size="small" text @click="goDetail(row)">详情</el-button>
<el-button type="primary" size="small" text @click="changeAdminHandle(row)">更换管理员</el-button>
<el-button size="small" text @click="changeAdminHandle(row)">更换管理员</el-button>
<el-dropdown trigger="click" placement="bottom-end" @command="(cmd:string) => handleMore(cmd, row)">
<el-button type="primary" size="small" text><el-icon><MoreFilled /></el-icon></el-button>
<el-button size="small" text><el-icon><MoreFilled /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="row.status===1" command="audit">审核</el-dropdown-item>
<el-dropdown-item v-if="row.status===1" command="audit">
<span class="dd-audit">审核</span>
</el-dropdown-item>
<el-dropdown-item command="label">标签</el-dropdown-item>
<el-dropdown-item command="rating">评级</el-dropdown-item>
<el-dropdown-item command="enabled">{{ row.enabled===1?'启用':'禁用' }}</el-dropdown-item>
<el-dropdown-item divided command="enabled">{{ row.enabled===1?'启用':'禁用' }}</el-dropdown-item>
<el-dropdown-item command="showFlag">{{ row.showFlag===1?'显示企业':'隐藏企业' }}</el-dropdown-item>
<el-dropdown-item command="homeShow">{{ row.homeShow===1?'取消推荐':'推荐显示' }}</el-dropdown-item>
<el-dropdown-item command="assign">分配业务员</el-dropdown-item>
<el-dropdown-item command="deleteRow">删除</el-dropdown-item>
<el-dropdown-item divided command="deleteRow" class="dd-danger">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@ -355,31 +491,67 @@ onMounted(() => load())
</DataTable>
</div>
<!-- 详情弹窗 (EnterpriseDTO 全字段) -->
<el-dialog v-model="detailVisible" title="企业详情" width="700px" :close-on-click-modal="false" destroy-on-close>
<template v-if="detailRow">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="企业名称">{{ detailRow.enterpriseName }}</el-descriptions-item>
<el-descriptions-item label="管理员">{{ detailRow.administrator }}</el-descriptions-item>
<el-descriptions-item label="管理员手机">{{ maskMobile(detailRow.administratorMobile) }}</el-descriptions-item>
<el-descriptions-item label="行业">{{ detailRow.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="供应商等级">{{ LEVEL_MAP[detailRow.supplierLevel as number] || '-' }}</el-descriptions-item>
<el-descriptions-item label="标签">{{ detailRow.supplierLabel || '-' }}</el-descriptions-item>
<el-descriptions-item label="业务员">{{ detailRow.salesperson || '暂未分配' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ STATUS_MAP[String(detailRow.status)] || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="detailRow.status===3" label="驳回原因" :span="2">{{ detailRow.failReason || '-' }}</el-descriptions-item>
<el-descriptions-item label="入驻时间">{{ fmtDate(detailRow.createDate) }}</el-descriptions-item>
<el-descriptions-item label="法人代表">{{ detailRow.legalMan }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ maskMobile(detailRow.linkMobile) }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detailRow.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ detailRow.enterpriseAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="主营产品" :span="2">{{ detailRow.mainProducts || '-' }}</el-descriptions-item>
<el-descriptions-item label="工商号">{{ detailRow.orgNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ fmtDate(detailRow.registerTime) }}</el-descriptions-item>
</el-descriptions>
</template>
<!-- 单行审核弹窗 -->
<el-dialog v-model="auditVisible" title="企业认证审核" width="480px" :close-on-click-modal="false" destroy-on-close>
<div style="background:var(--bg,#f6f8fb);border:1px solid var(--border);border-radius:8px;padding:10px 14px;font-size:13px;color:var(--ink-2);margin-bottom:18px">
正在审核:<strong style="color:var(--ink)">{{ auditForm.id ? (list.find(r => r.id === auditForm.id)?.enterpriseName || '--') : '--' }}</strong>
</div>
<el-form label-width="80px" label-position="left">
<el-form-item label="审核结果">
<el-radio-group v-model="auditForm.result">
<el-radio :value="2">审核通过</el-radio>
<el-radio :value="3">审核驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="auditForm.result === 3" label="驳回原因" required>
<el-input v-model="auditForm.failReason" type="textarea" :rows="4"
placeholder="请填写驳回原因,将随站内消息与短信通知企业" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="detailVisible=false">关闭</el-button>
<el-button @click="auditVisible=false">取消</el-button>
<el-button type="primary" :loading="auditBusy" @click="submitAudit">确定提交</el-button>
</template>
</el-dialog>
<!-- 批量审核弹窗 -->
<el-dialog v-model="batchAuditVisible" title="批量审核企业" width="480px" :close-on-click-modal="false" destroy-on-close>
<el-form label-width="80px" label-position="left">
<el-form-item label="审核结果">
<el-radio-group v-model="batchAuditForm.result">
<el-radio :value="2">全部审核通过</el-radio>
<el-radio :value="3">全部驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="batchAuditForm.result === 3" label="驳回原因" required>
<el-input v-model="batchAuditForm.failReason" type="textarea" :rows="4"
placeholder="请填写驳回原因,将随站内消息与短信通知企业" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item v-else label="通过说明">
<div style="font-size:12px;color:var(--ink-3);line-height:1.7;background:var(--bg,#f6f8fb);border-radius:8px;padding:10px 12px;width:100%">
已选 <strong style="color:var(--primary)">{{ selectedIds.length }}</strong> 家企业将通过认证,并通过站内消息与短信通知企业。
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchAuditVisible=false">取消</el-button>
<el-button type="primary" :loading="batchAuditBusy" @click="submitBatchAudit">确定提交</el-button>
</template>
</el-dialog>
<!-- 更换管理员弹窗 -->
<el-dialog v-model="adminDialogVisible" title="更换管理员" width="420px" :close-on-click-modal="false" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="管理员姓名" required>
<el-input v-model="adminForm.administrator" placeholder="请输入新管理员姓名" />
</el-form-item>
<el-form-item label="手机号" required>
<el-input v-model="adminForm.administratorMobile" placeholder="请输入新管理员手机号" maxlength="11" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="adminDialogVisible=false">取消</el-button>
<el-button type="primary" @click="submitChangeAdmin">确定</el-button>
</template>
</el-dialog>
</div>
@ -390,15 +562,82 @@ onMounted(() => load())
.rr-list-card {
background: var(--surface); border-radius: 12px; overflow: hidden;
}
/* ===== 批量操作栏 ===== */
.rr-batch-bar {
display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 16px;
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
border-bottom: 1px solid var(--border);
transition: background .2s;
}
.rr-status-tag {
display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px; font-weight: 500;
.rr-batch-bar.has-selection { background: var(--primary-soft, #e0ecff); }
.batch-count { font-size: 13px; color: var(--ink-2); }
.batch-count strong { color: var(--primary); font-size: 15px; }
.batch-hint { font-size: 12px; color: var(--ink-3); }
.batch-actions { margin-left: auto; display: flex; gap: 4px; flex-wrap: wrap; }
/* ===== 企业名称 ===== */
.ent-cell { display: flex; align-items: center; gap: 10px; }
.ent-avatar {
flex: 0 0 38px; width: 38px; height: 38px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 16px; font-weight: 700; flex-shrink: 0;
}
.rr-tag-warn { background: #fff3e0; color: #e65100; }
.rr-tag-ok { background: #e8f5e9; color: #2e7d32; }
.rr-tag-err { background: #fce4ec; color: #c62828; }
.ent-avatar-img { width: 100%; height: 100%; border-radius: 10px; }
.ent-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ent-name { font-size: 14px; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ent-sub { font-size: 12px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ===== 两行单元格 ===== */
.two-line { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.line-main { font-size: 13px; color: var(--ink); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.line-sub { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--ink-3); }
/* ===== 等级徽章 ===== */
.level-chip {
display: inline-flex; align-items: center; width: fit-content;
font-size: 11px; font-weight: 600; padding: 2px 10px; border-radius: 20px;
white-space: nowrap;
}
/* ===== 标签 ===== */
.label-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.label-tag {
font-size: 11px; color: var(--primary); background: var(--primary-soft, #e0ecff);
padding: 2px 10px; border-radius: 6px; white-space: nowrap;
}
.label-more { font-size: 11px; color: var(--ink-3); align-self: center; }
/* ===== 业务员 ===== */
.unassigned {
display: inline-flex; font-size: 11px; color: var(--ink-3);
background: var(--bg, #f6f8fb); border: 1px dashed var(--border);
padding: 2px 10px; border-radius: 20px;
}
/* ===== 状态标签 ===== */
.status-tag {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 500; padding: 3px 12px; border-radius: 20px;
white-space: nowrap;
}
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-tag.pulsing .status-dot { animation: pulse-dot 1.6s ease-in-out infinite; }
@keyframes pulse-dot {
0%, 100% { box-shadow: 0 0 0 0 rgba(180, 83, 9, .4); }
50% { box-shadow: 0 0 0 5px rgba(180, 83, 9, 0); }
}
/* ===== 入驻时间 ===== */
.date-cell { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--ink-2); font-family: 'SF Mono', Menlo, Consolas, monospace; }
/* ===== 操作列 ===== */
.action-cell { display: flex; align-items: center; gap: 2px; }
.dd-audit { color: #b45309; font-weight: 600; }
.dd-danger { color: #dc2626; }
.rr-muted { color: var(--ink-3); font-size: 12px; }
@media (max-width: 1200px) {
.batch-actions { margin-left: 0; }
}
</style>

View File

@ -88,10 +88,16 @@ watch(
() => props.visible,
async (v) => {
if (!v) return
// 2026-08-18 修复"先黑一下": loading 提前置 true 覆盖整个加载过程,
// 企业下拉与用户详情并行加载, 避免空表单闪现 + 后半程才出现遮罩
loading.value = true
resetForm()
await loadEnterprises()
if (props.userId) {
await loadDetail()
try {
const jobs: Promise<void>[] = [loadEnterprises()]
if (props.userId) jobs.push(loadDetail())
await Promise.all(jobs)
} finally {
loading.value = false
}
}
)
@ -213,7 +219,12 @@ async function submit() {
@update:model-value="(v: boolean) => emit('update:visible', v)"
@closed="resetForm"
>
<div v-loading="loading">
<!-- 透明遮罩 + 加载文案: 消除"先黑一下"观感 (暗色主题下黑遮罩叠加更明显) -->
<div
v-loading="loading"
element-loading-text="数据加载中…"
element-loading-background="transparent"
>
<el-form
ref="formRef"
:model="form"

View File

@ -1,13 +1,16 @@
<script setup lang="ts">
/**
* F006 用户管理 (前台用户列表) - W17 设计规范 v2 完整版
* F006 用户管理 (前台用户列表) - 2026-08-18 v2 简化版
*
* 按 ui-preview-v2.html 模板: 9 列 + 6 搜索 + 4 操作 + 状态 tab
* 用户反馈: 列表内容简化 - 单行显示、无头像、字段保持原样 (仅 UI 美化)
* 字段: 用户昵称 / 手机号 / 是否企业认证 / 关联企业 / 平台账户余额 / 禁用标识 / 注册时间 / 操作 (用户ID 已按需求隐藏)
* 后端端点: GET /user/user/page / GET /user/user/export / POST /user/user/update / POST /user/user/delete
* 保留功能: tab 全部/正常/已禁用(stats 与 tab 解耦) / 搜索 / 导出 / 新建编辑(UserFormDialog) / 批量操作
*/
import { onMounted, ref, reactive, computed } from 'vue'
import { http, httpGet, httpPost } from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { Plus, Phone, Clock } from '@element-plus/icons-vue'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
@ -35,6 +38,8 @@ const total = ref(0)
const enabledStats = reactive({ normal: 0, disabled: 0 })
const loading = ref(false)
const activeTab = ref('all')
const selectedIds = ref<string[]>([])
const selectedCount = ref(0)
// 表单弹窗状态
const dialogVisible = ref(false)
@ -44,7 +49,6 @@ const exporting = ref(false)
const query = reactive({
page: 1,
limit: 10,
userId: '',
nickName: '',
mobile: '',
enterpriseName: '',
@ -52,49 +56,32 @@ const query = reactive({
enabled: '' as '' | '0' | '1'
})
// 状态/认证胶囊色 (引用全局 --st-* 变量, 主题切换自动适配暗色)
const ENABLED_META: Record<number, { label: string; color: string; bg: string }> = {
0: { label: '正常', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
1: { label: '禁用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
const CERT_META: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '是', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
0: { label: '否', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' }
}
const columns: any[] = [
{ type: 'selection', width: 50, fixed: 'left', align: 'center' },
{ prop: 'id', label: '用户ID', width: 100, align: 'center' },
{ prop: 'nickName', label: '用户昵称', minWidth: 160, align: 'center' },
{ prop: 'mobile', label: '手机号', width: 140, align: 'center' },
{
prop: 'isEnterprise',
label: '是否企业认证',
width: 140,
align: 'center',
formatter: (row: FrontUser) => row.isEnterprise === 1 ? '是' : '否'
},
{
prop: 'enterpriseName',
label: '关联企业',
minWidth: 200,
align: 'center',
formatter: (row: FrontUser) => row.enterpriseName || '--'
},
{
prop: 'accountBalance',
label: '平台账户余额',
width: 140,
align: 'right',
formatter: (row: FrontUser) => Number(row.accountBalance || 0).toFixed(2)
},
{
prop: 'enabled',
label: '禁用标识',
width: 110,
align: 'center',
slot: 'enabled'
},
{
prop: 'createDate',
label: '注册时间',
width: 180,
align: 'center',
formatter: (row: FrontUser) => fmtDate(row.createDate)
},
{ label: '操作', width: 140, slot: 'action', fixed: 'right', align: 'center' }
{ type: 'selection', width: 48, fixed: 'left', align: 'center' },
{ prop: 'nickName', label: '用户昵称', minWidth: 200, slot: 'nickName' },
{ prop: 'mobile', label: '手机号', width: 140, slot: 'mobile' },
{ prop: 'isEnterprise', label: '是否企业认证', width: 120, slot: 'cert', align: 'center' },
{ prop: 'enterpriseName', label: '关联企业', minWidth: 180, slot: 'enterpriseName' },
{ prop: 'accountBalance', label: '平台账户余额', width: 150, slot: 'balance', align: 'right' },
{ prop: 'enabled', label: '禁用标识', width: 100, slot: 'enabled', align: 'center' },
{ label: '注册时间', width: 165, slot: 'createDate' },
{ label: '操作', width: 170, slot: 'action', fixed: 'right', align: 'center' }
]
function fmtMoney(n: number): string {
const v = Number(n || 0)
return `¥ ${v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function fmtDate(ts: number | null | undefined) {
if (!ts) return '--'
const d = new Date(Number(ts))
@ -104,12 +91,10 @@ function fmtDate(ts: number | null | undefined) {
}
/**
* 构建除 enabled (tab) 外的搜索参数 (用于 stats 查询)
* stats 必须与 tab 解耦, 只反映当前搜索条件下的全量计数
* 构建除 enabled (tab) 外的搜索参数 (用于 stats 查询) — stats 与 tab 解耦
*/
function buildSearchParams() {
const params: any = {}
if (query.userId) params.id = query.userId
if (query.nickName) params.nickName = query.nickName
if (query.mobile) params.mobile = query.mobile
if (query.enterpriseName) params.enterpriseName = query.enterpriseName
@ -152,11 +137,7 @@ async function loadList() {
}
}
/**
* 加载 stats (normal/disabled 全量计数)
* 与 tab 无关, 只跟搜索条件 (userId/nickName/mobile/isEnterprise/enterpriseName) 关联
* 通过 limit=1 + page=1 拿到 total (后端只 count 不返列表, 开销可忽略)
*/
/** 加载 stats (normal/disabled 全量计数) — 与 tab 无关 */
async function loadStats() {
try {
const baseParams = buildSearchParams()
@ -177,7 +158,6 @@ async function load() {
}
const tabItems = computed(() => [
// 全部 = 正常 + 已禁用 (跨 tab 一致, 与搜索条件关联)
{ key: 'all', label: '全部', count: enabledStats.normal + enabledStats.disabled },
{ key: 'normal', label: '正常', count: enabledStats.normal },
{ key: 'disabled', label: '已禁用', count: enabledStats.disabled }
@ -196,13 +176,15 @@ function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
function onSearch() { query.page = 1; load() }
function onReset() {
query.userId = ''; query.nickName = ''; query.mobile = ''
query.nickName = ''; query.mobile = ''
query.enterpriseName = ''; query.isEnterprise = ''; query.enabled = ''
activeTab.value = 'all'
query.page = 1
load()
}
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id); selectedCount.value = rows.length }
function onEdit(row: FrontUser) {
editingUserId.value = row.id
dialogVisible.value = true
@ -217,33 +199,20 @@ function onDialogSuccess() {
load()
}
/**
* 导出当前搜索条件下的用户列表为 Excel
* 后端: GET /user/user/export (返 Excel 文件流, Content-Type: application/vnd.ms-excel)
* 走 http 实例 (自动带 Bearer token), responseType: 'blob' 接二进制流
*/
/** 导出当前搜索条件下的用户列表为 Excel */
async function exportExcel() {
exporting.value = true
try {
const params = buildSearchParams()
if (query.enabled !== '') params.enabled = Number(query.enabled)
const resp = await http.get<Blob>('/user/user/export', {
params,
responseType: 'blob'
})
const resp = await http.get<Blob>('/user/user/export', { params, responseType: 'blob' })
const blob = resp.data
if (!blob || blob.size === 0) {
ElMessage.error('导出内容为空')
return
}
// 优先从响应头取文件名 (后端 ExcelUtils 会设置)
if (!blob || blob.size === 0) { ElMessage.error('导出内容为空'); return }
const dispo = resp.headers?.['content-disposition'] || resp.headers?.['Content-Disposition']
let filename = `用户列表_${new Date().toISOString().slice(0, 10)}.xls`
if (typeof dispo === 'string') {
const m = dispo.match(/filename\*?=(?:UTF-8\u0027\u0027)?["']?([^;"']+)["']?/i)
if (m && m[1]) {
try { filename = decodeURIComponent(m[1]) } catch { filename = m[1] }
}
if (m && m[1]) { try { filename = decodeURIComponent(m[1]) } catch { filename = m[1] } }
}
const url = URL.createObjectURL(new Blob([blob]))
const a = document.createElement('a')
@ -255,40 +224,57 @@ async function exportExcel() {
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${filename}`)
} catch (e: any) {
const msg = e?.msg || e?.message || '导出失败'
ElMessage.error(msg)
ElMessage.error(e?.msg || e?.message || '导出失败')
} finally {
exporting.value = false
}
}
// ========== 批量操作 ==========
async function batchToggle(next: number) {
const action = next === 1 ? '禁用' : '启用'
if (!selectedIds.value.length) { ElMessage.warning('请先选择用户'); return }
try {
await ElMessageBox.confirm(`确认${action}已选 ${selectedIds.value.length} 个用户?`, `批量${action}确认`, { type: 'warning' })
} catch { return }
let ok = 0
for (const id of selectedIds.value) {
try { await httpPost('/user/user/update', { id, enabled: next }); ok++ } catch {/* 单条跳过 */}
}
ElMessage.success(`已${action} ${ok} 个用户`)
load()
}
async function batchDelete() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择用户'); return }
try {
await ElMessageBox.confirm(`确认删除已选 ${selectedIds.value.length} 个用户?该操作不可恢复`, '批量删除确认', { type: 'warning' })
} catch { return }
let ok = 0
for (const id of selectedIds.value) {
try { await httpPost('/user/user/delete', { id }); ok++ } catch {/* 单条跳过 */}
}
ElMessage.success(`已删除 ${ok} 个用户`)
load()
}
async function toggleEnabled(row: FrontUser) {
// 当前 enabled: 0=正常 1=禁用; 要切换到对端
const next = row.enabled === 0 ? 1 : 0
const action = next === 1 ? '禁用' : '启用'
try {
await ElMessageBox.confirm(`确认${action}用户「${row.nickName}」?`, `${action}确认`, { type: 'warning' })
} catch { return }
try { await ElMessageBox.confirm(`确认${action}用户「${row.nickName}」?`, `${action}确认`, { type: 'warning' }) } catch { return }
try {
await httpPost('/user/user/update', { id: row.id, enabled: next })
ElMessage.success(`${action}成功`)
row.enabled = next
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || `${action}失败`)
}
} catch (e: any) { ElMessage.error(e?.msg || e?.message || `${action}失败`) }
}
async function deleteUser(row: FrontUser) {
try {
await ElMessageBox.confirm(`确认删除用户「${row.nickName}」?该操作不可恢复`, '删除确认', { type: 'warning' })
} catch { return }
try { await ElMessageBox.confirm(`确认删除用户「${row.nickName}」?该操作不可恢复`, '删除确认', { type: 'warning' }) } catch { return }
try {
await httpPost('/user/user/delete', { id: row.id })
ElMessage.success('删除成功')
load()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '删除失败')
}
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
}
onMounted(load)
@ -296,25 +282,20 @@ onMounted(load)
<template>
<div class="rr-page">
<PageHeader title="用户列表" :sub="`管理前台注册用户,共 ${enabledStats.normal + enabledStats.disabled} 个账号`">
<PageHeader title="用户列表" :sub="`共 ${enabledStats.normal + enabledStats.disabled} 个注册账号`">
<el-button @click="exportExcel" :loading="exporting">导出</el-button>
<el-button type="primary" @click="onCreate">
<el-icon style="margin-right: 4px; vertical-align: -2px;"><Plus /></el-icon>
新建用户
</el-button>
</PageHeader>
<UserFormDialog
v-model:visible="dialogVisible"
:user-id="editingUserId"
@success="onDialogSuccess"
/>
<UserFormDialog v-model:visible="dialogVisible" :user-id="editingUserId" @success="onDialogSuccess" />
<ListTabs v-model="activeTab" :items="tabItems" @change="onTabChange" />
<SearchBar
v-model:filters="query"
:fields="[
{ key: 'userId', label: '用户ID', type: 'text', placeholder: '用户ID' },
{ key: 'nickName', label: '用户昵称', type: 'text', placeholder: '用户昵称' },
{ key: 'mobile', label: '手机号', type: 'text', placeholder: '手机号' },
{ key: 'isEnterprise', label: '是否企业认证', type: 'select', placeholder: '所有状态', width: '160px',
@ -325,39 +306,128 @@ onMounted(load)
@reset="onReset"
/>
<DataTable
:columns="columns"
:data="list"
:loading="loading"
:total="total"
:page="query.page"
:limit="query.limit"
:page-sizes="[10, 20, 50, 100]"
:show-pagination="true"
:borderless="true"
row-key="id"
empty-text="暂无用户"
@page-change="onPageChange"
@limit-change="onLimitChange"
>
<template #enabled="{ row }">
<el-tag v-if="row.enabled === 0" type="success" size="small" effect="light" round>正常</el-tag>
<el-tag v-else type="danger" size="small" effect="light" round>禁用</el-tag>
</template>
<template #action="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '修改', type: 'primary', onClick: onEdit },
{ label: (r: any) => r.enabled === 0 ? '禁用' : '启用', type: (r: any) => r.enabled === 0 ? 'warning' : 'success', onClick: toggleEnabled },
{ label: '删除', type: 'danger', onClick: deleteUser }
]"
/>
</template>
</DataTable>
<div class="rr-list-card">
<!-- 批量操作栏 -->
<div class="batch-bar" :class="{ 'has-selection': selectedCount > 0 }">
<span v-if="selectedCount > 0" class="batch-count">
已选 <strong>{{ selectedCount }}</strong> 个用户
</span>
<span v-else class="batch-hint">勾选用户后可批量操作</span>
<span class="batch-actions">
<el-button size="small" type="warning" plain :disabled="!selectedCount" @click="batchToggle(1)">批量禁用</el-button>
<el-button size="small" type="success" plain :disabled="!selectedCount" @click="batchToggle(0)">批量启用</el-button>
<el-button size="small" type="danger" :disabled="!selectedCount" @click="batchDelete">批量删除</el-button>
</span>
</div>
<DataTable
:columns="columns"
:data="list"
:loading="loading"
:total="total"
:page="query.page"
:limit="query.limit"
:page-sizes="[10, 20, 50, 100]"
:show-pagination="true"
:borderless="true"
row-key="id"
empty-text="暂无用户"
@page-change="onPageChange"
@limit-change="onLimitChange"
@selection-change="onSelectionChange"
>
<!-- 用户昵称: 单行 -->
<template #nickName="{ row }">
<span class="nick-cell">{{ row.nickName }}</span>
</template>
<!-- 手机号 -->
<template #mobile="{ row }">
<span class="phone-cell"><el-icon><Phone /></el-icon>{{ row.mobile }}</span>
</template>
<!-- 是否企业认证: 胶囊 -->
<template #cert="{ row }">
<span class="chip" :style="{ color: CERT_META[row.isEnterprise].color, background: CERT_META[row.isEnterprise].bg }">
{{ CERT_META[row.isEnterprise].label }}
</span>
</template>
<!-- 关联企业: 单行 -->
<template #enterpriseName="{ row }">
<span class="text-ellipsis">{{ row.enterpriseName || '--' }}</span>
</template>
<!-- 平台余额: 货币化 -->
<template #balance="{ row }">
<span class="balance-cell">{{ fmtMoney(row.accountBalance) }}</span>
</template>
<!-- 禁用标识: 圆点胶囊 -->
<template #enabled="{ row }">
<span class="status-tag" :style="{ color: ENABLED_META[row.enabled].color, background: ENABLED_META[row.enabled].bg }">
<span class="status-dot"></span>{{ ENABLED_META[row.enabled].label }}
</span>
</template>
<!-- 注册时间 -->
<template #createDate="{ row }">
<span class="date-cell"><el-icon><Clock /></el-icon>{{ fmtDate(row.createDate) }}</span>
</template>
<!-- 操作 -->
<template #action="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '修改', type: 'primary', onClick: onEdit },
{ label: (r: any) => r.enabled === 0 ? '禁用' : '启用', type: (r: any) => r.enabled === 0 ? 'warning' : 'success', onClick: toggleEnabled },
{ label: '删除', type: 'danger', onClick: deleteUser }
]"
/>
</template>
</DataTable>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 100%; }
.rr-page { max-width: 100%; padding: 16px; }
.rr-list-card {
background: var(--surface); border-radius: 12px; overflow: hidden;
}
/* ===== 批量操作栏 ===== */
.batch-bar {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
transition: background .2s;
}
.batch-bar.has-selection { background: var(--primary-soft, #e0ecff); }
.batch-count { font-size: 13px; color: var(--ink-2); }
.batch-count strong { color: var(--primary); font-size: 15px; }
.batch-hint { font-size: 12px; color: var(--ink-3); }
.batch-actions { margin-left: auto; display: flex; gap: 4px; flex-wrap: wrap; }
/* ===== 单元格 (单行) ===== */
.nick-cell { font-size: 14px; font-weight: 600; color: var(--ink); }
.text-ellipsis {
display: inline-block; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
vertical-align: middle; font-size: 13px; color: var(--ink);
}
.phone-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
.date-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--ink-2); font-family: 'SF Mono', Menlo, Consolas, monospace; }
.balance-cell { font-size: 13px; font-weight: 600; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
/* ===== 胶囊 (全局 --st-* 变量暗色自适应) ===== */
.chip {
display: inline-flex; align-items: center; width: fit-content;
font-size: 12px; font-weight: 500; padding: 2px 12px; border-radius: 20px; white-space: nowrap;
}
.status-tag {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 500; padding: 3px 12px; border-radius: 20px; white-space: nowrap;
}
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
</style>

View File

@ -1,21 +1,34 @@
<script setup lang="ts">
/**
* F006 企业详情页 - 独立页面
* F006 企业详情页 - 专业 UI 重构版 (2026-08-18)
*
* 参考老 vue user/enterprise/enterprise-detail.vue 设计 + 新系统主题规范
* 数据源:
* - GET /user/enterprise/getInfo?enterpriseId= 企业全量信息(80+字段)
* - GET /user/enterprise/getInfo?enterpriseId= 企业全量信息(80+字段, 含聚合评分/商品数/入驻时长)
* - GET /user/performancescorerecord/page?enterpriseId= 履约评分记录
* 3 Tab: 基本信息 / 资质认证 / 履约情况
* - GET /user/enterprise/checkStatus?id=&status=&failReason= 审核(2通过/3驳回, 支持批量逗号)
* - POST /user/enterprise/enabled?id= 禁用/启用切换
* - POST /user/enterprise/showEnterprise?id= 显示/隐藏切换
* - GET /user/enterprise/homeShow?id=&homeShow= 首页推荐切换
*
* 结构: 状态横幅(审核操作) → 头部信息卡 → 认证流程时间线 → 3Tab(基本信息/资质认证/履约情况)
* 审核留痕: 后端无审核历史表, 时间线展示「入驻提交时间(真实)+当前审核结果(真实)
* +本会话审核操作(前端本地记录, 不伪造历史)」
*/
import { ref, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Shop } from '@element-plus/icons-vue'
import { httpGet } from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Shop, OfficeBuilding, MapLocation, User, Coin,
WarningFilled, SuccessFilled, CircleCloseFilled, Clock, Medal, Star
} from '@element-plus/icons-vue'
import { httpGet, httpPost } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const enterpriseId = computed(() => String(route.params.id || ''))
const loading = ref(false)
@ -24,15 +37,23 @@ const detail = ref<any>({})
const otherList = ref<any[]>([])
const performanceList = ref<any[]>([])
const IDENTITY_MAP: Record<number, string> = { 1: '供应商', 2: '生产厂商', 3: '渠道商', 4: '服务商' }
const STATUS_MAP: Record<number, { label: string; type: 'success' | 'warning' | 'danger' | 'info' }> = {
1: { label: '待审核', type: 'warning' },
2: { label: '已认证', type: 'success' },
3: { label: '已驳回', type: 'danger' }
// ================= 状态/映射常量 (与列表页统一 5 级) =================
const STATUS_META: Record<number, { label: string; type: 'warning' | 'success' | 'danger'; icon: any }> = {
1: { label: '待审核', type: 'warning', icon: WarningFilled },
2: { label: '已认证', type: 'success', icon: SuccessFilled },
3: { label: '已驳回', type: 'danger', icon: CircleCloseFilled }
}
const IDENTITY_MAP: Record<number, string> = { 1: '供应商', 2: '生产厂商', 3: '渠道商', 4: '服务商' }
const LEVEL_MAP: Record<number, string> = {
1: '优质供应商', 2: '钻石供应商', 3: '金牌供应商', 4: '银牌供应商', 5: '铜牌供应商'
}
const LEVEL_MAP: Record<number, string> = { 1: 'A级', 2: 'B级', 3: 'C级', 4: 'D级' }
const isCertified = computed(() => detail.value.status === 2)
const status = computed<number>(() => Number(detail.value.status ?? 0))
const isCertified = computed(() => status.value === 2)
const isPending = computed(() => status.value === 1)
const isRejected = computed(() => status.value === 3)
const statusMeta = computed(() => STATUS_META[status.value])
const logoText = computed(() => (detail.value.enterpriseName || '企').trim().charAt(0))
const registerYear = computed(() => {
const t = detail.value.registerTime
@ -49,7 +70,42 @@ const productList = computed<string[]>(() => {
if (!raw) return []
return String(raw).split(/[,,、;;\s]+/).map((s: string) => s.trim()).filter(Boolean)
})
const addressLine = computed(() => [detail.value.provinceName, detail.value.cityName, detail.value.areaName].filter(Boolean).join(' '))
const tenure = computed(() => {
const y = detail.value.year, m = detail.value.month, d = detail.value.day
if (y === undefined && m === undefined && d === undefined) return ''
const parts: string[] = []
if (y) parts.push(`${y}年`); if (m) parts.push(`${m}个月`); if (d) parts.push(`${d}天`)
return parts.join(' ') || '--'
})
// ================= 本会话审核操作记录 (前端本地留痕, 后端无历史表) =================
const localAuditLogs = ref<{ time: string; operator: string; result: string; reason: string }[]>([])
const auditVisible = ref(false)
const auditBusy = ref(false)
const auditForm = reactive({ result: 2, failReason: '' })
const timelineItems = computed(() => {
const items: { kind: string; title: string; time: string; desc: string; operator?: string }[] = []
// ① 提交认证 (真实数据)
items.push({ kind: 'submit', title: '提交企业认证', time: fmtDate(detail.value.createDate), desc: '企业提交了企业认证申请资料' })
// ② 当前审核结果 (真实数据)
if (status.value === 1) items.push({ kind: 'pending', title: '等待审核', time: '进行中', desc: '运营人员正在审核,请耐心等待' })
else if (status.value === 2) items.push({ kind: 'pass', title: '审核通过', time: '已认证', desc: '企业认证通过,可正常开展业务' })
else if (status.value === 3) items.push({ kind: 'fail', title: '审核驳回', time: '未通过', desc: detail.value.failReason || '未填写驳回原因' })
// ③ 本会话内完成的审核操作 (本地追加)
for (const log of localAuditLogs.value) {
items.push({
kind: log.result.includes('通过') ? 'pass' : 'fail',
title: log.result, time: log.time,
desc: log.reason || '—',
operator: log.operator
})
}
return items
})
// ================= 数据加载 =================
function maskMobile(m: string | number | null | undefined): string {
if (m === undefined || m === null || m === '') return '--'
const s = String(m)
@ -63,6 +119,10 @@ function fmtMoney(n: any): string {
const v = Number(n || 0)
return v ? `¥ ${v.toLocaleString()}` : '¥ 0'
}
function fmtNum(n: any): string {
const v = Number(n)
return Number.isFinite(v) ? String(v) : '--'
}
async function load() {
loading.value = true
@ -87,6 +147,72 @@ async function load() {
} catch { performanceList.value = [] }
}
// ================= 审核交互 =================
function openAudit(defaultResult: number = 2) {
auditForm.result = defaultResult
auditForm.failReason = ''
auditVisible.value = true
}
async function submitAudit() {
if (auditForm.result === 3 && !auditForm.failReason.trim()) {
ElMessage.warning('请填写驳回原因')
return
}
auditBusy.value = true
try {
await httpGet('/user/enterprise/checkStatus', {
id: enterpriseId.value,
status: auditForm.result,
failReason: auditForm.result === 3 ? auditForm.failReason.trim() : ''
})
const passed = auditForm.result === 2
ElMessage.success(passed ? '审核通过,企业已认证' : '已驳回,企业认证未通过')
auditVisible.value = false
// 本会话审计留痕
localAuditLogs.value.push({
time: fmtDate(new Date().toISOString()),
operator: userStore.displayName || '管理员',
result: passed ? '审核通过' : '审核驳回',
reason: passed ? '认可企业提交的认证资料' : auditForm.failReason.trim()
})
await load()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '审核操作失败')
} finally {
auditBusy.value = false
}
}
// ================= 状态切换 (后端均为 query 参数) =================
async function toggleEnabled() {
const disabling = detail.value.enabled !== 1
try { await ElMessageBox.confirm(`${disabling ? '禁用' : '启用'}后,该企业${disabling ? '将无法登录和使用平台服务' : '恢复正常使用'}。确认${disabling ? '禁用' : '启用'}?`, '操作确认', { type: 'warning' }) } catch { return }
try {
await httpPost(`/user/enterprise/enabled?id=${enterpriseId.value}`)
ElMessage.success(disabling ? '已禁用该企业' : '已启用该企业')
await load()
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
}
async function toggleShow() {
const hiding = detail.value.showFlag !== 1
try {
await httpPost(`/user/enterprise/showEnterprise?id=${enterpriseId.value}`)
ElMessage.success(hiding ? '已隐藏企业(列表不再展示)' : '已恢复显示企业')
await load()
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
}
async function toggleHome() {
const recommend = detail.value.homeShow === 1 ? 0 : 1
try {
await httpGet('/user/enterprise/homeShow', { id: enterpriseId.value, homeShow: recommend })
ElMessage.success(recommend === 1 ? '已推荐到首页' : '已取消首页推荐')
await load()
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
}
function goStore() {
const eid = detail.value.enterpriseId || detail.value.id
if (!eid) return
@ -99,10 +225,56 @@ onMounted(load)
<template>
<div class="ent-detail" v-loading="loading">
<PageHeader title="企业详情" :sub="`入驻企业信息总览`">
<el-button @click="router.back()">返回列表</el-button>
<el-button @click="router.back()">
<el-icon style="margin-right:4px"><Clock /></el-icon>返回列表
</el-button>
</PageHeader>
<!-- ===== Header 区: logo + 企业名 + 认证标签 + meta ===== -->
<!-- ===== ① 状态横幅 (审核操作区) ===== -->
<div class="audit-banner" :class="`banner-${status || 'unknown'}`" v-if="status">
<div class="banner-icon">
<el-icon v-if="statusMeta" :size="26"><component :is="statusMeta.icon" /></el-icon>
</div>
<div class="banner-main">
<div class="banner-title">
{{ isPending ? '企业认证待审核' : isCertified ? '企业认证已通过' : isRejected ? '企业认证未通过' : '状态未知' }}
</div>
<div class="banner-desc" v-if="isPending">
该企业已提交认证申请资料,请核查营业执照、法人证件及资质证书后给出审核结论。
</div>
<div class="banner-desc" v-else-if="isRejected">
驳回原因:<span class="reason-text">{{ detail.failReason || '未填写' }}</span>
</div>
<div class="banner-desc" v-else-if="isCertified">
企业已通过认证,可正常发布商品、参与询报价与交易。
</div>
<div class="banner-badges">
<el-tag v-if="detail.enabled === 1" type="danger" size="small" effect="dark" round>已禁用</el-tag>
<el-tag v-if="detail.showFlag === 1" type="info" size="small" effect="plain" round>已隐藏</el-tag>
<el-tag v-if="detail.homeShow === 1" type="success" size="small" effect="plain" round>首页推荐</el-tag>
</div>
</div>
<div class="banner-actions">
<template v-if="isPending">
<el-button type="primary" @click="openAudit(2)">
<el-icon style="margin-right:4px"><SuccessFilled /></el-icon>审核通过
</el-button>
<el-button type="danger" plain @click="openAudit(3)">驳 回</el-button>
</template>
<template v-else-if="isRejected">
<el-button type="primary" @click="openAudit(2)">
<el-icon style="margin-right:4px"><SuccessFilled /></el-icon>重新审核
</el-button>
</template>
<template v-else-if="isCertified">
<el-button size="small" :type="detail.enabled === 1 ? 'success' : 'danger'" plain @click="toggleEnabled">
{{ detail.enabled === 1 ? '启用企业' : '禁用企业' }}
</el-button>
</template>
</div>
</div>
<!-- ===== ② 头部信息卡 ===== -->
<div class="detail-header">
<div class="header-logo">
<el-image v-if="detail.enterpriseLogo" :src="detail.enterpriseLogo" fit="cover" class="logo-img" />
@ -112,12 +284,12 @@ onMounted(load)
<div class="header-title-row">
<h2 class="ent-name">{{ detail.enterpriseName || '--' }}</h2>
<span v-for="(t, i) in tags" :key="i" class="name-tag">{{ t }}</span>
<el-tag v-if="STATUS_MAP[detail.status]" :type="STATUS_MAP[detail.status].type" effect="light" round>{{ STATUS_MAP[detail.status].label }}</el-tag>
<el-tag v-if="detail.enabled === 1" type="info" effect="plain" round>已禁用</el-tag>
<el-tag v-if="statusMeta" :type="statusMeta.type" effect="light" round>{{ statusMeta.label }}</el-tag>
</div>
<div class="header-meta">
<span v-if="detail.industry" class="meta-item">{{ detail.industry }}</span>
<span v-if="registerYear !== '--'" class="meta-item">{{ registerYear }} 成立</span>
<span v-if="registerYear !== '--'" class="meta-item">成立于 {{ registerYear }}</span>
<span v-if="tenure" class="meta-item">入驻 {{ tenure }}</span>
<span v-if="detail.creditCode" class="meta-item">信用代码 {{ detail.creditCode }}</span>
</div>
</div>
@ -133,15 +305,53 @@ onMounted(load)
</div>
<div class="stat-cell">
<div class="stat-value">{{ detail.enterpriseRate ?? '--' }}</div>
<div class="stat-label">企业评级</div>
<div class="stat-label">好评率</div>
</div>
</div>
<el-button type="primary" class="store-btn" @click="goStore" :disabled="!detail.enterpriseId && !detail.id">
<el-icon style="margin-right:6px"><Shop /></el-icon>进入店铺
</el-button>
<div class="header-btns">
<el-button type="primary" class="store-btn" @click="goStore" :disabled="!detail.enterpriseId && !detail.id">
<el-icon style="margin-right:6px"><Shop /></el-icon>进入店铺
</el-button>
<el-dropdown trigger="click" placement="bottom-end" v-if="isCertified">
<el-button plain>
更多操作<el-icon class="el-icon--right"><Coin /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="1" @click="toggleShow">{{ detail.showFlag === 1 ? '恢复显示' : '隐藏企业' }}</el-dropdown-item>
<el-dropdown-item :command="2" @click="toggleHome">{{ detail.homeShow === 1 ? '取消首页推荐' : '推荐到首页' }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- ===== ③ 认证流程时间线 ===== -->
<div class="info-card timeline-card">
<div class="card-head">
<el-icon class="card-head-icon"><Medal /></el-icon>
<span class="card-title-text">认证流程</span>
<span class="card-sub">入驻申请 · 审核结果 · 本会话操作记录</span>
</div>
<el-timeline class="audit-timeline">
<el-timeline-item
v-for="(item, i) in timelineItems"
:key="i"
:type="item.kind === 'pass' ? 'success' : item.kind === 'fail' ? 'danger' : item.kind === 'pending' ? 'warning' : 'primary'"
:hollow="item.kind === 'pending'"
:timestamp="item.time"
placement="top"
>
<div class="tl-content">
<span class="tl-title">{{ item.title }}</span>
<span class="tl-desc">{{ item.desc }}</span>
<span v-if="item.operator" class="tl-operator">操作人:{{ item.operator }}</span>
</div>
</el-timeline-item>
</el-timeline>
</div>
<!-- ===== Tab 导航 ===== -->
<div class="tab-nav">
<div class="tab-item" :class="{ active: activeTab === 'basic' }" @click="activeTab = 'basic'">基本信息</div>
@ -154,21 +364,27 @@ onMounted(load)
<div class="info-grid">
<div class="info-col">
<section class="info-card">
<div class="card-title">公司信息</div>
<div class="card-head">
<el-icon class="card-head-icon"><OfficeBuilding /></el-icon>
<span class="card-title-text">公司信息</span>
</div>
<div class="info-grid-2">
<div class="info-item"><span class="label">公司名称</span><span class="value">{{ detail.enterpriseName || '--' }}</span></div>
<div class="info-item"><span class="label">注册时间</span><span class="value">{{ fmtDate(detail.registerTime) }}</span></div>
<div class="info-item"><span class="label">所属行业</span><span class="value">{{ detail.industry || '--' }}</span></div>
<div class="info-item"><span class="label">企业身份</span><span class="value">{{ IDENTITY_MAP[detail.enterpriseIdentity as number] || detail.enterpriseIdentity || '--' }}</span></div>
<div class="info-item"><span class="label">企业类型</span><span class="value">{{ detail.enterpriseType ?? '--' }}</span></div>
<div class="info-item"><span class="label">统一信用代码</span><span class="value">{{ detail.creditCode || detail.orgNo || '--' }}</span></div>
<div class="info-item"><span class="label">统一信用代码</span><span class="value mono">{{ detail.creditCode || detail.orgNo || '--' }}</span></div>
</div>
</section>
<section class="info-card">
<div class="card-title">地址信息</div>
<div class="card-head">
<el-icon class="card-head-icon"><MapLocation /></el-icon>
<span class="card-title-text">地址信息</span>
</div>
<div class="address-box">
<div class="addr-line">{{ [detail.provinceName, detail.cityName, detail.areaName].filter(Boolean).join(' ') || '--' }}</div>
<div class="addr-line">{{ addressLine || '--' }}</div>
<div class="value">{{ detail.enterpriseAddress || '--' }}</div>
</div>
</section>
@ -176,31 +392,29 @@ onMounted(load)
<div class="info-col">
<section class="info-card">
<div class="card-title">联系人信息</div>
<div class="card-head">
<el-icon class="card-head-icon"><User /></el-icon>
<span class="card-title-text">联系人信息</span>
</div>
<div class="contact-profile">
<div class="contact-avatar">{{ contactInitial }}</div>
<div class="contact-name">{{ detail.linkMan || '--' }}</div>
</div>
<div class="contact-methods">
<div class="contact-method">
<span class="label">联系电话</span><span class="value">{{ maskMobile(detail.linkMobile) }}</span>
</div>
<div class="contact-method">
<span class="label">电子邮箱</span><span class="value">{{ detail.email || '--' }}</span>
</div>
<div class="contact-method">
<span class="label">管理员</span><span class="value">{{ detail.administrator || '--' }} ({{ maskMobile(detail.administratorMobile) }})</span>
</div>
<div class="contact-method">
<span class="label">业务员</span><span class="value">{{ detail.salesperson || '暂未分配' }}</span>
</div>
<div class="contact-method"><span class="label">联系电话</span><span class="value">{{ maskMobile(detail.linkMobile) }}</span></div>
<div class="contact-method"><span class="label">电子邮箱</span><span class="value">{{ detail.email || '--' }}</span></div>
<div class="contact-method"><span class="label">管理员</span><span class="value">{{ detail.administrator || '--' }} ({{ maskMobile(detail.administratorMobile) }})</span></div>
<div class="contact-method"><span class="label">业务员</span><span class="value">{{ detail.salesperson || '暂未分配' }}</span></div>
</div>
</section>
</div>
</div>
<section class="info-card full">
<div class="card-title">经营信息</div>
<div class="card-head">
<el-icon class="card-head-icon"><Coin /></el-icon>
<span class="card-title-text">经营信息</span>
</div>
<div class="info-grid-2">
<div class="info-item span2"><span class="label">经营范围</span><span class="value">{{ detail.scope || detail.scopeOfLicence || '--' }}</span></div>
<div class="info-item span2"><span class="label">主营产品</span>
@ -209,10 +423,10 @@ onMounted(load)
<span v-if="!productList.length" class="value">--</span>
</span>
</div>
<div class="info-item"><span class="label">银行</span><span class="value">{{ detail.bankOfDeposit || '--' }}</span></div>
<div class="info-item"><span class="label">开户名称</span><span class="value">{{ detail.accountName || '--' }}</span></div>
<div class="info-item"><span class="label">账号</span><span class="value">{{ detail.accountCard || '--' }}</span></div>
<div class="info-item"><span class="label">开户银行</span><span class="value">{{ detail.bankOfDeposit || '--' }}</span></div>
<div class="info-item"><span class="label">账户余额</span><span class="value">{{ fmtMoney(detail.accountBalance) }}</span></div>
<div class="info-item"><span class="label">开户名称</span><span class="value">{{ detail.accountName || '--' }}</span></div>
<div class="info-item"><span class="label">银行账号</span><span class="value mono">{{ detail.accountCard || '--' }}</span></div>
</div>
</section>
</div>
@ -220,21 +434,29 @@ onMounted(load)
<!-- ===== Tab 2: 资质认证 ===== -->
<div v-show="activeTab === 'qual'" class="tab-body">
<section class="info-card full">
<div class="card-title">营业执照</div>
<div class="card-head">
<el-icon class="card-head-icon"><DocumentChecked /></el-icon>
<span class="card-title-text">营业执照</span>
<span class="card-sub">点击图片可放大预览</span>
</div>
<el-image v-if="detail.licenseImg" :src="detail.licenseImg" fit="contain" class="cert-img" :preview-src-list="[detail.licenseImg]" preview-teleported />
<div v-else class="cert-empty">暂无营业执照</div>
<div v-else class="cert-empty"><span>暂无营业执照</span></div>
</section>
<section class="info-card full">
<div class="card-title">法人身份证</div>
<div class="card-head">
<el-icon class="card-head-icon"><DocumentChecked /></el-icon>
<span class="card-title-text">法人身份证</span>
<span class="card-sub">点击图片可放大预览</span>
</div>
<div class="idcard-row">
<div class="idcard-cell">
<span class="label">正面</span>
<span class="label">正面(人像面)</span>
<el-image v-if="detail.frontImage" :src="detail.frontImage" fit="cover" class="idcard-img" :preview-src-list="[detail.frontImage]" preview-teleported />
<div v-else class="cert-empty small">暂无</div>
</div>
<div class="idcard-cell">
<span class="label">反面</span>
<span class="label">反面(国徽面)</span>
<el-image v-if="detail.backImage" :src="detail.backImage" fit="cover" class="idcard-img" :preview-src-list="[detail.backImage]" preview-teleported />
<div v-else class="cert-empty small">暂无</div>
</div>
@ -243,33 +465,70 @@ onMounted(load)
</section>
<section class="info-card full">
<div class="card-title">资质证书</div>
<div class="card-head">
<el-icon class="card-head-icon"><Medal /></el-icon>
<span class="card-title-text">资质证书</span>
<span class="card-sub" v-if="otherList.length">共 {{ otherList.length }} 项</span>
</div>
<template v-if="otherList.length">
<div v-for="(cert, i) in otherList" :key="i" class="cert-card">
<div class="cert-info">
<div class="cert-row"><span class="label">资质名称</span><span class="value">{{ cert.qualificationName || '--' }}</span></div>
<div class="cert-row"><span class="label">证书编号</span><span class="value">{{ cert.certificateNumber || '--' }}</span></div>
<div class="cert-row"><span class="label">有效期至</span><span class="value">{{ cert.expirationDate || '--' }}</span></div>
</div>
<div v-if="cert.imageList?.length" class="cert-imgs">
<el-image v-for="(img, j) in cert.imageList" :key="j" :src="img" fit="cover" class="cert-thumb" :preview-src-list="cert.imageList" preview-teleported />
<div class="cert-card" v-for="(cert, i) in otherList" :key="i">
<div class="cert-main">
<div class="cert-info">
<div class="cert-row"><span class="label">资质名称</span><span class="value">{{ cert.qualificationName || '--' }}</span></div>
<div class="cert-row"><span class="label">证书编号</span><span class="value mono">{{ cert.certificateNumber || '--' }}</span></div>
<div class="cert-row"><span class="label">有效期至</span>
<span class="value">{{ cert.expirationDate || '--' }}
<el-tag v-if="cert.isExpiration === 1" type="danger" size="small" style="margin-left:6px">已过期</el-tag>
<el-tag v-else-if="cert.expirationDate" type="success" size="small" style="margin-left:6px">有效</el-tag>
</span>
</div>
</div>
<div v-if="cert.imageList?.length" class="cert-imgs">
<el-image v-for="(img, j) in cert.imageList" :key="j" :src="img" fit="cover" class="cert-thumb" :preview-src-list="cert.imageList" preview-teleported />
</div>
</div>
</div>
</template>
<div v-else class="cert-empty">暂无资质证书</div>
<div v-else class="cert-empty"><span>暂无资质证书</span></div>
</section>
<section class="info-card full">
<div class="card-title">企业授权书</div>
<div class="card-head">
<el-icon class="card-head-icon"><DocumentChecked /></el-icon>
<span class="card-title-text">企业授权书</span>
<span class="card-sub">点击图片可放大预览</span>
</div>
<el-image v-if="detail.enterpriseAccredit" :src="detail.enterpriseAccredit" fit="cover" class="cert-img tall" :preview-src-list="[detail.enterpriseAccredit]" preview-teleported />
<div v-else class="cert-empty">暂无授权书</div>
<div v-else class="cert-empty"><span>暂无授权书</span></div>
</section>
</div>
<!-- ===== Tab 3: 履约情况 ===== -->
<div v-show="activeTab === 'perf'" class="tab-body">
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">企业评分</div>
<div class="kpi-value primary">{{ fmtNum(detail.enterpriseScore) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">好评率</div>
<div class="kpi-value">{{ fmtNum(detail.enterpriseRate) }}<span class="kpi-unit">%</span></div>
</div>
<div class="kpi-card">
<div class="kpi-label">评价总数</div>
<div class="kpi-value">{{ fmtNum(detail.enterpriseCommentNum) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">在售商品</div>
<div class="kpi-value">{{ fmtNum(detail.physicalGoodsNum) }}</div>
</div>
</div>
<section class="info-card full">
<div class="card-title">履约评分记录</div>
<div class="card-head">
<el-icon class="card-head-icon"><Star /></el-icon>
<span class="card-title-text">履约评分记录</span>
</div>
<template v-if="performanceList.length">
<div v-for="(item, i) in performanceList" :key="i" class="perf-item">
<span class="perf-date">{{ fmtDate(item.date) }}</span>
@ -277,24 +536,91 @@ onMounted(load)
<span class="perf-score">{{ item.score ?? '--' }} 分</span>
</div>
</template>
<div v-else class="cert-empty">暂无履约评分记录</div>
<div v-else class="perf-empty">暂无履约评分记录</div>
</section>
</div>
<!-- ===== 审核弹窗 ===== -->
<el-dialog v-model="auditVisible" title="企业认证审核" width="480px" :close-on-click-modal="false" destroy-on-close>
<div class="audit-dialog-tip">
正在审核:<strong>{{ detail.enterpriseName || '--' }}</strong>
</div>
<el-form label-width="80px" label-position="left">
<el-form-item label="审核结果">
<el-radio-group v-model="auditForm.result">
<el-radio :value="2">审核通过</el-radio>
<el-radio :value="3">审核驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="auditForm.result === 3" label="驳回原因" required>
<el-input
v-model="auditForm.failReason"
type="textarea" :rows="4"
placeholder="请填写驳回原因,将随站内消息与短信通知企业"
maxlength="200" show-word-limit
/>
</el-form-item>
<el-form-item v-else label="通过说明">
<div class="pass-tip">审核通过后将自动:完成企业认证、初始化审批流、创建默认分类与默认支付方式,并通过站内消息与短信通知企业。</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="auditVisible = false">取消</el-button>
<el-button type="primary" :loading="auditBusy" @click="submitAudit">确定提交</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.ent-detail { max-width: 1400px; }
/* ===== Header ===== */
/* ===== ① 状态横幅 ===== */
.audit-banner {
display: flex; align-items: center; gap: 18px;
border-radius: var(--radius-lg); padding: 16px 22px; margin-bottom: 16px;
border: 1px solid transparent;
}
.banner-warning {
background: linear-gradient(90deg, rgba(245,158,11,.14), rgba(245,158,11,.04));
border-color: rgba(245,158,11,.35);
.banner-icon { background: rgba(245,158,11,.18); color: #b45309; }
}
.banner-success {
background: linear-gradient(90deg, rgba(34,197,94,.12), rgba(34,197,94,.03));
border-color: rgba(34,197,94,.3);
.banner-icon { background: rgba(34,197,94,.18); color: #15803d; }
}
.banner-danger {
background: linear-gradient(90deg, rgba(239,68,68,.12), rgba(239,68,68,.03));
border-color: rgba(239,68,68,.32);
.banner-icon { background: rgba(239,68,68,.16); color: #b91c1c; }
}
.banner-unknown {
background: var(--bg, #f6f8fb); border-color: var(--border);
.banner-icon { background: var(--border); color: var(--ink-3); }
}
.banner-icon {
flex: 0 0 48px; width: 48px; height: 48px; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
}
.banner-main { flex: 1; min-width: 0; }
.banner-title { font-size: 16px; font-weight: 700; color: var(--ink); }
.banner-desc { font-size: 13px; color: var(--ink-2); margin-top: 4px; line-height: 1.6; }
.reason-text { color: #b91c1c; font-weight: 600; }
.banner-badges { display: flex; gap: 8px; margin-top: 8px; }
.banner-actions { display: flex; gap: 10px; flex-shrink: 0; }
/* ===== ② 头部信息卡 ===== */
.detail-header {
display: flex; gap: 24px; align-items: center;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 24px 28px; margin-bottom: 16px;
}
.header-logo {
flex: 0 0 88px; width: 88px; height: 88px; border-radius: 12px; overflow: hidden;
flex: 0 0 88px; width: 88px; height: 88px; border-radius: 14px; overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-soft, #93c5fd));
box-shadow: 0 6px 16px -8px var(--primary);
display: flex; align-items: center; justify-content: center;
}
.logo-img { width: 100%; height: 100%; }
@ -306,16 +632,21 @@ onMounted(load)
background: var(--primary-soft, #e0ecff); color: var(--primary);
font-size: 12px; padding: 2px 12px; border-radius: 20px; white-space: nowrap;
}
.header-meta { display: flex; gap: 20px; flex-wrap: wrap; color: var(--ink-2); font-size: 13px; margin-bottom: 10px; }
.meta-item::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--primary); margin-right: 6px; vertical-align: middle; }
.header-meta { display: flex; gap: 20px; flex-wrap: wrap; color: var(--ink-2); font-size: 13px; }
.meta-item::before {
content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%;
background: var(--primary); margin-right: 6px; vertical-align: middle;
}
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 14px; }
.header-stats { display: flex; gap: 32px; }
.stat-cell { text-align: center; }
.stat-value { font-size: 20px; font-weight: 700; color: var(--ink); }
.stat-value { font-size: 18px; font-weight: 700; color: var(--ink); }
.stat-label { font-size: 12px; color: var(--ink-3); margin-top: 4px; }
.header-btns { display: flex; gap: 10px; }
.store-btn { font-weight: 600; }
.mono { font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 13px; }
/* ===== Tabs ===== */
/* ===== Tab 导航 ===== */
.tab-nav {
display: flex; gap: 36px; border-bottom: 1px solid var(--border);
padding: 0 8px; margin-bottom: 20px;
@ -327,7 +658,7 @@ onMounted(load)
.tab-item:hover { color: var(--ink); }
.tab-item.active { color: var(--primary); font-weight: 600; border-bottom-color: var(--primary); }
/* ===== Body / cards ===== */
/* ===== 卡片通用 ===== */
.tab-body { display: flex; flex-direction: column; gap: 16px; }
.info-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 16px; }
.info-col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
@ -336,10 +667,17 @@ onMounted(load)
border-radius: var(--radius-lg); padding: 22px 24px;
}
.info-card.full { width: 100%; }
.card-title {
font-size: 15px; font-weight: 700; color: var(--ink); margin-bottom: 18px;
padding-left: 10px; border-left: 3px solid var(--primary);
.card-head {
display: flex; align-items: center; gap: 8px; margin-bottom: 18px;
}
.card-head-icon {
width: 26px; height: 26px; border-radius: 8px;
background: var(--primary-soft, #e0ecff); color: var(--primary);
display: flex; align-items: center; justify-content: center;
}
.card-title-text { font-size: 15px; font-weight: 700; color: var(--ink); }
.card-sub { font-size: 12px; color: var(--ink-3); margin-left: auto; }
.info-grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 48px; }
.info-item { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.info-item.span2 { grid-column: span 2; }
@ -371,30 +709,50 @@ onMounted(load)
font-size: 12px; padding: 3px 14px; border-radius: 6px;
}
/* ===== 时间线 ===== */
.timeline-card { margin-bottom: 16px; }
.audit-timeline { padding-left: 6px; }
.tl-content { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.tl-title { font-size: 14px; font-weight: 600; color: var(--ink); }
.tl-desc { font-size: 13px; color: var(--ink-2); }
.tl-operator { font-size: 12px; color: var(--ink-3); background: var(--bg, #f6f8fb); padding: 2px 10px; border-radius: 12px; }
/* ===== 资质 ===== */
.cert-img { width: 220px; height: 150px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg, #f6f8fb); }
.cert-img.tall { width: 160px; height: 200px; }
.cert-img {
width: 240px; height: 160px; border-radius: 8px; border: 1px solid var(--border);
background: var(--bg, #f6f8fb); cursor: zoom-in;
}
.cert-img.tall { width: 160px; height: 210px; }
.cert-empty {
width: 220px; height: 120px; border: 1px dashed var(--border); border-radius: 8px;
width: 240px; height: 120px; border: 1px dashed var(--border); border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: var(--ink-3); font-size: 13px; background: var(--bg, #f6f8fb);
}
.cert-empty.small { width: 150px; height: 100px; }
.idcard-row { display: flex; gap: 24px; margin-bottom: 16px; }
.cert-empty.small { width: 160px; height: 110px; }
.idcard-row { display: flex; gap: 32px; margin-bottom: 16px; }
.idcard-cell { display: flex; flex-direction: column; gap: 8px; }
.idcard-img { width: 180px; height: 120px; border-radius: 8px; border: 1px solid var(--border); }
.idcard-img { width: 200px; height: 130px; border-radius: 8px; border: 1px solid var(--border); cursor: zoom-in; }
.legal-row { display: flex; gap: 12px; align-items: baseline; }
.cert-card {
border: 1px solid var(--border); border-radius: 10px; padding: 16px 18px;
margin-bottom: 14px; background: var(--bg, #fafbfd);
}
.cert-row { display: flex; gap: 12px; margin-bottom: 6px; }
.cert-row { display: flex; gap: 12px; margin-bottom: 6px; align-items: baseline; }
.cert-row .label { min-width: 76px; }
.cert-imgs { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
.cert-thumb { width: 100px; height: 120px; border-radius: 6px; border: 1px solid var(--border); }
.cert-imgs { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px; }
.cert-thumb { width: 96px; height: 120px; border-radius: 6px; border: 1px solid var(--border); cursor: zoom-in; }
/* ===== 履约 ===== */
.kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.kpi-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px 24px;
}
.kpi-label { font-size: 12px; color: var(--ink-3); margin-bottom: 10px; }
.kpi-value { font-size: 28px; font-weight: 700; color: var(--ink); line-height: 1; }
.kpi-value.primary { color: var(--primary); }
.kpi-unit { font-size: 14px; color: var(--ink-3); margin-left: 2px; }
.perf-item {
display: flex; align-items: center; gap: 16px; padding: 12px 0;
border-bottom: 1px solid var(--border-light, var(--border));
@ -403,9 +761,23 @@ onMounted(load)
.perf-date { font-size: 13px; color: var(--ink-3); min-width: 140px; }
.perf-content { flex: 1; font-size: 14px; color: var(--ink); }
.perf-score { font-size: 15px; font-weight: 700; color: var(--primary); }
.perf-empty { color: var(--ink-3); font-size: 13px; padding: 24px 0; text-align: center; }
/* ===== 审核弹窗 ===== */
.audit-dialog-tip {
background: var(--bg, #f6f8fb); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 14px; font-size: 13px; color: var(--ink-2);
margin-bottom: 18px;
}
.pass-tip {
font-size: 12px; color: var(--ink-3); line-height: 1.7;
background: var(--bg, #f6f8fb); border-radius: 8px; padding: 10px 12px; width: 100%;
}
@media (max-width: 1100px) {
.info-grid { grid-template-columns: 1fr; }
.header-stats { display: none; }
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.banner-actions { flex-direction: column; }
}
</style>

125
src/styles/global-ui.scss Normal file
View File

@ -0,0 +1,125 @@
/* ============================================================
* 后台全局 UI 统一规范 (2026-08-18 建立)
* 由 UI 审查驱动, 解决跨页视觉一致性缺口:
* 1. 页面骨架统一 (rr-page 默认内边距)
* 2. 表格视觉统一 (表头底色/圆角/消除老页面 border 竖线)
* 3. 卡片圆角统一
* 4. 状态标签/el-tag 暗色主题适配
* 设计原则: 只做"兜底默认", 页面 scoped 样式优先覆盖
* ============================================================ */
/* ---------- 1. 页面骨架: 默认 16px 内边距 (页面未定义时生效) ---------- */
.rr-page { padding: 16px; }
/* ---------- 2. 表格视觉统一 ---------- */
/* 表头底色/文字统一 (el-table 全局) */
.el-table {
--el-table-header-bg-color: var(--surface-2, #f1f5f9);
--el-table-header-text-color: var(--ink-3, #64748b);
}
.el-table th.el-table__cell {
background: var(--surface-2, #f1f5f9) !important; /* !important 覆盖 EP .el-table--border 表头白底 */
color: var(--ink-3, #64748b);
font-weight: 600;
}
/* 单元格内边距统一 (与 DataTable 规范一致) */
.el-table td.el-table__cell { padding: 10px 12px; }
/* 消除老页面裸 el-table border 属性产生的边框/竖线 → 统一 borderless 视觉 */
.el-table--border,
.el-table--border .el-table__cell,
.el-table--border th.el-table__cell,
.el-table--border td.el-table__cell {
border: none !important;
}
.el-table--border .el-table__inner-wrapper::after,
.el-table--border .el-table__inner-wrapper::before,
.el-table::before,
.el-table::after {
display: none !important;
}
/* ---------- 3. 卡片圆角统一 ---------- */
.rr-list-card,
.rr-card,
.info-card {
border-radius: 12px;
}
/* ---------- 5. el-tag 统一胶囊外观 (所有页面状态/徽标一致) ---------- */
/* EP 变量级联下 --el-tag-border-radius 仍被覆盖为 4px, 直接属性 !important 兜底 */
.el-tag {
border-radius: 999px !important;
font-weight: 500;
}
/* ---------- 6. 通用状态胶囊 (圆点+胶囊, 颜色经 inline style 传 var() 或 --st-* 变量) ---------- */
.st-pill {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 500; padding: 3px 12px; border-radius: 999px; white-space: nowrap;
}
.st-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* ---------- 7. 批量操作栏通用样式 (所有列表页复用) ---------- */
.batch-bar {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
transition: background .2s;
}
.batch-bar.has-selection { background: var(--primary-soft, #e0ecff); }
.batch-count { font-size: 13px; color: var(--ink-2); }
.batch-count strong { color: var(--primary); font-size: 15px; }
.batch-hint { font-size: 12px; color: var(--ink-3); }
.batch-actions { margin-left: auto; display: flex; gap: 4px; flex-wrap: wrap; }
/* ---------- 4. 状态色暗色适配 ---------- */
/* 状态语义色 token (亮色默认) — 各页面状态标签通过 var() 引用, 主题切换自动适配 */
:root,
[data-theme="tech-blue"],
[data-theme="red"],
[data-theme="purple"] {
--st-warn-fg: #b45309; --st-warn-bg: #fef3c7;
--st-ok-fg: #15803d; --st-ok-bg: #dcfce7;
--st-err-fg: #b91c1c; --st-err-bg: #fee2e2;
--st-lv1-fg: #92400e; --st-lv1-bg: #fef3c7;
--st-lv2-fg: #6d28d9; --st-lv2-bg: #ede9fe;
--st-lv3-fg: #c2410c; --st-lv3-bg: #ffedd5;
--st-lv4-fg: #475569; --st-lv4-bg: #f1f5f9;
--st-lv5-fg: #a16207; --st-lv5-bg: #fefce8;
}
[data-theme="dark"] {
--st-warn-fg: #fcd34d; --st-warn-bg: rgba(245, 158, 11, 0.16);
--st-ok-fg: #6ee7b7; --st-ok-bg: rgba(16, 185, 129, 0.16);
--st-err-fg: #fca5a5; --st-err-bg: rgba(239, 68, 68, 0.16);
--st-lv1-fg: #fcd34d; --st-lv1-bg: rgba(245, 158, 11, 0.14);
--st-lv2-fg: #c4b5fd; --st-lv2-bg: rgba(109, 40, 217, 0.2);
--st-lv3-fg: #fdba74; --st-lv3-bg: rgba(194, 65, 12, 0.2);
--st-lv4-fg: #cbd5e1; --st-lv4-bg: rgba(100, 116, 139, 0.2);
--st-lv5-fg: #fde047; --st-lv5-bg: rgba(161, 98, 7, 0.2);
/* el-tag 四态: 暗色下深底浅字 */
.el-tag--success {
--el-tag-bg-color: rgba(16, 185, 129, 0.16);
--el-tag-text-color: #6ee7b7;
--el-tag-border-color: rgba(16, 185, 129, 0.4);
}
.el-tag--warning {
--el-tag-bg-color: rgba(245, 158, 11, 0.16);
--el-tag-text-color: #fcd34d;
--el-tag-border-color: rgba(245, 158, 11, 0.4);
}
.el-tag--danger {
--el-tag-bg-color: rgba(239, 68, 68, 0.16);
--el-tag-text-color: #fca5a5;
--el-tag-border-color: rgba(239, 68, 68, 0.4);
}
.el-tag--info {
--el-tag-bg-color: rgba(148, 163, 184, 0.16);
--el-tag-text-color: #cbd5e1;
--el-tag-border-color: rgba(148, 163, 184, 0.4);
}
/* 老式 rr-tag-* 状态类 (浅底深字 -> 深底浅字) */
.rr-tag-warn { background: rgba(245, 158, 11, 0.16); color: #fcd34d; }
.rr-tag-ok { background: rgba(16, 185, 129, 0.16); color: #6ee7b7; }
.rr-tag-err { background: rgba(239, 68, 68, 0.16); color: #fca5a5; }
}