feat(wm-revenue): #55 客服知识库+公告板+KPI看板完整实现

- Entity: KbArticle(知识库文章), Announcement(公告), KpiDashboard(KPI看板VO)
- Mapper: KbArticleMapper, AnnouncementMapper (MyBatis-Plus)
- Service: KnowledgeBaseService(知识库CRUD+搜索+分类+点赞+热门),
           AnnouncementService(公告发布/编辑/按类型筛选/按范围推送/撤回),
           KpiService(KPI聚合计算:待处理量/时效/满意率/趋势/排行)
- Controller: CsSupportController (/api/revenue/cs/*)
- SQL DDL: V_cs_support.sql (cs_kb_article + cs_announcement 表+示例数据)
- Frontend: KnowledgeBaseView.vue(列表/卡片/Markdown编辑器),
            AnnouncementView.vue(类型标签/状态切换/时间范围),
            KpiDashboardView.vue(ECharts趋势图+饼图+排行)
- Unit Test: CsSupportServiceTest (知识库/公告/KPI三组测试)
- Router: 新增 /cs/knowledge, /cs/announcement, /cs/kpi 路由
This commit is contained in:
2026-06-14 16:28:05 +08:00
parent 98f91e5d74
commit b445b089c8
35 changed files with 4758 additions and 26 deletions
+3
View File
@@ -13,6 +13,9 @@ const routes = [
{ path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
{ path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
{ path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
{ path: 'cs/knowledge', name: 'csKnowledge', component: () => import('@/views/cs/KnowledgeBaseView.vue') },
{ path: 'cs/announcement', name: 'csAnnouncement', component: () => import('@/views/cs/AnnouncementView.vue') },
{ path: 'cs/kpi', name: 'csKpi', component: () => import('@/views/cs/KpiDashboardView.vue') },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
+288
View File
@@ -0,0 +1,288 @@
<template>
<div class="announcement-mgmt">
<!-- 搜索区 -->
<el-card shadow="never">
<el-form :inline="true" :model="filterForm">
<el-form-item label="关键词">
<el-input v-model="filterForm.keyword" placeholder="标题/内容/范围" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filterForm.type" placeholder="全部" clearable @change="handleSearch">
<el-option label="停水" value="water_outage" />
<el-option label="水质" value="water_quality" />
<el-option label="维修" value="maintenance" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
<el-option label="已撤回" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 统计卡片 -->
<el-row :gutter="12" style="margin-top: 12px">
<el-col :span="6" v-for="stat in typeStats" :key="stat.type">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stat.count }}</div>
<div class="stat-label">{{ typeLabel(stat.type) }}</div>
</el-card>
</el-col>
</el-row>
<!-- 操作栏 -->
<div style="margin-top: 16px">
<el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 发布公告</el-button>
</div>
<!-- 列表 -->
<el-table :data="tableData" border style="margin-top: 12px" v-loading="loading">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="typeTag(row.type)" size="small">{{ typeLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="90">
<template #default="{ row }">
<el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="affectedArea" label="影响范围" width="150" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="publishTime" label="发布时间" width="170" />
<el-table-column prop="publisherName" label="发布人" width="100" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
<el-button link type="warning" v-if="row.status === 0" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="success" v-if="row.status === 0" @click="handlePublish(row)">发布</el-button>
<el-button link type="warning" v-if="row.status === 1" @click="handleWithdraw(row)">撤回</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchData" />
<!-- 编辑对话框 -->
<el-dialog v-model="showEditor" :title="isEdit ? '编辑公告' : '发布公告'" width="700px" destroy-on-close>
<el-form :model="form" label-width="100px">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="公告标题" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="类型" required>
<el-select v-model="form.type" style="width: 100%">
<el-option label="停水" value="water_outage" />
<el-option label="水质" value="water_quality" />
<el-option label="维修" value="maintenance" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="优先级">
<el-select v-model="form.priority" style="width: 100%">
<el-option label="低" value="low" />
<el-option label="中" value="medium" />
<el-option label="高" value="high" />
<el-option label="紧急" value="urgent" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="影响范围">
<el-input v-model="form.affectedArea" placeholder="描述受影响的区域" />
</el-form-item>
<el-form-item label="区域编码">
<el-input v-model="form.areaCode" placeholder="用于定向推送(选填)" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="计划开始">
<el-date-picker v-model="form.plannedStart" type="datetime" style="width: 100%"
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="开始时间" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划结束">
<el-date-picker v-model="form.plannedEnd" type="datetime" style="width: 100%"
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="结束时间" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="内容">
<el-input v-model="form.content" type="textarea" :rows="6" placeholder="公告内容" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditor = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog v-model="showDetail" :title="detailItem?.title" width="600px">
<el-descriptions :column="2" border>
<el-descriptions-item label="类型">
<el-tag :type="typeTag(detailItem?.type)">{{ typeLabel(detailItem?.type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="priorityTag(detailItem?.priority)">{{ priorityLabel(detailItem?.priority) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(detailItem?.status) }}</el-descriptions-item>
<el-descriptions-item label="影响范围">{{ detailItem?.affectedArea }}</el-descriptions-item>
<el-descriptions-item label="计划时间" :span="2">
{{ detailItem?.plannedStart }} ~ {{ detailItem?.plannedEnd }}
</el-descriptions-item>
<el-descriptions-item label="发布人">{{ detailItem?.publisherName }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ detailItem?.publishTime }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<div class="detail-content">{{ detailItem?.content }}</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import request from '@/api/request'
const API = '/api/revenue/cs/announcement'
const loading = ref(false)
const saving = ref(false)
const showEditor = ref(false)
const showDetail = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const tableData = ref<any[]>([])
const typeStats = ref<any[]>([])
const detailItem = ref<any>(null)
const filterForm = reactive({ keyword: '', type: '', status: undefined as number | undefined })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const form = reactive({
title: '', content: '', type: 'water_outage', affectedArea: '', areaCode: '',
plannedStart: '', plannedEnd: '', priority: 'medium', publisherName: '当前用户'
})
onMounted(() => {
fetchData()
fetchStats()
})
async function fetchData() {
loading.value = true
try {
const res = await request.get(`${API}/list`, {
params: { page: pagination.page, size: pagination.size, ...filterForm }
})
tableData.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const res = await request.get(`${API}/stats`)
typeStats.value = res.data || []
} catch (e) { /* ignore */ }
}
function handleSearch() { pagination.page = 1; fetchData() }
function handleReset() {
filterForm.keyword = ''; filterForm.type = ''; filterForm.status = undefined; handleSearch()
}
function openCreateDialog() {
isEdit.value = false; editId.value = null
Object.assign(form, { title: '', content: '', type: 'water_outage', affectedArea: '', areaCode: '',
plannedStart: '', plannedEnd: '', priority: 'medium' })
showEditor.value = true
}
function openEditDialog(row: any) {
isEdit.value = true; editId.value = row.id
Object.assign(form, row)
showEditor.value = true
}
async function handleSave() {
if (!form.title) { ElMessage.warning('请输入标题'); return }
saving.value = true
try {
if (isEdit.value && editId.value) {
await request.put(`${API}/${editId.value}`, form)
} else {
await request.post(API, form)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
showEditor.value = false
fetchData(); fetchStats()
} finally {
saving.value = false
}
}
function viewDetail(row: any) { detailItem.value = row; showDetail.value = true }
async function handlePublish(row: any) {
await ElMessageBox.confirm(`确定发布「${row.title}」?`, '确认发布')
await request.post(`${API}/${row.id}/publish`)
ElMessage.success('发布成功'); fetchData(); fetchStats()
}
async function handleWithdraw(row: any) {
await ElMessageBox.confirm(`确定撤回「${row.title}」?`, '确认撤回')
await request.post(`${API}/${row.id}/withdraw`)
ElMessage.success('撤回成功'); fetchData(); fetchStats()
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '确认删除', { type: 'warning' })
await request.delete(`${API}/${row.id}`)
ElMessage.success('删除成功'); fetchData(); fetchStats()
}
const typeMap: Record<string, string> = { water_outage: '停水', water_quality: '水质', maintenance: '维修', other: '其他' }
const typeTagMap: Record<string, string> = { water_outage: 'danger', water_quality: 'warning', maintenance: '', other: 'info' }
function typeLabel(t: string) { return typeMap[t] || t }
function typeTag(t: string) { return (typeTagMap[t] || 'info') as any }
function priorityLabel(p: string) { return { low: '低', medium: '中', high: '高', urgent: '紧急' }[p] || p }
function priorityTag(p: string) { return ({ low: 'info', medium: '', high: 'warning', urgent: 'danger' }[p] || 'info') as any }
function statusLabel(s: number) { return ['草稿', '已发布', '已撤回'][s] || '未知' }
function statusTag(s: number) { return ['info', 'success', 'warning'][s] as any || 'info' }
</script>
<style scoped>
.stat-card { text-align: center; cursor: pointer; }
.stat-value { font-size: 28px; font-weight: 700; color: #409eff; }
.stat-label { font-size: 13px; color: #666; margin-top: 4px; }
.detail-content { white-space: pre-wrap; line-height: 1.8; }
</style>
+298
View File
@@ -0,0 +1,298 @@
<template>
<div class="knowledge-base">
<!-- 搜索过滤区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filterForm">
<el-form-item label="关键词">
<el-input v-model="filterForm.keyword" placeholder="搜索标题/内容/标签" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filterForm.category" placeholder="全部分类" clearable @change="handleSearch">
<el-option v-for="cat in categories" :key="cat.category" :label="cat.category" :value="cat.category">
<span>{{ cat.category }}</span>
<span style="float: right; color: #999; font-size: 12px">{{ cat.count }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
<el-option label="已归档" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
<el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 新建文章</el-button>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button label="list">列表</el-radio-button>
<el-radio-button label="card">卡片</el-radio-button>
</el-radio-group>
</div>
<!-- 列表视图 -->
<el-table v-if="viewMode === 'list'" :data="tableData" border style="margin-top: 12px" v-loading="loading">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="category" label="分类" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="tags" label="标签" width="150" show-overflow-tooltip />
<el-table-column prop="viewCount" label="浏览" width="80" align="center" />
<el-table-column prop="likeCount" label="点赞" width="80" align="center" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="authorName" label="作者" width="100" />
<el-table-column prop="updatedAt" label="更新时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
<el-button link type="warning" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 卡片视图 -->
<el-row v-else :gutter="16" style="margin-top: 12px" v-loading="loading">
<el-col :span="6" v-for="item in tableData" :key="item.id">
<el-card shadow="hover" class="article-card" @click="viewDetail(item)">
<template #header>
<div class="card-header">
<span class="card-title">{{ item.title }}</span>
<el-tag :type="statusTag(item.status)" size="small">{{ statusLabel(item.status) }}</el-tag>
</div>
</template>
<p class="card-summary">{{ item.summary || '暂无摘要' }}</p>
<div class="card-meta">
<el-tag size="small">{{ item.category }}</el-tag>
<span class="meta-count">👁 {{ item.viewCount || 0 }} · 👍 {{ item.likeCount || 0 }}</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- 分页 -->
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchData" />
<!-- 编辑对话框 -->
<el-dialog v-model="showEditor" :title="isEdit ? '编辑文章' : '新建文章'" width="800px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入文章标题" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="分类" required>
<el-select v-model="form.category" placeholder="选择分类" style="width: 100%">
<el-option label="FAQ" value="FAQ" />
<el-option label="政策法规" value="政策法规" />
<el-option label="操作指南" value="操作指南" />
<el-option label="常见问题" value="常见问题" />
<el-option label="通知公告" value="通知公告" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="标签">
<el-input v-model="form.tags" placeholder="多个标签用逗号分隔" />
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="form.summary" type="textarea" :rows="2" placeholder="文章摘要(选填)" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="form.content" type="textarea" :rows="12" placeholder="支持 Markdown 格式" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditor = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog v-model="showDetail" :title="detailArticle?.title" width="700px">
<div class="detail-meta">
<el-tag>{{ detailArticle?.category }}</el-tag>
<span>作者: {{ detailArticle?.authorName }}</span>
<span>浏览: {{ detailArticle?.viewCount }}</span>
<span>点赞: {{ detailArticle?.likeCount }}</span>
</div>
<el-divider />
<div class="detail-content" v-html="renderMarkdown(detailArticle?.content || '')"></div>
<template #footer>
<el-button @click="handleLike" :icon="Star">点赞</el-button>
<el-button type="primary" @click="showDetail = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Star } from '@element-plus/icons-vue'
import request from '@/api/request'
const API = '/api/revenue/cs/kb'
const loading = ref(false)
const saving = ref(false)
const viewMode = ref('list')
const showEditor = ref(false)
const showDetail = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const tableData = ref<any[]>([])
const categories = ref<any[]>([])
const detailArticle = ref<any>(null)
const filterForm = reactive({ keyword: '', category: '', status: undefined as number | undefined })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const form = reactive({
title: '', content: '', summary: '', category: 'FAQ', tags: '', status: 0, authorName: '当前用户'
})
onMounted(() => {
fetchData()
fetchCategories()
})
async function fetchData() {
loading.value = true
try {
const res = await request.get(`${API}/list`, {
params: { page: pagination.page, size: pagination.size, ...filterForm }
})
tableData.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally {
loading.value = false
}
}
async function fetchCategories() {
try {
const res = await request.get(`${API}/categories`)
categories.value = res.data || []
} catch (e) { /* ignore */ }
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
filterForm.keyword = ''
filterForm.category = ''
filterForm.status = undefined
handleSearch()
}
function openCreateDialog() {
isEdit.value = false
editId.value = null
Object.assign(form, { title: '', content: '', summary: '', category: 'FAQ', tags: '', status: 0 })
showEditor.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editId.value = row.id
Object.assign(form, { title: row.title, content: row.content, summary: row.summary,
category: row.category, tags: row.tags, status: row.status, authorName: row.authorName })
showEditor.value = true
}
async function handleSave() {
if (!form.title) { ElMessage.warning('请输入标题'); return }
saving.value = true
try {
if (isEdit.value && editId.value) {
await request.put(`${API}/${editId.value}`, form)
} else {
await request.post(API, form)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
showEditor.value = false
fetchData()
fetchCategories()
} finally {
saving.value = false
}
}
async function viewDetail(row: any) {
try {
const res = await request.get(`${API}/${row.id}`)
detailArticle.value = res.data
showDetail.value = true
fetchData() // refresh view count
} catch (e) {
detailArticle.value = row
showDetail.value = true
}
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '确认删除', { type: 'warning' })
await request.delete(`${API}/${row.id}`)
ElMessage.success('删除成功')
fetchData()
}
async function handleLike() {
if (!detailArticle.value) return
await request.post(`${API}/${detailArticle.value.id}/like`)
ElMessage.success('点赞成功')
detailArticle.value.likeCount = (detailArticle.value.likeCount || 0) + 1
}
function statusLabel(s: number) { return ['草稿', '已发布', '已归档'][s] || '未知' }
function statusTag(s: number) { return ['info', 'success', 'warning'][s] as any || 'info' }
function renderMarkdown(md: string) {
// 简单 Markdown 渲染(生产环境可用 marked)
return md.replace(/### (.*)/g, '<h3>$1</h3>')
.replace(/## (.*)/g, '<h2>$1</h2>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/^- (.*)/gm, '<li>$1</li>')
.replace(/\n/g, '<br/>')
}
</script>
<style scoped>
.filter-card { margin-bottom: 8px; }
.article-card { margin-bottom: 16px; cursor: pointer; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.card-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.card-summary { color: #666; font-size: 13px; height: 40px; overflow: hidden; }
.card-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.meta-count { font-size: 12px; color: #999; }
.detail-meta { display: flex; gap: 16px; align-items: center; color: #666; }
.detail-content { line-height: 1.8; }
</style>
+190
View File
@@ -0,0 +1,190 @@
<template>
<div class="kpi-dashboard">
<!-- 统计卡片 -->
<el-row :gutter="16">
<el-col :span="4" v-for="card in statCards" :key="card.key">
<el-card shadow="hover" class="kpi-card" :class="'kpi-' + card.color">
<div class="kpi-icon">{{ card.icon }}</div>
<div class="kpi-value">{{ card.value }}</div>
<div class="kpi-label">{{ card.label }}</div>
<div class="kpi-sub" v-if="card.sub">{{ card.sub }}</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="14">
<el-card shadow="never">
<template #header>
<span>📈 7日工单趋势</span>
</template>
<div ref="trendChartRef" style="height: 320px"></div>
</el-card>
</el-col>
<el-col :span="10">
<el-card shadow="never">
<template #header>
<span>📊 工单类型分布</span>
</template>
<div ref="pieChartRef" style="height: 320px"></div>
</el-card>
</el-col>
</el-row>
<!-- 排行榜 -->
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<span>🏆 处理时效排行</span>
</template>
<el-table :data="efficiencyRank" size="small" :show-header="true">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="name" label="处理人/部门" />
<el-table-column prop="completed_count" label="完成数" width="80" align="center" />
<el-table-column prop="avg_hours" label="平均时效(h)" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.avg_hours < 4 ? 'success' : row.avg_hours < 8 ? '' : 'warning'" size="small">
{{ Number(row.avg_hours).toFixed(1) }}h
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<span>📋 快捷操作</span>
</template>
<div class="quick-actions">
<el-button @click="$router.push('/cs/knowledge')" size="large">
📚 知识库管理
</el-button>
<el-button @click="$router.push('/cs/announcement')" size="large">
📢 公告管理
</el-button>
<el-button @click="refreshDashboard" size="large" :loading="loading">
🔄 刷新数据
</el-button>
</div>
<el-divider />
<div class="summary-info">
<p>📅 数据更新时间: {{ lastUpdate }}</p>
<p>📊 本月解决率: <el-progress :percentage="Number(kpiData.monthResolveRate || 0)" :stroke-width="16" /></p>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, shallowRef } from 'vue'
import request from '@/api/request'
import * as echarts from 'echarts'
const API = '/api/revenue/cs/kpi/dashboard'
const loading = ref(false)
const kpiData = reactive<any>({})
const efficiencyRank = ref<any[]>([])
const lastUpdate = ref('')
const trendChartRef = ref<HTMLElement>()
const pieChartRef = ref<HTMLElement>()
let trendChart: echarts.ECharts | null = null
let pieChart: echarts.ECharts | null = null
const statCards = computed(() => [
{ key: 'pending', icon: '⏳', label: '待处理工单', value: kpiData.pendingWorkOrders ?? '-', color: 'orange' },
{ key: 'today_new', icon: '📥', label: '今日新增', value: kpiData.todayNewWorkOrders ?? '-', color: 'blue' },
{ key: 'month_rate', icon: '✅', label: '本月解决率', value: (kpiData.monthResolveRate ?? 0) + '%', color: 'green',
sub: `${kpiData.monthResolvedCount ?? 0}/${kpiData.monthTotalCount ?? 0}` },
{ key: 'avg_hours', icon: '⏱️', label: '平均时效', value: (kpiData.avgProcessHours ?? 0) + 'h', color: 'purple' },
{ key: 'satisfaction', icon: '😊', label: '满意率', value: (kpiData.satisfactionRate ?? 0) + '%', color: 'green' },
{ key: 'complaints', icon: '📞', label: '今日投诉', value: kpiData.todayComplaints ?? '-', color: 'red' },
])
onMounted(() => {
refreshDashboard()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
trendChart?.dispose()
pieChart?.dispose()
})
async function refreshDashboard() {
loading.value = true
try {
const res = await request.get(API)
Object.assign(kpiData, res.data)
efficiencyRank.value = res.data?.efficiencyRank || []
lastUpdate.value = new Date().toLocaleString()
await nextTick()
renderTrendChart()
renderPieChart()
} finally {
loading.value = false
}
}
function renderTrendChart() {
if (!trendChartRef.value) return
trendChart = trendChart || echarts.init(trendChartRef.value)
const data = kpiData.weeklyTrend || []
trendChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: data.map((d: any) => d.date?.slice(5) || '') },
yAxis: { type: 'value', name: '工单数' },
series: [{
name: '工单数', type: 'line', smooth: true,
data: data.map((d: any) => d.count || 0),
areaStyle: { opacity: 0.15 },
itemStyle: { color: '#409eff' }
}]
})
}
function renderPieChart() {
if (!pieChartRef.value) return
pieChart = pieChart || echarts.init(pieChartRef.value)
const data = kpiData.typeDistribution || []
const nameMap: Record<string, string> = { pending: '待处理', in_progress: '处理中', completed: '已完成' }
pieChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0 },
series: [{
type: 'pie', radius: ['40%', '65%'],
label: { show: true, formatter: '{b}\n{c}' },
data: data.map((d: any) => ({ name: nameMap[d.type] || d.type, value: d.count }))
}]
})
}
function handleResize() {
trendChart?.resize()
pieChart?.resize()
}
</script>
<style scoped>
.kpi-card { text-align: center; padding: 8px 0; }
.kpi-icon { font-size: 28px; }
.kpi-value { font-size: 32px; font-weight: 700; margin: 4px 0; }
.kpi-label { font-size: 13px; color: #666; }
.kpi-sub { font-size: 12px; color: #999; margin-top: 2px; }
.kpi-orange .kpi-value { color: #e6a23c; }
.kpi-blue .kpi-value { color: #409eff; }
.kpi-green .kpi-value { color: #67c23a; }
.kpi-purple .kpi-value { color: #9b59b6; }
.kpi-red .kpi-value { color: #f56c6c; }
.quick-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.summary-info { margin-top: 16px; }
.summary-info p { margin: 8px 0; color: #666; }
</style>