260720
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user