Files
tuanwei-admin/src/views/gxmu/reviewList/components/moduleDataModal.vue
T
weicw c81e24c977 feat(gxmu): 审核列表「查看数据」按申报表分组展示并新增上传材料页签
- 详情弹窗改为「申报表 / 上传材料」两个页签,按模块配置分组渲染字段
- 明细数组(团队成员、指导老师、评审成员、团员记录、成果归档等)渲染为表格
- 新增附件解析工具与只读附件列表组件,兼容单地址、JSON 数组、对象数组
- 未来学术之星展示经费/中期/结题/成果归档材料,挑战杯拉取 gxmu_tzbcy_material
- 未在配置中声明的字段兜底进「其他信息」,避免数据遗漏
2026-11-01 17:06:07 +08:00

983 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<ele-modal
:width="1000"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="modalTitle"
:body-style="{ paddingBottom: '20px' }"
okText="关闭"
@update:visible="updateVisible"
@ok="updateVisible(false)"
>
<a-spin :spinning="loading">
<a-empty v-if="!loading && showEmpty" :description="emptyDescription" />
<a-tabs v-else v-model:activeKey="activeTab" class="detail-tabs">
<a-tab-pane key="sheet" :tab="sheetTabLabel">
<div class="sheet">
<div v-if="sheetHeading" class="sheet-title">{{
sheetHeading
}}</div>
<div
v-for="(section, sectionIndex) in sheetSections"
:key="`section-${sectionIndex}-${section.title}`"
class="sheet-section"
>
<div class="sheet-section-title">{{ section.title }}</div>
<a-descriptions
bordered
:column="2"
size="small"
:label-style="{ width: '160px' }"
>
<a-descriptions-item
v-for="item in section.items"
:key="item.key"
:label="item.label"
:span="item.span"
>
<template v-if="item.type === 'rich'">
<!-- eslint-disable vue/no-v-html -->
<div
class="rich-text-content markdown-body"
v-html="item.text"
></div>
</template>
<template v-else-if="item.type === 'image'">
<div class="image-list">
<a-image
v-for="(url, index) in item.images"
:key="`${item.key}-image-${index}`"
:src="url"
:width="120"
/>
</div>
</template>
<template v-else-if="item.type === 'files'">
<AttachmentList
:files="item.files"
empty-text="未上传材料"
/>
</template>
<template v-else-if="item.type === 'table'">
<a-table
size="small"
bordered
:columns="item.columns"
:data-source="item.rows"
:pagination="false"
:scroll="{ x: 'max-content' }"
row-key="__rowKey"
>
<template #bodyCell="{ column, record }">
<template v-if="isFileColumn(column)">
<AttachmentList
:value="record[column.dataIndex as string]"
empty-text="未上传"
/>
</template>
<template v-else>
{{
formatCellValue(record[column.dataIndex as string])
}}
</template>
</template>
</a-table>
</template>
<template v-else-if="item.type === 'object'">
<div
v-for="(line, index) in item.lines"
:key="`${item.key}-line-${index}`"
class="field-line"
>
{{ line }}
</div>
</template>
<template v-else-if="item.type === 'longtext'">
<div class="field-line multiline-text">{{ item.text }}</div>
</template>
<template v-else>
{{ item.text }}
</template>
</a-descriptions-item>
</a-descriptions>
</div>
<div v-if="footerLines.length" class="sheet-footer">
<span
v-for="(line, index) in footerLines"
:key="`footer-${index}`"
class="sheet-footer-item"
>
{{ line }}
</span>
</div>
</div>
</a-tab-pane>
<a-tab-pane v-if="showMaterialTab" key="materials" tab="上传材料">
<div class="material-pane">
<div
v-for="group in materialGroups"
:key="`material-${group.key}`"
class="material-group"
>
<div class="material-group-title">{{ group.label }}</div>
<AttachmentList :files="group.files" empty-text="未上传材料" />
</div>
<div
v-for="group in materialTableGroups"
:key="`material-table-${group.key}`"
class="material-group"
>
<div class="material-group-title">{{ group.label }}</div>
<div
v-for="(row, index) in group.rows"
:key="`${group.key}-${index}`"
class="material-row"
>
<div class="material-row-title">{{ row.title }}</div>
<AttachmentList :files="row.files" empty-text="未上传材料" />
</div>
<a-empty
v-if="!group.rows.length"
class="material-empty"
description="暂无归档材料"
/>
</div>
<div v-if="showTzbcyMaterials" class="material-group">
<div class="material-group-title">申报材料</div>
<a-table
size="small"
bordered
row-key="id"
:loading="materialLoading"
:columns="tzbcyMaterialColumns"
:data-source="tzbcyMaterials"
:pagination="false"
:scroll="{ x: 'max-content' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fileName'">
<a
:href="record.downloadUrl || record.fileUrl"
target="_blank"
rel="noreferrer"
>
{{ record.fileName || '未命名文件' }}
</a>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getMaterialStatusColor(record.status)">
{{ getMaterialStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'createTime'">
{{ formatDateTime(record.createTime) }}
</template>
</template>
</a-table>
</div>
<a-empty
v-if="
!materialGroups.length &&
!materialTableGroups.length &&
!showTzbcyMaterials
"
description="该模块暂无上传材料"
/>
</div>
</a-tab-pane>
</a-tabs>
</a-spin>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { toDateString } from 'ele-admin-pro';
import 'github-markdown-css/github-markdown-light.css';
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import { getManuscript } from '@/api/cms/manuscript';
import { getQmgcForm } from '@/api/gxmu/qmgc-form';
import { getSjqnForm } from '@/api/gxmu/sjqn-form';
import { getSjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form';
import { getTyglForm } from '@/api/gxmu/tygl-form';
import { getTzbcyForm } from '@/api/gxmu/tzbcy-form';
import { getWshqtwForm } from '@/api/gxmu/wshqtw-form';
import { getWshqtzbForm } from '@/api/gxmu/wshqtzb-form';
import { getWxxzxForm } from '@/api/gxmu/wxxzx-form';
import { getYxgqtdgbForm } from '@/api/gxmu/yxgqtdgb-form';
import { getYxgqtyForm } from '@/api/gxmu/yxgqty-form';
import { listTzbcyMaterial } from '@/api/gxmu/tzbcy-material';
import type { TzbcyMaterial } from '@/api/gxmu/tzbcy-material/model';
import {
COMMON_LABELS,
TABLE_FIELD_LABELS,
getModuleDetailConfig,
type ModuleDetailConfig
} from './moduleDetailConfig';
import {
isFileUrl,
parseAttachments,
parseAttachmentImages,
type AttachmentFile
} from '@/views/gxmu/utils/attachment';
type SheetFieldType =
| 'text'
| 'longtext'
| 'rich'
| 'image'
| 'files'
| 'table'
| 'object';
interface TableColumnConfig {
title: string;
dataIndex: string;
key: string;
file?: boolean;
}
interface SheetItem {
key: string;
label: string;
type: SheetFieldType;
span: number;
text?: string;
images?: string[];
files?: AttachmentFile[];
lines?: string[];
columns?: TableColumnConfig[];
rows?: Record<string, unknown>[];
}
interface SheetSection {
title: string;
items: SheetItem[];
}
interface MaterialGroup {
key: string;
label: string;
files: AttachmentFile[];
}
interface MaterialTableGroup {
key: string;
label: string;
rows: { title: string; files: AttachmentFile[] }[];
}
interface ModuleFetcher {
title: string;
fetcher: (id: number) => Promise<Record<string, unknown>>;
}
const MODULE_FETCHERS: Record<string, ModuleFetcher> = {
cms_manuscript: {
title: '稿件',
fetcher: (id) => getManuscript(id) as Promise<Record<string, unknown>>
},
gxmu_sjqn: {
title: '十佳青年岗位能手',
fetcher: (id) => getSjqnForm(id) as Promise<Record<string, unknown>>
},
gxmu_sjtbzbsj: {
title: '十佳团支部书记',
fetcher: (id) => getSjtbzbsjForm(id) as Promise<Record<string, unknown>>
},
gxmu_wshqtw: {
title: '五四红旗团委',
fetcher: (id) => getWshqtwForm(id) as Promise<Record<string, unknown>>
},
gxmu_wshqtzb: {
title: '五四红旗团支部',
fetcher: (id) => getWshqtzbForm(id) as Promise<Record<string, unknown>>
},
gxmu_yxgqtdgb: {
title: '优秀共青团干部',
fetcher: (id) => getYxgqtdgbForm(id) as Promise<Record<string, unknown>>
},
gxmu_yxgqty: {
title: '优秀共青团员',
fetcher: (id) => getYxgqtyForm(id) as Promise<Record<string, unknown>>
},
gxmu_tzbcy_form: {
title: '挑战杯',
fetcher: (id) => getTzbcyForm(id) as Promise<Record<string, unknown>>
},
gxmu_qmgc_form: {
title: '青马工程',
fetcher: (id) => getQmgcForm(id) as Promise<Record<string, unknown>>
},
gxmu_wxxzx_project: {
title: '未来学术之星',
fetcher: (id) => getWxxzxForm(id) as Promise<Record<string, unknown>>
},
gxmu_tygl_form: {
title: '团员管理',
fetcher: (id) => getTyglForm(id) as Promise<Record<string, unknown>>
},
gxmu_tygl: {
title: '团员管理',
fetcher: (id) => getTyglForm(id) as Promise<Record<string, unknown>>
}
};
// 不在申报表中展示的字段
const HIDDEN_FIELDS = new Set([
'id',
'reviewList',
'deleted',
'tenantId',
'userId',
'branchOrganizationId',
'contentFormat'
]);
const IMAGE_FIELDS = new Set(['photo', 'cover', 'promiseSignature']);
const RICH_TEXT_FIELDS = new Set(['content']);
const WIDE_FIELDS = new Set([
'projectBrief',
'socialValue',
'practiceProcess',
'innovationMeaning',
'developmentProspect',
'teamCooperation',
'projectMaterials',
'otherProofs',
'projectSummary',
'teamIntro',
'practiceLog',
'resume',
'awards',
'academicPerformance',
'secondaryLeagueOpinion',
'secondaryPartyOpinion',
'schoolLeagueOpinion',
'experience',
'mainStory',
'leagueOpinion',
'partyOpinion',
'schoolOpinion',
'workSummaryThreeYears',
'honorsFiveYears',
'rationale',
'teacherOpinion',
'reviewOpinion',
'collegeOpinion',
'cadreExperience',
'midtermRemark',
'finalRemark',
'archiveAwards'
]);
const props = defineProps<{
visible: boolean;
module?: string;
moduleName?: string;
pk?: number;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const loading = ref(false);
const maxable = ref(true);
const detail = ref<Record<string, unknown>>({});
const loadFailed = ref(false);
const activeTab = ref('sheet');
const tzbcyMaterials = ref<TzbcyMaterial[]>([]);
const materialLoading = ref(false);
const currentConfig = computed<ModuleDetailConfig | undefined>(() =>
getModuleDetailConfig(props.module)
);
const modalTitle = computed(() => {
return `${
props.moduleName ||
currentConfig.value?.title ||
MODULE_FETCHERS[props.module || '']?.title ||
'数据'
}详情`;
});
const sheetTabLabel = computed(() =>
props.module === 'cms_manuscript' ? '稿件内容' : '申报表'
);
const showEmpty = computed(() => {
if (loadFailed.value || !currentConfig.value) {
return true;
}
return !sheetSections.value.length && !showMaterialTab.value;
});
const emptyDescription = computed(() => {
if (loadFailed.value) {
return '数据加载失败';
}
if (!currentConfig.value) {
return '暂不支持查看该模块数据';
}
return '暂无可展示数据';
});
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return Object.prototype.toString.call(value) === '[object Object]';
};
const hasVisibleValue = (value: unknown) => {
if (value === null || value === undefined) {
return false;
}
if (typeof value === 'string') {
return value.trim().length > 0;
}
if (Array.isArray(value)) {
return value.length > 0;
}
if (isPlainObject(value)) {
return Object.values(value).some((item) => hasVisibleValue(item));
}
return true;
};
const getFieldLabel = (key: string) => {
const label =
currentConfig.value?.labels[key] ||
COMMON_LABELS[key] ||
TABLE_FIELD_LABELS[key];
if (label) {
return label;
}
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (text) => text.toUpperCase())
.trim();
};
const formatDateTime = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '-';
}
return toDateString(String(value), 'yyyy-MM-dd HH:mm:ss');
};
const formatStatusValue = (value: unknown) => {
if (props.module === 'cms_manuscript') {
if (Number(value) === 0) {
return '待审核';
}
if (Number(value) === 1) {
return '显示';
}
}
return String(value);
};
const formatCellValue = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '-';
}
if (Array.isArray(value)) {
return value.join('、');
}
if (isPlainObject(value)) {
return JSON.stringify(value);
}
return String(value);
};
const formatDisplayValue = (key: string, value: unknown) => {
if (key === 'createTime' || key === 'updateTime') {
return formatDateTime(value);
}
if (key === 'status') {
return formatStatusValue(value);
}
return formatCellValue(value);
};
/**
* 判断字段值是否是一组附件(全部为文件地址时才认为命中)
*/
const isAttachmentValue = (value: unknown) => {
if (typeof value === 'string' || Array.isArray(value)) {
const files = parseAttachments(value);
return files.length > 0;
}
return false;
};
/**
* 上传材料的字段集合:配置声明 + 自动识别
*/
const materialKeySet = computed(() => {
const keys = new Set<string>(currentConfig.value?.materialFields || []);
Object.keys(detail.value).forEach((key) => {
if (HIDDEN_FIELDS.has(key) || keys.has(key)) {
return;
}
if (IMAGE_FIELDS.has(key)) {
return;
}
if (isAttachmentValue(detail.value[key])) {
keys.add(key);
}
});
return keys;
});
const showMaterialTab = computed(() => {
if (!currentConfig.value) {
return false;
}
if (materialKeySet.value.size) {
return true;
}
if (currentConfig.value.materialTables?.length) {
return true;
}
return currentConfig.value.materialSource === 'tzbcy';
});
const sheetHeading = computed(() => {
const name = currentConfig.value?.sheetName;
if (!name) {
return '';
}
const year = detail.value.year ? `${detail.value.year}年` : '';
const noSuffix = props.module === 'cms_manuscript' || name.endsWith('表');
return `${year}“${name}”${noSuffix ? '' : '申报表'}`;
});
const buildTableColumns = (rows: Record<string, unknown>[]) => {
const columnKeys: string[] = [];
rows.forEach((row) => {
Object.keys(row).forEach((key) => {
if (HIDDEN_FIELDS.has(key) || key === '__rowKey') {
return;
}
if (!columnKeys.includes(key)) {
columnKeys.push(key);
}
});
});
return columnKeys.map((key) => ({
title: getFieldLabel(key),
dataIndex: key,
key,
file: key === 'proofFile'
}));
};
const buildSheetItem = (key: string, value: unknown): SheetItem => {
const label = getFieldLabel(key);
if (RICH_TEXT_FIELDS.has(key)) {
return {
key,
label,
type: 'rich',
span: 2,
text: String(value ?? '')
};
}
if (IMAGE_FIELDS.has(key)) {
const images = parseAttachmentImages(value);
if (images.length) {
return { key, label, type: 'image', span: 2, images };
}
// 非图片(如 pdf 扫描件)按附件展示
if (isFileUrl(String(value ?? ''))) {
return {
key,
label,
type: 'files',
span: 2,
files: parseAttachments(value)
};
}
return {
key,
label,
type: 'image',
span: 2,
images: []
};
}
if (Array.isArray(value)) {
const files = parseAttachments(value);
const allObjects = value.every((item) => isPlainObject(item));
if (files.length && !allObjects) {
return { key, label, type: 'files', span: 2, files };
}
if (allObjects) {
const rows = (value as Record<string, unknown>[]).map((row, index) => ({
...row,
__rowKey: `${key}-${index}`
}));
return {
key,
label,
type: 'table',
span: 2,
rows,
columns: buildTableColumns(rows)
};
}
return {
key,
label,
type: 'longtext',
span: 2,
text: value.map((item) => formatCellValue(item)).join('、')
};
}
if (isPlainObject(value)) {
const lines = Object.keys(value)
.filter(
(item) => !HIDDEN_FIELDS.has(item) && hasVisibleValue(value[item])
)
.map(
(item) => `${getFieldLabel(item)}:${formatCellValue(value[item])}`
);
return {
key,
label,
type: 'object',
span: 2,
lines: lines.length ? lines : ['-']
};
}
const text = formatDisplayValue(key, value);
const isLong =
WIDE_FIELDS.has(key) ||
(typeof value === 'string' &&
(value.includes('\n') || value.length > 60));
return {
key,
label,
type: isLong ? 'longtext' : 'text',
span: isLong ? 2 : 1,
text
};
};
const sheetSections = computed<SheetSection[]>(() => {
const source = detail.value || {};
const config = currentConfig.value;
const usedKeys = new Set<string>();
const sections: SheetSection[] = [];
const sectionsConfig = config?.sections?.length
? config.sections
: [{ title: '全部字段', fields: Object.keys(source) }];
sectionsConfig.forEach((section) => {
const items: SheetItem[] = [];
section.fields.forEach((key) => {
usedKeys.add(key);
if (HIDDEN_FIELDS.has(key) || materialKeySet.value.has(key)) {
return;
}
const value = source[key];
if (!hasVisibleValue(value)) {
return;
}
items.push(buildSheetItem(key, value));
});
if (items.length) {
sections.push({ title: section.title, items });
}
});
// 兜底:未在配置中声明的字段归入其他信息,避免数据遗漏
const extraItems: SheetItem[] = Object.keys(source)
.filter((key) => {
if (usedKeys.has(key) || HIDDEN_FIELDS.has(key)) {
return false;
}
if (key === 'createTime' || key === 'updateTime') {
return false;
}
if (materialKeySet.value.has(key)) {
return false;
}
return hasVisibleValue(source[key]);
})
.map((key) => buildSheetItem(key, source[key]));
if (extraItems.length) {
sections.push({ title: '其他信息', items: extraItems });
}
return sections;
});
const materialGroups = computed<MaterialGroup[]>(() => {
const config = currentConfig.value;
if (!config) {
return [];
}
const keys = new Set<string>(config.materialFields || []);
materialKeySet.value.forEach((key) => keys.add(key));
return Array.from(keys)
.filter((key) => key !== 'photo' && key !== 'cover')
.map((key) => ({
key,
label: getFieldLabel(key),
files: parseAttachments(detail.value[key])
}))
.filter((group) => group.files.length > 0);
});
const materialTableGroups = computed<MaterialTableGroup[]>(() => {
const config = currentConfig.value;
if (!config?.materialTables?.length) {
return [];
}
return config.materialTables.map((table) => {
const rows = Array.isArray(detail.value[table.field])
? (detail.value[table.field] as Record<string, unknown>[])
: [];
return {
key: table.field,
label: table.label,
rows: rows
.map((row) => ({
title:
(table.titleField
? formatCellValue(row[table.titleField])
: '') || getFieldLabel(table.field),
files: parseAttachments(row[table.fileField])
}))
.filter((row) => row.files.length > 0)
};
});
});
const showTzbcyMaterials = computed(
() => currentConfig.value?.materialSource === 'tzbcy'
);
const tzbcyMaterialColumns = [
{ title: '材料类型', dataIndex: 'materialType', key: 'materialType' },
{ title: '文件名称', dataIndex: 'fileName', key: 'fileName' },
{ title: '审核状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '驳回原因', dataIndex: 'rejectReason', key: 'rejectReason' },
{
title: '上传时间',
dataIndex: 'createTime',
key: 'createTime',
width: 170
}
];
const getMaterialStatusText = (status?: number) => {
if (status === 1) {
return '通过';
}
if (status === 2) {
return '驳回';
}
return '待审核';
};
const getMaterialStatusColor = (status?: number) => {
if (status === 1) {
return 'green';
}
if (status === 2) {
return 'red';
}
return 'default';
};
const isFileColumn = (column: Record<string, unknown>) => {
return !!column.file;
};
const footerLines = computed(() => {
const lines: string[] = [];
if (hasVisibleValue(detail.value.createTime)) {
lines.push(`提交时间:${formatDateTime(detail.value.createTime)}`);
}
if (hasVisibleValue(detail.value.updateTime)) {
lines.push(`更新时间:${formatDateTime(detail.value.updateTime)}`);
}
return lines;
});
const loadTzbcyMaterials = async () => {
if (!props.pk) {
tzbcyMaterials.value = [];
return;
}
materialLoading.value = true;
try {
tzbcyMaterials.value = await listTzbcyMaterial({ formId: props.pk });
} catch (e: any) {
tzbcyMaterials.value = [];
message.error(e.message || '申报材料加载失败');
} finally {
materialLoading.value = false;
}
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const loadDetail = async () => {
if (!props.visible) {
return;
}
detail.value = {};
loadFailed.value = false;
activeTab.value = 'sheet';
tzbcyMaterials.value = [];
if (!props.pk || !currentConfig.value) {
if (props.module && !currentConfig.value) {
loadFailed.value = true;
}
return;
}
loading.value = true;
try {
const fetcher = currentConfig.value
? MODULE_FETCHERS[props.module || '']
: undefined;
if (fetcher) {
detail.value = (await fetcher.fetcher(props.pk)) || {};
}
if (currentConfig.value.materialSource === 'tzbcy') {
await loadTzbcyMaterials();
}
} catch (e: any) {
loadFailed.value = true;
message.error(e.message || '数据加载失败');
} finally {
loading.value = false;
}
};
watch(
() => [props.visible, props.module, props.pk],
([visible]) => {
if (visible) {
loadDetail();
} else {
detail.value = {};
tzbcyMaterials.value = [];
loadFailed.value = false;
loading.value = false;
}
},
{ immediate: true }
);
</script>
<script lang="ts">
export default {
name: 'ModuleDataModal'
};
</script>
<style lang="less" scoped>
.detail-tabs :deep(.ant-tabs-nav) {
margin-bottom: 16px;
}
.sheet-title {
margin-bottom: 16px;
color: #1f2329;
font-weight: 600;
font-size: 18px;
text-align: center;
}
.sheet-section + .sheet-section {
margin-top: 18px;
}
.sheet-section-title {
margin-bottom: 8px;
padding-left: 8px;
border-left: 3px solid #1677ff;
color: #1f2329;
font-weight: 600;
font-size: 14px;
line-height: 16px;
}
.sheet-footer {
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-top: 14px;
color: #8c8c8c;
font-size: 12px;
}
.field-line {
line-height: 1.8;
white-space: pre-wrap;
word-break: break-all;
}
.multiline-text {
white-space: pre-wrap;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
:deep(.markdown-body) {
background: #fff;
color: rgba(0, 0, 0, 0.88);
}
.rich-text-content {
min-height: 32px;
line-height: 1.8;
word-break: break-word;
}
.rich-text-content:deep(p:last-child) {
margin-bottom: 0;
}
.material-group + .material-group {
margin-top: 18px;
}
.material-group-title {
margin-bottom: 8px;
padding-left: 8px;
border-left: 3px solid #1677ff;
color: #1f2329;
font-weight: 600;
font-size: 14px;
line-height: 16px;
}
.material-row + .material-row {
margin-top: 12px;
}
.material-row-title {
margin-bottom: 6px;
color: #5f6b7a;
font-size: 13px;
}
.material-empty {
margin: 12px 0;
}
</style>