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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="未来学术之星"
file-name="未来学术之星导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:import-description="description"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addWxxzxForm } from '@/api/gxmu/wxxzx-form';
import type { WxxzxForm } from '@/api/gxmu/wxxzx-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const description =
'本模板仅导入未来学术之星主表字段。指导教师、申请人、预算、评审成员、过程材料与归档记录请在详情中继续维护。';
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '课题名称', key: 'topicName', required: true },
{ header: '课题负责人', key: 'leader' },
{ header: '学院、年级、班别', key: 'collegeGradeClass' },
{ header: '联系电话', key: 'phone' },
{ header: '指导老师', key: 'advisor' },
{ header: '课题类别', key: 'topicType' },
{ header: '立项依据', key: 'rationale' },
{ header: '指导教师意见', key: 'teacherOpinion' },
{ header: '学院意见', key: 'collegeOpinion' },
{ header: '学校意见', key: 'schoolOpinion' },
{ header: '承诺签字人', key: 'promiseSigner' },
{ header: '评审名单-学院', key: 'reviewCollege' },
{ header: '评审名单-汇报人', key: 'reviewReporter' },
{ header: '评审名单-日期', key: 'reviewDate' }
] as const;
const sampleRow = {
年份: '2025',
课题名称: '医学生科研素养提升路径研究',
课题负责人: '周八',
'学院、年级、班别': '第一临床医学院 2022级 1班',
联系电话: '13800138000',
指导老师: '李教授',
课题类别: '重点课题',
立项依据: '围绕医学生科研训练现状展开研究',
指导教师意见: '',
学院意见: '',
学校意见: '',
承诺签字人: '周八',
'评审名单-学院': '',
'评审名单-汇报人': '',
'评审名单-日期': ''
};
const createPayload = (row: Record<string, any>): WxxzxForm => ({
year: String(row['年份'] || '').trim(),
topicName: String(row['课题名称'] || '').trim(),
leader: String(row['课题负责人'] || '').trim(),
collegeGradeClass: String(row['学院、年级、班别'] || '').trim(),
phone: String(row['联系电话'] || '').trim(),
advisor: String(row['指导老师'] || '').trim(),
topicType: String(row['课题类别'] || '').trim(),
rationale: String(row['立项依据'] || '').trim(),
teacherOpinion: String(row['指导教师意见'] || '').trim(),
collegeOpinion: String(row['学院意见'] || '').trim(),
schoolOpinion: String(row['学校意见'] || '').trim(),
promiseSigner: String(row['承诺签字人'] || '').trim(),
reviewCollege: String(row['评审名单-学院'] || '').trim(),
reviewReporter: String(row['评审名单-汇报人'] || '').trim(),
reviewDate: String(row['评审名单-日期'] || '').trim(),
guidanceTeachers: [],
applicants: [],
budgets: [],
reviewMembers: [],
archiveRecords: []
});
const saveItem = (item: WxxzxForm) => addWxxzxForm(item);
</script>
@@ -0,0 +1,383 @@
<template>
<a-drawer
placement="right"
width="760"
:visible="visible"
title="项目过程材料"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<div class="material-header">
<div class="material-title">{{ props.data?.topicName || '未命名课题' }}</div>
<div class="material-subtitle">
负责人:{{ props.data?.leader || '-' }} | 指导老师:{{ props.data?.advisor || '-' }}
</div>
</div>
<a-tabs v-model:activeKey="activeKey" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="funding" tab="经费管理">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="请分别上传经费预算表和经费划拨明细表,支持覆盖更新。"
/>
<a-form layout="vertical" class="stage-form">
<a-form-item label="经费预算表">
<stage-file-upload
v-model:value="form.fundingBudgetFile"
button-text="上传经费预算表"
:multiple="false"
/>
</a-form-item>
<a-form-item label="经费划拨明细表">
<stage-file-upload
v-model:value="form.fundingAllocationFile"
button-text="上传经费划拨明细表"
:multiple="false"
/>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane key="midterm" tab="中期检查">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="学生团队可在此提交中期检查所需的阶段性材料。"
/>
<a-form layout="vertical" class="stage-form">
<a-form-item label="补充说明">
<a-textarea
v-model:value="form.midtermRemark"
:rows="4"
placeholder="请输入中期检查说明,如材料清单、阶段进展等"
/>
</a-form-item>
<a-form-item label="中期检查材料">
<stage-file-upload
v-model:value="form.midtermMaterials"
button-text="上传中期检查材料"
:multiple="true"
/>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane key="final" tab="结题验收">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="学生团队可上传结题验收阶段的最终材料。"
/>
<a-form layout="vertical" class="stage-form">
<a-form-item label="补充说明">
<a-textarea
v-model:value="form.finalRemark"
:rows="4"
placeholder="请输入结题说明,如成果概述、材料目录等"
/>
</a-form-item>
<a-form-item label="结题材料">
<stage-file-upload
v-model:value="form.finalMaterials"
button-text="上传结题材料"
:multiple="true"
/>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane key="archive" tab="成果归档">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="请以表格形式维护成果归档,可添加多条获奖记录并分别上传凭证。"
/>
<div class="archive-toolbar">
<a-button type="dashed" @click="addArchiveRecord">新增一条</a-button>
</div>
<a-table
row-key="__rowKey"
:columns="archiveColumns"
:data-source="form.archiveRecords"
:pagination="false"
:scroll="{ x: 980 }"
class="archive-table"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'awardName'">
<a-input
v-model:value="form.archiveRecords[index].awardName"
placeholder="请输入获奖名称"
/>
</template>
<template v-else-if="column.key === 'awardTime'">
<a-date-picker
v-model:value="form.archiveRecords[index].awardTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择获奖时间"
/>
</template>
<template v-else-if="column.key === 'remark'">
<a-textarea
v-model:value="form.archiveRecords[index].remark"
:rows="2"
placeholder="请输入备注说明"
/>
</template>
<template v-else-if="column.key === 'proofFile'">
<stage-file-upload
v-model:value="form.archiveRecords[index].proofFile"
button-text="上传凭证"
:multiple="false"
:limit="1"
/>
</template>
<template v-else-if="column.key === 'action'">
<a class="ele-text-danger" @click="removeArchiveRecord(index)">
删除
</a>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { updateWxxzxMaterials } from '@/api/gxmu/wxxzx-form';
import type {
WxxzxArchiveRecord,
WxxzxForm
} from '@/api/gxmu/wxxzx-form/model';
import StageFileUpload from './stageFileUpload.vue';
type MaterialStageKey = 'funding' | 'midterm' | 'final' | 'archive';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = withDefaults(
defineProps<{
visible: boolean;
data?: WxxzxForm | null;
initialStage?: MaterialStageKey;
}>(),
{
initialStage: 'funding'
}
);
const loading = ref(false);
const activeKey = ref<MaterialStageKey>('funding');
const createArchiveRecord = (): WxxzxArchiveRecord & { __rowKey: string } => ({
id: undefined,
year: '',
awardName: '',
awardTime: '',
remark: '',
proofFile: '',
__rowKey: `${Date.now()}-${Math.random().toString(16).slice(2)}`
});
const normalizeArchiveRecord = (record?: WxxzxArchiveRecord | null) => ({
...createArchiveRecord(),
...record,
__rowKey:
(record as any)?.__rowKey ||
`${Date.now()}-${Math.random().toString(16).slice(2)}`
});
const archiveColumns = [
{
title: '获奖名称',
dataIndex: 'awardName',
key: 'awardName',
width: 180
},
{
title: '获奖时间',
dataIndex: 'awardTime',
key: 'awardTime',
width: 160
},
{
title: '备注说明',
dataIndex: 'remark',
key: 'remark',
width: 280
},
{
title: '上传凭证',
dataIndex: 'proofFile',
key: 'proofFile',
width: 280
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right'
}
];
const createDefaultForm = () => ({
fundingBudgetFile: '',
fundingAllocationFile: '',
midtermRemark: '',
midtermMaterials: '',
finalRemark: '',
finalMaterials: '',
archiveAwards: '',
archiveProofs: '',
archiveRecords: [createArchiveRecord()]
});
const form = reactive(createDefaultForm());
watch(
() => props.visible,
(visible) => {
if (visible) {
Object.assign(form, createDefaultForm(), {
fundingBudgetFile: props.data?.fundingBudgetFile || '',
fundingAllocationFile: props.data?.fundingAllocationFile || '',
midtermRemark: props.data?.midtermRemark || '',
midtermMaterials: props.data?.midtermMaterials || '',
finalRemark: props.data?.finalRemark || '',
finalMaterials: props.data?.finalMaterials || '',
archiveAwards: props.data?.archiveAwards || '',
archiveProofs: props.data?.archiveProofs || '',
archiveRecords:
props.data?.archiveRecords?.length
? props.data.archiveRecords.map((item) =>
normalizeArchiveRecord(item)
)
: [createArchiveRecord()]
});
activeKey.value = props.initialStage;
} else {
Object.assign(form, createDefaultForm());
}
},
{ immediate: true }
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
if (!props.data?.id) {
message.error('请先保存课题申报信息');
return;
}
loading.value = true;
updateWxxzxMaterials(props.data.id, {
...form,
archiveRecords: form.archiveRecords
.map(({ __rowKey, ...item }: any) => item)
.filter(
(item) =>
item.awardName ||
item.awardTime ||
item.remark ||
item.proofFile
)
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
const addArchiveRecord = () => {
form.archiveRecords.push(createArchiveRecord());
};
const removeArchiveRecord = (index: number) => {
form.archiveRecords.splice(index, 1);
if (!form.archiveRecords.length) {
form.archiveRecords.push(createArchiveRecord());
}
};
</script>
<style lang="less" scoped>
@import '../../tag-tabs.less';
.gxmu-tag-tabs();
.material-header {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: linear-gradient(135deg, #f7fbff 0%, #edf5ff 100%);
}
.material-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.material-subtitle {
margin-top: 8px;
color: rgba(0, 0, 0, 0.65);
}
.stage-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
background: #fff;
}
.stage-form {
margin-top: 16px;
}
.archive-toolbar {
display: flex;
justify-content: flex-end;
margin: 16px 0 12px;
}
.archive-table :deep(.ant-table-cell) {
vertical-align: top;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
</style>
@@ -0,0 +1,224 @@
<template>
<div class="stage-file-upload">
<a-upload
:accept="accept"
:multiple="multiple"
:showUploadList="false"
:customRequest="handleUpload"
>
<a-button class="ele-btn-icon">
<template #icon>
<UploadOutlined />
</template>
<span>{{ buttonText }}</span>
</a-button>
</a-upload>
<div v-if="fileItems.length" class="file-list">
<div
v-for="(item, index) in fileItems"
:key="`${item.url}-${index}`"
class="file-item"
>
<a :href="item.url" target="_blank" rel="noreferrer">
{{ item.name || getFileName(item.url) }}
</a>
<a class="file-remove" @click="removeFile(index)">删除</a>
</div>
</div>
<div v-else class="empty-text">{{ placeholder }}</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { uploadFile } from '@/api/system/file';
interface StageUploadFileItem {
name: string;
url: string;
}
const emit = defineEmits<{
(e: 'update:value', value: string): void;
}>();
const props = withDefaults(
defineProps<{
value?: string;
multiple?: boolean;
accept?: string;
buttonText?: string;
placeholder?: string;
limit?: number;
}>(),
{
value: '',
multiple: false,
accept:
'.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar,.jpg,.jpeg,.png',
buttonText: '上传文件',
placeholder: '暂未上传材料',
limit: 20
}
);
const fileItems = ref<StageUploadFileItem[]>([]);
const getFileName = (url?: string) => {
if (!url) {
return '未命名文件';
}
const cleanUrl = url.split('?')[0];
const fileName = cleanUrl.split('/').pop() || cleanUrl;
return decodeURIComponent(fileName);
};
const normalizeItem = (item: unknown) => {
if (typeof item === 'string') {
const url = item.trim();
if (!url) {
return null;
}
return {
name: getFileName(url),
url
};
}
if (
item &&
typeof item === 'object' &&
typeof (item as StageUploadFileItem).url === 'string'
) {
const url = (item as StageUploadFileItem).url.trim();
if (!url) {
return null;
}
return {
name:
(item as StageUploadFileItem).name?.trim() || getFileName(url),
url
};
}
return null;
};
const parseValue = (value?: string) => {
const text = value?.trim();
if (!text) {
return [];
}
if (!props.multiple) {
return normalizeItem(text) ? [normalizeItem(text)!] : [];
}
try {
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) {
return normalizeItem(text) ? [normalizeItem(text)!] : [];
}
return parsed
.map((item) => normalizeItem(item))
.filter((item): item is StageUploadFileItem => !!item);
} catch (e) {
return normalizeItem(text) ? [normalizeItem(text)!] : [];
}
};
const emitValue = (list: StageUploadFileItem[]) => {
if (!props.multiple) {
emit('update:value', list[0]?.url || '');
return;
}
emit('update:value', list.length ? JSON.stringify(list) : '');
};
const syncFileItems = (value?: string) => {
fileItems.value = parseValue(value);
};
watch(
() => props.value,
(value) => {
syncFileItems(value);
},
{ immediate: true }
);
const handleUpload = async (options: any) => {
const rawFile = options.file as File;
if (rawFile.size / 1024 / 1024 > 100) {
message.error('大小不能超过 100MB');
options.onError?.(new Error('文件过大'));
return;
}
if (props.multiple && fileItems.value.length >= props.limit) {
message.warning(`最多上传 ${props.limit} 个文件`);
options.onError?.(new Error('超出上传数量限制'));
return;
}
const hide = message.loading('上传中..', 0);
try {
const result = await uploadFile(rawFile);
const nextItem = {
name: result.name || rawFile.name,
url: String(result.url || result.downloadUrl || result.path || '')
};
const nextList = props.multiple ? [...fileItems.value, nextItem] : [nextItem];
fileItems.value = nextList;
emitValue(nextList);
message.success('上传成功');
options.onSuccess?.(result);
} catch (e: any) {
message.error(e.message || '上传失败');
options.onError?.(e);
} finally {
hide();
}
};
const removeFile = (index: number) => {
const nextList = fileItems.value.filter((_, currentIndex) => currentIndex !== index);
fileItems.value = nextList;
emitValue(nextList);
};
</script>
<style lang="less" scoped>
.file-list {
margin-top: 12px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fafafa;
}
.file-item + .file-item {
margin-top: 8px;
}
.file-item a:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-remove {
color: #ff4d4f;
flex-shrink: 0;
}
.empty-text {
margin-top: 8px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+378
View File
@@ -0,0 +1,378 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openDocValidate"
>
<template #icon>
<FileSearchOutlined />
</template>
<span>文件校验</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入课题名称/负责人/学院"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<div class="action-links">
<a @click="openDetail(record)">详情</a>
<a v-if="!shouldHideEditByReviewList(record.reviewList)" @click="openEdit(record)">编辑</a>
<a @click="openMaterial(record, 'funding')">经费管理</a>
<a @click="openMaterial(record, 'midterm')">中期检查</a>
<a @click="openMaterial(record, 'final')">结题验收</a>
<a @click="openMaterial(record, 'archive')">成果归档</a>
<a @click="openReviewHistory(record)">审核历史</a>
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</div>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<materials-modal
v-model:visible="showMaterials"
:data="materialCurrent"
:initial-stage="materialStage"
@done="reload"
/>
<doc-validate-modal v-model:visible="showDocValidate" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="未来学术之星"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined,
FileSearchOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import DocValidateModal from './components/docValidate.vue';
import MaterialsModal from './components/materials.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportWxxzxForm,
getWxxzxForm,
userPageWxxzxForm,
removeWxxzxForm
} from '@/api/gxmu/wxxzx-form';
import type { WxxzxForm, WxxzxFormParam } from '@/api/gxmu/wxxzx-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const current = ref<WxxzxForm>();
const showDetail = ref(false);
const detailCurrent = ref<WxxzxForm | null>(null);
const showMaterials = ref(false);
const materialCurrent = ref<WxxzxForm>();
const materialStage = ref<'funding' | 'midterm' | 'final' | 'archive'>(
'funding'
);
const showDocValidate = ref(false);
const REVIEW_MODULE = 'gxmu_wxxzx_project';
const showReviewHistory = ref(false);
const reviewCurrent = ref<WxxzxForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '课题名称',
dataIndex: 'topicName',
key: 'topicName',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '课题负责人',
dataIndex: 'leader',
key: 'leader',
align: 'center',
width: 120
},
{
title: '学院、年级、班别',
dataIndex: 'collegeGradeClass',
key: 'collegeGradeClass',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140
},
{
title: '指导老师',
dataIndex: 'advisor',
key: 'advisor',
align: 'center',
width: 140
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 420
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageWxxzxForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WxxzxFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const buildQueryParams = (): WxxzxFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WxxzxFormParam;
};
const openImport = () => {
showImport.value = true;
};
const openDocValidate = () => {
showDocValidate.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(payload?.url || payload?.downloadUrl || payload?.path || '');
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportWxxzxForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: WxxzxForm) => {
if (!record?.id) {
current.value = undefined;
showEdit.value = true;
return;
}
const hide = message.loading('加载中..', 0);
getWxxzxForm(record.id)
.then((data) => {
hide();
current.value = JSON.parse(JSON.stringify(data)) as WxxzxForm;
showEdit.value = true;
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const openReviewHistory = (record: WxxzxForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: WxxzxForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const openMaterial = (
record: WxxzxForm,
stage: 'funding' | 'midterm' | 'final' | 'archive'
) => {
if (!record.id) {
message.error('缺少课题记录ID');
return;
}
materialStage.value = stage;
const hide = message.loading('加载中..', 0);
getWxxzxForm(record.id)
.then((data) => {
hide();
materialCurrent.value = JSON.parse(JSON.stringify(data)) as WxxzxForm;
showMaterials.value = true;
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const remove = (record: WxxzxForm) => {
const hide = message.loading('请求中..', 0);
removeWxxzxForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'WXXZXForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
.action-links {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px 12px;
line-height: 1.5;
}
</style>