Files
tuanwei-admin/src/views/gxmu/WXXZXForm/components/docValidate.vue
T
weicw-dshsa 6f7f2f0d9a feat(ai): 后台 AI 链路改走 HTTP 流式,WS 地址与心跳收敛到 utils
写作 / 图片 / 大纲 / 稿件四条链路用普通 POST 时,生成期间链路上零字节,
会被 60s 空闲超时掐成 504(出图实测 58s 断开)。后端已给每条链路开
/stream(SSE,每 15s 一帧心跳),这里改成消费它。

- 新增 utils/ai-stream.ts:SSE 帧解析 + 末帧 {type:'final', result} 拆包;
  runCreativeAssistantStream / runManuscriptGenStream 对外仍返回原来的 data,
  页面渲染逻辑不动
- 新增 utils/chat-socket.ts:buildWebSocketUrl 统一拼 /api/chat/{userId}
  (网关只放行 /api 前缀),attachHeartbeat 每 25s 发 ping 顶住代理空闲超时
- chatgpt 对话先试 WebSocket,握手失败落 /ai/chat/message/stream,
  帧内容与 WS 完全一致,处理函数直接复用
- 四个页面删掉各自重复的 buildWebSocketUrl,统一从 utils 引入
2026-10-21 19:18:08 +08:00

