6f7f2f0d9a
写作 / 图片 / 大纲 / 稿件四条链路用普通 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 引入
700 lines
18 KiB
Vue
700 lines
18 KiB
Vue
<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>
|