增加客户留言板界面

This commit is contained in:
lxd 2025-06-26 15:26:00 +08:00
parent 5786bd4f76
commit 2d6b89dfec
3 changed files with 783 additions and 0 deletions

47
src/api/feedback/index.ts Normal file
View File

@ -0,0 +1,47 @@
import request from '@/config/axios'
// 留言板 VO
export interface FeedbackVO {
id: number // 主键
content: string // 客户提交的反馈内容
backContent: string // 医生回复的内容
userId: number // 客户id
doctorName: string // 医生姓名
doctorId: number // 医生id
backTime: Date | number // 医生回复时间支持Date对象或时间戳
orgid: number // 机构ID
orgname: string // 机构名称
}
// 留言板 API
export const FeedbackApi = {
// 查询留言板分页
getFeedbackPage: async (params: any) => {
return await request.get({ url: `/system/feedback/page`, params })
},
// 查询留言板详情
getFeedback: async (id: number) => {
return await request.get({ url: `/system/feedback/get?id=` + id })
},
// 新增留言板
createFeedback: async (data: FeedbackVO) => {
return await request.post({ url: `/system/feedback/create`, data })
},
// 修改留言板
updateFeedback: async (data: FeedbackVO) => {
return await request.put({ url: `/system/feedback/update`, data })
},
// 删除留言板
deleteFeedback: async (id: number) => {
return await request.delete({ url: `/system/feedback/delete?id=` + id })
},
// 导出留言板 Excel
exportFeedback: async (params) => {
return await request.download({ url: `/system/feedback/export-excel`, params })
},
}

View File