700 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<ele-modal
:width="960"
:footer="null"
title="项目申报书文件校验"
:visible="visible"
@update:visible="updateVisible"
>
<a-spin :spinning="loading">
<div class="validator-layout">
<div class="upload-panel">
<div class="validator-intro">
上传项目申报书后,系统将调用项目书质量初审模型,从创新性、可行性、应用前景、文本规范性等维度生成初审报告。
</div>
<div class="upload-label">项目申报书</div>
<a-upload-dragger
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
:file-list="projectFileList"
:before-upload="beforeProjectUpload"
:max-count="1"
@remove="removeProjectFile"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-text">点击或拖拽上传待审项目申报书</p>
<p class="ant-upload-hint">支持 PDF、DOC、DOCX,大小不超过 50MB</p>
</a-upload-dragger>
<div class="upload-label optional-label">自定义评分细则</div>
<a-upload
accept=".pdf,.doc,.docx,.csv,.xls,.xlsx,.txt,.md"
:file-list="rubricFileList"
:before-upload="beforeRubricUpload"
:max-count="1"
@remove="removeRubricFile"
>
<a-button>
<template #icon>
<upload-outlined />
</template>
上传评分细则
</a-button>
</a-upload>
<div class="action-row">
<a-button
type="primary"
:disabled="!projectFile || loading"
@click="startReview"
>
开始校验
</a-button>
<a-button :disabled="!loading" @click="stopReview">停止</a-button>
<a-button :disabled="loading && !reportMarkdown" @click="clearResult">
清空
</a-button>
</div>
</div>
<div class="result-panel">
<div class="result-toolbar">
<div>
<div class="result-title">AI 初审报告</div>
<div class="result-subtitle">
{{ statusText || '报告生成后将在此展示' }}
</div>
</div>
<a-space>
<a-tag :color="loading ? 'processing' : reportMarkdown ? 'success' : 'default'">
{{ loading ? '生成中' : reportMarkdown ? '已完成' : '待开始' }}
</a-tag>
<a-button size="small" :disabled="!reportMarkdown" @click="exportReport">
导出
</a-button>
<a-button
size="small"
:disabled="!reportMarkdown"
@click="fullscreenVisible = true"
>
全屏查看
</a-button>
<a-button size="small" :disabled="!reportMarkdown" @click="copyReport">
复制
</a-button>
</a-space>
</div>
<div ref="reportShellRef" class="report-shell">
<div v-if="loading && !reportMarkdown" class="report-streaming">
AI 正在分析项目申报书...
</div>
<ByteMdViewer
v-else-if="reportMarkdown"
class="report-viewer"
:value="reportMarkdown"
:plugins="plugins"
/>
<a-empty v-else description="请先上传项目申报书并开始校验" />
</div>
</div>
</div>
</a-spin>
</ele-modal>
<ele-modal
:width="'92vw'"
:footer="null"
title="AI 初审报告"
:visible="fullscreenVisible"
@update:visible="fullscreenVisible = $event"
>
<div class="fullscreen-toolbar">
<a-space>
<a-button :disabled="!reportMarkdown" @click="exportReport">
导出
</a-button>
<a-button :disabled="!reportMarkdown" @click="copyReport">
复制
</a-button>
</a-space>
</div>
<div class="fullscreen-report-shell">
<ByteMdViewer
v-if="reportMarkdown"
class="report-viewer"
:value="reportMarkdown"
:plugins="plugins"
/>
<a-empty v-else description="暂无初审报告" />
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import 'github-markdown-css/github-markdown-light.css';
import gfm from '@bytemd/plugin-gfm';
import zhHansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import highlight from '@bytemd/plugin-highlight-ssr';
import 'highlight.js/styles/default.css';
import { computed, ref, watch, onBeforeUnmount, nextTick } from 'vue';
import { message } from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { CloudUploadOutlined, UploadOutlined } from '@ant-design/icons-vue';
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { reviewWxxzxProjectBookQuality } from '@/api/gxmu/wxxzx-form';
import { stopMessage } from '@/api/oa/chatgpt';
import { attachHeartbeat, buildWebSocketUrl } from '@/utils/chat-socket';
import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common';
import type { WxxzxProjectQualityReviewResult } from '@/api/gxmu/wxxzx-form/model';
const STREAM_END_TEXT = '__END__';
const SOCKET_CONNECTED_TEXT = '连接成功';
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
}>();
const loginUser = computed(() => useUserStore().info ?? {});
const plugins = ref([
gfm({
locale: zhHansGfm
}),
highlight()
]);
const loading = ref(false);
const statusText = ref('');
const projectFile = ref<File>();
const rubricFile = ref<File>();
const reportMarkdown = ref('');
const activeTaskId = ref<string>();
const streamStarted = ref(false);
const fullscreenVisible = ref(false);
const reportShellRef = ref<HTMLElement | null>(null);
const webSocketRef = ref<WebSocket | null>(null);
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
const streamResolverRef = ref<{
resolve: () => void;
reject: (error: Error) => void;
} | null>(null);
const projectFileList = computed(() =>
projectFile.value
? [
{
uid: 'project',
name: projectFile.value.name,
status: 'done' as const
}
]
: []
);
const rubricFileList = computed(() =>
rubricFile.value
? [
{
uid: 'rubric',
name: rubricFile.value.name,
status: 'done' as const
}
]
: []
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const beforeProjectUpload: UploadProps['beforeUpload'] = (file) => {
if (file.size / 1024 / 1024 > 50) {
message.error('项目申报书不能超过 50MB');
return false;
}
projectFile.value = file as File;
return false;
};
const beforeRubricUpload: UploadProps['beforeUpload'] = (file) => {
if (file.size / 1024 / 1024 > 20) {
message.error('评分细则不能超过 20MB');
return false;
}
rubricFile.value = file as File;
return false;
};
const removeProjectFile = () => {
projectFile.value = undefined;
return true;
};
const removeRubricFile = () => {
rubricFile.value = undefined;
return true;
};
const scrollReportToBottom = () => {
nextTick(() => {
const el = reportShellRef.value;
if (el) {
el.scrollTop = el.scrollHeight;
}
});
};
const clearStreamResolver = () => {
streamResolverRef.value = null;
};
const resolveStream = () => {
streamResolverRef.value?.resolve();
clearStreamResolver();
};
const rejectStream = (error: Error) => {
streamResolverRef.value?.reject(error);
clearStreamResolver();
};
const createStreamWaiter = () => {
streamStarted.value = false;
return new Promise<void>((resolve, reject) => {
streamResolverRef.value = { resolve, reject };
});
};
const disconnectWebSocket = () => {
wsConnectPromiseRef.value = null;
const socket = webSocketRef.value;
webSocketRef.value = null;
if (
socket &&
(socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING)
) {
socket.close();
}
};
const pickReportText = (payload: any): string => {
if (!payload) {
return '';
}
if (typeof payload === 'string') {
return payload;
}
const outputs = payload.outputs || payload.data?.outputs || payload.raw?.data?.outputs;
const direct =
payload.answer ||
payload.result ||
payload.output ||
payload.content ||
outputs?.answer ||
outputs?.result ||
outputs?.output ||
outputs?.content ||
outputs?.report ||
outputs?.markdown;
if (typeof direct === 'string') {
return direct;
}
if (outputs) {
const firstText = Object.values(outputs).find(
(item) => typeof item === 'string' && item.trim()
);
if (typeof firstText === 'string') {
return firstText;
}
}
return '';
};
const appendReportChunk = (chunk: string, taskId?: string) => {
if (!chunk) {
return;
}
if (taskId && taskId !== '0') {
activeTaskId.value = taskId;
}
if (!streamStarted.value) {
streamStarted.value = true;
reportMarkdown.value = '';
}
reportMarkdown.value += chunk;
statusText.value = '正在通过 WebSocket 接收流式报告';
scrollReportToBottom();
};
const finishStream = (taskId?: string) => {
if (taskId && taskId !== '0') {
activeTaskId.value = taskId;
}
loading.value = false;
statusText.value = '项目书质量初审完成';
scrollReportToBottom();
resolveStream();
disconnectWebSocket();
};
const handleSocketMessage = (raw: string) => {
if (!raw) {
return;
}
try {
const data = JSON.parse(raw);
if (data?.stopType && data.stopType !== 'projectQualityReview') {
return;
}
if (data?.answer === STREAM_END_TEXT) {
finishStream(data?.taskId);
return;
}
if (data?.taskId && data.taskId !== '0') {
activeTaskId.value = data.taskId;
}
if (data?.loading === 'true' || data?.loading === true) {
statusText.value = 'AI 正在检索历史案例并组织评审意见...';
return;
}
if (typeof data?.answer === 'string') {
appendReportChunk(data.answer, data?.taskId);
}
} catch (e) {
appendReportChunk(raw);
}
};
const connectWebSocket = async () => {
const userId = loginUser.value.userId;
if (!userId) {
return Promise.reject(new Error('未获取到当前登录用户'));
}
const currentSocket = webSocketRef.value;
if (currentSocket?.readyState === WebSocket.OPEN) {
return;
}
if (
(currentSocket?.readyState === WebSocket.OPEN ||
currentSocket?.readyState === WebSocket.CONNECTING) &&
wsConnectPromiseRef.value
) {
return wsConnectPromiseRef.value;
}
let cleanupPending = () => undefined;
const connectPromise = new Promise<void>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(buildWebSocketUrl(userId));
socket.onopen = () => {
// 握手成功后再挂心跳:连接没建起来时发出去只会失败
attachHeartbeat(socket);
};
cleanupPending = () => {
if (wsConnectPromiseRef.value === connectPromise) {
wsConnectPromiseRef.value = null;
}
};
socket.onmessage = (event) => {
const raw = String(event.data || '');
if (raw === SOCKET_CONNECTED_TEXT) {
if (!settled) {
settled = true;
cleanupPending();
resolve();
}
return;
}
handleSocketMessage(raw);
};
socket.onclose = () => {
if (webSocketRef.value === socket) {
webSocketRef.value = null;
}
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('项目书质量初审连接已关闭'));
return;
}
if (loading.value) {
rejectStream(new Error('项目书质量初审连接已关闭'));
loading.value = false;
}
cleanupPending();
};
socket.onerror = () => {
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('项目书质量初审连接失败'));
return;
}
if (loading.value) {
rejectStream(new Error('项目书质量初审连接失败'));
loading.value = false;
}
};
webSocketRef.value = socket;
});
wsConnectPromiseRef.value = connectPromise;
return connectPromise;
};
const clearResult = () => {
reportMarkdown.value = '';
statusText.value = '';
activeTaskId.value = undefined;
streamStarted.value = false;
};
const startReview = async () => {
if (!projectFile.value) {
message.warning('请先上传项目申报书');
return;
}
loading.value = true;
clearResult();
statusText.value = '正在建立 WebSocket 连接...';
try {
await connectWebSocket();
statusText.value = '连接成功,已提交项目书质量初审任务...';
const waitStream = createStreamWaiter();
const result = (await reviewWxxzxProjectBookQuality(
projectFile.value,
rubricFile.value
)) as WxxzxProjectQualityReviewResult;
const directReport = pickReportText(result);
if (!streamStarted.value && directReport) {
reportMarkdown.value = directReport;
loading.value = false;
statusText.value = '项目书质量初审完成';
scrollReportToBottom();
resolveStream();
disconnectWebSocket();
} else {
await waitStream;
}
if (!reportMarkdown.value.trim()) {
message.warning('接口已返回,但未解析到初审报告内容');
return;
}
message.success('项目书质量初审完成');
} catch (e: any) {
disconnectWebSocket();
clearStreamResolver();
loading.value = false;
statusText.value = '';
message.error(e.message || '项目书质量初审失败');
}
};
const stopReview = async () => {
try {
await stopMessage({
taskId: activeTaskId.value,
mode: 'projectQualityReview' as any
});
loading.value = false;
statusText.value = '已停止项目书质量初审';
resolveStream();
disconnectWebSocket();
message.success('已停止');
} catch (e: any) {
message.error(e.message || '停止失败');
}
};
const copyReport = () => {
if (!reportMarkdown.value.trim()) {
message.warning('暂无可复制内容');
return;
}
copyText(reportMarkdown.value);
};
const exportReport = () => {
if (!reportMarkdown.value.trim()) {
message.warning('暂无可导出内容');
return;
}
const baseName =
projectFile.value?.name?.replace(/\.[^.]+$/, '') || '项目书质量初审报告';
const safeName = baseName.replace(/[\\/:*?"<>|]/g, '_');
const blob = new Blob([reportMarkdown.value], {
type: 'text/markdown;charset=utf-8'
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${safeName}-AI初审报告.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('导出成功');
};
watch(
() => props.visible,
(value) => {
if (value) {
clearResult();
} else {
disconnectWebSocket();
clearStreamResolver();
loading.value = false;
}
}
);
onBeforeUnmount(() => {
disconnectWebSocket();
clearStreamResolver();
});
</script>
<style lang="less" scoped>
.validator-layout {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
align-items: start;
gap: 20px;
max-height: calc(100vh - 220px);
overflow: auto;
}
.upload-panel {
min-width: 0;
align-self: start;
}
.validator-intro {
margin-bottom: 16px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.7;
}
.upload-label {
margin: 14px 0 8px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.optional-label {
margin-top: 16px;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
}
.result-panel {
min-width: 0;
align-self: start;
border: 1px solid #edf0f5;
border-radius: 8px;
background: #fff;
}
.result-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid #edf0f5;
}
.result-title {
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.result-subtitle {
margin-top: 4px;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
.report-shell {
height: 360px;
overflow: auto;
padding: 18px;
}
.report-streaming {
color: rgba(0, 0, 0, 0.65);
line-height: 1.8;
white-space: pre-wrap;
}
.report-viewer :deep(.markdown-body) {
font-size: 14px;
line-height: 1.8;
}
.fullscreen-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.fullscreen-report-shell {
height: calc(100vh - 210px);
overflow: auto;
padding: 20px 24px;
border: 1px solid #edf0f5;
border-radius: 8px;
background: #fff;
}
.fullscreen-report-shell :deep(.markdown-body) {
max-width: 980px;
margin: 0 auto;
font-size: 15px;
line-height: 1.9;
}
@media (max-width: 860px) {
.validator-layout {
grid-template-columns: 1fr;
max-height: calc(100vh - 180px);
}
.report-shell {
height: 320px;
}
}
:deep(.ant-upload.ant-upload-drag) {
height: 180px;
}
:deep(.ant-upload.ant-upload-drag .ant-upload) {
padding: 24px 16px;
}
:deep(.ant-upload.ant-upload-drag p.ant-upload-drag-icon) {
margin-bottom: 10px;
}
:deep(.ant-upload.ant-upload-drag p.ant-upload-text) {
margin-bottom: 6px;
font-size: 15px;
}
:deep(.ant-upload-list) {
max-height: 74px;
overflow: auto;
}
</style>