feat(review-ui): 优化会议项目页与邮件状态并提升AI对话可读性

pull/874/head
Codewoc 2026-03-26 17:23:28 +08:00
parent 0e0124872c
commit 00f3b0f7ec
4 changed files with 234 additions and 289 deletions

View File

@ -152,6 +152,10 @@ export const sendMailInvitation = (id: number) =>
export const retrySmsLog = (smsLogId: number) =>
request.post({ url: '/project/review-meeting/retry-sms', params: { smsLogId } })
/** 手动重发失败邮件 */
export const retryMailLog = (mailLogId: number) =>
request.post({ url: '/project/review-meeting/retry-mail', params: { mailLogId } })
/** 获取短信发送状态列表 */
export const getSmsLogList = (reviewMeetingId: number) =>
request.get({ url: '/project/review-meeting/sms-log-list', params: { reviewMeetingId } })

View File

@ -11,13 +11,33 @@
<el-table-column label="重发次数" prop="retryCount" width="90" align="center" />
<el-table-column label="最后发送时间" prop="sendTime" width="170" />
<el-table-column label="失败原因" prop="errorMsg" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<el-button
v-if="row.status === 2"
v-hasPermi="['review:meeting:send-mail']"
type="primary"
link
:loading="retryingIds.has(row.id)"
@click="handleRetry(row)"
>
重新发送
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { getMailLogList, type ReviewMeetingMailLogRespVO } from '@/api/review/meeting'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getMailLogList,
retryMailLog,
type ReviewMeetingMailLogRespVO
} from '@/api/review/meeting'
defineOptions({ name: 'MailStatusDialog' })
@ -26,6 +46,7 @@ const loading = ref(false)
const meetingName = ref('')
const reviewMeetingId = ref<number>()
const logList = ref<ReviewMeetingMailLogRespVO[]>([])
const retryingIds = ref<Set<number>>(new Set())
const MAIL_STATUS_LABEL: Record<number, string> = { 0: '待发送', 1: '成功', 2: '失败', 3: '已忽略' }
const MAIL_STATUS_TYPE: Record<number, string> = { 0: 'info', 1: 'success', 2: 'danger', 3: 'warning' }
@ -46,5 +67,21 @@ const loadData = async () => {
}
}
const handleRetry = async (row: ReviewMeetingMailLogRespVO) => {
await ElMessageBox.confirm(
`确认重新发送给 ${row.expertName || row.mail} 的邮件邀请函?`,
'重发确认',
{ type: 'warning', confirmButtonText: '确认重发' }
)
retryingIds.value.add(row.id)
try {
await retryMailLog(row.id)
ElMessage.success('重发任务已触发')
await loadData()
} finally {
retryingIds.value.delete(row.id)
}
}
defineExpose({ open })
</script>

View File

