feat: 实现客服工作台功能 (Issue #54)
- 添加 CustomerServiceController 后端 API - 实现前端客服工作台界面 - 支持水费查询(户号/手机号) - 集成 TTS 语音查询功能 - 添加数据库表结构和示例数据 - 更新路由配置 Resolves: #54
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
export interface QueryBillsParams {
|
||||
phoneOrCustomerNo: string
|
||||
}
|
||||
|
||||
export interface KnowledgeSearchParams {
|
||||
keyword: string
|
||||
}
|
||||
|
||||
export interface NoticeParams {
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface KpiData {
|
||||
pending_bills: number
|
||||
pending_installs: number
|
||||
avg_install_hours: number
|
||||
}
|
||||
|
||||
export const serviceApi = {
|
||||
// 水费查询
|
||||
queryBills: (params: QueryBillsParams) => {
|
||||
return request.get('/service/query-bills', { params })
|
||||
},
|
||||
|
||||
// 知识库搜索
|
||||
searchKnowledge: (params: KnowledgeSearchParams) => {
|
||||
return request.get('/service/search-knowledge', { params })
|
||||
},
|
||||
|
||||
// 获取公告
|
||||
getNotices: (params: NoticeParams) => {
|
||||
return request.get('/service/notices/{type}', { params })
|
||||
},
|
||||
|
||||
// 获取KPI
|
||||
getKpi: () => {
|
||||
return request.get('/service/kpi')
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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: 'service/workbench', name: 'serviceWorkbench', component: () => import('@/views/service/CustomerServiceWorkbench.vue') },
|
||||
]
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* TTS语音服务
|
||||
*/
|
||||
export class TTSService {
|
||||
private static instance: TTSService | null = null
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): TTSService {
|
||||
if (!TTSService.instance) {
|
||||
TTSService.instance = new TTSService()
|
||||
}
|
||||
return TTSService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放语音查询结果
|
||||
*/
|
||||
async playQueryResult(text: string): Promise<void> {
|
||||
try {
|
||||
// 使用Web Speech API
|
||||
if ('speechSynthesis' in window) {
|
||||
this.playWithWebSpeech(text)
|
||||
} else {
|
||||
// 回退方案:使用第三方TTS服务
|
||||
await this.playWithExternalTTS(text)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('TTS播放失败:', error)
|
||||
throw new Error('语音播放失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用Web Speech API
|
||||
*/
|
||||
private playWithWebSpeech(text: string): void {
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
utterance.lang = 'zh-CN'
|
||||
utterance.rate = 0.9
|
||||
utterance.pitch = 1
|
||||
utterance.volume = 1
|
||||
|
||||
speechSynthesis.speak(utterance)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用外部TTS服务(可替换为实际的服务API)
|
||||
*/
|
||||
private async playWithExternalTTS(text: string): Promise<void> {
|
||||
// 这里可以集成百度TTS、阿里云TTS等服务
|
||||
// 目前模拟实现
|
||||
return new Promise((resolve) => {
|
||||
console.log('外部TTS服务调用:', text)
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止语音播放
|
||||
*/
|
||||
stop(): void {
|
||||
if ('speechSynthesis' in window) {
|
||||
speechSynthesis.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查浏览器是否支持语音合成
|
||||
*/
|
||||
isSupported(): boolean {
|
||||
return 'speechSynthesis' in window
|
||||
}
|
||||
}
|
||||
|
||||
export const ttsService = TTSService.getInstance()
|
||||
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="customer-service-workbench">
|
||||
<el-card class="workbench-header">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>🏢 客服工作台</span>
|
||||
<span class="timestamp">{{ currentTime }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="kpi-cards">
|
||||
<el-card class="kpi-card">
|
||||
<div class="kpi-item">
|
||||
<div class="kpi-value">{{ kpiData.pending_bills }}</div>
|
||||
<div class="kpi-label">待处理账单</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="kpi-card">
|
||||
<div class="kpi-item">
|
||||
<div class="kpi-value">{{ kpiData.pending_installs }}</div>
|
||||
<div class="kpi-label">待处理报装</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="kpi-card">
|
||||
<div class="kpi-item">
|
||||
<div class="kpi-value">{{ kpiData.avg_install_hours }}h</div>
|
||||
<div class="kpi-label">平均处理时长</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- 水费查询区域 -->
|
||||
<el-card class="query-section">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>💧 水费查询</span>
|
||||
<el-radio-group v-model="queryType" size="small">
|
||||
<el-radio-button value="phone">手机号</el-radio-button>
|
||||
<el-radio-button value="customer">户号</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="query-form">
|
||||
<el-input
|
||||
v-model="queryValue"
|
||||
placeholder="请输入手机号或户号"
|
||||
class="query-input"
|
||||
@keyup.enter="handleQuery"
|
||||
>
|
||||
<template #append>
|
||||
<el-button type="primary" @click="handleQuery">查询</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 查询结果 -->
|
||||
<div v-if="billResults.length > 0" class="query-results">
|
||||
<h4>最近12个月账单记录</h4>
|
||||
<el-table :data="billResults" stripe style="width: 100%">
|
||||
<el-table-column prop="bill_period" label="账期" width="100" />
|
||||
<el-table-column prop="customer_name" label="客户名称" width="120" />
|
||||
<el-table-column prop="consumption" label="用水量(m³)" width="120" />
|
||||
<el-table-column prop="water_fee" label="水费(元)" width="120" />
|
||||
<el-table-column prop="sewage_fee" label="污水处理费(元)" width="150" />
|
||||
<el-table-column prop="total_fee" label="总金额(元)" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusType(scope.row.status)">
|
||||
{{ getStatusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="due_date" label="截止日期" width="100" />
|
||||
</el-table>
|
||||
|
||||
<div class="voice-query">
|
||||
<el-button type="info" @click="handleVoiceQuery">
|
||||
🔊 语音自助查询
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 知识库和公告 -->
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card class="info-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>📚 知识库</span>
|
||||
<el-input
|
||||
v-model="knowledgeKeyword"
|
||||
placeholder="搜索知识库"
|
||||
size="small"
|
||||
style="width: 200px"
|
||||
@input="handleKnowledgeSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="knowledgeResults.length > 0" class="knowledge-list">
|
||||
<div
|
||||
v-for="item in knowledgeResults"
|
||||
:key="item.dict_value"
|
||||
class="knowledge-item"
|
||||
@click="selectKnowledgeItem(item)"
|
||||
>
|
||||
<div class="knowledge-title">{{ item.dict_label }}</div>
|
||||
<div class="knowledge-content">{{ item.dict_value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<span>暂无相关知识点</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-card class="info-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>📢 公告板</span>
|
||||
<el-tabs v-model="noticeType" size="small">
|
||||
<el-tab-pane label="停水公告" name="water_stop" />
|
||||
<el-tab-pane label="水质公告" name="water_quality" />
|
||||
<el-tab-pane label="服务通知" name="service" />
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="noticeResults.length > 0" class="notice-list">
|
||||
<div
|
||||
v-for="notice in noticeResults"
|
||||
:key="notice.dict_value"
|
||||
class="notice-item"
|
||||
>
|
||||
<div class="notice-title">{{ notice.dict_label }}</div>
|
||||
<div class="notice-date">{{ formatDate(notice.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<span>暂无公告信息</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { serviceApi, type KpiData } from '@/api/customerService'
|
||||
import { ttsService } from '@/utils/tts'
|
||||
|
||||
const currentTime = ref('')
|
||||
const kpiData = ref<KpiData>({
|
||||
pending_bills: 0,
|
||||
pending_installs: 0,
|
||||
avg_install_hours: 0
|
||||
})
|
||||
|
||||
// 查询相关
|
||||
const queryType = ref<'phone' | 'customer'>('phone')
|
||||
const queryValue = ref('')
|
||||
const billResults = ref<any[]>([])
|
||||
|
||||
// 知识库相关
|
||||
const knowledgeKeyword = ref('')
|
||||
const knowledgeResults = ref<any[]>([])
|
||||
|
||||
// 公告相关
|
||||
const noticeType = ref('water_stop')
|
||||
const noticeResults = ref<any[]>([])
|
||||
|
||||
// 更新当前时间
|
||||
const updateCurrentTime = () => {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取KPI数据
|
||||
const fetchKpi = async () => {
|
||||
try {
|
||||
const response = await serviceApi.getKpi()
|
||||
kpiData.value = response.data
|
||||
} catch (error) {
|
||||
console.error('获取KPI数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理水费查询
|
||||
const handleQuery = async () => {
|
||||
if (!queryValue.value.trim()) {
|
||||
ElMessage.warning('请输入查询内容')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await serviceApi.queryBills({
|
||||
phoneOrCustomerNo: queryValue.value
|
||||
})
|
||||
billResults.value = response.data
|
||||
} catch (error) {
|
||||
console.error('查询失败:', error)
|
||||
ElMessage.error('查询失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理语音查询
|
||||
const handleVoiceQuery = async () => {
|
||||
if (!queryValue.value.trim()) {
|
||||
ElMessage.warning('请先输入查询内容')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 先进行正常查询
|
||||
await handleQuery()
|
||||
|
||||
if (billResults.value.length === 0) {
|
||||
const noResultText = `没有找到户号为${queryValue.value}或手机号为${queryValue.value}的水费记录`
|
||||
await ttsService.playQueryResult(noResultText)
|
||||
ElMessage.info('没有找到相关记录')
|
||||
return
|
||||
}
|
||||
|
||||
// 播放查询结果摘要
|
||||
const latestBill = billResults.value[0]
|
||||
const summary = `户${queryValue.value}最新账单信息:${latestBill.bill_period}期,用水量${latestBill.consumption}立方米,应付金额${latestBill.total_fee}元,状态${getStatusText(latestBill.status)}`
|
||||
|
||||
await ttsService.playQueryResult(summary)
|
||||
ElMessage.success('语音播报完成')
|
||||
|
||||
} catch (error) {
|
||||
console.error('语音查询失败:', error)
|
||||
ElMessage.error('语音查询失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理知识库搜索
|
||||
const handleKnowledgeSearch = async () => {
|
||||
if (!knowledgeKeyword.value.trim()) {
|
||||
knowledgeResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await serviceApi.searchKnowledge({
|
||||
keyword: knowledgeKeyword.value
|
||||
})
|
||||
knowledgeResults.value = response.data
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 选择知识库项目
|
||||
const selectKnowledgeItem = (item: any) => {
|
||||
ElMessage.success(`已选择知识点: ${item.dict_label}`)
|
||||
}
|
||||
|
||||
// 获取公告
|
||||
const fetchNotices = async () => {
|
||||
try {
|
||||
const response = await serviceApi.getNotices({
|
||||
type: noticeType.value
|
||||
})
|
||||
noticeResults.value = response.data
|
||||
} catch (error) {
|
||||
console.error('获取公告失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'warning'
|
||||
case 'paid': return 'success'
|
||||
case 'overdue': return 'danger'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return '待缴费'
|
||||
case 'paid': return '已缴费'
|
||||
case 'partial': return '部分缴费'
|
||||
case 'overdue': return '已逾期'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
updateCurrentTime()
|
||||
setInterval(updateCurrentTime, 1000)
|
||||
|
||||
fetchKpi()
|
||||
fetchNotices()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.customer-service-workbench {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.workbench-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.kpi-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.kpi-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.query-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.query-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.query-input {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.query-results h4 {
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.voice-query {
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.knowledge-list,
|
||||
.notice-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.knowledge-item,
|
||||
.notice-item {
|
||||
padding: 10px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.knowledge-item:hover,
|
||||
.notice-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.knowledge-title,
|
||||
.notice-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.knowledge-content {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.notice-date {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user