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:
@@ -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' }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user