@ -4,55 +4,37 @@
<div class="page-title">项目列表</div>
<!-- 会议摘要信息 -->
<div class="meeting-summary-bar">
<span class="summary-label">所属会议</span>
<span class="summary-meeting-name">{{ meetingInfo.name || '-' }}</span>
<span class="summary-sep">|</span>
<span class="summary-label">会议时间</span>
<span class="summary-value">
{{ meetingInfo.startTime ? formatDate(meetingInfo.startTime, 'YYYY-MM-DD HH:mm') : '-' }}
&nbsp;~&nbsp;
{{ meetingInfo.endTime ? formatDate(meetingInfo.endTime, 'YYYY-MM-DD HH:mm') : '-' }}
</span>
<span class="summary-sep">|</span>
<span class="summary-label">会议地点</span>
<span class="summary-value">{{ meetingInfo.location || '-' }}</span>
<span class="summary-sep">|</span>
<span class="summary-label">状态</span>
<span :class="`status-text status-${meetingInfo.status}`">{{ STATUS_LABEL[meetingInfo.status] }}</span>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="queryParams.projectTitle"
placeholder="项目标题"
clearable
class="search-input"
@keyup.enter="handleQuery"
/>
<el-input
v-model="queryParams.reporter"
placeholder="报告人"
clearable
class="search-input"
@keyup.enter="handleQuery"
/>
<el-select v-model="queryParams.agendaCategory" placeholder="议程分类" clearable class="search-input">
<el-option v-for="item in REVIEW_AGENDA_CATEGORY_OPTIONS" :key="item" :label="item" :value="item" />
</el-select>
<button class="btn-reset" @click="resetQuery"></button>
<button class="btn-search" @click="handleQuery"></button>
</div>
<!-- 操作按钮 -->
<div class="toolbar">
<button class="btn-default" @click="openForm('create')">
<span class="btn-icon">+</span> 新增评审项目
</button>
<button class="btn-default btn-danger" :disabled="selectedIds.length === 0" @click="handleDelete()">
批量删除
</button>
<div class="meeting-info-card">
<div class="meeting-info-head">
<div class="meeting-title-wrap">
<div class="meeting-caption">所属会议</div>
<div class="meeting-name">{{ meetingInfo.name || '-' }}</div>
<div class="meeting-time">
{{ meetingInfo.startTime ? formatDate(meetingInfo.startTime, 'YYYY-MM-DD HH:mm') : '-' }}
<span class="meeting-time-sep">~</span>
{{ meetingInfo.endTime ? formatDate(meetingInfo.endTime, 'YYYY-MM-DD HH:mm') : '-' }}
</div>
</div>
<span :class="`status-pill status-pill-${meetingInfo.status}`">{{ STATUS_LABEL[meetingInfo.status] || '-' }}</span>
</div>
<div class="meeting-meta-grid">
<div class="meta-item">
<span class="meta-label">会议地点</span>
<span class="meta-value">{{ meetingInfo.location || '-' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">组织单位</span>
<span class="meta-value">{{ meetingInfo.organizationUnit || '-' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">会议主持人</span>
<span class="meta-value">{{ meetingInfo.host || '-' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">参会专家数</span>
<span class="meta-value">{{ meetingInfo.expertCount ?? 0 }}</span>
</div>
</div>
</div>
<!-- 列表 -->
@ -64,9 +46,7 @@
size="small"
border
class="review-table"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="46" align="center" />
<el-table-column label="拖拽" width="54" align="center">
<template #default>
<span class="drag-handle" title="拖拽排序"></span>
@ -195,49 +175,6 @@
</el-table-column>
</el-table>
<!-- 编辑/新增项目弹窗 -->
<el-dialog v-model="formVisible" :title="formType === 'create' ? '新增评审项目' : '编辑评审项目'" width="550px">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="序号" prop="seqNo">
<el-input-number v-model="formData.seqNo" :min="1" />
</el-form-item>
<el-form-item label="时间范围">
<div style="display: flex; gap: 10px; width: 100%;">
<el-time-picker v-model="formData.startTime" format="HH:mm" value-format="HH:mm" placeholder="开始时间" style="flex: 1" />
<span>-</span>
<el-time-picker v-model="formData.endTime" format="HH:mm" value-format="HH:mm" placeholder="结束时间" style="flex: 1" />
</div>
</el-form-item>
<el-form-item label="议程分类" prop="agendaCategory">
<el-select v-model="formData.agendaCategory" placeholder="请选择议程分类" style="width: 100%">
<el-option v-for="item in REVIEW_AGENDA_CATEGORY_OPTIONS" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="项目标题" prop="projectTitle">
<el-input v-model="formData.projectTitle" placeholder="请输入项目标题" />
</el-form-item>
<el-form-item label="汇报人" prop="reporter">
<el-input v-model="formData.reporter" placeholder="请输入汇报人" />
</el-form-item>
<el-form-item label="报告单位" prop="reporterUnit">
<el-input v-model="formData.reporterUnit" placeholder="请输入报告单位" />
</el-form-item>
<el-form-item label="评审日期" prop="reviewDate">
<el-date-picker v-model="formData.reviewDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择评审日期" style="width: 100%" />
</el-form-item>
<el-form-item label="评审结果" prop="reviewResult">
<el-select v-model="formData.reviewResult" placeholder="请选择评审结果" clearable style="width: 100%">
<el-option label="通过" value="PASS" />
<el-option label="不通过" value="REJECT" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false"> </el-button>
<el-button type="primary" :loading="formLoading" @click="submitForm"> </el-button>
</template>
</el-dialog>
<!-- 分页 -->
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" @pagination="getList" />
</ContentWrap>
@ -254,7 +191,6 @@ import {
getReviewProjectPage,
updateReviewProject,
updateReviewProjectSeqBatch,
createReviewProject,
deleteReviewProject,
type ReviewMeetingProjectRespVO
} from '@/api/review/project'
@ -295,10 +231,7 @@ const inlineSnapshotMap = ref<Record<number, InlineEditableFields>>({})
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
reviewMeetingId,
projectTitle: undefined as string | undefined,
agendaCategory: undefined as string | undefined,
reporter: undefined as string | undefined
reviewMeetingId
})
const getList = async () => {
@ -397,11 +330,6 @@ const initSortable = () => {
}
const handleSortEnd = async (oldIndex: number, newIndex: number) => {
if (queryParams.projectTitle || queryParams.agendaCategory || queryParams.reporter) {
ElMessage.warning('请先清空筛选条件后再拖拽排序')
await getList()
return
}
const moved = list.value.splice(oldIndex, 1)[0]
if (!moved) return
list.value.splice(newIndex, 0, moved)
@ -446,68 +374,13 @@ const handleSortEnd = async (oldIndex: number, newIndex: number) => {
}
}
const handleQuery = () => { queryParams.pageNo = 1; getList() }
const resetQuery = () => {
queryParams.projectTitle = undefined
queryParams.agendaCategory = undefined
queryParams.reporter = undefined
handleQuery()
}
const selectedIds = ref<number[]>([])
const handleSelectionChange = (val: ReviewMeetingProjectRespVO[]) => {
selectedIds.value = val.map(v => v.id)
}
const handleDelete = async (id?: number) => {
const ids = id ? [id] : selectedIds.value
if (ids.length === 0) return
await ElMessageBox.confirm(`确认删除选中的 ${ids.length} 个项目吗?`, '警告', { type: 'warning' })
await deleteReviewProject(ids)
const handleDelete = async (id: number) => {
await ElMessageBox.confirm('确认删除该项目吗?', '警告', { type: 'warning' })
await deleteReviewProject([id])
ElMessage.success('删除成功')
getList()
}
const formVisible = ref(false)
const formType = ref<'create' | 'update'>('create')
const formLoading = ref(false)
const formRef = ref()
const formData = reactive<Partial<ReviewMeetingProjectRespVO>>({})
const formRules = {
agendaCategory: [{ required: true, message: '请选择议程分类', trigger: 'change' }],
projectTitle: [{ required: true, message: '项目标题不能为空', trigger: 'blur' }]
}
const openForm = (type: 'create' | 'update', row?: ReviewMeetingProjectRespVO) => {
formType.value = type
if (type === 'create') {
Object.keys(formData).forEach(key => delete formData[key as keyof ReviewMeetingProjectRespVO])
formData.reviewMeetingId = reviewMeetingId
} else if (row) {
Object.assign(formData, row)
}
formVisible.value = true
}
const submitForm = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
formLoading.value = true
try {
if (formType.value === 'create') {
await createReviewProject(formData)
ElMessage.success('新增成功')
} else {
await updateReviewProject(formData)
ElMessage.success('修改成功')
}
formVisible.value = false
getList()
} finally {
formLoading.value = false
}
}
const goToDetail = (row: ReviewMeetingProjectRespVO) => {
router.push({
name: 'ReviewProjectDetail',
@ -548,42 +421,93 @@ onBeforeUnmount(() => {
border-bottom: 1px solid #e1e7f0;
}
/* ── 会议摘要栏 ── */
.meeting-summary-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 10px 14px;
background-color: rgba(41, 90, 188, 0.05);
border: 1px solid rgba(41, 90, 188, 0.12);
border-radius: 6px;
/* ── 会议信息卡 ── */
.meeting-info-card {
padding: 16px;
background: linear-gradient(180deg, rgba(41, 90, 188, 0.1) 0%, rgba(41, 90, 188, 0.04) 100%);
border: 1px solid rgba(41, 90, 188, 0.16);
border-radius: 10px;
margin-bottom: 16px;
font-size: 14px;
color: #333;
}
.summary-label {
color: #666;
.meeting-info-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.summary-meeting-name {
.meeting-title-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.meeting-caption {
color: #4f6d9f;
font-size: 12px;
margin: 0;
}
.meeting-name {
color: #295abc;
font-size: 20px;
line-height: 1.3;
font-weight: 600;
margin: 0;
}
.meeting-time {
margin: 0;
color: #2f3f5e;
font-size: 14px;
font-weight: 500;
}
.summary-value {
color: #333;
.meeting-time-sep {
margin: 0 6px;
color: #7d91b4;
}
.summary-sep {
color: #ddd;
margin: 0 4px;
.status-pill {
align-self: flex-start;
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
}
.status-pill-0 {
color: #b57616;
background: rgba(236, 174, 75, 0.2);
}
.status-pill-1 {
color: #267d1e;
background: rgba(115, 192, 71, 0.2);
}
.status-pill-2,
.status-pill-3 {
color: #596b89;
background: rgba(125, 145, 180, 0.16);
}
.meeting-meta-grid {
display: grid;
gap: 10px 14px;
grid-template-columns: repeat(4, minmax(140px, 1fr));
}
.meta-item {
background: #fff;
border: 1px solid rgba(41, 90, 188, 0.1);
border-radius: 8px;
padding: 10px 12px;
}
.meta-label {
display: block;
color: #6a7f9f;
font-size: 12px;
margin-bottom: 4px;
}
.meta-value {
display: block;
color: #20314f;
font-size: 14px;
font-weight: 600;
}
/* ── 状态文字 ── */
.status-text { font-size: 14px; font-weight: 500; }
.status-0 { color: #ecae4b; }
.status-1 { color: #73c047; }
.status-2 { color: #999; }
.status-3 { color: #999; }
.review-result-pass {
color: #67c23a;
font-weight: 500;
@ -603,100 +527,6 @@ onBeforeUnmount(() => {
font-size: 16px;
}
/* ── 搜索栏 ── */
.search-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.search-input {
width: 180px;
}
:deep(.search-input .el-input__wrapper) {
height: 40px;
border-radius: 6px;
border-color: #dcdedf;
}
:deep(.search-input .el-input__inner) {
font-size: 15px;
color: #333;
}
.btn-search {
height: 40px;
padding: 0 22px;
background-color: #295abc;
color: #fff;
border: none;
border-radius: 6px;
font-size: 15px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-search:hover { background-color: rgba(41, 90, 188, 0.88); }
.btn-reset {
height: 40px;
padding: 0 8px;
background: none;
border: none;
color: #295abc;
font-size: 15px;
cursor: pointer;
}
.btn-reset:hover { opacity: 0.8; }
/* ── 工具栏 ── */
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.btn-default {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 16px;
background-color: #fff;
border: 1px solid #d5d5d5;
border-radius: 6px;
font-size: 14px;
color: #333;
cursor: pointer;
transition: all 0.2s;
}
.btn-default:hover {
background-color: rgba(41, 90, 188, 0.08);
border-color: #295abc;
color: #295abc;
}
.btn-default:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-danger {
border-color: #fc4f54;
color: #fc4f54;
}
.btn-danger:hover {
background-color: rgba(252, 79, 84, 0.08);
border-color: #fc4f54;
color: #fc4f54;
}
.btn-icon {
font-size: 16px;
line-height: 1;
}
/* ── 项目名称 ── */
.project-name-text {
color: #295abc;
font-size: 14px;
}
.inline-time-range {
display: flex;
align-items: center;
@ -781,4 +611,15 @@ onBeforeUnmount(() => {
:deep(.drag-chosen > td) {
background-color: rgba(41, 90, 188, 0.06);
}
@media (max-width: 1200px) {
.meeting-meta-grid {
grid-template-columns: repeat(2, minmax(140px, 1fr));
}
}
@media (max-width: 768px) {
.meeting-meta-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -206,7 +206,12 @@
>
<div class="msg-bubble">
<span class="msg-role">{{ msg.type === 'user' ? '您' : 'AI' }}</span>
<div class="msg-content" v-html="renderMsgContent(msg.content)"></div>
<div class="msg-content">
<div
class="md-content"
v-html="msg.type === 'user' ? renderUserMsgContent(msg.content) : renderAiMsgContent(msg.content)"
></div>
</div>
</div>
</div>
<!-- 流式生成中的气泡 -->
@ -215,7 +220,7 @@
<span class="msg-role">AI</span>
<div class="msg-content">
<span v-if="streamingLoading && !streamingText" class="streaming-dot"></span>
<span v-else>{{ streamingText }}</span>
<div v-else class="md-content" v-html="renderAiMsgContent(streamingText)"></div>
</div>
</div>
</div>
@ -274,6 +279,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { Document, Folder, InfoFilled, Link, Loading, Refresh, WarningFilled } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import MarkdownIt from 'markdown-it'
import { formatDate } from '@/utils/formatTime'
import {
getFileOpenUrl,
@ -776,9 +782,23 @@ const scheduleOfficeWarmup = (files: ReviewMeetingFileRespVO[]) => {
})
}
const renderMsgContent = (text: string) =>
text.replace(/\n/g, '<br/>').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/&lt;br\/&gt;/g, '<br/>')
const markdown = new MarkdownIt({
html: false,
linkify: true,
breaks: true
})
const escapeHtml = (text: string) =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
const renderUserMsgContent = (text: string) =>
escapeHtml(text || '').replace(/\n/g, '<br/>')
const renderAiMsgContent = (text: string) =>
markdown.render(text || '')
loadCatalog()
</script>
@ -1003,9 +1023,52 @@ loadCatalog()
word-break: break-word;
}
.user-msg .msg-content { background: #2563eb; color: #fff; border-bottom-right-radius: 3px; }
.ai-msg .msg-content { background: #f3f4f6; color: #1f2937; border-bottom-left-radius: 3px; }
.ai-msg .msg-content { background: #f3f4f6; color: #1f2937; border-bottom-left-radius: 3px; overflow-x: auto; }
.streaming-dot { color: #9ca3af; }
.msg-content :deep(.md-content > *:first-child) { margin-top: 0; }
.msg-content :deep(.md-content > *:last-child) { margin-bottom: 0; }
.msg-content :deep(p) { margin: 0 0 6px; }
.msg-content :deep(ul),
.msg-content :deep(ol) { margin: 0 0 6px 18px; padding: 0; }
.msg-content :deep(li) { margin: 0 0 4px; }
.msg-content :deep(code) {
padding: 1px 4px;
border-radius: 4px;
font-size: 12px;
background: rgba(15, 23, 42, 0.08);
}
.msg-content :deep(pre) {
margin: 6px 0;
padding: 8px;
border-radius: 8px;
overflow-x: auto;
background: rgba(15, 23, 42, 0.06);
}
.msg-content :deep(pre code) {
padding: 0;
border-radius: 0;
background: transparent;
}
.msg-content :deep(table) {
width: 100%;
min-width: 560px;
border-collapse: collapse;
margin: 6px 0;
background: #fff;
}
.msg-content :deep(th),
.msg-content :deep(td) {
padding: 6px 8px;
text-align: left;
border: 1px solid #d1d5db;
vertical-align: top;
}
.msg-content :deep(th) { background: #f8fafc; font-weight: 600; }
.user-msg .msg-content :deep(code) { background: rgba(255, 255, 255, 0.18); }
.user-msg .msg-content :deep(a) { color: #dbeafe; }
.ai-msg .msg-content :deep(a) { color: #1d4ed8; }
/* 快捷问题 */
.quick-questions {
flex-shrink: 0;