admin-vue3/src/views/review/tablet/index.vue

1068 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/&lt;br\/&gt;/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>