@ -0,0 +1,204 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isViewMode ? '查看回复' : '回复留言'"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="reply-dialog-content">
<!-- 原留言内容 -->
<div class="original-message">
<h4>客户留言</h4>
<div class="message-box">
<p>{{ feedbackData.content }}</p>
<div class="message-info">
<span>客户ID: {{ feedbackData.userId }}</span>
<span>机构: {{ feedbackData.orgname }}</span>
</div>
</div>
</div>
<!-- 回复表单 -->
<div class="reply-form">
<h4>{{ isViewMode ? '回复内容:' : '请输入回复:' }}</h4>
<el-form :model="replyForm" ref="replyFormRef" :rules="replyRules">
<el-form-item prop="backContent">
<el-input
v-model="replyForm.backContent"
type="textarea"
:rows="6"
placeholder="请输入回复内容..."
:disabled="isViewMode"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
v-if="!isViewMode"
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
提交回复
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { FeedbackApi, type FeedbackVO } from '@/api/feedback'
//
interface Props {
visible: boolean
feedbackData: FeedbackVO
doctorName?: string
}
//
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
//
const submitLoading = ref(false)
const replyFormRef = ref<FormInstance>()
//
const replyForm = reactive<FeedbackVO>({
id: 0,
content: '',
backContent: '',
userId: 0,
doctorName: '',
doctorId: 0,
backTime: Date.now(),
orgid: 0,
orgname: ''
})
//
const replyRules: FormRules = {
backContent: [
{ required: true, message: '请输入回复内容', trigger: 'blur' },
{ min: 1, max: 500, message: '回复内容长度在 1 到 500 个字符', trigger: 'blur' }
]
}
//
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
const isViewMode = computed(() => {
return !!props.feedbackData.backTime
})
//
watch(() => props.feedbackData, (newData) => {
if (newData && newData.id) {
Object.assign(replyForm, newData)
}
}, { immediate: true, deep: true })
//
const handleSubmit = async () => {
try {
await replyFormRef.value?.validate()
submitLoading.value = true
const submitData = {
...replyForm,
backTime: Date.now(),
doctorName: props.doctorName || '未知医生'
}
await FeedbackApi.updateFeedback(submitData)
ElMessage.success('回复成功')
emit('success')
handleClose()
} catch (error: any) {
console.error('回复失败:', error)
ElMessage.error('回复失败')
} finally {
submitLoading.value = false
}
}
//
const handleClose = () => {
dialogVisible.value = false
//
replyFormRef.value?.resetFields()
}
</script>
<style scoped lang="scss">
.reply-dialog-content {
.original-message {
margin-bottom: 24px;
h4 {
margin: 0 0 12px 0;
color: #303133;
font-size: 16px;
}
.message-box {
background: #f8f9fa;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
p {
margin: 0 0 12px 0;
line-height: 1.6;
color: #303133;
}
.message-info {
display: flex;
gap: 16px;
font-size: 12px;
color: #909399;
span {
background: #e4e7ed;
padding: 2px 8px;
border-radius: 4px;
}
}
}
}
.reply-form {
h4 {
margin: 0 0 12px 0;
color: #303133;
font-size: 16px;
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@ -0,0 +1,532 @@
<template>
<div class="msgboard-container">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<h1 class="page-title">
<el-icon class="title-icon"><ChatDotRound /></el-icon>
留言板管理
</h1>
<p class="page-desc">查看客户留言并及时回复提升客户满意度</p>
</div>
</div>
<!-- 搜索区域 -->
<div class="search-section">
<el-card class="search-card" shadow="never">
<el-form :model="queryParams" ref="queryFormRef" :inline="true" class="search-form">
<el-form-item label="客户ID" prop="userId">
<el-input
v-model="queryParams.userId"
placeholder="请输入客户ID"
clearable
style="width: 120px"
/>
</el-form-item>
<el-form-item label="回复状态" prop="replyStatus">
<el-select v-model="queryParams.replyStatus" placeholder="请选择回复状态" clearable style="width: 150px">
<el-option label="已回复" value="replied" />
<el-option label="未回复" value="unreplied" />
</el-select>
</el-form-item>
<el-form-item label="回复时间" prop="backTimeRange">
<el-date-picker
v-model="queryParams.backTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
<el-button type="success" @click="handleExport">
<el-icon><Download /></el-icon>
导出
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
<!-- 留言列表 -->
<div class="message-list-section">
<el-card class="list-card" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">留言列表</span>
<div class="header-stats">
<el-tag type="info">总计: {{ total }}</el-tag>
<el-tag type="success">已回复: {{ repliedCount }}</el-tag>
<el-tag type="warning">未回复: {{ unrepliedCount }}</el-tag>
</div>
</div>
</template>
<el-table
v-loading="loading"
:data="messageList"
style="width: 100%"
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="留言内容" prop="content" min-width="300" show-overflow-tooltip>
<template #default="{ row }">
<div class="message-content">
<div class="content-text">{{ row.content }}</div>
<div class="content-meta">
<el-tag size="small" type="info">客户ID: {{ row.userId }}</el-tag>
<el-tag size="small" type="primary">{{ row.orgname }}</el-tag>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="回复状态" prop="backContent" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="row.backTime ? 'success' : 'warning'"
size="small"
>
{{ row.backTime ? '已回复' : '未回复' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="回复内容" prop="backContent" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.backContent" class="reply-content">{{ row.backContent }}</span>
<span v-else class="no-reply">暂无回复</span>
</template>
</el-table-column>
<el-table-column label="回复医生" prop="doctorName" width="100" align="center">
<template #default="{ row }">
<span v-if="row.doctorName">{{ row.doctorName }}</span>
<span v-else class="no-doctor">-</span>
</template>
</el-table-column>
<el-table-column label="回复时间" prop="backTime" width="160" align="center">
<template #default="{ row }">
<span v-if="row.backTime">{{ formatDate(row.backTime) }}</span>
<span v-else class="no-time">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleReply(row)"
:disabled="!!row.backTime"
>
<el-icon><ChatDotRound /></el-icon>
{{ row.backTime ? '查看回复' : '回复' }}
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</div>
<!-- 回复对话框 -->
<FeedbackReply
v-model:visible="replyDialogVisible"
:feedback-data="currentFeedback"
:doctor-name="doctorName"
@success="handleReplySuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { ChatDotRound, Download, Search, RefreshLeft } from '@element-plus/icons-vue'
import { FeedbackApi, type FeedbackVO } from '@/api/feedback'
import FeedbackReply from './FeedbackReply.vue'
import { getUserProfile } from '@/api/system/user/profile'
//
const loading = ref(false)
const replyDialogVisible = ref(false)
const messageList = ref<FeedbackVO[]>([])
const total = ref(0)
const selectedIds = ref<number[]>([])
const currentFeedback = ref<FeedbackVO>({
id: 0,
content: '',
backContent: '',
userId: 0,
doctorName: '',
doctorId: 0,
backTime: Date.now(),
orgid: 0,
orgname: ''
})
const userProfile = ref<any>({})
//
const queryParams = reactive({
pageNo: 1,
pageSize: 20,
userId: '',
replyStatus: '',
backTimeRange: [] as string[],
backTime: [] as string[],
orgid: undefined as number | undefined
})
//
const queryFormRef = ref<FormInstance>()
//
const repliedCount = computed(() => {
return messageList.value.filter(item => item.backTime).length
})
const unrepliedCount = computed(() => {
return messageList.value.filter(item => !item.backTime).length
})
// 使使
const doctorName = computed(() => {
return userProfile.value?.nickname || userProfile.value?.username || '未知医生'
})
//
const getMessageList = async () => {
try {
loading.value = true
const params = { ...queryParams }
// - backTime
if (params.backTimeRange && params.backTimeRange.length === 2) {
//
const startDate = params.backTimeRange[0].includes(' ')
? params.backTimeRange[0]
: params.backTimeRange[0] + ' 00:00:00'
const endDate = params.backTimeRange[1].includes(' ')
? params.backTimeRange[1]
: params.backTimeRange[1] + ' 23:59:59'
params.backTime = [startDate, endDate]
} else {
params.backTime = []
}
params.backTimeRange = []
// orgid
if (userProfile.value?.dept?.id) {
params.orgid = userProfile.value.dept.id
}
const res = await FeedbackApi.getFeedbackPage(params)
// 访 - res
if (res) {
messageList.value = res.list || res.records || res.data || []
total.value = res.total || res.totalCount || 0
} else {
messageList.value = []
total.value = 0
}
} catch (error: any) {
console.error('获取留言列表失败:', error)
ElMessage.error('获取留言列表失败')
messageList.value = []
total.value = 0
} finally {
loading.value = false
}
}
//
const handleQuery = () => {
queryParams.pageNo = 1
getMessageList()
}
//
const handleReset = () => {
queryFormRef.value?.resetFields()
queryParams.pageNo = 1
getMessageList()
}
//
const handleExport = async () => {
try {
const exportParams = { ...queryParams }
// - backTime
if (exportParams.backTimeRange && exportParams.backTimeRange.length === 2) {
//
const startDate = exportParams.backTimeRange[0].includes(' ')
? exportParams.backTimeRange[0]
: exportParams.backTimeRange[0] + ' 00:00:00'
const endDate = exportParams.backTimeRange[1].includes(' ')
? exportParams.backTimeRange[1]
: exportParams.backTimeRange[1] + ' 23:59:59'
exportParams.backTime = [startDate, endDate]
} else {
exportParams.backTime = []
}
exportParams.backTimeRange = []
// orgid
if (userProfile.value?.dept?.id) {
exportParams.orgid = userProfile.value.dept.id
}
await FeedbackApi.exportFeedback(exportParams)
ElMessage.success('导出成功')
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败')
}
}
//
const handleSizeChange = (val: number) => {
queryParams.pageSize = val
getMessageList()
}
const handleCurrentChange = (val: number) => {
queryParams.pageNo = val
getMessageList()
}
//
const handleSelectionChange = (selection: FeedbackVO[]) => {
selectedIds.value = selection.map(item => item.id)
}
//
const handleReply = (row: FeedbackVO) => {
currentFeedback.value = { ...row }
replyDialogVisible.value = true
}
//
const handleReplySuccess = () => {
getMessageList()
}
//
const formatDate = (date: Date | string | number) => {
if (!date) return ''
let d: Date
if (typeof date === 'number') {
d = new Date(date)
} else {
d = new Date(date)
}
if (isNaN(d.getTime())) {
return ''
}
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
//
onMounted(async () => {
try {
//
userProfile.value = await getUserProfile()
queryParams.orgid = userProfile.value.dept.id
//
await getMessageList()
} catch (error) {
console.error('初始化失败:', error)
ElMessage.error('初始化失败')
}
})
</script>
<style scoped lang="scss">
.msgboard-container {
padding: 20px;
background: #f5f7fa;
height: 100%;
overflow-y: auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
padding: 16px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.15);
.header-content {
.page-title {
display: flex;
align-items: center;
font-size: 24px;
font-weight: 600;
margin: 0 0 6px 0;
.title-icon {
margin-right: 12px;
font-size: 28px;
}
}
.page-desc {
margin: 0;
opacity: 0.9;
font-size: 14px;
}
}
}
.search-section {
margin-bottom: 20px;
.search-card {
border-radius: 12px;
border: none;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
:deep(.el-card__body) {
padding: 16px 20px;
}
.search-form {
margin: 0;
:deep(.el-form-item) {
margin-bottom: 12px;
margin-right: 16px;
}
:deep(.el-form-item__label) {
padding-bottom: 4px;
}
}
}
}
.message-list-section {
.list-card {
border-radius: 12px;
border: none;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
:deep(.el-card__body) {
padding: 16px 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.header-stats {
display: flex;
gap: 8px;
}
}
:deep(.el-table) {
.el-table__body-wrapper {
overflow-x: auto;
}
}
}
}
.message-content {
.content-text {
margin-bottom: 8px;
line-height: 1.5;
color: #303133;
}
.content-meta {
display: flex;
gap: 8px;
}
}
.reply-content {
color: #67c23a;
font-weight: 500;
}
.no-reply {
color: #909399;
font-style: italic;
}
.no-doctor,
.no-time {
color: #c0c4cc;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ebeef5;
}
//
@media (max-width: 768px) {
.msgboard-container {
padding: 12px;
}
.page-header {
flex-direction: column;
gap: 16px;
text-align: center;
}
.search-form {
:deep(.el-form-item) {
margin-bottom: 12px;
}
}
}
</style>