1068 lines
35 KiB
Vue
1068 lines
35 KiB
Vue
<template>
|
||
<div class="tablet-full-page" v-loading="pageLoading">
|
||
<!-- ── 顶部工具栏 ── -->
|
||
<header class="page-toolbar">
|
||
<div class="title-wrap">
|
||
<h2>专家评审资料</h2>
|
||
<span>当日项目编目 · {{ projectCount }} 项</span>
|
||
</div>
|
||
<div class="tool-wrap">
|
||
<el-input
|
||
v-model="keyword"
|
||
clearable
|
||
placeholder="搜索项目/资料"
|
||
class="search-input"
|
||
/>
|
||
<el-button :icon="Refresh" @click="loadCatalog">刷新</el-button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ── 主体三栏 ── -->
|
||
<div class="page-body" :class="{ 'ai-open': aiPanelOpen && !!activeProjectId }">
|
||
|
||
<!-- 左:项目目录 -->
|
||
<aside class="directory-panel">
|
||
<div class="panel-title">项目目录</div>
|
||
<el-empty v-if="treeData.length === 0" description="当前时段暂无可查看资料" />
|
||
<el-tree
|
||
v-else
|
||
ref="treeRef"
|
||
class="catalog-tree"
|
||
:data="treeData"
|
||
node-key="id"
|
||
highlight-current
|
||
:expand-on-click-node="false"
|
||
:default-expanded-keys="expandedKeys"
|
||
:props="treeProps"
|
||
:filter-node-method="filterNode"
|
||
@node-expand="handleNodeExpand"
|
||
@node-collapse="handleNodeCollapse"
|
||
@node-click="handleNodeClick"
|
||
>
|
||
<template #default="{ data }">
|
||
<div v-if="data.type === 'project'" class="tree-project">
|
||
<div class="node-left">
|
||
<el-icon class="node-icon folder-icon"><Folder /></el-icon>
|
||
<span class="node-name">{{ data.label }}</span>
|
||
</div>
|
||
<el-tag size="small" type="info">{{ data.fileCount }} 份</el-tag>
|
||
</div>
|
||
<div v-else class="tree-file">
|
||
<div class="node-left">
|
||
<el-icon class="node-icon file-icon"><Document /></el-icon>
|
||
<span class="node-name">{{ data.label }}</span>
|
||
</div>
|
||
<span class="file-type">{{ data.fileType?.toUpperCase() }}</span>
|
||
</div>
|
||
</template>
|
||
</el-tree>
|
||
</aside>
|
||
|
||
<!-- 中:资料预览 -->
|
||
<section class="preview-panel">
|
||
<div class="preview-header">
|
||
<div class="preview-meta">
|
||
<div class="preview-title">{{ activeFileName || '请选择左侧资料进行预览' }}</div>
|
||
<div class="preview-sub">{{ activeFileDesc }}</div>
|
||
</div>
|
||
<div class="preview-actions">
|
||
<el-button :disabled="!activeFileId" @click="refreshPreview">刷新预览</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:icon="Link"
|
||
:disabled="!previewUrl"
|
||
@click="openInNewWindow"
|
||
>
|
||
新窗口打开
|
||
</el-button>
|
||
<!-- AI 助手开关 -->
|
||
<el-button
|
||
v-if="activeProjectId"
|
||
:type="aiPanelOpen ? 'primary' : 'default'"
|
||
@click="toggleAiPanel"
|
||
>
|
||
🤖 AI 助手
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-body" v-loading="previewLoading">
|
||
<div v-if="!activeFileId && !previewLoading" class="welcome-panel">
|
||
<div class="welcome-title">欢迎各位专家莅临指导工作</div>
|
||
<div class="welcome-sub">请从左侧目录中选择评审资料开始查阅</div>
|
||
</div>
|
||
<el-result v-else-if="previewError" icon="error" title="预览加载失败" :sub-title="previewError">
|
||
<template #extra>
|
||
<el-button type="primary" :disabled="!activeFileId" @click="refreshPreview">重试</el-button>
|
||
<el-button :disabled="!previewUrl" @click="openInNewWindow">新窗口打开</el-button>
|
||
</template>
|
||
</el-result>
|
||
<template v-else-if="previewUrl">
|
||
<iframe
|
||
:key="previewFrameKey"
|
||
:src="previewUrl"
|
||
class="preview-iframe"
|
||
frameborder="0"
|
||
allowfullscreen
|
||
@load="handlePreviewFrameLoad"
|
||
@error="handlePreviewFrameError"
|
||
></iframe>
|
||
<div v-if="previewFrameLoading && !previewError" class="preview-loading-mask">
|
||
<div class="welcome-title">{{ previewLoadingTitle }}</div>
|
||
<div class="welcome-sub">{{ previewLoadingSubtitle }}</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 右:AI 助手面板 -->
|
||
<aside v-if="aiPanelOpen && activeProjectId" class="ai-panel">
|
||
<!-- 面板头部 -->
|
||
<div class="ai-header">
|
||
<div class="ai-header-left">
|
||
<span class="ai-title">🤖 AI 助手</span>
|
||
<span
|
||
class="ai-status-badge"
|
||
:class="`status-${aiSummary?.status ?? 0}`"
|
||
>{{ aiSummary?.statusName ?? '未生成' }}</span>
|
||
</div>
|
||
<div class="ai-header-right">
|
||
<el-tooltip content="清空当前项目问答记录" placement="top">
|
||
<el-button
|
||
size="small"
|
||
text
|
||
:disabled="!aiConversationId || aiChatLoading"
|
||
@click="handleClearMessages"
|
||
>清空对话</el-button>
|
||
</el-tooltip>
|
||
<el-button size="small" text @click="aiPanelOpen = false">✕</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 摘要区 -->
|
||
<div class="ai-summary-wrap" v-loading="aiSummaryLoading">
|
||
<!-- 未生成 -->
|
||
<div v-if="!aiSummary || aiSummary.status === 0" class="ai-summary-empty">
|
||
<el-icon><InfoFilled /></el-icon>
|
||
<span>当前项目尚未生成 AI 摘要,请上传资料后自动触发生成</span>
|
||
</div>
|
||
<!-- 生成中 -->
|
||
<div v-else-if="aiSummary.status === 1" class="ai-summary-building">
|
||
<el-icon class="is-loading"><Loading /></el-icon>
|
||
<span>AI 摘要生成中,请稍候…</span>
|
||
</div>
|
||
<!-- 失败 -->
|
||
<div v-else-if="aiSummary.status === 3" class="ai-summary-failed">
|
||
<el-icon><WarningFilled /></el-icon>
|
||
<span>摘要生成失败:{{ aiSummary.errorMessage || '未知错误' }}</span>
|
||
</div>
|
||
<!-- 已完成 -->
|
||
<div v-else-if="aiSummary.status === 2 && aiSummary.summary" class="ai-summary-card">
|
||
<div class="summary-updated">
|
||
最后更新:{{ aiSummary.updatedTime ? formatDate(new Date(aiSummary.updatedTime), 'YYYY-MM-DD HH:mm') : '-' }}
|
||
</div>
|
||
<div class="summary-field">
|
||
<span class="field-label">项目概述</span>
|
||
<span class="field-value">{{ aiSummary.summary.projectOverview }}</span>
|
||
</div>
|
||
<div class="summary-field">
|
||
<span class="field-label">建设目标</span>
|
||
<span class="field-value">{{ aiSummary.summary.businessGoal }}</span>
|
||
</div>
|
||
<div class="summary-field">
|
||
<span class="field-label">建设范围</span>
|
||
<span class="field-value">{{ aiSummary.summary.constructionScope }}</span>
|
||
</div>
|
||
<div class="summary-field">
|
||
<span class="field-label">实施计划</span>
|
||
<span class="field-value">{{ aiSummary.summary.implementationPlan }}</span>
|
||
</div>
|
||
<div class="summary-field">
|
||
<span class="field-label">预算信息</span>
|
||
<span class="field-value">{{ aiSummary.summary.budgetInfo }}</span>
|
||
</div>
|
||
<div v-if="aiSummary.summary.sourceFiles?.length" class="summary-field">
|
||
<span class="field-label">来源文件</span>
|
||
<span class="field-value">{{ aiSummary.summary.sourceFiles.join(';') }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分隔线 -->
|
||
<div class="ai-divider" v-if="aiConversationId || aiSummary?.status === 2"></div>
|
||
|
||
<!-- 对话区(仅摘要 SUCCESS 后才能对话) -->
|
||
<template v-if="aiSummary?.status === 2">
|
||
<!-- 消息列表 -->
|
||
<div class="ai-messages" ref="aiMessagesRef">
|
||
<div v-if="aiChatLoading && aiMessages.length === 0" class="ai-messages-loading">
|
||
<el-icon class="is-loading"><Loading /></el-icon> 加载中…
|
||
</div>
|
||
<div
|
||
v-for="msg in aiMessages"
|
||
:key="msg.id"
|
||
class="chat-msg"
|
||
:class="msg.type === 'user' ? 'user-msg' : 'ai-msg'"
|
||
>
|
||
<div class="msg-bubble">
|
||
<span class="msg-role">{{ msg.type === 'user' ? '您' : 'AI' }}</span>
|
||
<div class="msg-content" v-html="renderMsgContent(msg.content)"></div>
|
||
</div>
|
||
</div>
|
||
<!-- 流式生成中的气泡 -->
|
||
<div v-if="streamingText || streamingLoading" class="chat-msg ai-msg">
|
||
<div class="msg-bubble">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 快捷问题 -->
|
||
<div class="quick-questions">
|
||
<button
|
||
v-for="q in QUICK_QUESTIONS"
|
||
:key="q"
|
||
class="quick-btn"
|
||
:disabled="streamingLoading"
|
||
@click="sendQuestion(q)"
|
||
>{{ q }}</button>
|
||
</div>
|
||
|
||
<!-- 输入框 -->
|
||
<div class="ai-input-wrap">
|
||
<el-input
|
||
v-model="chatInput"
|
||
type="textarea"
|
||
:rows="2"
|
||
resize="none"
|
||
placeholder="输入问题,基于当前项目资料回答…"
|
||
:disabled="streamingLoading"
|
||
@keydown.enter.exact.prevent="handleSend"
|
||
/>
|
||
<div class="ai-input-actions">
|
||
<el-button
|
||
v-if="streamingLoading"
|
||
type="danger"
|
||
size="small"
|
||
@click="stopStream"
|
||
>停止</el-button>
|
||
<el-button
|
||
v-else
|
||
type="primary"
|
||
size="small"
|
||
:disabled="!chatInput.trim()"
|
||
@click="handleSend"
|
||
>发送</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 摘要未就绪时提示 -->
|
||
<div v-else-if="aiSummary && aiSummary.status !== 2" class="ai-chat-unavail">
|
||
AI 摘要就绪后才能开始对话
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
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 { formatDate } from '@/utils/formatTime'
|
||
import {
|
||
getFileOpenUrl,
|
||
getProjectFiles,
|
||
getTodayCatalog,
|
||
type ReviewTabletCatalogVO,
|
||
type ReviewTabletOpenUrlVO
|
||
} from '@/api/review/tablet'
|
||
import type { ReviewMeetingFileRespVO } from '@/api/review/project'
|
||
import {
|
||
AI_SUMMARY_STATUS,
|
||
clearProjectAiMessages,
|
||
getProjectAiSummary,
|
||
openProjectAiConversation,
|
||
sendProjectAiChatStream,
|
||
type ReviewAiSummaryVO
|
||
} from '@/api/review/ai'
|
||
import { ChatMessageApi, type ChatMessageVO } from '@/api/ai/chat/message/index'
|
||
|
||
defineOptions({ name: 'ReviewTablet' })
|
||
|
||
// ── 快捷问题 ──────────────────────────────────────────────────
|
||
const QUICK_QUESTIONS = [
|
||
'这个项目要解决什么问题?',
|
||
'建设范围包括哪些内容?',
|
||
'实施计划是怎样的?',
|
||
'预算或投入信息有哪些?'
|
||
]
|
||
|
||
// ── 类型 ──────────────────────────────────────────────────────
|
||
type TreeNode = ProjectTreeNode | FileTreeNode
|
||
|
||
interface ProjectTreeNode {
|
||
id: string
|
||
type: 'project'
|
||
label: string
|
||
projectId: number
|
||
meetingName: string
|
||
fileCount: number
|
||
seqNo: number
|
||
loaded: boolean
|
||
loading: boolean
|
||
source: ReviewTabletCatalogVO
|
||
children: FileTreeNode[]
|
||
}
|
||
|
||
interface FileTreeNode {
|
||
id: string
|
||
type: 'file'
|
||
label: string
|
||
projectId: number
|
||
fileId: number
|
||
fileType: string
|
||
fileSize: number
|
||
createTime: string
|
||
source: ReviewMeetingFileRespVO
|
||
}
|
||
|
||
// ── 目录 & 预览 state ─────────────────────────────────────────
|
||
const pageLoading = ref(false)
|
||
const previewLoading = ref(false)
|
||
const previewError = ref('')
|
||
const keyword = ref('')
|
||
|
||
const treeRef = ref<any>()
|
||
const treeData = ref<ProjectTreeNode[]>([])
|
||
const expandedKeys = ref<string[]>([])
|
||
const fileCache = ref<Record<number, ReviewMeetingFileRespVO[]>>({})
|
||
|
||
const activeProjectId = ref<number>()
|
||
const activeFileId = ref<number>()
|
||
const activeFileNode = ref<FileTreeNode>()
|
||
const previewUrl = ref('')
|
||
const previewPayload = ref<ReviewTabletOpenUrlVO>()
|
||
const previewFrameLoading = ref(false)
|
||
const previewFrameKey = ref(0)
|
||
const previewRequestToken = ref(0)
|
||
|
||
const treeProps = { label: 'label', children: 'children' }
|
||
|
||
const projectCount = computed(() => treeData.value.length)
|
||
const activeFileName = computed(() => activeFileNode.value?.label || previewPayload.value?.fileName || '')
|
||
const activeFileDesc = computed(() => {
|
||
if (!activeFileNode.value) return ''
|
||
const segs: string[] = []
|
||
if (activeFileNode.value.fileType) segs.push(activeFileNode.value.fileType.toUpperCase())
|
||
if (activeFileNode.value.fileSize) segs.push(formatFileSize(activeFileNode.value.fileSize))
|
||
if (activeFileNode.value.createTime)
|
||
segs.push(`上传于 ${formatDate(new Date(activeFileNode.value.createTime), 'YYYY-MM-DD HH:mm')}`)
|
||
return segs.join(' · ')
|
||
})
|
||
const previewLoadingTitle = computed(() =>
|
||
isOfficeFile(activeFileNode.value?.fileType) ? '正在转换文档预览' : '正在加载预览'
|
||
)
|
||
const previewLoadingSubtitle = computed(() =>
|
||
isOfficeFile(activeFileNode.value?.fileType)
|
||
? 'Office 文件首次打开可能需要一些时间,请稍候'
|
||
: '正在为您打开当前资料'
|
||
)
|
||
|
||
// ── AI 面板 state ─────────────────────────────────────────────
|
||
const aiPanelOpen = ref(false)
|
||
const aiSummaryLoading = ref(false)
|
||
const aiSummary = ref<ReviewAiSummaryVO | null>(null)
|
||
const aiConversationId = ref<number | null>(null)
|
||
const aiMessages = ref<ChatMessageVO[]>([])
|
||
const aiChatLoading = ref(false)
|
||
const chatInput = ref('')
|
||
const streamingText = ref('')
|
||
const streamingLoading = ref(false)
|
||
const aiMessagesRef = ref<HTMLElement>()
|
||
let streamAbortCtrl: AbortController | null = null
|
||
let summaryPollingTimer: ReturnType<typeof setTimeout> | null = null
|
||
let officeWarmupTimers: ReturnType<typeof setTimeout>[] = []
|
||
let previewFrameTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
// ── 搜索过滤 ──────────────────────────────────────────────────
|
||
watch(keyword, (val) => treeRef.value?.filter(val.trim()))
|
||
|
||
const filterNode = (value: string, data: TreeNode): boolean => {
|
||
if (!value) return true
|
||
const kw = value.toLowerCase()
|
||
if (data.type === 'project') {
|
||
return [data.label, data.meetingName].join('|').toLowerCase().includes(kw)
|
||
|| data.children.some((f) => f.label.toLowerCase().includes(kw))
|
||
}
|
||
return [data.label, data.fileType].join('|').toLowerCase().includes(kw)
|
||
}
|
||
|
||
// ── 树节点构建 ────────────────────────────────────────────────
|
||
const buildProjectNode = (item: ReviewTabletCatalogVO): ProjectTreeNode => ({
|
||
id: `project-${item.reviewMeetingProjectId}`,
|
||
type: 'project',
|
||
label: item.projectTitle,
|
||
projectId: item.reviewMeetingProjectId,
|
||
meetingName: item.meetingName,
|
||
fileCount: item.fileCount,
|
||
seqNo: item.seqNo,
|
||
loaded: false,
|
||
loading: false,
|
||
source: item,
|
||
children: []
|
||
})
|
||
|
||
const toFileNode = (file: ReviewMeetingFileRespVO, projectId: number): FileTreeNode => ({
|
||
id: `file-${projectId}-${file.id}`,
|
||
type: 'file',
|
||
label: file.fileName,
|
||
projectId,
|
||
fileId: file.id,
|
||
fileType: file.fileType,
|
||
fileSize: file.fileSize,
|
||
createTime: file.createTime,
|
||
source: file
|
||
})
|
||
|
||
// ── 文件加载 ──────────────────────────────────────────────────
|
||
const loadProjectFiles = async (projectNode: ProjectTreeNode) => {
|
||
if (projectNode.loaded || projectNode.loading) return
|
||
const cached = fileCache.value[projectNode.projectId]
|
||
if (cached) {
|
||
projectNode.children = cached.map((f) => toFileNode(f, projectNode.projectId))
|
||
projectNode.loaded = true
|
||
return
|
||
}
|
||
projectNode.loading = true
|
||
try {
|
||
const files = await getProjectFiles(projectNode.projectId)
|
||
fileCache.value[projectNode.projectId] = files
|
||
projectNode.children = files.map((f) => toFileNode(f, projectNode.projectId))
|
||
projectNode.loaded = true
|
||
scheduleOfficeWarmup(files)
|
||
} catch {
|
||
ElMessage.error('加载项目资料失败,请稍后重试')
|
||
throw new Error('load-project-files-failed')
|
||
} finally {
|
||
projectNode.loading = false
|
||
}
|
||
}
|
||
|
||
// ── 预览 ──────────────────────────────────────────────────────
|
||
const loadPreview = async (fileId: number) => {
|
||
const requestToken = ++previewRequestToken.value
|
||
clearPreviewFrameTimeout()
|
||
previewLoading.value = true
|
||
previewFrameLoading.value = true
|
||
previewError.value = ''
|
||
previewUrl.value = ''
|
||
previewPayload.value = undefined
|
||
previewFrameKey.value += 1
|
||
try {
|
||
const payload = await getFileOpenUrl(fileId)
|
||
if (requestToken !== previewRequestToken.value) return
|
||
previewPayload.value = payload
|
||
if (!payload?.openUrl) { previewUrl.value = ''; previewFrameLoading.value = false; previewError.value = '未获取到预览地址'; return }
|
||
previewUrl.value = payload.openUrl
|
||
startPreviewFrameTimeout(requestToken)
|
||
} catch {
|
||
if (requestToken !== previewRequestToken.value) return
|
||
previewUrl.value = ''
|
||
previewFrameLoading.value = false
|
||
previewError.value = '预览服务暂不可用,请稍后重试'
|
||
} finally {
|
||
if (requestToken === previewRequestToken.value) {
|
||
previewLoading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const selectFileNode = async (fileNode: FileTreeNode) => {
|
||
activeProjectId.value = fileNode.projectId
|
||
activeFileId.value = fileNode.fileId
|
||
activeFileNode.value = fileNode
|
||
await loadPreview(fileNode.fileId)
|
||
}
|
||
|
||
const openInNewWindow = () => {
|
||
const url = previewPayload.value?.openUrl || previewUrl.value
|
||
if (!url) { ElMessage.warning('暂无可打开的预览地址'); return }
|
||
window.open(url, '_blank', 'noopener,noreferrer')
|
||
}
|
||
|
||
const refreshPreview = async () => { if (activeFileId.value) await loadPreview(activeFileId.value) }
|
||
|
||
const handlePreviewFrameLoad = () => {
|
||
clearPreviewFrameTimeout()
|
||
previewFrameLoading.value = false
|
||
}
|
||
|
||
const handlePreviewFrameError = () => {
|
||
clearPreviewFrameTimeout()
|
||
previewFrameLoading.value = false
|
||
previewError.value = '预览内容加载失败,请稍后重试'
|
||
}
|
||
|
||
// ── 树事件 ────────────────────────────────────────────────────
|
||
const handleNodeExpand = async (data: TreeNode) => {
|
||
if (data.type !== 'project') return
|
||
if (!expandedKeys.value.includes(data.id)) expandedKeys.value.push(data.id)
|
||
await loadProjectFiles(data)
|
||
}
|
||
|
||
const handleNodeCollapse = (data: TreeNode) => {
|
||
if (data.type !== 'project') return
|
||
expandedKeys.value = expandedKeys.value.filter((id) => id !== data.id)
|
||
}
|
||
|
||
const handleNodeClick = async (data: TreeNode) => {
|
||
if (data.type === 'project') {
|
||
const switched = activeProjectId.value !== data.projectId
|
||
activeProjectId.value = data.projectId
|
||
await loadProjectFiles(data)
|
||
if (switched) resetProjectAiState()
|
||
if (aiPanelOpen.value) await loadProjectAi(data.projectId)
|
||
return
|
||
}
|
||
await selectFileNode(data)
|
||
}
|
||
|
||
// ── 目录加载 ──────────────────────────────────────────────────
|
||
const loadCatalog = async () => {
|
||
pageLoading.value = true
|
||
clearPreviewFrameTimeout()
|
||
clearOfficeWarmupTimers()
|
||
previewRequestToken.value += 1
|
||
previewError.value = ''
|
||
previewUrl.value = ''
|
||
previewPayload.value = undefined
|
||
activeFileId.value = undefined
|
||
activeFileNode.value = undefined
|
||
activeProjectId.value = undefined
|
||
resetProjectAiState()
|
||
aiPanelOpen.value = false
|
||
try {
|
||
const catalog = await getTodayCatalog()
|
||
treeData.value = catalog
|
||
.sort((a, b) => (a.seqNo || 0) - (b.seqNo || 0))
|
||
.map((item) => buildProjectNode(item))
|
||
fileCache.value = {}
|
||
expandedKeys.value = []
|
||
await nextTick()
|
||
treeRef.value?.setCurrentKey(undefined)
|
||
} finally {
|
||
pageLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ── AI 面板 ───────────────────────────────────────────────────
|
||
const toggleAiPanel = async () => {
|
||
aiPanelOpen.value = !aiPanelOpen.value
|
||
if (aiPanelOpen.value && activeProjectId.value) {
|
||
await loadProjectAi(activeProjectId.value)
|
||
}
|
||
}
|
||
|
||
const resetProjectAiState = () => {
|
||
stopStream()
|
||
clearSummaryPolling()
|
||
aiSummary.value = null
|
||
aiConversationId.value = null
|
||
aiMessages.value = []
|
||
chatInput.value = ''
|
||
streamingText.value = ''
|
||
}
|
||
|
||
const loadProjectAi = async (projectId: number) => {
|
||
resetProjectAiState()
|
||
aiSummaryLoading.value = true
|
||
try {
|
||
aiSummary.value = await getProjectAiSummary(projectId)
|
||
// 如果生成中,开启轮询
|
||
if (aiSummary.value?.status === AI_SUMMARY_STATUS.BUILDING) {
|
||
startSummaryPolling(projectId)
|
||
}
|
||
// 已完成则加载会话
|
||
if (aiSummary.value?.status === AI_SUMMARY_STATUS.SUCCESS) {
|
||
await openAndLoadConversation(projectId)
|
||
}
|
||
} catch (e: any) {
|
||
// 如果是视图窗口未到,静默处理
|
||
if (e?.code !== 1900009006) ElMessage.error('加载 AI 摘要失败')
|
||
} finally {
|
||
aiSummaryLoading.value = false
|
||
}
|
||
}
|
||
|
||
const openAndLoadConversation = async (projectId: number) => {
|
||
aiChatLoading.value = true
|
||
try {
|
||
const conv = await openProjectAiConversation(projectId)
|
||
aiConversationId.value = conv.conversationId
|
||
const msgs = await ChatMessageApi.getChatMessageListByConversationId(conv.conversationId)
|
||
aiMessages.value = msgs || []
|
||
await scrollAiToBottom()
|
||
} catch {
|
||
// 会话打开失败不影响主流程
|
||
} finally {
|
||
aiChatLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 摘要生成中轮询
|
||
const startSummaryPolling = (projectId: number) => {
|
||
clearSummaryPolling()
|
||
summaryPollingTimer = setTimeout(async () => {
|
||
if (activeProjectId.value !== projectId || !aiPanelOpen.value) return
|
||
try {
|
||
aiSummary.value = await getProjectAiSummary(projectId)
|
||
} catch { /* silent */ }
|
||
if (aiSummary.value?.status === AI_SUMMARY_STATUS.BUILDING) {
|
||
startSummaryPolling(projectId)
|
||
} else if (aiSummary.value?.status === AI_SUMMARY_STATUS.SUCCESS) {
|
||
await openAndLoadConversation(projectId)
|
||
}
|
||
}, 4000)
|
||
}
|
||
|
||
const clearSummaryPolling = () => {
|
||
if (summaryPollingTimer) { clearTimeout(summaryPollingTimer); summaryPollingTimer = null }
|
||
}
|
||
|
||
// ── AI 对话 ───────────────────────────────────────────────────
|
||
const sendQuestion = (q: string) => { chatInput.value = q; handleSend() }
|
||
|
||
const handleSend = async () => {
|
||
const content = chatInput.value.trim()
|
||
if (!content || streamingLoading.value || !aiConversationId.value || !activeProjectId.value) return
|
||
chatInput.value = ''
|
||
|
||
// 先插入用户消息
|
||
aiMessages.value.push({ id: Date.now(), type: 'user', content, createTime: new Date() } as any)
|
||
streamingLoading.value = true
|
||
streamingText.value = ''
|
||
await scrollAiToBottom()
|
||
|
||
streamAbortCtrl = new AbortController()
|
||
let isFirst = true
|
||
const projectId = activeProjectId.value
|
||
const convId = aiConversationId.value
|
||
|
||
try {
|
||
await sendProjectAiChatStream(
|
||
projectId,
|
||
convId,
|
||
content,
|
||
streamAbortCtrl,
|
||
async (res: any) => {
|
||
const { code, data, msg } = JSON.parse(res.data)
|
||
if (code !== 0) {
|
||
ElMessage.error(`AI 对话异常:${msg}`)
|
||
streamingLoading.value = false
|
||
return
|
||
}
|
||
if (data?.receive?.content === '' && !data?.receive?.reasoningContent) return
|
||
if (isFirst) {
|
||
isFirst = false
|
||
// 用真实 send 消息替换假消息
|
||
aiMessages.value.pop()
|
||
aiMessages.value.push(data.send)
|
||
}
|
||
if (data?.receive?.content) {
|
||
streamingText.value += data.receive.content
|
||
}
|
||
await scrollAiToBottom()
|
||
},
|
||
(_err: any) => {
|
||
streamingLoading.value = false
|
||
throw _err
|
||
},
|
||
async () => {
|
||
// 流式结束,把完整回复追加为消息记录
|
||
if (streamingText.value) {
|
||
aiMessages.value.push({
|
||
id: Date.now() + 1,
|
||
type: 'assistant',
|
||
content: streamingText.value,
|
||
createTime: new Date()
|
||
} as any)
|
||
streamingText.value = ''
|
||
}
|
||
streamingLoading.value = false
|
||
await scrollAiToBottom()
|
||
}
|
||
)
|
||
} catch {
|
||
streamingLoading.value = false
|
||
}
|
||
}
|
||
|
||
const stopStream = () => {
|
||
if (streamAbortCtrl) { streamAbortCtrl.abort(); streamAbortCtrl = null }
|
||
if (streamingText.value) {
|
||
aiMessages.value.push({
|
||
id: Date.now() + 1,
|
||
type: 'assistant',
|
||
content: streamingText.value,
|
||
createTime: new Date()
|
||
} as any)
|
||
streamingText.value = ''
|
||
}
|
||
streamingLoading.value = false
|
||
}
|
||
|
||
const handleClearMessages = async () => {
|
||
await ElMessageBox.confirm('确认清空当前项目的全部问答记录?摘要和知识库不受影响。', '提示', { type: 'warning' })
|
||
if (!activeProjectId.value) return
|
||
stopStream()
|
||
await clearProjectAiMessages(activeProjectId.value)
|
||
aiMessages.value = []
|
||
ElMessage.success('问答记录已清空')
|
||
}
|
||
|
||
const scrollAiToBottom = async () => {
|
||
await nextTick()
|
||
if (aiMessagesRef.value) aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
|
||
}
|
||
|
||
// ── 工具 ──────────────────────────────────────────────────────
|
||
const formatFileSize = (bytes: number): string => {
|
||
if (!bytes) return '-'
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||
}
|
||
|
||
const isOfficeFile = (fileType?: string) => ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes((fileType || '').toLowerCase())
|
||
|
||
const clearOfficeWarmupTimers = () => {
|
||
officeWarmupTimers.forEach((timer) => clearTimeout(timer))
|
||
officeWarmupTimers = []
|
||
}
|
||
|
||
const clearPreviewFrameTimeout = () => {
|
||
if (previewFrameTimeoutTimer) {
|
||
clearTimeout(previewFrameTimeoutTimer)
|
||
previewFrameTimeoutTimer = null
|
||
}
|
||
}
|
||
|
||
const startPreviewFrameTimeout = (requestToken: number) => {
|
||
clearPreviewFrameTimeout()
|
||
previewFrameTimeoutTimer = setTimeout(() => {
|
||
if (requestToken !== previewRequestToken.value) return
|
||
if (!previewFrameLoading.value) return
|
||
previewFrameLoading.value = false
|
||
previewError.value = '预览服务响应超时,请稍后重试;若持续失败请检查 kkFileView 服务状态'
|
||
}, 30000)
|
||
}
|
||
|
||
const scheduleOfficeWarmup = (files: ReviewMeetingFileRespVO[]) => {
|
||
clearOfficeWarmupTimers()
|
||
files
|
||
.filter((file) => isOfficeFile(file.fileType))
|
||
.slice(0, 3)
|
||
.forEach((file, index) => {
|
||
const timer = setTimeout(() => {
|
||
getFileOpenUrl(file.id).catch(() => undefined)
|
||
}, index * 3000)
|
||
officeWarmupTimers.push(timer)
|
||
})
|
||
}
|
||
|
||
const renderMsgContent = (text: string) =>
|
||
text.replace(/\n/g, '<br/>').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/<br\/>/g, '<br/>')
|
||
|
||
loadCatalog()
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* ── 整体布局 ── */
|
||
.tablet-full-page {
|
||
height: 100vh;
|
||
min-height: 100vh;
|
||
padding: 12px;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
background: #eef2f7;
|
||
}
|
||
|
||
.page-toolbar {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
border: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.title-wrap h2 { margin: 0; font-size: 22px; font-weight: 700; }
|
||
.title-wrap span { display: block; margin-top: 4px; font-size: 13px; color: #6b7280; }
|
||
.tool-wrap { display: flex; align-items: center; gap: 8px; }
|
||
.search-input { width: 320px; }
|
||
|
||
.page-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: grid;
|
||
grid-template-columns: 360px minmax(0, 1fr);
|
||
gap: 12px;
|
||
transition: grid-template-columns 0.25s ease;
|
||
}
|
||
|
||
.page-body.ai-open {
|
||
grid-template-columns: 280px minmax(0, 1fr) 360px;
|
||
}
|
||
|
||
/* ── 目录面板 ── */
|
||
.directory-panel,
|
||
.preview-panel,
|
||
.ai-panel {
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 12px;
|
||
background: #fff;
|
||
}
|
||
|
||
.directory-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
padding: 10px;
|
||
}
|
||
|
||
.panel-title { padding: 6px 8px 10px; font-size: 15px; font-weight: 600; }
|
||
|
||
.catalog-tree { flex: 1; min-height: 0; overflow: auto; }
|
||
|
||
.tree-project, .tree-file {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
.node-left { display: inline-flex; align-items: center; min-width: 0; gap: 6px; }
|
||
.node-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.node-icon { font-size: 16px; }
|
||
.folder-icon { color: #2563eb; }
|
||
.file-icon { color: #475569; }
|
||
.file-type { font-size: 11px; color: #64748b; }
|
||
|
||
/* ── 预览面板 ── */
|
||
.preview-panel { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||
|
||
.preview-header {
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.preview-meta { min-width: 0; }
|
||
.preview-title { font-size: 16px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.preview-sub { margin-top: 4px; color: #64748b; font-size: 12px; }
|
||
.preview-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||
|
||
.preview-body { flex: 1; min-height: 0; background: #f8fafc; position: relative; }
|
||
.welcome-panel {
|
||
height: 100%;
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
color: #1f2937; text-align: center; padding: 24px;
|
||
}
|
||
.welcome-title { font-size: 30px; font-weight: 700; letter-spacing: 1px; }
|
||
.welcome-sub { margin-top: 12px; font-size: 15px; color: #64748b; }
|
||
.preview-iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; }
|
||
.preview-loading-mask {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
background: rgba(248, 250, 252, 0.92);
|
||
z-index: 2;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ── AI 面板 ── */
|
||
.ai-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* AI 头部 */
|
||
.ai-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
flex-shrink: 0;
|
||
}
|
||
.ai-header-left { display: flex; align-items: center; gap: 8px; }
|
||
.ai-header-right { display: flex; align-items: center; gap: 4px; }
|
||
.ai-title { font-size: 15px; font-weight: 600; }
|
||
.ai-status-badge {
|
||
font-size: 11px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-weight: 500;
|
||
}
|
||
.status-0 { background: #f3f4f6; color: #6b7280; }
|
||
.status-1 { background: #fef3c7; color: #d97706; }
|
||
.status-2 { background: #d1fae5; color: #059669; }
|
||
.status-3 { background: #fee2e2; color: #dc2626; }
|
||
|
||
/* 摘要区 */
|
||
.ai-summary-wrap {
|
||
flex-shrink: 0;
|
||
max-height: 280px;
|
||
overflow-y: auto;
|
||
padding: 10px 12px;
|
||
background: #f8fafc;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.ai-summary-empty,
|
||
.ai-summary-building,
|
||
.ai-summary-failed {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
padding: 8px 0;
|
||
}
|
||
.ai-summary-failed { color: #dc2626; }
|
||
|
||
.ai-summary-card { display: flex; flex-direction: column; gap: 8px; }
|
||
.summary-updated { font-size: 11px; color: #9ca3af; }
|
||
.summary-field { display: flex; flex-direction: column; gap: 2px; }
|
||
.field-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #2563eb;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.field-value { font-size: 13px; color: #374151; line-height: 1.5; }
|
||
|
||
/* 分隔线 */
|
||
.ai-divider { height: 1px; background: #e5e7eb; flex-shrink: 0; }
|
||
|
||
/* 消息列表 */
|
||
.ai-messages {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 10px 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.ai-messages-loading {
|
||
display: flex; align-items: center; gap: 6px;
|
||
font-size: 13px; color: #9ca3af; padding: 8px 0;
|
||
}
|
||
|
||
.chat-msg { display: flex; }
|
||
.user-msg { justify-content: flex-end; }
|
||
.ai-msg { justify-content: flex-start; }
|
||
|
||
.msg-bubble {
|
||
max-width: 85%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.user-msg .msg-bubble { align-items: flex-end; }
|
||
.ai-msg .msg-bubble { align-items: flex-start; }
|
||
|
||
.msg-role { font-size: 11px; color: #9ca3af; }
|
||
|
||
.msg-content {
|
||
padding: 8px 12px;
|
||
border-radius: 10px;
|
||
font-size: 13px;
|
||
line-height: 1.55;
|
||
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; }
|
||
.streaming-dot { color: #9ca3af; }
|
||
|
||
/* 快捷问题 */
|
||
.quick-questions {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
padding: 8px 12px;
|
||
border-top: 1px solid #e5e7eb;
|
||
}
|
||
.quick-btn {
|
||
padding: 4px 10px;
|
||
font-size: 12px;
|
||
background: #eff6ff;
|
||
border: 1px solid #bfdbfe;
|
||
border-radius: 12px;
|
||
color: #1d4ed8;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.quick-btn:hover:not(:disabled) { background: #dbeafe; }
|
||
.quick-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
/* 输入区 */
|
||
.ai-input-wrap {
|
||
flex-shrink: 0;
|
||
padding: 8px 12px;
|
||
border-top: 1px solid #e5e7eb;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.ai-input-actions { display: flex; justify-content: flex-end; }
|
||
|
||
/* 不可用提示 */
|
||
.ai-chat-unavail {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 13px;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
/* ── 树样式 ── */
|
||
:deep(.el-tree-node__content) { height: 42px; padding-right: 8px; }
|
||
:deep(.el-tree-node__content:hover) { background: #eff6ff; }
|
||
|
||
/* ── 响应式 ── */
|
||
@media (max-width: 1100px) {
|
||
.tablet-full-page { height: auto; min-height: 100vh; }
|
||
.page-toolbar { flex-direction: column; align-items: stretch; }
|
||
.search-input { width: 100%; }
|
||
.page-body { grid-template-columns: 1fr !important; }
|
||
.directory-panel { max-height: 42vh; }
|
||
.preview-panel { min-height: 56vh; }
|
||
.ai-panel { min-height: 50vh; }
|
||
}
|
||
</style>
|