Merge remote-tracking branch 'origin/feature/issue-48'
# Conflicts: # frontend/src/router/index.ts # wm-production/pom.xml
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,9 +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: '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: '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,558 @@
|
||||
<template>
|
||||
<div class="problem-reporting">
|
||||
<el-card class="reporting-form">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>巡检问题上报</span>
|
||||
<el-tag type="success">{{ problemCount }} 个问题待处理</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="problemForm"
|
||||
:model="problemForm"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
@submit.prevent="submitProblem"
|
||||
>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="问题类型" prop="problemType">
|
||||
<el-select
|
||||
v-model="problemForm.problemType"
|
||||
placeholder="请选择问题类型"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="设备故障" value="设备故障" />
|
||||
<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="问题级别" prop="problemLevel">
|
||||
<el-select
|
||||
v-model="problemForm.problemLevel"
|
||||
placeholder="请选择问题级别"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="低" value="low" />
|
||||
<el-option label="普通" value="normal" />
|
||||
<el-option label="高" value="high" />
|
||||
<el-option label="紧急" value="critical" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="问题标题" prop="problemTitle">
|
||||
<el-input
|
||||
v-model="problemForm.problemTitle"
|
||||
placeholder="请输入问题标题"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="问题描述" prop="problemDescription">
|
||||
<el-input
|
||||
v-model="problemForm.problemDescription"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请详细描述问题情况"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="问题位置" prop="location">
|
||||
<el-input
|
||||
v-model="problemForm.location"
|
||||
placeholder="请输入问题发生位置"
|
||||
maxlength="300"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="设备名称" prop="deviceName">
|
||||
<el-input
|
||||
v-model="problemForm.deviceName"
|
||||
placeholder="请输入相关设备名称"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-form-item label="经纬度">
|
||||
<el-input
|
||||
v-model="coordinates"
|
||||
placeholder="经度, 纬度"
|
||||
readonly
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="getCurrentLocation">获取位置</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="现场照片">
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
action="/api/upload"
|
||||
list-type="picture-card"
|
||||
:limit="5"
|
||||
:on-success="handleUploadSuccess"
|
||||
:on-remove="handleRemove"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="upload-tip">最多上传5张照片,支持JPG、PNG格式</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="submitProblem"
|
||||
:loading="submitting"
|
||||
>
|
||||
{{ isEditing ? '更新问题' : '提交问题' }}
|
||||
</el-button>
|
||||
<el-button @click="resetForm">重置</el-button>
|
||||
<el-button v-if="isEditing" @click="cancelEdit">取消编辑</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 问题列表 -->
|
||||
<el-card class="problem-list">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>问题列表</span>
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索问题..."
|
||||
style="width: 200px"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="filteredProblems"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-table-column prop="problemNo" label="问题编号" width="120" />
|
||||
<el-table-column prop="problemTitle" label="问题标题" min-width="200" />
|
||||
<el-table-column prop="problemType" label="问题类型" width="120" />
|
||||
<el-table-column prop="problemLevel" label="级别" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getLevelType(row.problemLevel)">
|
||||
{{ getLevelText(row.problemLevel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reportTime" label="上报时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.reportTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
@click="viewProblem(row)"
|
||||
>
|
||||
查看
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="editProblem(row)"
|
||||
v-if="row.status === 'reported'"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="success"
|
||||
@click="createWorkOrder(row)"
|
||||
v-if="row.status === 'reported' && !row.workOrderId"
|
||||
>
|
||||
创建工单
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="info"
|
||||
@click="viewWorkOrder(row)"
|
||||
v-if="row.workOrderId"
|
||||
>
|
||||
查看工单
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="totalProblems"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const problemForm = ref({
|
||||
id: null,
|
||||
taskId: null,
|
||||
pointSeq: null,
|
||||
deviceId: null,
|
||||
deviceName: '',
|
||||
problemType: '',
|
||||
problemLevel: 'normal',
|
||||
problemTitle: '',
|
||||
problemDescription: '',
|
||||
location: '',
|
||||
lng: null,
|
||||
lat: null,
|
||||
photoUrls: [],
|
||||
reporterId: 1, // 当前用户ID
|
||||
reporterName: '巡检员',
|
||||
status: 'reported'
|
||||
})
|
||||
|
||||
const fileList = ref([])
|
||||
const coordinates = ref('')
|
||||
const submitting = ref(false)
|
||||
const loading = ref(false)
|
||||
const problems = ref([])
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalProblems = ref(0)
|
||||
const isEditing = ref(false)
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
problemType: [{ required: true, message: '请选择问题类型', trigger: 'change' }],
|
||||
problemTitle: [{ required: true, message: '请输入问题标题', trigger: 'blur' }],
|
||||
problemDescription: [{ required: true, message: '请输入问题描述', trigger: 'blur' }],
|
||||
location: [{ required: true, message: '请输入问题位置', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const problemCount = computed(() => {
|
||||
return problems.value.filter(p => p.status === 'reported').length
|
||||
})
|
||||
|
||||
const filteredProblems = computed(() => {
|
||||
let filtered = problems.value
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(p =>
|
||||
p.problemTitle.toLowerCase().includes(query) ||
|
||||
p.problemType.toLowerCase().includes(query) ||
|
||||
p.problemNo.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
// 获取问题列表
|
||||
const fetchProblems = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/patrol/problems/status/reported')
|
||||
problems.value = response.data
|
||||
totalProblems.value = problems.value.length
|
||||
} catch (error) {
|
||||
console.error('获取问题列表失败:', error)
|
||||
ElMessage.error('获取问题列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交问题
|
||||
const submitProblem = async () => {
|
||||
try {
|
||||
const formRef = document.querySelector('.problem-reporting .reporting-form form')
|
||||
if (!formRef) return
|
||||
|
||||
// 这里可以添加表单验证逻辑
|
||||
submitting.value = true
|
||||
|
||||
// 处理文件上传
|
||||
const photoUrls = []
|
||||
for (const file of fileList.value) {
|
||||
if (file.response) {
|
||||
photoUrls.push(file.response.url)
|
||||
}
|
||||
}
|
||||
problemForm.value.photoUrls = photoUrls
|
||||
|
||||
// 解析坐标
|
||||
if (coordinates.value) {
|
||||
const [lng, lat] = coordinates.value.split(',').map(s => parseFloat(s.trim()))
|
||||
problemForm.value.lng = lng
|
||||
problemForm.value.lat = lat
|
||||
}
|
||||
|
||||
const response = await axios.post('/api/patrol/problems', problemForm.value)
|
||||
|
||||
if (response.data) {
|
||||
ElMessage.success('问题提交成功')
|
||||
resetForm()
|
||||
fetchProblems()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交问题失败:', error)
|
||||
ElMessage.error('提交问题失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
problemForm.value = {
|
||||
id: null,
|
||||
taskId: null,
|
||||
pointSeq: null,
|
||||
deviceId: null,
|
||||
deviceName: '',
|
||||
problemType: '',
|
||||
problemLevel: 'normal',
|
||||
problemTitle: '',
|
||||
problemDescription: '',
|
||||
location: '',
|
||||
lng: null,
|
||||
lat: null,
|
||||
photoUrls: [],
|
||||
reporterId: 1,
|
||||
reporterName: '巡检员',
|
||||
status: 'reported'
|
||||
}
|
||||
fileList.value = []
|
||||
coordinates.value = ''
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
// 编辑问题
|
||||
const editProblem = (problem) => {
|
||||
problemForm.value = { ...problem }
|
||||
fileList.value = problem.photoUrls.map(url => ({ url, name: url }))
|
||||
coordinates.value = problem.lng && problem.lat ? `${problem.lng}, ${problem.lat}` : ''
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
const cancelEdit = () => {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 查看问题详情
|
||||
const viewProblem = (problem) => {
|
||||
ElMessageBox.alert(
|
||||
`问题编号:${problem.problemNo}\n` +
|
||||
`问题类型:${problem.problemType}\n` +
|
||||
`问题级别:${problem.problemLevel}\n` +
|
||||
`问题标题:${problem.problemTitle}\n` +
|
||||
`问题位置:${problem.location}\n` +
|
||||
`问题描述:${problem.problemDescription}\n` +
|
||||
`上报时间:${formatDate(problem.reportTime)}`,
|
||||
'问题详情',
|
||||
{ confirmButtonText: '确定' }
|
||||
)
|
||||
}
|
||||
|
||||
// 创建工单
|
||||
const createWorkOrder = (problem) => {
|
||||
ElMessageBox.confirm(
|
||||
`确认为问题 "${problem.problemTitle}" 创建工单吗?`,
|
||||
'创建工单',
|
||||
{ confirmButtonText: '确定', cancelButtonText: '取消' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const response = await axios.post(`/api/patrol/problems/${problem.id}/auto-create-work-order`)
|
||||
if (response.data) {
|
||||
ElMessage.success('工单创建成功')
|
||||
fetchProblems()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建工单失败:', error)
|
||||
ElMessage.error('创建工单失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 查看工单
|
||||
const viewWorkOrder = (problem) => {
|
||||
// 这里可以跳转到工单详情页
|
||||
ElMessage.info(`查看工单 ${problem.workOrderId}`)
|
||||
}
|
||||
|
||||
// 获取当前位置
|
||||
const getCurrentLocation = () => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const { latitude, longitude } = position.coords
|
||||
coordinates.value = `${longitude}, ${latitude}`
|
||||
problemForm.value.lng = longitude
|
||||
problemForm.value.lat = latitude
|
||||
},
|
||||
(error) => {
|
||||
ElMessage.error('获取位置失败:' + error.message)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ElMessage.error('浏览器不支持地理位置定位')
|
||||
}
|
||||
}
|
||||
|
||||
// 文件上传相关
|
||||
const handleUploadSuccess = (response, file) => {
|
||||
file.url = response.url
|
||||
}
|
||||
|
||||
const handleRemove = (file, fileList) => {
|
||||
fileList.value = fileList
|
||||
}
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isJPG = file.type === 'image/jpeg'
|
||||
const isPNG = file.type === 'image/png'
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
|
||||
if (!isJPG && !isPNG) {
|
||||
ElMessage.error('上传图片只能是 JPG 或 PNG 格式!')
|
||||
return false
|
||||
}
|
||||
if (!isLt5M) {
|
||||
ElMessage.error('上传图片大小不能超过 5MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const getLevelType = (level) => {
|
||||
switch (level) {
|
||||
case 'low': return 'info'
|
||||
case 'normal': return ''
|
||||
case 'high': return 'warning'
|
||||
case 'critical': return 'danger'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getLevelText = (level) => {
|
||||
switch (level) {
|
||||
case 'low': return '低'
|
||||
case 'normal': return '普通'
|
||||
case 'high': return '高'
|
||||
case 'critical': return '紧急'
|
||||
default: return level
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
switch (status) {
|
||||
case 'reported': return 'warning'
|
||||
case 'processing': return 'primary'
|
||||
case 'completed': return 'success'
|
||||
case 'closed': return 'info'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'reported': return '已上报'
|
||||
case 'processing': return '处理中'
|
||||
case 'completed': return '已完成'
|
||||
case 'closed': return '已关闭'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
fetchProblems()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
currentPage.value = val
|
||||
fetchProblems()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchProblems()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.problem-reporting {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.problem-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,552 @@
|
||||
<template>
|
||||
<div class="work-order-management">
|
||||
<el-card class="work-order-stats">
|
||||
<template #header>
|
||||
<span>工单统计</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ statistics.totalOrders }}</div>
|
||||
<div class="stat-label">总工单数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ statistics.pendingCount }}</div>
|
||||
<div class="stat-label">待处理</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ statistics.processingCount }}</div>
|
||||
<div class="stat-label">处理中</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ statistics.completedCount }}</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card class="work-order-list">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>工单列表</span>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 120px; margin-right: 10px;">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="已分配" value="assigned" />
|
||||
<el-option label="处理中" value="processing" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索工单..."
|
||||
style="width: 200px"
|
||||
clearable
|
||||
/>
|
||||
<el-button type="primary" @click="createNewWorkOrder">新建工单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="filteredWorkOrders"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-table-column prop="orderNo" label="工单编号" width="120" />
|
||||
<el-table-column prop="title" label="工单标题" min-width="200" />
|
||||
<el-table-column prop="orderType" label="工单类型" width="120" />
|
||||
<el-table-column prop="priority" label="优先级" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getPriorityType(row.priority)">
|
||||
{{ getPriorityText(row.priority) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assigneeName" label="处理人" width="100" />
|
||||
<el-table-column prop="location" label="位置" width="150" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completionTime" label="完成时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.completionTime ? formatDate(row.completionTime) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
@click="viewWorkOrder(row)"
|
||||
>
|
||||
查看
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="assignWorkOrder(row)"
|
||||
v-if="row.status === 'pending'"
|
||||
>
|
||||
分派
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="success"
|
||||
@click="startWorkOrder(row)"
|
||||
v-if="row.status === 'assigned'"
|
||||
>
|
||||
开始处理
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="info"
|
||||
@click="completeWorkOrder(row)"
|
||||
v-if="row.status === 'processing'"
|
||||
>
|
||||
完成
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="cancelWorkOrder(row)"
|
||||
v-if="row.status !== 'completed' && row.status !== 'cancelled'"
|
||||
>
|
||||
取消
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="totalWorkOrders"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 工单详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="80%"
|
||||
:before-close="handleDialogClose"
|
||||
>
|
||||
<div v-if="currentWorkOrder">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="工单编号">{{ currentWorkOrder.orderNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="工单类型">{{ currentWorkOrder.orderType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="优先级">
|
||||
<el-tag :type="getPriorityType(currentWorkOrder.priority)">
|
||||
{{ getPriorityText(currentWorkOrder.priority) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="getStatusType(currentWorkOrder.status)">
|
||||
{{ getStatusText(currentWorkOrder.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处理人">{{ currentWorkOrder.assigneeName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预计工时">{{ currentWorkOrder.estimatedDuration }}分钟</el-descriptions-item>
|
||||
<el-descriptions-item label="问题标题">{{ currentWorkOrder.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="位置">{{ currentWorkOrder.location }}</el-descriptions-item>
|
||||
<el-descriptions-item label="问题描述" :span="2">{{ currentWorkOrder.description }}</el-descriptions-item>
|
||||
<el-descriptions-item label="解决方案" :span="2">{{ currentWorkOrder.solutionDescription || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理结果" :span="2">{{ currentWorkOrder.solutionResult || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户反馈" :span="2">{{ currentWorkOrder.customerFeedback || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 处理记录 -->
|
||||
<div style="margin-top: 20px;">
|
||||
<h4>处理记录</h4>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="record in processRecords"
|
||||
:key="record.id"
|
||||
:timestamp="formatDate(record.createdAt)"
|
||||
:type="getProcessStepType(record.processStep)"
|
||||
>
|
||||
<h5>{{ getProcessStepText(record.processStep) }}</h5>
|
||||
<p>{{ record.comment }}</p>
|
||||
<p v-if="record.processorName">处理人:{{ record.processorName }}</p>
|
||||
<div v-if="record.photos && record.photos.length > 0">
|
||||
<el-image
|
||||
v-for="(photo, index) in record.photos"
|
||||
:key="index"
|
||||
:src="photo"
|
||||
style="width: 100px; height: 100px; margin-right: 10px; margin-top: 10px;"
|
||||
fit="cover"
|
||||
:preview-src-list="record.photos"
|
||||
/>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
const workOrders = ref([])
|
||||
const statistics = ref({
|
||||
totalOrders: 0,
|
||||
pendingCount: 0,
|
||||
assignedCount: 0,
|
||||
processingCount: 0,
|
||||
completedCount: 0,
|
||||
cancelledCount: 0
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const currentWorkOrder = ref(null)
|
||||
const processRecords = ref([])
|
||||
const statusFilter = ref('')
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalWorkOrders = ref(0)
|
||||
|
||||
// 计算属性
|
||||
const filteredWorkOrders = computed(() => {
|
||||
let filtered = workOrders.value
|
||||
|
||||
// 状态筛选
|
||||
if (statusFilter.value) {
|
||||
filtered = filtered.filter(w => w.status === statusFilter.value)
|
||||
}
|
||||
|
||||
// 搜索筛选
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(w =>
|
||||
w.title.toLowerCase().includes(query) ||
|
||||
w.orderNo.toLowerCase().includes(query) ||
|
||||
w.location.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return currentWorkOrder.value ? `工单详情 - ${currentWorkOrder.value.orderNo}` : '工单详情'
|
||||
})
|
||||
|
||||
// 获取工单列表
|
||||
const fetchWorkOrders = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/work-orders/status/pending')
|
||||
workOrders.value = response.data
|
||||
totalWorkOrders.value = workOrders.value.length
|
||||
await fetchStatistics()
|
||||
} catch (error) {
|
||||
console.error('获取工单列表失败:', error)
|
||||
ElMessage.error('获取工单列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/work-orders/statistics')
|
||||
statistics.value = response.data
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新工单
|
||||
const createNewWorkOrder = () => {
|
||||
ElMessage.info('跳转到新建工单页面')
|
||||
}
|
||||
|
||||
// 查看工单详情
|
||||
const viewWorkOrder = async (workOrder) => {
|
||||
currentWorkOrder.value = workOrder
|
||||
dialogVisible.value = true
|
||||
|
||||
// 获取处理记录
|
||||
try {
|
||||
const response = await axios.get(`/api/work-orders/process/${workOrder.id}`)
|
||||
processRecords.value = response.data
|
||||
} catch (error) {
|
||||
console.error('获取处理记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分派工单
|
||||
const assignWorkOrder = (workOrder) => {
|
||||
ElMessageBox.prompt(
|
||||
'请输入处理人ID',
|
||||
'分派工单',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /^\d+$/,
|
||||
inputErrorMessage: '请输入有效的用户ID'
|
||||
}
|
||||
).then(async ({ value }) => {
|
||||
try {
|
||||
const assigneeName = '处理人' // 实际应用中应该根据ID获取用户名
|
||||
const response = await axios.put(`/api/work-orders/${workOrder.id}/assign`, {
|
||||
assigneeId: parseInt(value),
|
||||
assigneeName: assigneeName
|
||||
})
|
||||
|
||||
if (response.data) {
|
||||
ElMessage.success('工单分派成功')
|
||||
fetchWorkOrders()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('分派工单失败:', error)
|
||||
ElMessage.error('分派工单失败')
|
||||
}
|
||||
}).catch(() => {
|
||||
// 用户取消
|
||||
})
|
||||
}
|
||||
|
||||
// 开始处理工单
|
||||
const startWorkOrder = async (workOrder) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认为工单 "${workOrder.title}" 开始处理吗?`,
|
||||
'开始处理',
|
||||
{ confirmButtonText: '确定', cancelButtonText: '取消' }
|
||||
)
|
||||
|
||||
const response = await axios.put(`/api/work-orders/${workOrder.id}/start`)
|
||||
if (response.data) {
|
||||
ElMessage.success('开始处理成功')
|
||||
fetchWorkOrders()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('开始处理失败:', error)
|
||||
ElMessage.error('开始处理失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 完成工单
|
||||
const completeWorkOrder = (workOrder) => {
|
||||
ElMessageBox.prompt(
|
||||
'请输入处理结果',
|
||||
'完成工单',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '请详细描述处理结果'
|
||||
}
|
||||
).then(async ({ value }) => {
|
||||
try {
|
||||
const response = await axios.put(`/api/work-orders/${workOrder.id}/complete`, {
|
||||
solutionResult: value
|
||||
})
|
||||
|
||||
if (response.data) {
|
||||
ElMessage.success('工单完成成功')
|
||||
fetchWorkOrders()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('完成工单失败:', error)
|
||||
ElMessage.error('完成工单失败')
|
||||
}
|
||||
}).catch(() => {
|
||||
// 用户取消
|
||||
})
|
||||
}
|
||||
|
||||
// 取消工单
|
||||
const cancelWorkOrder = (workOrder) => {
|
||||
ElMessageBox.confirm(
|
||||
`确认为工单 "${workOrder.title}" 取消吗?`,
|
||||
'取消工单',
|
||||
{ confirmButtonText: '确定', cancelButtonText: '取消' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const response = await axios.put(`/api/work-orders/${workOrder.id}/status`, {
|
||||
status: 'cancelled',
|
||||
processStatus: 'terminated'
|
||||
})
|
||||
|
||||
if (response.data) {
|
||||
ElMessage.success('工单取消成功')
|
||||
fetchWorkOrders()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消工单失败:', error)
|
||||
ElMessage.error('取消工单失败')
|
||||
}
|
||||
}).catch(() => {
|
||||
// 用户取消
|
||||
})
|
||||
}
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = () => {
|
||||
currentWorkOrder.value = null
|
||||
processRecords.value = []
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const getPriorityType = (priority) => {
|
||||
switch (priority) {
|
||||
case 'low': return 'info'
|
||||
case 'normal': return ''
|
||||
case 'high': return 'warning'
|
||||
case 'critical': return 'danger'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityText = (priority) => {
|
||||
switch (priority) {
|
||||
case 'low': return '低'
|
||||
case 'normal': return '普通'
|
||||
case 'high': return '高'
|
||||
case 'critical': return '紧急'
|
||||
default: return priority
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'warning'
|
||||
case 'assigned': return 'primary'
|
||||
case 'processing': = 'primary'
|
||||
case 'completed': return 'success'
|
||||
case 'cancelled': return 'info'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'pending': return '待处理'
|
||||
case 'assigned': return '已分配'
|
||||
case 'processing': return '处理中'
|
||||
case 'completed': return '已完成'
|
||||
case 'cancelled': return '已取消'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const getProcessStepType = (step) => {
|
||||
switch (step) {
|
||||
case 'created': return 'primary'
|
||||
case 'accepted': return 'success'
|
||||
case 'in_progress': return 'warning'
|
||||
case 'completed': return 'success'
|
||||
default: return 'primary'
|
||||
}
|
||||
}
|
||||
|
||||
const getProcessStepText = (step) => {
|
||||
switch (step) {
|
||||
case 'created': return '工单创建'
|
||||
case 'accepted': return '工单接受'
|
||||
case 'in_progress': return '处理中'
|
||||
case 'completed': return '工单完成'
|
||||
default: return step
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
fetchWorkOrders()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
currentPage.value = val
|
||||
fetchWorkOrders()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchWorkOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.work-order-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.work-order-stats {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -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