This commit is contained in:
2026-07-20 11:50:47 +08:00
parent 95d43746f6
commit 019ed28d37
401 changed files with 61622 additions and 23869 deletions
@@ -0,0 +1,706 @@
<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 { API_BASE_URL } from '@/config/setting';
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 buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/chat/${userId}`;
};
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));
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>