10 Commits

Author SHA1 Message Date
weicw 0444846422 feat(declare): 申报记录支持撤回/作废,导入支持跳过与失败清单
- 申报记录详情弹窗新增撤回、作废(强制放行)入口,覆盖 9 个模块
- 申报管理支持配置允许重复提交与最多同时条数
- 批量导入逐行容错,命中重复时跳过并给出失败清单导出
2026-11-02 19:28:56 +08:00
weicw 42fddc5c85 feat(cms): 图片库升级为素材库,支持文档与音视频,缩略图统一 4:3
/cms/photo 原来只能传图片(accept=image/*,前后端各拦一次),甲方要求支持常见
文档格式与音视频,并统一缩略图宽高。

- index:标题改「素材库」,上传放开全类型,新增类型筛选(全部/图片/文档/音频/视频)
- image / list:封面统一 4:3(网格 aspect-ratio + object-fit: cover,列表 120x90);
  图片改用 thumbnail 地址(原来直接拉原图);文档/音频用类型图标卡,视频用图标卡
  加播放角标;点击视频/音频开 MediaPreview 弹窗,文档新窗口打开
- photo-edit:去掉上传表单(上传即入库,弹窗只改元数据),顺带修掉「上传已由后端
  落一条记录、点保存又 addFiles 插一条无 path 空记录」的重复入库 bug;分享代码按
  类型分流(图片 img 系列 / 视频 video / 音频 audio / 文档只给链接与下载)
- utils/media.ts:媒体大类判定、上传校验、大小格式化、类型图标与配色的唯一出口,
  MEDIA_ACCEPT 是后端 UploadRules 的镜像,两边要一起改
2026-11-01 21:03:56 +08:00
weicw 6d19cb5a05 feat(gxmu): 文稿工作台新增「一键排版」,按原文排版进富文本编辑器
生成的 markdown 正文现在能一键转成带格式的富文本:
- 正文区改为「Markdown 预览 / 富文本排版」两个 tab,一键排版后自动切到编辑器
- 转换统一走 utils/manuscript-content 的 markdownToHtml,新增 plugins 参数,
  传 gfm 插件还原表格、删除线(不挂 gfm 时表格会漏成一堆 | 管道符)
- 生成标题不拼进正文:标题有独立字段,导出 Word 时才拼进导出件
- 编辑器已有内容且文本不一致时才二次确认覆盖,同一份内容重复排版不打扰
- 富文本下方新增「保存为稿件」,入库写 contentFormat: 'html';
  存过之后按钮置灰,避免连点存出重复草稿
- oa/项目资料 里指向外部排版站的同名按钮改成「公众号排版」,避开同名不同义
2026-11-01 19:27:46 +08:00
weicw 4b810a731c fix(gxmu): 审核驳回状态在各页面统一显示为「驳回」
申报模块状态标签、审核历史、稿件审核记录与列表、我的申报历史、审核列表编辑表单及接口注释,原「不通过」文案统一改为「驳回」。
2026-11-01 18:21:46 +08:00
weicw 11e16ca311 fix(gxmu): 审核状态文案「拒绝」统一改为「驳回」
审核列表状态标签、审核流通过/驳回操作与状态文案同步调整。
2026-11-01 18:18:05 +08:00
weicw 3888438694 feat(gxmu): 审核流的审核意见改为必填项
通过/拒绝审核时审核意见为空或仅空白字符将被拦截,提交前去除首尾空格,输入框限制 500 字并显示字数。
2026-11-01 17:06:13 +08:00
weicw c81e24c977 feat(gxmu): 审核列表「查看数据」按申报表分组展示并新增上传材料页签
- 详情弹窗改为「申报表 / 上传材料」两个页签,按模块配置分组渲染字段
- 明细数组(团队成员、指导老师、评审成员、团员记录、成果归档等)渲染为表格
- 新增附件解析工具与只读附件列表组件,兼容单地址、JSON 数组、对象数组
- 未来学术之星展示经费/中期/结题/成果归档材料,挑战杯拉取 gxmu_tzbcy_material
- 未在配置中声明的字段兜底进「其他信息」,避免数据遗漏
2026-11-01 17:06:07 +08:00
weicw 92d6bdf811 fix(index): 首页能力描述由「AI+党建的双向赋能」更正为「AI+团建的双向赋能」 2026-11-01 16:36:48 +08:00
weicw fd443aced3 feat(gxmu): 申报管理选择挑战杯模块时预填申报表与公开展示信息表配置
- 选中挑战杯模块即按挑战杯申报表口径预填项目申报表与公开展示信息表的
  项目类型(3 项)、项目分组(5 项)
- 默认值复用 TZBCYForm/options 中的常量,避免两处维护产生漂移
- 仅在配置为空(含纯空行)时填充,用户手动编辑或已配置的记录不被覆盖
- 切到其他模块不再清空已填配置,切回挑战杯时保留用户内容
2026-11-01 16:35:56 +08:00
weicw 35e527ce53 Merge pull request 'feat(system): 角色管理表格行增加编辑入口(名称、备注可改,角色标识锁定)' (#2) from weicw-dshsa/tuanwei-admin:agent/role-edit-button-ba5a05896d2c into master
Reviewed-on: b2894lxlx/tuanwei-admin#2
2026-11-01 15:08:56 +08:00
38 changed files with 3576 additions and 1032 deletions
+34
View File
@@ -0,0 +1,34 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
/**
* 撤回申报:待审核 -> 草稿
*/
export async function withdrawDeclareRecord(module: string, id: number) {
const res = await request.post<ApiResult<unknown>>(
'/gxmu/declare-record/withdraw',
{ module, id }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 作废申报(等同强制放行,作废后申报人可重新申报)
*/
export async function voidDeclareRecord(
module: string,
id: number,
reason?: string
) {
const res = await request.post<ApiResult<unknown>>(
'/gxmu/declare-record/void',
{ module, id, reason }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
+13 -1
View File
@@ -36,6 +36,18 @@ export interface Declare {
createTime?: string;
// 更新时间
updateTime?: string;
// 是否允许重复提交:0否 1是
allowRepeat?: number;
// 最多同时进行中的申报条数
maxPending?: number;
// 当前用户在该项目下的最新申报状态:0草稿 1待审核 2通过 3驳回 4作废
myStatus?: number;
// 当前用户在该项目下的最新申报记录id
myRecordId?: number;
// 当前用户在该项目下的最新申报记录提交时间
mySubmitTime?: string;
// 当前用户在该项目下的最新申报记录状态备注
myStatusRemark?: string;
// 删除标记:0未删 1已删
deleted?: number;
// 是否可申报
@@ -55,4 +67,4 @@ export interface DeclareParam extends PageParam {
formProjectGroup?: string;
publicProjectType?: string;
publicProjectGroup?: string;
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ export interface ReviewList {
businessName?: string;
// 主键
pk?: number;
// 状态(0不通过 1通过)
// 状态(0驳回 1通过)
status?: number;
// 审核意见
content?: string;
+2
View File
@@ -39,6 +39,8 @@ export interface FileRecordParam extends PageParam {
name?: string;
path?: string;
contentType?: string;
// 媒体大类: image/video/audio/doc,空表示不限
mediaCategory?: string;
createNickname?: string;
count?: number;
page?: number;
+13 -3
View File
@@ -10,6 +10,7 @@
* 所以打开编辑弹窗时先把非 HTML 的正文转成 HTML,保存时统一写 `contentFormat: 'html'`。
*/
import { getProcessor } from 'bytemd';
import type { BytemdPlugin } from 'bytemd';
const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i;
const MARKDOWN_LINE_RE =
@@ -57,11 +58,20 @@ export function detectFormat(content?: string | null): ContentFormat {
return looksLikeMarkdown(text) ? 'markdown' : 'text';
}
/** markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。 */
export function markdownToHtml(markdown: string): string {
/**
* markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。
*
* `plugins` 决定润色到什么程度:默认不挂 GFM,表格会原样漏成一堆 `|` 管道符、
* 删除线也会丢。要让「表格/删除线」和预览一致,调用方传 gfm 插件进来
* (generate 页的一键排版就是这么做的)。转换口径只留这一处,别再各写一套。
*/
export function markdownToHtml(
markdown: string,
plugins: BytemdPlugin[] = []
): string {
try {
// processSync 返回的是 vfile(`toString()` 即 HTML),不是裸字符串
const result = getProcessor({}).processSync(String(markdown ?? ''));
const result = getProcessor({ plugins }).processSync(String(markdown ?? ''));
return String(result);
} catch (e) {
console.error('markdown 转换失败', e);
+220
View File
@@ -0,0 +1,220 @@
import type { Component } from 'vue';
import {
CustomerServiceOutlined,
FileExcelOutlined,
FileMarkdownOutlined,
FilePdfOutlined,
FilePptOutlined,
FileTextOutlined,
FileUnknownOutlined,
FileWordOutlined,
VideoCameraOutlined
} from '@ant-design/icons-vue';
import type { FileRecord } from '@/api/system/file/model';
/**
* 媒体大类。
*
* 注意它和 `contentType`(MIME 字符串)不是一回事:MIME 里「文档」没有统一前缀
* (pdf/docx/zip 都是 application/…),所以大类由 MIME 前缀 + 后缀一起判定。
*/
export type MediaKind = 'image' | 'video' | 'audio' | 'doc';
/** 与后端 UploadRules 的白名单保持一致,改这里要同步改后端 */
export const MEDIA_ACCEPT = [
'image/*',
'.pdf',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
'.txt',
'.md',
'.csv',
'.mp3',
'.wav',
'.m4a',
'.aac',
'.mp4',
'.mov',
'.webm'
].join(',');
const DOC_SUFFIXES = [
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'md',
'csv'
];
const VIDEO_SUFFIXES = ['mp4', 'mov', 'webm'];
const AUDIO_SUFFIXES = ['mp3', 'wav', 'm4a', 'aac'];
/** 图片大小上限(MB),其余类型上限(MB),受后端 multipart 100MB 硬顶约束 */
export const MEDIA_MAX_MB_IMAGE = 10;
export const MEDIA_MAX_MB_OTHER = 100;
/** 类型筛选项,value 为空表示不限 */
export const MEDIA_CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: '', label: '全部类型' },
{ value: 'image', label: '图片' },
{ value: 'doc', label: '文档' },
{ value: 'audio', label: '音频' },
{ value: 'video', label: '视频' }
];
export const MEDIA_KIND_LABEL: Record<MediaKind, string> = {
image: '图片',
video: '视频',
audio: '音频',
doc: '文档'
};
/** 取小写后缀,不带点 */
export function mediaSuffix(name?: string | null): string {
if (!name || name.lastIndexOf('.') < 0) {
return '';
}
return name.substring(name.lastIndexOf('.') + 1).toLowerCase();
}
/** 由 MIME + 文件名判定大类 */
export function mediaKindOf(
contentType?: string | null,
name?: string | null
): MediaKind {
const mime = (contentType || '').toLowerCase();
if (mime.startsWith('image/')) {
return 'image';
}
if (mime.startsWith('video/')) {
return 'video';
}
if (mime.startsWith('audio/')) {
return 'audio';
}
const suffix = mediaSuffix(name);
if (VIDEO_SUFFIXES.includes(suffix)) {
return 'video';
}
if (AUDIO_SUFFIXES.includes(suffix)) {
return 'audio';
}
return 'doc';
}
/** 由文件记录判定大类 */
export function mediaKindOfRecord(record?: FileRecord | null): MediaKind {
return mediaKindOf(record?.contentType, record?.path || record?.name);
}
/**
* 上传前校验,通过返回 null,不通过返回给用户看的错误文案。
* 与后端同规则:图片按 MIME 认,其余按后缀白名单认。
*/
export function validateMediaFile(file: File): string | null {
const mime = (file.type || '').toLowerCase();
const suffix = mediaSuffix(file.name);
const isImage = mime.startsWith('image/');
if (!isImage) {
const allowed =
DOC_SUFFIXES.includes(suffix) ||
VIDEO_SUFFIXES.includes(suffix) ||
AUDIO_SUFFIXES.includes(suffix);
if (!allowed) {
return '不支持的文件格式,仅支持图片、文档、音频、视频';
}
}
const maxMb = isImage ? MEDIA_MAX_MB_IMAGE : MEDIA_MAX_MB_OTHER;
if (file.size / 1024 / 1024 > maxMb) {
return `文件大小不能超过 ${maxMb}MB`;
}
return null;
}
/** 文件大小格式化 */
export function formatFileSize(length?: number | null): string {
const size = Number(length ?? 0);
if (!size || size < 0) {
return '-';
}
if (size < 1024) {
return size + 'B';
}
if (size < 1024 * 1024) {
return (size / 1024).toFixed(1) + 'KB';
}
if (size < 1024 * 1024 * 1024) {
return (size / 1024 / 1024).toFixed(1) + 'M';
}
return (size / 1024 / 1024 / 1024).toFixed(1) + 'G';
}
/** 卡片/表格里显示的类型图标 */
export function mediaIcon(kind: MediaKind, name?: string | null): Component {
if (kind === 'audio') {
return CustomerServiceOutlined;
}
if (kind === 'video') {
return VideoCameraOutlined;
}
switch (mediaSuffix(name)) {
case 'pdf':
return FilePdfOutlined;
case 'doc':
case 'docx':
return FileWordOutlined;
case 'xls':
case 'xlsx':
case 'csv':
return FileExcelOutlined;
case 'ppt':
case 'pptx':
return FilePptOutlined;
case 'md':
return FileMarkdownOutlined;
case 'txt':
return FileTextOutlined;
default:
return FileUnknownOutlined;
}
}
/** 图标色,按类型区分但都压在中低饱和,避免卡片花花绿绿 */
export function mediaColor(kind: MediaKind, name?: string | null): string {
if (kind === 'audio') {
return '#805ad5';
}
if (kind === 'video') {
return '#2b6cb0';
}
switch (mediaSuffix(name)) {
case 'pdf':
return '#e5484d';
case 'doc':
case 'docx':
return '#2b6cb0';
case 'xls':
case 'xlsx':
case 'csv':
return '#2f855a';
case 'ppt':
case 'pptx':
return '#c05621';
default:
return '#718096';
}
}
/** 非图片卡片右下角的扩展名徽标文本 */
export function mediaExtLabel(record?: FileRecord | null): string {
const suffix = mediaSuffix(record?.path || record?.name);
return suffix ? suffix.toUpperCase() : 'FILE';
}
@@ -47,7 +47,7 @@
:color="item.status === 1 ? 'success' : item.status === 0 ? 'error' : 'default'"
style="margin-left: 8px"
>
{{ item.status === 1 ? '通过' : item.status === 0 ? '不通过' : '-' }}
{{ item.status === 1 ? '通过' : item.status === 0 ? '驳回' : '-' }}
</a-tag>
</div>
<div v-if="item.user?.realName" style="margin-bottom: 4px">
+1 -1
View File
@@ -29,7 +29,7 @@
<a-tag color="green">通过</a-tag>
</template>
<template v-else-if="getLastReview(record)?.status === 2">
<a-tag color="red">不通过</a-tag>
<a-tag color="red">驳回</a-tag>
</template>
<template v-else>
<a-tag>未审核</a-tag>
@@ -0,0 +1,93 @@
<!-- 音频 / 视频预览弹窗:素材库里非图片项的统一播放器 -->
<template>
<ele-modal
:width="kind === 'video' ? 880 : 480"
:footer="null"
:visible="visible"
:title="record?.name"
:destroy-on-close="true"
@update:visible="updateVisible"
>
<div v-if="visible && record" class="media-preview">
<ele-xg-player
v-if="kind === 'video'"
:key="'v-' + record.id"
:config="playerConfig"
/>
<div v-else class="media-preview-audio">
<customer-service-outlined class="media-preview-audio-icon" />
<div class="media-preview-audio-name ele-text-secondary">
{{ record.name }}
</div>
<audio
:key="'a-' + record.id"
:src="record.url"
controls
autoplay
class="media-preview-audio-player"
></audio>
</div>
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { CustomerServiceOutlined } from '@ant-design/icons-vue';
import type { FileRecord } from '@/api/system/file/model';
import { mediaKindOfRecord } from '@/utils/media';
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 当前预览的文件
record?: FileRecord | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const kind = computed(() => mediaKindOfRecord(props.record));
// xgplayer 配置,key 变化时重建实例,避免沿用上一条的播放状态
const playerConfig = computed(() => ({
id: 'cmsPhotoPreviewPlayer',
lang: 'zh-cn',
fluid: true,
url: props.record?.url,
playbackRate: [0.5, 1, 1.5, 2],
pip: true
}));
/* 更新 visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>
<style lang="less" scoped>
.media-preview-audio {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0 8px;
}
.media-preview-audio-icon {
font-size: 48px;
color: #805ad5;
}
.media-preview-audio-name {
margin: 12px 0 20px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-preview-audio-player {
width: 100%;
}
</style>
+113 -113
View File
@@ -1,10 +1,10 @@
<!-- 角色编辑弹窗 -->
<!-- 素材编辑弹窗:文件在列表页上传时已入库,这里只改元数据和分享代码 -->
<template>
<ele-modal
:width="600"
:visible="visible"
:confirm-loading="loading"
:title="isUpdate ? '编辑' : '上传文件'"
title="编辑素材"
:body-style="{ paddingBottom: '8px' }"
okText="保存"
@update:visible="updateVisible"
@@ -19,27 +19,22 @@
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="上传文件" name="fileName" v-if="!isUpdate">
<span
class="ele-text-success"
v-if="fileName"
style="margin-right: 10px"
>
{{ fileName }}
</span>
<a-upload
:show-upload-list="false"
:accept="'video/*'"
v-if="!fileName"
:customRequest="onUpload"
>
<a-button type="primary" class="ele-btn-icon">
<template #icon>
<UploadOutlined />
</template>
<span>上传文件</span>
</a-button>
</a-upload>
<a-form-item label="文件">
<div class="media-file">
<component
:is="fileIcon"
class="media-file-icon"
:style="{ color: fileColor }"
/>
<div class="media-file-info">
<div class="media-file-name" :title="record?.name">
{{ record?.name }}
</div>
<div class="ele-text-secondary media-file-sub">
{{ kindLabel }} · {{ fileSize }}
</div>
</div>
</div>
</a-form-item>
<a-form-item label="文件名称" name="name">
<a-input
@@ -53,17 +48,17 @@
<a-textarea
:rows="4"
:maxlength="200"
placeholder="图片描述"
placeholder="素材描述"
v-model:value="form.comments"
/>
</a-form-item>
<div style="margin-bottom: 22px">
<a-divider orientation="left">图片分享</a-divider>
<a-divider orientation="left">{{ shareTitle }}</a-divider>
</div>
<a-form-item label="图片URL链接">
<a-form-item label="素材链接">
<a-input v-model:value="share.url" @click="copyText(share.url)" />
</a-form-item>
<a-form-item label="网页代码(HTML)">
<a-form-item label="网页代码(HTML)" v-if="share.html">
<a-input v-model:value="share.html" @click="copyText(share.html)" />
</a-form-item>
<a-form-item label="Markdown">
@@ -72,38 +67,36 @@
@click="copyText(share.Markdown)"
/>
</a-form-item>
<a-form-item label="论坛BBCode">
<a-form-item label="论坛BBCode" v-if="share.bbCode">
<a-input v-model:value="share.bbCode" @click="copyText(share.bbCode)" />
</a-form-item>
<!-- <a-form-item label="封面图" name="images">-->
<!-- <ele-image-upload-->
<!-- v-model:value="images"-->
<!-- :limit="1"-->
<!-- :drag="true"-->
<!-- :item-style="{ width: '60px', height: '60px' }"-->
<!-- :accept="'image/*'"-->
<!-- :upload-handler="uploadHandler"-->
<!-- @upload="onUpload"-->
<!-- />-->
<!-- </a-form-item>-->
<a-form-item label="下载地址">
<a-input
v-model:value="share.download"
@click="copyText(share.download)"
/>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue/es';
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
import { storeToRefs } from 'pinia';
import { useThemeStore } from '@/store/modules/theme';
import useFormData from '@/utils/use-form-data';
import type { FileRecord } from '@/api/system/file/model';
import { messageLoading } from 'ele-admin-pro';
import { addFiles, updateFiles, uploadFile } from '@/api/system/file';
import { UploadOutlined } from '@ant-design/icons-vue';
import { RuleObject } from 'ant-design-vue/es/form';
import { updateFiles } from '@/api/system/file';
import { copyText } from '@/utils/common';
import {
formatFileSize,
mediaColor,
mediaIcon,
mediaKindOfRecord,
MEDIA_KIND_LABEL
} from '@/utils/media';
// 是否开启响应式布局
const themeStore = useThemeStore();
@@ -121,20 +114,11 @@
data?: FileRecord | null;
}>();
//
const formRef = ref<FormInstance | null>(null);
const fileName = ref('');
// 是否是修改
const isUpdate = ref(false);
// 当前编辑的原始记录,分享代码依赖它的 url,不随表单变化
const record = ref<FileRecord | null>(null);
// 提交状态
const loading = ref(false);
// 图片资源分享
const share = ref({
url: '',
Markdown: '',
html: '',
bbCode: ''
});
// 表单数据
const { form, resetFields, assignFields } = useFormData<FileRecord>({
@@ -143,22 +127,49 @@
comments: ''
});
const kind = computed(() => mediaKindOfRecord(record.value));
const kindLabel = computed(() => MEDIA_KIND_LABEL[kind.value]);
const fileSize = computed(() => formatFileSize(record.value?.length));
const fileIcon = computed(() =>
mediaIcon(kind.value, record.value?.path || record.value?.name)
);
const fileColor = computed(() =>
mediaColor(kind.value, record.value?.path || record.value?.name)
);
// 文档给的是链接与下载地址,图片/音视频给的是嵌入代码
const shareTitle = computed(() =>
kind.value === 'doc' ? '素材链接' : '素材分享'
);
/**
* 分享代码按类型生成:图片沿用 img 系列,音视频给 video/audio 标签,
* 文档只给直链和下载地址(不提供 HTML 嵌入)。
*/
const share = computed(() => {
const data = record.value;
const url = data?.url || '';
const name = data?.name || '';
const result = {
url,
html: '',
Markdown: `[${name}](${url})`,
bbCode: `[url=${url}]${name}[/url]`,
download: data?.downloadUrl || ''
};
if (kind.value === 'image') {
result.html = `<a href="${url}"><img src="${url}" alt="${name}" border="0" /></a>`;
result.Markdown = `[![${name}](${url})](${url})`;
result.bbCode = `[url=${url}][img]${url}[/img][/url]`;
} else if (kind.value === 'video') {
result.html = `<video src="${url}" controls></video>`;
} else if (kind.value === 'audio') {
result.html = `<audio src="${url}" controls></audio>`;
}
return result;
});
// 表单验证规则
const rules = reactive<Record<string, Rule[]>>({
fileName: [
{
required: true,
message: '请上传文件',
type: 'string',
trigger: 'blur',
validator: async (_rule: RuleObject) => {
if (!isUpdate.value && fileName.value.length == 0) {
return Promise.reject('请上传文件');
}
return Promise.resolve();
}
}
],
name: [
{
required: true,
@@ -169,39 +180,6 @@
]
});
// 上传文件
const onUpload = (item) => {
const { file } = item;
if (!file.type.startsWith('video')) {
message.error('文件格式不正确!');
return;
}
if (file.size / 1024 / 1024 > 100) {
message.error('大小不能超过 100MB');
return;
}
const hide = messageLoading({
content: '上传中..',
duration: 0,
mask: true
});
uploadFile(file)
.then((data) => {
hide();
fileName.value = String(data.name);
// images.value.push({
// uid: data.id,
// url: FILE_THUMBNAIL + data.path,
// status: 'done'
// });
message.success('上传成功');
})
.catch((e) => {
message.error(e.message);
hide();
});
};
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
@@ -211,8 +189,7 @@
.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateFiles : addFiles;
saveOrUpdate(form)
updateFiles(form)
.then((msg) => {
loading.value = false;
message.success(msg);
@@ -236,21 +213,44 @@
() => props.visible,
(visible) => {
if (visible) {
record.value = props.data ?? null;
if (props.data) {
assignFields(props.data);
// 图片分享代码
share.value.url = `${props.data.url}`;
share.value.html = `<a href="${props.data.url}"><img src="${props.data.url}" alt="${props.data.name}" border="0" /></a>`;
share.value.Markdown = `[![${props.data.name}](${props.data.url})](${props.data.url})`;
share.value.bbCode = `[url=${props.data.url}][img]${props.data.url}[/img][/url]`;
isUpdate.value = true;
} else {
isUpdate.value = false;
}
} else {
record.value = null;
resetFields();
formRef.value?.clearValidate();
}
}
);
</script>
<style lang="less" scoped>
.media-file {
display: flex;
align-items: center;
min-width: 0;
}
.media-file-icon {
flex: none;
font-size: 32px;
}
.media-file-info {
flex: 1;
min-width: 0;
margin-left: 10px;
}
.media-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-file-sub {
font-size: 12px;
}
</style>
+131 -28
View File
@@ -1,9 +1,10 @@
<!-- 素材库 · 网格视图 -->
<template>
<div class="page">
<a-row :gutter="20">
<a-col
v-for="(item, index) in data"
:key="index"
v-for="item in data"
:key="item.id"
v-bind="
styleResponsive
? { xl: 4, lg: 8, md: 12, sm: 12, xs: 24 }
@@ -12,10 +13,33 @@
>
<a-card :bordered="false" hoverable style="margin-top: 16px">
<template #cover>
<a-image :src="item.url" alt="" />
<!-- 统一 4:3 封面盒子:图片裁剪填充,其余类型放类型图标 -->
<div class="media-cover" @click="openPreview(item)">
<a-image
v-if="kindOf(item) === 'image'"
class="media-cover-image"
:src="item.thumbnail || item.url"
:preview="{ src: item.url }"
/>
<template v-else>
<component
:is="iconOf(item)"
class="media-cover-icon"
:style="{ color: colorOf(item) }"
/>
<span class="media-cover-ext">{{ extOf(item) }}</span>
<play-circle-filled
v-if="kindOf(item) === 'video'"
class="media-cover-play"
/>
</template>
</div>
</template>
<a-card-meta :title="item.name" @click="openEdit(item)">
<template #description>
<div class="media-meta">
{{ labelOf(item) }} · {{ sizeOf(item) }}
</div>
<div
class="project-list-desc"
v-if="item.comments"
@@ -41,58 +65,85 @@
</a-row>
<!-- 编辑弹窗 -->
<PhotoEdit v-model:visible="showEdit" :data="current" @done="query" />
<!-- 音视频预览 -->
<MediaPreview v-model:visible="showPreview" :record="current" />
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { PlayCircleFilled } from '@ant-design/icons-vue';
import { useThemeStore } from '@/store/modules/theme';
import { FileRecord, FileRecordParam } from '@/api/system/file/model';
import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
import PhotoEdit from './components/photo-edit.vue';
import MediaPreview from './components/media-preview.vue';
import { timeAgo } from 'ele-admin-pro';
import {
formatFileSize,
mediaColor,
mediaExtLabel,
mediaIcon,
mediaKindOfRecord,
MEDIA_KIND_LABEL,
type MediaKind
} from '@/utils/media';
const props = defineProps<{
// 搜索关键字
searchText: string;
defineProps<{
// 列表数据
data?: FileRecord[];
// 搜索条件
where?: FileRecordParam | null;
// 修改回显的数据
data?: FileRecord | null;
}>();
// 当前编辑数据
const current = ref<FileRecord | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
const emit = defineEmits<{
(e: 'done'): void;
}>();
// 当前操作的数据(编辑 / 预览共用)
const current = ref<FileRecord | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示音视频预览
const showPreview = ref(false);
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const kindOf = (item: FileRecord): MediaKind => mediaKindOfRecord(item);
const iconOf = (item: FileRecord) =>
mediaIcon(kindOf(item), item.path || item.name);
const colorOf = (item: FileRecord) =>
mediaColor(kindOf(item), item.path || item.name);
const extOf = (item: FileRecord) => mediaExtLabel(item);
const labelOf = (item: FileRecord) => MEDIA_KIND_LABEL[kindOf(item)];
const sizeOf = (item: FileRecord) => formatFileSize(item.length);
/* 打开编辑弹窗 */
const openEdit = (row?: FileRecord) => {
current.value = row ?? null;
showEdit.value = true;
};
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
/* 点击封面:图片由 a-image 自己预览,音视频开播放器,文档新窗口打开 */
const openPreview = (row: FileRecord) => {
const kind = kindOf(row);
if (kind === 'image') {
return;
}
if (kind === 'doc') {
window.open(row.url, '_blank');
return;
}
current.value = row;
showPreview.value = true;
};
/* 刷新列表 */
const query = () => {
emit('done');
};
watch(
() => props.data,
(visible) => {
if (visible) {
console.log(props.data);
} else {
}
}
);
</script>
<script lang="ts">
@@ -102,6 +153,58 @@
</script>
<style lang="less" scoped>
.media-cover {
position: relative;
display: flex;
align-items: center;
justify-content: center;
// 所有类型共用 4:3,混排时卡片高度才对得齐
aspect-ratio: 4 / 3;
overflow: hidden;
background: #f5f6f8;
cursor: pointer;
:deep(.ant-image) {
width: 100%;
height: 100%;
}
:deep(.ant-image-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.media-cover-icon {
font-size: 40px;
}
.media-cover-ext {
position: absolute;
right: 8px;
bottom: 8px;
padding: 0 6px;
font-size: 12px;
line-height: 18px;
color: #718096;
background: rgba(255, 255, 255, 0.9);
border-radius: 3px;
}
.media-cover-play {
position: absolute;
font-size: 44px;
color: rgba(255, 255, 255, 0.92);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.35));
}
.media-meta {
margin-bottom: 2px;
font-size: 12px;
color: #8c8c8c;
}
.project-list-desc {
height: 44px;
line-height: 22px;
+37 -27
View File
@@ -1,7 +1,7 @@
<template>
<div class="page">
<a-page-header :ghost="false" title="图片库">
<div class="ele-text-secondary"> 上传、分享有价值的图片素材 </div>
<a-page-header :ghost="false" title="素材库">
<div class="ele-text-secondary"> 上传、分享图片、文档与音视频素材 </div>
<template #footer>
<a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="1" tab="网格模式" />
@@ -10,16 +10,29 @@
<div style="margin-bottom: 10px">
<a-upload
:show-upload-list="false"
:accept="'image/*'"
:accept="MEDIA_ACCEPT"
:customRequest="onUpload"
>
<a-button key="1" type="primary" class="ele-btn-icon">
<template #icon>
<UploadOutlined />
</template>
<span>上传图片</span>
<span>上传素材</span>
</a-button>
</a-upload>
<a-select
v-model:value="category"
style="width: 120px; margin-left: 10px"
@change="onCategoryChange"
>
<a-select-option
v-for="item in MEDIA_CATEGORY_OPTIONS"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
<a-input-search
allow-clear
style="width: 360px; margin-left: 10px"
@@ -68,15 +81,22 @@
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { FileRecord, FileRecordParam } from '@/api/system/file/model';
import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
import Image from './image.vue';
import List from './list.vue';
import { messageLoading } from 'ele-admin-pro';
import { pageFiles, uploadFile } from '@/api/system/file';
import { message } from 'ant-design-vue/es';
import {
MEDIA_ACCEPT,
MEDIA_CATEGORY_OPTIONS,
validateMediaFile
} from '@/utils/media';
const type = ref('name');
const searchText = ref('');
// 媒体大类筛选,空串表示全部
const category = ref('');
const data = ref<FileRecord[]>([]);
// 当前选项卡
const activeKey = ref('1');
@@ -94,7 +114,6 @@
/* 查询数据 */
const query = () => {
console.log('query()');
if (type.value == 'name') {
where.name = searchText.value;
}
@@ -103,8 +122,8 @@
}
where.page = page.value;
where.limit = limit.value;
where.contentType = 'image';
console.log(where);
// 空串会被当成一个真实的筛选值,必须置空
where.mediaCategory = category.value || undefined;
const hide = messageLoading('请求中..', 0);
pageFiles(where).then((res) => {
count.value = Number(res?.count);
@@ -113,17 +132,20 @@
});
};
/* 切换类型回到第一页 */
const onCategoryChange = () => {
page.value = 1;
query();
};
query();
// 上传文件
const onUpload = (item) => {
const { file } = item;
if (!file.type.startsWith('image')) {
message.error('只能选择图片');
return;
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
const error = validateMediaFile(file);
if (error) {
message.error(error);
return;
}
const hide = messageLoading({
@@ -132,7 +154,7 @@
mask: true
});
uploadFile(file)
.then((data) => {
.then(() => {
hide();
message.success('上传成功');
query();
@@ -149,15 +171,3 @@
name: 'PhotoIndex'
};
</script>
<style lang="less" scoped>
.project-list-desc {
height: 44px;
line-height: 22px;
margin-bottom: 20px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>
+133 -31
View File
@@ -1,3 +1,4 @@
<!-- 素材库 · 列表视图 -->
<template>
<div class="page">
<a-card :bordered="false">
@@ -8,21 +9,21 @@
:columns="columns"
:datasource="datasource"
v-model:selection="selection"
:scroll="{ x: 800 }"
cache-key="proCmsVideoTable"
:scroll="{ x: 860 }"
cache-key="proCmsPhotoTable"
>
<template #toolbar>
<a-space>
<a-upload
:show-upload-list="false"
:accept="'image/*'"
:accept="MEDIA_ACCEPT"
:customRequest="onUpload"
>
<a-button type="primary" class="ele-btn-icon">
<template #icon>
<UploadOutlined />
</template>
<span>上传图片</span>
<span>上传素材</span>
</a-button>
</a-upload>
<a-button
@@ -37,6 +38,19 @@
</template>
<span>删除</span>
</a-button>
<a-select
v-model:value="category"
style="width: 120px"
@change="reload()"
>
<a-select-option
v-for="item in MEDIA_CATEGORY_OPTIONS"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
<a-input-search
allow-clear
v-model:value="searchText"
@@ -60,7 +74,23 @@
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'path'">
<a-image :src="record.url" :width="120" />
<!-- 与网格视图同一套 4:3 缩略图规则 -->
<div class="media-thumb" @click="openPreview(record)">
<a-image
v-if="kindOf(record) === 'image'"
:src="record.thumbnail || record.url"
:preview="{ src: record.url }"
:width="120"
/>
<template v-else>
<component
:is="iconOf(record)"
class="media-thumb-icon"
:style="{ color: colorOf(record) }"
/>
<span class="media-thumb-ext">{{ extOf(record) }}</span>
</template>
</div>
</template>
<template v-if="column.dataIndex === 'name'">
<span>{{ record.name }}</span>
@@ -78,7 +108,7 @@
<a-divider type="vertical" />
<a-popconfirm
placement="topRight"
title="确定要删除此评价吗?"
title="确定要删除此素材吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
@@ -90,6 +120,8 @@
</a-card>
<!-- 编辑弹窗 -->
<PhotoEdit v-model:visible="showEdit" :data="current" @done="reload" />
<!-- 音视频预览 -->
<MediaPreview v-model:visible="showPreview" :record="current" />
</div>
</template>
@@ -109,6 +141,7 @@
} from 'ele-admin-pro/es/ele-pro-table/types';
import { messageLoading, toDateString } from 'ele-admin-pro/es';
import PhotoEdit from './components/photo-edit.vue';
import MediaPreview from './components/media-preview.vue';
import {
pageFiles,
removeFile,
@@ -119,17 +152,33 @@
FileRecord,
FileRecordParam
} from '@/api/system/file/model/index';
import {
formatFileSize,
mediaColor,
mediaExtLabel,
mediaIcon,
mediaKindOfRecord,
MEDIA_ACCEPT,
MEDIA_CATEGORY_OPTIONS,
MEDIA_KIND_LABEL,
validateMediaFile,
type MediaKind
} from '@/utils/media';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<FileRecord[]>([]);
// 当前编辑数据
// 当前操作的数据(编辑 / 预览共用)
const current = ref<FileRecord | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示音视频预览
const showPreview = ref(false);
const type = ref('name');
const searchText = ref('');
// 媒体大类筛选,空串表示全部
const category = ref('');
// 表格列配置
const columns = ref<ColumnItem[]>([
{
@@ -141,11 +190,17 @@
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
},
{
title: '图片',
title: '缩略图',
dataIndex: 'path',
width: 180,
width: 140,
key: 'path'
},
{
title: '类型',
dataIndex: 'contentType',
width: 100,
customRender: ({ record }) => MEDIA_KIND_LABEL[kindOf(record)]
},
{
title: '文件名称',
dataIndex: 'name'
@@ -161,26 +216,16 @@
sorter: true,
showSorterTooltip: false,
ellipsis: true,
customRender: ({ text }) => {
if (text < 1024) {
return text + 'B';
} else if (text < 1024 * 1024) {
return (text / 1024).toFixed(1) + 'KB';
} else if (text < 1024 * 1024 * 1024) {
return (text / 1024 / 1024).toFixed(1) + 'M';
} else {
return (text / 1024 / 1024 / 1024).toFixed(1) + 'G';
}
},
customRender: ({ text }) => formatFileSize(text),
width: 120
},
{
title: '发布者',
title: '上传人',
width: 120,
dataIndex: 'createNickname'
},
{
title: '发布时间',
title: '上传时间',
dataIndex: 'createTime',
sorter: true,
width: 180,
@@ -196,6 +241,13 @@
}
]);
const kindOf = (record: FileRecord): MediaKind => mediaKindOfRecord(record);
const iconOf = (record: FileRecord) =>
mediaIcon(kindOf(record), record.path || record.name);
const colorOf = (record: FileRecord) =>
mediaColor(kindOf(record), record.path || record.name);
const extOf = (record: FileRecord) => mediaExtLabel(record);
// 表格数据源
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
where = {};
@@ -205,7 +257,8 @@
if (type.value == 'createNickname') {
where.createNickname = searchText.value;
}
where.contentType = 'image';
// 空串会被当成一个真实的筛选值,必须置空
where.mediaCategory = category.value || undefined;
return pageFiles({ ...where, ...orders, page, limit });
};
@@ -221,6 +274,20 @@
showEdit.value = true;
};
/* 点击缩略图:音视频开播放器,文档新窗口打开 */
const openPreview = (row: FileRecord) => {
const kind = kindOf(row);
if (kind === 'image') {
return;
}
if (kind === 'doc') {
window.open(row.url, '_blank');
return;
}
current.value = row;
showPreview.value = true;
};
/* 删除单个 */
const remove = (row: FileRecord) => {
const hide = messageLoading('请求中..', 0);
@@ -266,12 +333,9 @@
// 上传文件
const onUpload = (item) => {
const { file } = item;
if (!file.type.startsWith('image')) {
message.error('只能选择图片');
return;
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
const error = validateMediaFile(file);
if (error) {
message.error(error);
return;
}
const hide = messageLoading({
@@ -280,7 +344,7 @@
mask: true
});
uploadFile(file)
.then((data) => {
.then(() => {
hide();
message.success('上传成功');
reload();
@@ -294,6 +358,44 @@
<script lang="ts">
export default {
name: 'VideoIndex'
name: 'PhotoList'
};
</script>
<style lang="less" scoped>
.media-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 120px;
height: 90px;
overflow: hidden;
background: #f5f6f8;
border-radius: 4px;
cursor: pointer;
:deep(.ant-image),
:deep(.ant-image-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.media-thumb-icon {
font-size: 28px;
}
.media-thumb-ext {
position: absolute;
right: 4px;
bottom: 4px;
padding: 0 4px;
font-size: 11px;
line-height: 16px;
color: #718096;
background: rgba(255, 255, 255, 0.9);
border-radius: 3px;
}
</style>
+2 -1
View File
@@ -99,6 +99,7 @@
:module="REVIEW_MODULE"
module-name="青马工程"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -363,4 +364,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE"
module-name="十佳青年岗位能手"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -386,4 +387,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE"
module-name="十佳团支部书记"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -376,4 +377,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -114,6 +114,7 @@
module="gxmu_tygl_form"
module-name="团员管理"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -510,4 +511,4 @@
gap: 8px 12px;
line-height: 1.5;
}
</style>
</style>
+2 -1
View File
@@ -84,6 +84,7 @@
:module="REVIEW_MODULE"
module-name="挑战杯"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -289,4 +290,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE"
module-name="五四红旗团委"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -444,4 +445,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE"
module-name="五四红旗团支部"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -478,4 +479,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -105,6 +105,7 @@
:module="REVIEW_MODULE"
module-name="未来学术之星"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -375,4 +376,4 @@
gap: 8px 12px;
line-height: 1.5;
}
</style>
</style>
+2 -1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE"
module-name="优秀共青团干部"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -409,4 +410,4 @@
<style lang="less" scoped>
@import '../toolbar.less';
</style>
</style>
+2 -1
View File
@@ -333,6 +333,7 @@
:module="REVIEW_MODULE"
module-name="优秀共青团员"
:pk="detailCurrent?.id"
@done="reload"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
@@ -867,4 +868,4 @@
grid-template-columns: 1fr;
}
}
</style>
</style>
@@ -0,0 +1,120 @@
<template>
<div class="attachment-list">
<a-empty
v-if="!files.length"
:description="emptyText"
class="attachment-empty"
/>
<div
v-for="(file, index) in files"
:key="`${file.url}-${index}`"
class="attachment-item"
>
<a-image
v-if="isImageFile(file.url)"
:src="file.url"
:width="96"
:height="96"
class="attachment-image"
/>
<div class="attachment-body">
<a
:href="file.url"
target="_blank"
rel="noreferrer"
class="attachment-name"
:title="file.name"
>
{{ file.name }}
</a>
<span class="attachment-tip">点击可预览</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import {
isImageFile,
parseAttachments,
type AttachmentFile
} from '@/views/gxmu/utils/attachment';
const props = withDefaults(
defineProps<{
// 原始附件字段值
value?: unknown;
// 已解析好的文件列表(优先使用)
files?: AttachmentFile[];
emptyText?: string;
}>(),
{
value: undefined,
files: undefined,
emptyText: '暂无材料'
}
);
const files = computed<AttachmentFile[]>(() => {
if (props.files) {
return props.files;
}
return parseAttachments(props.value);
});
</script>
<script lang="ts">
export default {
name: 'GxmuAttachmentList'
};
</script>
<style lang="less" scoped>
.attachment-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.attachment-item {
display: flex;
gap: 12px;
align-items: center;
padding: 8px 10px;
border: 1px solid #eef1f6;
border-radius: 8px;
background: #fbfcfe;
}
.attachment-image {
flex-shrink: 0;
overflow: hidden;
border-radius: 6px;
}
.attachment-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.attachment-name {
overflow: hidden;
max-width: 100%;
color: #1677ff;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-tip {
color: #999;
font-size: 12px;
}
.attachment-empty {
margin: 12px 0;
}
</style>
+70 -6
View File
@@ -26,6 +26,21 @@
</p>
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
</a-upload-dragger>
<a-alert
v-if="failures.length"
type="warning"
show-icon
:message="`${successCount} 条导入成功,${failures.length} 条被跳过`"
>
<template #description>
<div class="import-failures">
<div v-for="failure in failures" :key="failure.row">
第 {{ failure.row }} 行:{{ failure.reason }}
</div>
</div>
<a-button size="small" @click="exportFailures">下载失败清单</a-button>
</template>
</a-alert>
</a-space>
</a-spin>
</ele-modal>
@@ -63,6 +78,8 @@
}>();
const loading = ref(false);
const failures = ref<{ row: number; reason: string }[]>([]);
const successCount = ref(0);
const description = computed(
() =>
@@ -141,13 +158,17 @@
if (!rows.length) {
throw new Error('导入文件内容为空');
}
const data = rows
.map((row, index) => props.createPayload(row, index + 2))
.filter((item) =>
const entries = rows
.map((row, index) => ({
rowNo: index + 2,
item: props.createPayload(row, index + 2)
}))
.filter(({ item }) =>
props.templateFields.some((field) =>
!!String(item[field.key] ?? '').trim()
)
);
const data = entries.map((entry) => entry.item);
if (!data.length) {
throw new Error('未识别到有效数据');
}
@@ -155,10 +176,28 @@
if (errors.length) {
throw new Error(errors.slice(0, 5).join(';'));
}
for (const item of data) {
await props.saveItem(item);
failures.value = [];
successCount.value = 0;
for (const entry of entries) {
try {
await props.saveItem(entry.item);
successCount.value += 1;
} catch (e: any) {
failures.value.push({
row: entry.rowNo,
reason: e?.message || '保存失败'
});
}
}
message.success(`成功导入 ${data.length} 条${props.moduleName}数据`);
if (failures.value.length) {
// 被跳过的是「同一申报项目已有进行中/已通过/被驳回」的记录,保留在清单里供人工处理
message.warning(
`成功 ${successCount.value} 条,跳过 ${failures.value.length} 条`
);
emit('done');
return false;
}
message.success(`成功导入 ${successCount.value} 条${props.moduleName}数据`);
updateVisible(false);
emit('done');
} catch (e: any) {
@@ -169,7 +208,32 @@
return false;
};
const exportFailures = () => {
if (!failures.value.length) {
return;
}
const sheet = utils.aoa_to_sheet([
['行号', '跳过原因'],
...failures.value.map((item) => [item.row, item.reason])
]);
sheet['!cols'] = [{ wch: 10 }, { wch: 64 }];
writeFile(
{
SheetNames: ['失败清单'],
Sheets: { 失败清单: sheet }
},
`${props.moduleName}导入失败清单.xlsx`
);
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>
<style lang="less" scoped>
.import-failures {
max-height: 180px;
overflow: auto;
margin-bottom: 8px;
}
</style>
@@ -33,7 +33,7 @@
:color="item.status === 1 ? 'success' : item.status === 2 ? 'error' : 'default'"
style="margin-left: 8px"
>
{{ item.status === 1 ? '通过' : item.status === 2 ? '不通过' : '待审核' }}
{{ item.status === 1 ? '通过' : item.status === 2 ? '驳回' : '待审核' }}
</a-tag>
</div>
<div v-if="item.user?.realName" style="margin-bottom: 4px">
@@ -3,7 +3,7 @@
<a-tag color="green">通过</a-tag>
</template>
<template v-else-if="latest?.status === 2">
<a-tag color="red">不通过</a-tag>
<a-tag color="red">驳回</a-tag>
</template>
<template v-else>
<a-tag>待审核</a-tag>
@@ -149,6 +149,19 @@
</div>
</a-form-item>
</template>
<a-form-item
label="允许重复提交"
name="allowRepeat"
extra="关闭时,同一用户在同一申报项目下同时只能有一条待审核申报;开启后可配置最多同时几条"
>
<a-switch
:checked="form.allowRepeat === 1"
@change="(checked: boolean) => (form.allowRepeat = checked ? 1 : 0)"
/>
</a-form-item>
<a-form-item v-if="form.allowRepeat === 1" label="最多同时几条" name="maxPending">
<a-input-number :min="1" :max="20" class="ele-fluid" v-model:value="form.maxPending" />
</a-form-item>
<a-form-item label="开始时间" name="startTime">
<a-date-picker
allow-clear
@@ -182,6 +195,10 @@
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
import { DECLARE_MODULE_OPTIONS } from '../moduleOptions';
import {
TZBCY_PROJECT_GROUP_OPTIONS,
TZBCY_PROJECT_TYPE_OPTIONS
} from '../../TZBCYForm/options';
import {
normalizeDeclareRecord,
stringifyDeclareMultiValueField
@@ -244,6 +261,8 @@
publicProjectGroup: [''],
startTime: undefined,
endTime: undefined,
allowRepeat: 0,
maxPending: 1,
userId: undefined,
tenantId: undefined,
createTime: undefined,
@@ -273,29 +292,68 @@
const { resetFields } = useForm(form, rules);
const isTzbcyModule = computed(() => form.module === 'gxmu_tzbcy_form');
/**
* 挑战杯模块的默认配置
* 与挑战杯申报表共用 TZBCYForm/options 中的同一份常量,避免两处维护产生漂移
*/
type ChallengeCupConfigKey =
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup';
const CHALLENGE_CUP_CONFIG_KEYS: ChallengeCupConfigKey[] = [
'formProjectType',
'formProjectGroup',
'publicProjectType',
'publicProjectGroup'
];
const toOptionValues = (options: readonly { value: string }[]) =>
options.map((item) => item.value);
const getChallengeCupDefaultConfig = (
key: ChallengeCupConfigKey
): string[] => {
switch (key) {
case 'formProjectType':
case 'publicProjectType':
return toOptionValues(TZBCY_PROJECT_TYPE_OPTIONS);
default:
return toOptionValues(TZBCY_PROJECT_GROUP_OPTIONS);
}
};
// 列表是否为空(没有任意一个有效值,空行不算)
const isBlankConfigList = (list?: string[]) =>
!(list || []).some((item) => !!item?.trim());
// 仅填充空配置,已有的配置(含用户手动编辑过的内容)保持不变
const fillBlankConfigWithDefault = () => {
CHALLENGE_CUP_CONFIG_KEYS.forEach((key) => {
if (isBlankConfigList(form[key])) {
form[key] = getChallengeCupDefaultConfig(key);
}
});
};
// 选中挑战杯模块时套用默认配置
const applyChallengeCupPreset = () => {
if (isTzbcyModule.value) {
fillBlankConfigWithDefault();
}
};
const normalizeEditableList = (list?: string[]) => {
const values = (list || []).map((item) => item?.trim() || '').filter(Boolean);
return values.length ? values : [''];
};
const addConfigRow = (
key:
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup'
) => {
const addConfigRow = (key: ChallengeCupConfigKey) => {
form[key].push('');
};
const removeConfigRow = (
key:
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup',
index: number
) => {
const removeConfigRow = (key: ChallengeCupConfigKey, index: number) => {
form[key].splice(index, 1);
if (!form[key].length) {
form[key].push('');
@@ -418,6 +476,8 @@
);
yearValue.value = props.data.year ? String(props.data.year) : undefined;
isUpdate.value = true;
// 历史记录若未配置过挑战杯配置,补上默认值;已配置的记录保持原样
applyChallengeCupPreset();
} else {
Object.assign(form, createDefaultForm());
isUpdate.value = false;
@@ -435,14 +495,15 @@
watch(
() => form.module,
(value) => {
if (value !== 'gxmu_tzbcy_form') {
form.projectType = [];
form.projectGroup = [];
form.formProjectType = [''];
form.formProjectGroup = [''];
form.publicProjectType = [''];
form.publicProjectGroup = [''];
if (value === 'gxmu_tzbcy_form') {
// 选中挑战杯模块时预填项目申报表 / 公开展示信息表的默认配置
applyChallengeCupPreset();
return;
}
// 切到其他模块时不清空配置,避免用户手滑切换后已编辑的内容丢失
// (非挑战杯模块不会展示也不会提交这些字段)
form.projectType = [];
form.projectGroup = [];
}
);
</script>
@@ -471,4 +532,4 @@
gap: 8px;
margin-bottom: 8px;
}
</style>
</style>
+3 -2
View File
@@ -63,7 +63,7 @@
</template>
<template v-else-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">通过</a-tag>
<a-tag v-else-if="record.status === 2" color="red">不通过</a-tag>
<a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
<a-tag v-else>待审核</a-tag>
</template>
<template v-else-if="column.key === 'submitTime'">
@@ -92,6 +92,7 @@
:module="detailCurrent?.module"
:module-name="getDeclareModuleLabel(detailCurrent?.module)"
:pk="detailCurrent?.id"
@done="loadHistoryData"
/>
<ReviewFlowModal
v-model:visible="showReviewFlow"
@@ -499,4 +500,4 @@ export default {
line-height: 1.5;
}
}
</style>
</style>
+274 -13
View File
@@ -152,6 +152,13 @@
</template>
<template #extra>
<a-space>
<a-button
type="primary"
:disabled="loading || !markdownContent"
@click="formatRichText"
>
一键排版
</a-button>
<a-button :disabled="loading || !markdownContent" @click="exportWord">导出 Word</a-button>
<a-button :disabled="!markdownContent" @click="copyGenerated">复制正文</a-button>
<a-button :disabled="!generatedTitle" @click="copyGeneratedTitle">复制标题</a-button>
@@ -171,17 +178,55 @@
/>
</a-form-item>
<a-form-item label="正文内容">
<div class="markdown-shell">
<div v-if="loading" class="markdown-streaming">
{{ markdownContent || 'AI 正在生成内容...' }}
</div>
<div
v-else-if="markdownContent"
class="markdown-body markdown-viewer"
v-html="renderedMarkdownHtml"
></div>
<div v-else class="markdown-empty">生成结果将显示在这里</div>
</div>
<a-tabs
v-model:activeKey="contentTab"
size="small"
class="content-tabs"
>
<a-tab-pane key="markdown" tab="Markdown 预览">
<div class="markdown-shell">
<div v-if="loading" class="markdown-streaming">
{{ markdownContent || 'AI 正在生成内容...' }}
</div>
<div
v-else-if="markdownContent"
class="markdown-body markdown-viewer"
v-html="renderedMarkdownHtml"
></div>
<div v-else class="markdown-empty">生成结果将显示在这里</div>
</div>
</a-tab-pane>
<a-tab-pane key="rich" tab="富文本排版">
<div class="rich-shell">
<div class="rich-bar">
<span class="rich-bar-tip">
一键排版按原文的标题层级、段落、列表、表格还原,可直接在编辑器里继续改
</span>
<a-button
size="small"
type="primary"
:disabled="saveDisabled"
@click="saveAsManuscript"
>
{{ savedUpToDate ? '已保存为稿件' : '保存为稿件' }}
</a-button>
</div>
<Toolbar
class="rich-toolbar"
:editor="editorRef"
:defaultConfig="toolbarConfig"
mode="default"
/>
<Editor
v-model="richContentHtml"
class="rich-editor"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleEditorCreated"
/>
</div>
</a-tab-pane>
</a-tabs>
</a-form-item>
</a-form>
</template>
@@ -198,10 +243,10 @@
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import highlight from '@bytemd/plugin-highlight-ssr';
import 'highlight.js/styles/default.css';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { Modal, message } from 'ant-design-vue';
import { messageLoading } from 'ele-admin-pro';
import { saveAs } from 'file-saver';
import { asBlob } from 'html-docx-js-typescript';
@@ -215,6 +260,16 @@
import type { FileRecord } from '@/api/system/file/model';
import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
import { markdownToHtml } from '@/utils/manuscript-content';
import { addManuscript } from '@/api/cms/manuscript';
import type { Manuscript } from '@/api/cms/manuscript/model';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import type {
IDomEditor,
IEditorConfig,
IToolbarConfig
} from '@wangeditor/editor';
import '@wangeditor/editor/dist/css/style.css';
interface FormState {
/** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */
@@ -248,6 +303,46 @@
highlight()
]);
const loading = ref(false);
/** 正文区域展示方式:默认看 Markdown 预览,一键排版后切到富文本 */
const contentTab = ref<'markdown' | 'rich'>('markdown');
/** 富文本编辑器里的 HTML(一键排版灌入,人工可继续改) */
const richContentHtml = ref('');
const savingManuscript = ref(false);
/** 上次保存时的正文文本快照:内容没变就不再让点「保存为稿件」,避免存出重复草稿 */
const savedSnapshot = ref('');
const editorRef = shallowRef<IDomEditor>();
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['group-video']
};
const editorConfig: Partial<IEditorConfig> = {
placeholder: '点上方「一键排版」,把生成结果按原文排版放进这里',
MENU_CONF: {
uploadImage: {
allowedFileTypes: ['image/*'],
maxFileSize: 2 * 1024 * 1024,
base64LimitSize: 0,
async customUpload(file: File, insertFn: (url: string) => void) {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片');
return;
}
try {
const result = await uploadFile(file);
insertFn(result.url || '');
} catch (e: any) {
message.error(e.message || '图片上传失败');
}
}
}
}
};
const handleEditorCreated = (editor: IDomEditor) => {
editorRef.value = editor;
};
const generatedTitle = ref('');
const generatedContent = ref('');
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
@@ -569,6 +664,10 @@
const clearGenerated = () => {
generatedTitle.value = '';
generatedContent.value = '';
// 排版结果是这份 markdown 的产物,正文换了就不能留着旧的
richContentHtml.value = '';
savedSnapshot.value = '';
contentTab.value = 'markdown';
};
const resetForm = () => {
@@ -627,6 +726,127 @@
form.number = 600;
};
/** 只留文本、丢掉标签与空白,用来判断两份富文本是不是同一份内容 */
const normalizeForCompare = (html: string) =>
String(html || '')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, '');
/** 编辑器里的内容是否已经存成稿件(按文本比,编辑器规整过标签属性也算同一份) */
const savedUpToDate = computed(
() =>
!!savedSnapshot.value &&
savedSnapshot.value === normalizeForCompare(richContentHtml.value)
);
/** 保存按钮的可用状态:没内容、正在存、这份已经存过,都不给点 */
const saveDisabled = computed(
() =>
savingManuscript.value ||
!hasRichText(richContentHtml.value) ||
savedUpToDate.value
);
/** 编辑器里的“空”:wangEditor 空内容是一段 `<p><br></p>`,不能只看字符串长度 */
const hasRichText = (html: string) => {
const text = String(html || '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, '')
.trim();
return !!text || /<img\b/i.test(String(html || ''));
};
/**
* 一键排版:把生成结果按原文的标题层级、段落、列表、表格转成 HTML,
* 灌进富文本编辑器。
* - 用 gfm 插件,和页面上 Markdown 预览同一套解析(表格不会漏成管道符);
* - 不把生成标题拼进正文:标题有独立字段,导出 Word 时也只在导出件里拼;
* - 不落库:保存是用户在编辑器里确认过的第二次动作(见 saveAsManuscript)。
*/
const formatRichText = () => {
if (loading.value) {
message.warning('正在生成中,暂不能排版');
return;
}
if (!markdownContent.value) {
message.warning('暂无可排版的正文内容');
return;
}
if (markdownContent.value === FAILED_TEXT) {
message.error('生成失败的内容不能排版,请重新生成');
return;
}
const nextHtml = markdownToHtml(markdownContent.value, [
gfm({ locale: zh_HansGfm })
]);
if (!nextHtml.trim()) {
message.warning('当前内容转换不出富文本,请检查正文');
return;
}
const apply = () => {
richContentHtml.value = nextHtml;
// 排版换了内容,之前那次保存的状态就作废
savedSnapshot.value = '';
contentTab.value = 'rich';
message.success('已按原文排版放进富文本编辑器,请检查后保存');
};
// 编辑器里有内容且不是同一份排版(用户改过),才需要确认覆盖。
// 比的是「文本」而不是 HTML:wangEditor 会把 setHtml 进来的标签属性、
// 空白重新规整一遍,按 HTML 比会把「原样再排一次」也判成要覆盖。
if (
hasRichText(richContentHtml.value) &&
normalizeForCompare(richContentHtml.value) !== normalizeForCompare(nextHtml)
) {
Modal.confirm({
title: '覆盖编辑器里的内容?',
content: '富文本编辑器里已有内容,重新排版会覆盖掉当前改动。',
okText: '覆盖',
cancelText: '取消',
onOk: apply
});
return;
}
apply();
};
/**
* 保存为稿件:入库一律写 html,和「编辑稿件」弹窗的口径一致。
* add 接口不回 id,所以这里只负责“存成一条新稿件”,不冒充“再编辑同一篇”:
* 存过之后按钮置灰,改过正文才会重新可点(避免连点存出多条草稿)。
*/
const saveAsManuscript = async () => {
const title = (generatedTitle.value || form.theme).trim();
if (!title) {
message.warning('请先填写生成标题,稿件需要标题');
return;
}
if (!hasRichText(richContentHtml.value)) {
message.warning('富文本里还没有内容,请先点「一键排版」');
return;
}
if (savedUpToDate.value) {
message.info('这份排版已经保存过了,改完再点可以另存一条');
return;
}
savingManuscript.value = true;
try {
const msg = await addManuscript({
title,
content: richContentHtml.value,
contentFormat: 'html',
// 后端 status 是 Integer(列表里 0 待审核 / 1 显示),前端类型写成 string 是历史遗留
status: 1 as unknown as Manuscript['status'],
sortNumber: 0
});
savedSnapshot.value = normalizeForCompare(richContentHtml.value);
message.success(msg || '已保存为稿件');
} catch (e: any) {
message.error(e.message || '保存稿件失败');
} finally {
savingManuscript.value = false;
}
};
const removeUploadedFile = (index: number) => {
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
};
@@ -852,6 +1072,7 @@
onBeforeUnmount(() => {
clearStreamResolver();
editorRef.value?.destroy();
});
</script>
@@ -1047,6 +1268,46 @@
color: #1f2d3d;
}
.content-tabs :deep(.ant-tabs-nav) {
margin-bottom: 10px;
}
.rich-shell {
border: 1px solid #c7e8ff;
border-radius: 16px;
overflow: hidden;
background: #fffdfb;
}
.rich-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #eef4f9;
background: #f5fbff;
}
.rich-bar-tip {
color: #5f7f9f;
font-size: 12px;
line-height: 1.6;
}
.rich-toolbar {
border-bottom: 1px solid #f0f0f0;
}
.rich-editor {
min-height: 360px;
}
.rich-editor :deep(.w-e-text-container) {
min-height: 360px;
}
.full-width {
width: 100%;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,904 @@
/**
* 审核列表「查看数据」详情的申报表配置。
*
* sections 决定申报表的字段分组与展示顺序,materialFields 声明需要在上传材料
* 中展示的附件字段,未列出的字段会兜底追加到「其他信息」分组,避免数据丢失。
*/
export interface ModuleSection {
// 分组标题
title: string;
// 分组内字段(按展示顺序)
fields: string[];
}
export interface ModuleMaterialTable {
// 明细数组字段
field: string;
// 明细中保存附件地址的字段
fileField: string;
// 明细标题字段
titleField?: string;
// 材料分组标题
label: string;
}
export interface ModuleDetailConfig {
// 模块名称
title: string;
// 申报表名称,用于拼接表单标题
sheetName: string;
// 字段中文名
labels: Record<string, string>;
// 申报表分组
sections: ModuleSection[];
// 附件字段(上传材料)
materialFields: string[];
// 明细数组中的附件
materialTables?: ModuleMaterialTable[];
// 额外的材料数据来源
materialSource?: 'tzbcy';
}
/**
* 通用字段中文名
*/
export const COMMON_LABELS: Record<string, string> = {
year: '年份',
id: 'ID',
title: '标题',
content: '内容',
contentFormat: '正文格式',
cover: '封面',
createTime: '提交时间',
updateTime: '更新时间',
status: '状态',
sortNumber: '排序',
provinceCity: '省市',
schoolName: '学校名称',
projectName: '项目名称',
projectType: '项目类型',
projectGroup: '项目分组',
publicProjectType: '公开展示项目类型',
publicProjectGroup: '公开展示项目分组',
leader: '负责人',
phone: '联系电话',
teamMembers: '团队成员',
advisors: '指导老师',
projectBrief: '项目简介',
socialValue: '社会价值',
practiceProcess: '实践过程',
innovationMeaning: '创新意义',
developmentProspect: '发展前景',
teamCooperation: '团队协作情况',
projectMaterials: '项目介绍材料',
otherProofs: '其他相关证明材料',
projectSummary: '项目总结',
teamIntro: '团队介绍',
teamSlogan: '团队口号',
practiceLog: '实践日志',
name: '姓名',
gender: '性别',
nation: '民族',
photo: '电子版照片',
birthMonth: '出生年月',
politics: '政治面貌',
nativePlace: '籍贯',
wechat: '微信号',
email: '邮箱',
qq: 'QQ',
idCardNo: '身份证号',
hobby: '兴趣爱好',
willingToWorkInGuangxi: '是否愿意留桂工作',
schoolInfo: '学校信息',
leaguePosition: '团内职务',
resume: '个人简历',
awards: '获得奖励',
academicPerformance: '学习成绩',
secondaryLeagueOpinion: '二级团组织意见',
secondaryLeagueOpinionDate: '二级团组织意见日期',
secondaryPartyOpinion: '二级党组织意见',
secondaryPartyOpinionDate: '二级党组织意见日期',
schoolLeagueOpinion: '学校团组织意见',
schoolLeagueOpinionDate: '学校团组织意见日期',
applyType: '申报类别',
education: '学历',
position: '职务',
unit: '工作单位',
experience: '主要经历',
mainStory: '主要事迹',
leagueOpinion: '团组织意见',
partyOpinion: '党组织意见',
schoolOpinion: '学校意见',
collegeClass: '学院班级',
branch: '团支部名称',
eduEval: '教育评议情况',
serialNo: '序号',
archiveInCurrentOrg: '团籍是否在本组织',
joinMonth: '入团时间',
memberRecords: '团员记录',
growthArchives: '成长档案',
orgName: '组织名称',
memberTotal: '团员总数',
memberDeveloped2024: '上一年度发展团员数',
smartSystemLogin: '智慧团建系统录入情况',
committeeCount: '团委委员数',
fulltimeCadreCount: '专职团干部数',
parttimeCadreCount: '兼职团干部数',
lastElectionTime: '最近一次换届时间',
feeReceivable2024: '上一年度应收团费',
feeReceived2024: '上一年度实收团费',
feePayable2024: '上一年度应缴团费',
feePaid2024: '上一年度实缴团费',
branchCount: '团支部数量',
standardizedWork2024: '上一年度标准化建设开展情况',
recommendActivist2024: '上一年度推优入党人数',
activistConfirmed: '入党积极分子确定情况',
recommendDevTarget2024: '上一年度推荐发展对象人数',
devTargetConfirmed: '发展对象确定情况',
honorsFiveYears: '近五年获奖情况',
workSummaryThreeYears: '近三年工作总结',
branchName: '团支部名称',
secondOrg: '所属二级党组织',
secretary: '团支部书记',
contact: '联系方式',
establishTime: '成立时间',
branchCommitteeMeetingCount: '支委会次数',
branchMemberMeetingCount: '支部大会次数',
eduEvalDone: '教育评议是否完成',
annualRegDone: '年度团籍注册是否完成',
classCount: '班级数量',
smartSystem100: '智慧团建系统覆盖率',
topicName: '课题名称',
collegeGradeClass: '学院年级班级',
advisor: '指导老师',
topicType: '课题类别',
guidanceTeachers: '指导教师组',
applicants: '申请人信息',
budgets: '经费预算',
rationale: '立项依据',
teacherOpinion: '指导教师意见',
teacherSign: '指导教师签名',
reviewOpinion: '评审意见',
reviewScore: '评审成绩',
reviewLeaderSign: '评审负责人签名',
collegeOpinion: '学院意见',
promiseSigner: '承诺签字人',
promiseSignature: '承诺书签名',
reviewCollege: '评审学院',
reviewReporter: '汇报人',
reviewDate: '评审日期',
reviewMembers: '评审成员',
fundingBudgetFile: '经费预算表',
fundingAllocationFile: '经费划拨明细表',
midtermRemark: '中期检查说明',
midtermMaterials: '中期检查材料',
finalRemark: '结题说明',
finalMaterials: '结题材料',
archiveAwards: '获奖情况',
archiveProofs: '佐证材料',
archiveRecords: '成果归档',
grade: '年级',
className: '班级',
major: '专业',
role: '角色',
item: '项目',
amount: '金额',
reason: '原因',
dept: '部门',
sign: '签名',
department: '部门',
remark: '备注',
type: '类型',
recordTime: '时间',
archiveTime: '时间',
identity: '身份',
organization: '所在组织',
memberNo: '团员编号',
currentDutyTime: '现任职务时间',
cadreYears: '从事团学工作年限',
assessment2024: '上一年度考核情况',
volunteerRegTime: '志愿汇注册时间',
cadreExperience: '团学工作经历',
joinTime: '入团时间',
collegeMajorClass: '学院专业班级',
eduEval2024: '上一年度教育评议情况',
smartSystem: '智慧团建系统录入情况',
totalVolunteerHours: '累计志愿服务时长',
volunteerHours2024: '上一年度志愿服务时长',
duty: '职务',
college: '学院',
gradeMajor: '年级专业',
birthDate: '出生日期',
branchOrganizationName: '所属团支部',
awardName: '获奖名称',
awardTime: '获奖时间',
proofFile: '证明材料',
archiveOrder: '序号'
};
/**
* 五四类申报表的意见栏分组
*/
const OPINION_SECTION: ModuleSection = {
title: '意见栏',
fields: ['leagueOpinion', 'partyOpinion', 'schoolOpinion']
};
export const MODULE_DETAIL_CONFIGS: Record<string, ModuleDetailConfig> = {
gxmu_sjqn: {
title: '十佳青年岗位能手',
sheetName: '十佳青年岗位能手',
labels: {
applyType: '申报类别',
education: '学历',
position: '职务',
title: '职称',
unit: '所在单位',
awards: '近三年曾获校级及以上奖励',
experience: '工作(学习)经历',
mainStory: '主要事迹',
leagueOpinion: '所在二级团组织意见',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'applyType',
'name',
'gender',
'birthMonth',
'nation',
'politics',
'education',
'position',
'title',
'unit'
]
},
{
title: '主要事迹与经历',
fields: ['awards', 'experience', 'mainStory']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_sjtbzbsj: {
title: '十佳团支部书记',
sheetName: '十佳团支部书记',
labels: {
collegeClass: '学院班级/单位科室',
branch: '所在团支部',
eduEval: '上一年度团员教育评议等次',
awards: '近三年曾获校级及以上奖励',
experience: '工作(学习)经历',
mainStory: '主要事迹',
leagueOpinion: '所在二级团组织意见',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'name',
'gender',
'birthMonth',
'nation',
'politics',
'collegeClass',
'branch',
'eduEval'
]
},
{
title: '主要事迹与经历',
fields: ['awards', 'experience', 'mainStory']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_wshqtw: {
title: '五四红旗团委',
sheetName: '五四红旗团委',
labels: {
orgName: '二级团组织全称',
leader: '负责人',
phone: '联系电话',
memberTotal: '现有团员总数',
memberDeveloped2024: '上一年度发展团员人数',
smartSystemLogin: '是否已登录智慧团建系统',
committeeCount: '团委委员人数',
fulltimeCadreCount: '专职团干部数',
parttimeCadreCount: '兼职团干部数',
lastElectionTime: '团委最近一次换届时间',
feeReceivable2024: '上一年度应收团费',
feeReceived2024: '上一年度实收团费',
feePayable2024: '上一年度应上缴团费',
feePaid2024: '上一年度实际上缴团费',
branchCount: '团支部数',
standardizedWork2024: '是否开展规范化建设工作',
recommendActivist2024: '推荐入党积极分子人数',
activistConfirmed: '确定为入党积极分子数',
recommendDevTarget2024: '推荐党的发展对象人数',
devTargetConfirmed: '确定为党的发展对象数',
honorsFiveYears: '近五年获得校级及以上荣誉情况',
workSummaryThreeYears: '近三年开展的主要工作及取得的效果',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{ title: '组织信息', fields: ['year', 'orgName', 'leader', 'phone'] },
{
title: '团员与团干部情况',
fields: [
'memberTotal',
'memberDeveloped2024',
'smartSystemLogin',
'committeeCount',
'fulltimeCadreCount',
'parttimeCadreCount',
'lastElectionTime'
]
},
{
title: '团费收缴情况',
fields: [
'feeReceivable2024',
'feeReceived2024',
'feePayable2024',
'feePaid2024'
]
},
{
title: '团支部与推优情况',
fields: [
'branchCount',
'recommendActivist2024',
'activistConfirmed',
'recommendDevTarget2024',
'devTargetConfirmed'
]
},
{
title: '工作与荣誉',
fields: [
'standardizedWork2024',
'honorsFiveYears',
'workSummaryThreeYears'
]
},
{ title: '意见栏', fields: ['partyOpinion', 'schoolOpinion'] }
],
materialFields: []
},
gxmu_wshqtzb: {
title: '五四红旗团支部',
sheetName: '五四红旗团支部',
labels: {
branchName: '团支部全称',
secondOrg: '所属二级团组织',
secretary: '团支部书记',
politics: '政治面貌',
contact: '联系方式',
establishTime: '成立时间',
lastElectionTime: '最近一次换届时间',
smartSystemLogin: '是否已登录智慧团建系统',
memberTotal: '现有团员总数',
memberDeveloped2024: '上一年度发展团员数',
feeReceivable2024: '上一年度应收团费',
feeReceived2024: '上一年度实收团费',
feePayable2024: '上一年度应上缴团费',
feePaid2024: '上一年度实际上缴团费',
recommendActivist2024: '推荐入党积极分子人数',
activistConfirmed: '确定为入党积极分子数',
recommendDevTarget2024: '推荐党的发展对象人数',
devTargetConfirmed: '确定为党的发展对象数',
branchCommitteeMeetingCount: '团支部委员会会议召开次数',
branchMemberMeetingCount: '团支部团员大会召开次数',
eduEvalDone: '是否开展团员教育评议',
annualRegDone: '是否开展团员年度团籍注册',
classCount: '开展团课次数',
smartSystem100: '是否100%录入智慧团建',
honorsFiveYears: '近五年获得院级及以上荣誉情况',
workSummaryThreeYears: '近三年开展的主要工作及取得的效果',
leagueOpinion: '所在二级团组织意见',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '支部信息',
fields: [
'year',
'branchName',
'secondOrg',
'secretary',
'politics',
'contact',
'establishTime',
'lastElectionTime'
]
},
{
title: '团员与团费情况',
fields: [
'smartSystemLogin',
'memberTotal',
'memberDeveloped2024',
'feeReceivable2024',
'feeReceived2024',
'feePayable2024',
'feePaid2024'
]
},
{
title: '推优情况',
fields: [
'recommendActivist2024',
'activistConfirmed',
'recommendDevTarget2024',
'devTargetConfirmed'
]
},
{
title: '组织生活',
fields: [
'branchCommitteeMeetingCount',
'branchMemberMeetingCount',
'eduEvalDone',
'annualRegDone',
'classCount',
'smartSystem100'
]
},
{
title: '工作与荣誉',
fields: ['honorsFiveYears', 'workSummaryThreeYears']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_yxgqtdgb: {
title: '优秀共青团干部',
sheetName: '优秀共青团干部',
labels: {
identity: '身份',
organization: '所在团组织',
contact: '联系方式',
memberNo: '发展团员编号',
currentDutyTime: '任现团内职务时间',
cadreYears: '担任团干部年限',
assessment2024: '上一年度工作考核结果',
volunteerRegTime: '成为注册志愿者时间',
cadreExperience: '从事团干部经历',
honorsFiveYears: '近五年获得校级及以上荣誉情况',
mainStory: '主要事迹',
leagueOpinion: '所在单位团组织意见',
partyOpinion: '所在单位党组织意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'name',
'gender',
'nation',
'birthMonth',
'politics',
'position',
'identity',
'organization',
'contact',
'memberNo'
]
},
{
title: '团干部履历',
fields: [
'currentDutyTime',
'cadreYears',
'assessment2024',
'volunteerRegTime',
'cadreExperience'
]
},
{
title: '主要事迹与荣誉',
fields: ['honorsFiveYears', 'mainStory']
},
{ title: '意见栏', fields: ['leagueOpinion', 'partyOpinion'] }
],
materialFields: []
},
gxmu_yxgqty: {
title: '优秀共青团员',
sheetName: '优秀共青团员',
labels: {
joinTime: '入团时间',
collegeMajorClass: '所在学院、专业、班级',
position: '职务',
volunteerRegTime: '成为注册志愿者时间',
eduEval2024: '上一年度团员教育评议等次',
smartSystem: '是否已登录智慧团建系统',
contact: '联系电话',
totalVolunteerHours: '累计志愿服务时长',
volunteerHours2024: '上一年度志愿服务时长',
memberNo: '发展团员编号',
honorsFiveYears: '近五年获得荣誉情况',
mainStory: '主要事迹',
leagueOpinion: '学生社团指导教师意见/所在单位团委',
partyOpinion: '业务指导单位意见/所在单位党组织',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'name',
'gender',
'nation',
'politics',
'birthMonth',
'joinTime',
'collegeMajorClass',
'position',
'contact',
'memberNo'
]
},
{
title: '志愿服务与教育评议',
fields: [
'volunteerRegTime',
'eduEval2024',
'smartSystem',
'totalVolunteerHours',
'volunteerHours2024'
]
},
{
title: '主要事迹与荣誉',
fields: ['honorsFiveYears', 'mainStory']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_tzbcy_form: {
title: '挑战杯',
sheetName: '挑战杯',
labels: {
projectMaterials: '项目介绍材料',
otherProofs: '其他相关证明材料',
teamMembers: '团队成员',
advisors: '指导老师'
},
sections: [
{
title: '项目信息',
fields: [
'year',
'projectName',
'provinceCity',
'schoolName',
'projectType',
'projectGroup'
]
},
{ title: '团队成员', fields: ['teamMembers'] },
{ title: '指导老师', fields: ['advisors'] },
{
title: '项目说明',
fields: [
'projectBrief',
'socialValue',
'practiceProcess',
'innovationMeaning',
'developmentProspect',
'teamCooperation',
'projectMaterials',
'otherProofs'
]
},
{
title: '公开展示信息',
fields: [
'publicProjectType',
'publicProjectGroup',
'projectSummary',
'teamIntro',
'teamSlogan',
'practiceLog'
]
}
],
materialFields: [],
materialSource: 'tzbcy'
},
gxmu_qmgc_form: {
title: '青马工程',
sheetName: '青马工程',
labels: {
hobby: '兴趣爱好',
willingToWorkInGuangxi: '是否愿意留桂工作',
schoolInfo: '学校信息',
leaguePosition: '团内职务',
resume: '个人简历',
awards: '获得奖励',
academicPerformance: '学习成绩',
secondaryLeagueOpinion: '二级团组织意见',
secondaryLeagueOpinionDate: '二级团组织意见日期',
secondaryPartyOpinion: '二级党组织意见',
secondaryPartyOpinionDate: '二级党组织意见日期',
schoolLeagueOpinion: '学校团组织意见',
schoolLeagueOpinionDate: '学校团组织意见日期'
},
sections: [
{
title: '个人信息',
fields: [
'year',
'name',
'gender',
'nation',
'photo',
'birthMonth',
'politics',
'nativePlace',
'phone',
'wechat',
'email',
'qq',
'idCardNo',
'hobby',
'willingToWorkInGuangxi',
'schoolInfo',
'leaguePosition'
]
},
{
title: '学习与经历',
fields: ['resume', 'awards', 'academicPerformance']
},
{
title: '意见栏',
fields: [
'secondaryLeagueOpinion',
'secondaryLeagueOpinionDate',
'secondaryPartyOpinion',
'secondaryPartyOpinionDate',
'schoolLeagueOpinion',
'schoolLeagueOpinionDate'
]
}
],
materialFields: []
},
gxmu_wxxzx_project: {
title: '未来学术之星',
sheetName: '未来学术之星',
labels: {
topicName: '课题名称',
leader: '课题负责人',
collegeGradeClass: '学院年级班级',
phone: '联系电话',
advisor: '指导老师',
topicType: '课题类别',
guidanceTeachers: '指导教师组',
applicants: '申请人信息',
budgets: '经费预算',
rationale: '立项依据',
teacherOpinion: '指导教师意见',
teacherSign: '指导教师签名',
reviewCollege: '评审学院',
reviewReporter: '汇报人',
reviewDate: '评审日期',
reviewOpinion: '评审意见',
reviewScore: '评审成绩',
reviewLeaderSign: '评审负责人签名',
collegeOpinion: '学院意见',
schoolOpinion: '学校意见',
reviewMembers: '评审成员',
midtermRemark: '中期检查说明',
midtermMaterials: '中期检查材料',
finalRemark: '结题说明',
finalMaterials: '结题材料',
archiveRecords: '成果归档',
archiveAwards: '获奖情况',
archiveProofs: '佐证材料',
promiseSigner: '承诺签字人',
promiseSignature: '承诺书签名',
fundingBudgetFile: '经费预算表',
fundingAllocationFile: '经费划拨明细表',
proofFile: '证明材料'
},
sections: [
{
title: '课题信息',
fields: [
'year',
'topicName',
'leader',
'collegeGradeClass',
'phone',
'advisor',
'topicType'
]
},
{ title: '指导教师组', fields: ['guidanceTeachers'] },
{ title: '申请人信息', fields: ['applicants'] },
{
title: '经费预算',
fields: ['budgets', 'fundingBudgetFile', 'fundingAllocationFile']
},
{ title: '立项依据', fields: ['rationale'] },
{
title: '审核评审',
fields: [
'teacherOpinion',
'teacherSign',
'reviewCollege',
'reviewReporter',
'reviewDate',
'reviewOpinion',
'reviewScore',
'reviewLeaderSign',
'collegeOpinion',
'schoolOpinion'
]
},
{ title: '评审成员', fields: ['reviewMembers'] },
{
title: '中期检查与结题',
fields: [
'midtermRemark',
'midtermMaterials',
'finalRemark',
'finalMaterials'
]
},
{
title: '成果归档',
fields: ['archiveRecords', 'archiveAwards', 'archiveProofs']
},
{
title: '承诺书',
fields: ['promiseSigner', 'promiseSignature']
}
],
materialFields: [
'fundingBudgetFile',
'fundingAllocationFile',
'midtermMaterials',
'finalMaterials',
'archiveProofs'
],
materialTables: [
{
field: 'archiveRecords',
fileField: 'proofFile',
titleField: 'awardName',
label: '成果归档证明材料'
}
]
},
gxmu_tygl_form: {
title: '团员管理',
sheetName: '团员信息登记表',
labels: {
serialNo: '序号',
college: '学院',
className: '班级',
phone: '手机号码',
leaguePosition: '团内职务',
branchOrganizationName: '所属团支部',
idCardNo: '身份证号',
birthDate: '出生日期',
archiveInCurrentOrg: '团籍是否在本组织',
joinMonth: '入团年月',
memberRecords: '团员记录',
growthArchives: '成长档案'
},
sections: [
{
title: '基本信息',
fields: [
'serialNo',
'name',
'gender',
'nation',
'politics',
'college',
'className',
'phone',
'leaguePosition',
'branchOrganizationName',
'idCardNo',
'birthDate',
'archiveInCurrentOrg',
'joinMonth'
]
},
{ title: '团员记录', fields: ['memberRecords'] },
{ title: '成长档案', fields: ['growthArchives'] }
],
materialFields: []
},
cms_manuscript: {
title: '稿件',
sheetName: '稿件',
labels: {
title: '标题',
content: '正文',
contentFormat: '正文格式',
cover: '封面',
status: '状态',
sortNumber: '排序'
},
sections: [
{
title: '基本信息',
fields: ['title', 'status', 'sortNumber']
},
{ title: '封面', fields: ['cover'] },
{ title: '正文', fields: ['content'] }
],
materialFields: []
}
};
// 团员管理模块历史上存在两个模块标识
MODULE_DETAIL_CONFIGS.gxmu_tygl = MODULE_DETAIL_CONFIGS.gxmu_tygl_form;
/**
* 明细数组表头字段中文名
*/
export const TABLE_FIELD_LABELS: Record<string, string> = {
name: '姓名',
gender: '性别',
nation: '民族',
politics: '政治面貌',
birthMonth: '出生年月',
title: '职称',
duty: '职务',
department: '部门',
dept: '部门',
remark: '备注',
college: '学院',
gradeMajor: '年级专业',
grade: '年级',
className: '班级',
major: '专业',
role: '角色',
phone: '联系电话',
sign: '签名',
item: '项目',
amount: '金额',
reason: '原因',
type: '类型',
content: '内容',
recordTime: '时间',
archiveTime: '时间',
awardName: '获奖名称',
awardTime: '获奖时间',
archiveOrder: '序号',
proofFile: '证明材料',
year: '年份'
};
export const getModuleDetailConfig = (module?: string) => {
if (!module) {
return undefined;
}
return MODULE_DETAIL_CONFIGS[module];
};
@@ -15,7 +15,9 @@
<a-tag>{{ moduleName || module || '-' }}</a-tag>
<span>业务ID:</span>
<a-tag>{{ pk ?? '-' }}</a-tag>
<a-button size="small" :loading="loading" @click="loadData">刷新</a-button>
<a-button size="small" :loading="loading" @click="loadData"
>刷新</a-button
>
</a-space>
<div v-if="flowList.length" class="flow-wrap">
@@ -23,7 +25,9 @@
<a-step v-for="item in flowList" :key="item.id">
<template #title>
<div class="flow-title">
<span>{{ item.user?.realName || item.user?.username || '未分配审核人' }}</span>
<span>{{
item.user?.realName || item.user?.username || '未分配审核人'
}}</span>
<a-tag :color="getStatusColor(item.status)">
{{ getStatusText(item.status) }}
</a-tag>
@@ -31,14 +35,28 @@
</template>
<template #description>
<div class="flow-desc">
<div>创建时间:{{ toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss') }}</div>
<div
>创建时间:{{
toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss')
}}</div
>
<div v-if="item.content">审核意见:{{ item.content }}</div>
<a-space v-if="canReview(item)" style="margin-top: 12px">
<a-button type="primary" size="small" :loading="submitting" @click="openActionModal('approve', item)">
<a-button
type="primary"
size="small"
:loading="submitting"
@click="openActionModal('approve', item)"
>
通过
</a-button>
<a-button danger size="small" :loading="submitting" @click="openActionModal('reject', item)">
拒绝
<a-button
danger
size="small"
:loading="submitting"
@click="openActionModal('reject', item)"
>
驳回
</a-button>
</a-space>
</div>
@@ -50,19 +68,26 @@
<a-modal
v-model:visible="actionVisible"
:title="actionType === 'approve' ? '审核通过' : '审核拒绝'"
:title="actionType === 'approve' ? '审核通过' : '审核驳回'"
ok-text="确认"
cancel-text="取消"
:confirm-loading="submitting"
@ok="submitReview"
>
<a-form layout="vertical">
<a-form-item label="审核意见(非必填)">
<a-form
ref="actionFormRef"
layout="vertical"
:model="actionForm"
:rules="actionRules"
>
<a-form-item label="审核意见" name="content">
<a-textarea
v-model:value="actionContent"
v-model:value="actionForm.content"
:rows="4"
:maxlength="500"
show-count
allow-clear
placeholder="请输入审核意见"
placeholder="请输入审核意见(必填)"
/>
</a-form-item>
</a-form>
@@ -71,163 +96,187 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Empty, message } from 'ant-design-vue';
import { toDateString } from 'ele-admin-pro';
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
import type { ReviewList } from '@/api/gxmu/reviewList/model';
import { useUserStore } from '@/store/modules/user';
import { computed, reactive, ref, watch } from 'vue';
import { Empty, message } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue/es/form';
import { toDateString } from 'ele-admin-pro';
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
import type { ReviewList } from '@/api/gxmu/reviewList/model';
import { useUserStore } from '@/store/modules/user';
const props = defineProps<{
visible: boolean;
module?: string;
moduleName?: string;
pk?: number;
}>();
const props = defineProps<{
visible: boolean;
module?: string;
moduleName?: string;
pk?: number;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'done'): void;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'done'): void;
}>();
const userStore = useUserStore();
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
const loading = ref(false);
const submitting = ref(false);
const flowList = ref<ReviewList[]>([]);
const actionVisible = ref(false);
const actionType = ref<'approve' | 'reject'>('approve');
const actionContent = ref('');
const actionTarget = ref<ReviewList | null>(null);
const userStore = useUserStore();
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
const loading = ref(false);
const submitting = ref(false);
const flowList = ref<ReviewList[]>([]);
const actionVisible = ref(false);
const actionType = ref<'approve' | 'reject'>('approve');
const actionFormRef = ref<FormInstance | null>(null);
const actionForm = reactive<{ content: string }>({ content: '' });
const actionTarget = ref<ReviewList | null>(null);
const currentStepIndex = computed(() => {
const pendingIndex = flowList.value.findIndex((item) => item.status === 0);
if (pendingIndex >= 0) {
return pendingIndex;
}
return Math.max(flowList.value.length - 1, 0);
});
// 审核意见必填(仅空白字符视为未填写)
const actionRules = {
content: [
{
required: true,
validator: (_rule: unknown, value: string) => {
const text = (value || '').trim();
return text
? Promise.resolve()
: Promise.reject(new Error('请输入审核意见'));
},
trigger: 'blur'
}
]
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const getStatusText = (status?: number) => {
if (status === 1) return '通过';
if (status === 2) return '拒绝';
return '待审核';
};
const getStatusColor = (status?: number) => {
if (status === 1) return 'success';
if (status === 2) return 'error';
return 'default';
};
const canReview = (item: ReviewList) => {
return item.status === 0 && Number(item.userId || 0) === loginUserId.value;
};
const loadData = () => {
if (!props.module || !props.pk) {
flowList.value = [];
return;
}
loading.value = true;
listReviewList({
module: props.module,
pk: props.pk
})
.then((data) => {
flowList.value = [...(data || [])].sort(
(a, b) => Number(b.id || 0) - Number(a.id || 0)
);
})
.catch((e) => {
flowList.value = [];
message.error(e.message);
})
.finally(() => {
loading.value = false;
});
};
const openActionModal = (type: 'approve' | 'reject', item: ReviewList) => {
if (!canReview(item)) {
message.error('当前记录不是由你审核');
return;
}
actionType.value = type;
actionTarget.value = item;
actionContent.value = '';
actionVisible.value = true;
};
const submitReview = () => {
if (!actionTarget.value?.id) {
actionVisible.value = false;
return;
}
if (!canReview(actionTarget.value)) {
actionVisible.value = false;
message.error('当前记录不是由你审核');
return;
}
submitting.value = true;
updateReviewList({
id: actionTarget.value.id,
status: actionType.value === 'approve' ? 1 : 2,
module: actionTarget.value.module,
pk: actionTarget.value.pk,
sortNumber: actionTarget.value.sortNumber,
content: actionContent.value || undefined
})
.then((msg) => {
message.success(msg);
actionVisible.value = false;
emit('done');
loadData();
})
.catch((e) => {
message.error(e.message);
})
.finally(() => {
submitting.value = false;
});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
loadData();
} else {
flowList.value = [];
actionVisible.value = false;
actionTarget.value = null;
actionContent.value = '';
const currentStepIndex = computed(() => {
const pendingIndex = flowList.value.findIndex((item) => item.status === 0);
if (pendingIndex >= 0) {
return pendingIndex;
}
},
{ immediate: true }
);
return Math.max(flowList.value.length - 1, 0);
});
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const getStatusText = (status?: number) => {
if (status === 1) return '通过';
if (status === 2) return '驳回';
return '待审核';
};
const getStatusColor = (status?: number) => {
if (status === 1) return 'success';
if (status === 2) return 'error';
return 'default';
};
const canReview = (item: ReviewList) => {
return item.status === 0 && Number(item.userId || 0) === loginUserId.value;
};
const loadData = () => {
if (!props.module || !props.pk) {
flowList.value = [];
return;
}
loading.value = true;
listReviewList({
module: props.module,
pk: props.pk
})
.then((data) => {
flowList.value = [...(data || [])].sort(
(a, b) => Number(b.id || 0) - Number(a.id || 0)
);
})
.catch((e) => {
flowList.value = [];
message.error(e.message);
})
.finally(() => {
loading.value = false;
});
};
const openActionModal = (type: 'approve' | 'reject', item: ReviewList) => {
if (!canReview(item)) {
message.error('当前记录不是由你审核');
return;
}
actionType.value = type;
actionTarget.value = item;
actionForm.content = '';
actionVisible.value = true;
};
const submitReview = () => {
const target = actionTarget.value;
if (!target?.id) {
actionVisible.value = false;
return;
}
if (!canReview(target)) {
actionVisible.value = false;
message.error('当前记录不是由你审核');
return;
}
actionFormRef.value
?.validate()
.then(() => {
submitting.value = true;
return updateReviewList({
id: target.id,
status: actionType.value === 'approve' ? 1 : 2,
module: target.module,
pk: target.pk,
sortNumber: target.sortNumber,
content: actionForm.content.trim()
})
.then((msg) => {
message.success(msg);
actionVisible.value = false;
emit('done');
loadData();
})
.catch((e) => {
message.error(e.message);
})
.finally(() => {
submitting.value = false;
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
loadData();
} else {
flowList.value = [];
actionVisible.value = false;
actionTarget.value = null;
actionForm.content = '';
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.flow-wrap {
max-height: 560px;
overflow-y: auto;
padding-right: 8px;
}
.flow-wrap {
max-height: 560px;
overflow-y: auto;
padding-right: 8px;
}
.flow-title {
display: flex;
align-items: center;
gap: 8px;
}
.flow-title {
display: flex;
align-items: center;
gap: 8px;
}
.flow-desc {
color: rgba(0, 0, 0, 0.65);
line-height: 1.8;
}
.flow-desc {
color: rgba(0, 0, 0, 0.65);
line-height: 1.8;
}
</style>
@@ -33,7 +33,7 @@
v-model:value="form.pk"
/>
</a-form-item>
<a-form-item label="状态(0不通过 1通过)" name="status">
<a-form-item label="状态(0驳回 1通过)" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">显示</a-radio>
<a-radio :value="0">隐藏</a-radio>
+170 -150
View File
@@ -23,7 +23,7 @@
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0">待审核</a-tag>
<a-tag v-else-if="record.status === 1" color="green">通过</a-tag>
<a-tag v-else-if="record.status === 2" color="red">拒绝</a-tag>
<a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
@@ -36,12 +36,17 @@
</ele-pro-table>
</a-card>
<ReviewListEdit v-model:visible="showEdit" :data="current" @done="reload" />
<ReviewListEdit
v-model:visible="showEdit"
:data="current"
@done="reload"
/>
<ModuleDataModal
v-model:visible="showDataVisible"
:module="dataCurrent?.module"
:module-name="dataCurrent?.moduleName"
:pk="dataCurrent?.pk"
@done="reload"
/>
<ReviewFlowModal
v-model:visible="showFlowVisible"
@@ -55,162 +60,177 @@
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type { DatasourceFunction, ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import ReviewListEdit from './components/reviewListEdit.vue';
import ModuleDataModal from './components/moduleDataModal.vue';
import ReviewFlowModal from './components/reviewFlowModal.vue';
import { pageGroupReviewList, removeBatchReviewList } from '@/api/gxmu/reviewList';
import type { ReviewList, ReviewListParam } from '@/api/gxmu/reviewList/model';
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import ReviewListEdit from './components/reviewListEdit.vue';
import ModuleDataModal from './components/moduleDataModal.vue';
import ReviewFlowModal from './components/reviewFlowModal.vue';
import {
pageGroupReviewList,
removeBatchReviewList
} from '@/api/gxmu/reviewList';
import type {
ReviewList,
ReviewListParam
} from '@/api/gxmu/reviewList/model';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const selection = ref<ReviewList[]>([]);
const current = ref<ReviewList | null>(null);
const dataCurrent = ref<ReviewList | null>(null);
const flowCurrent = ref<ReviewList | null>(null);
const showEdit = ref(false);
const showDataVisible = ref(false);
const showFlowVisible = ref(false);
const showMove = ref(false);
const loading = ref(true);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const selection = ref<ReviewList[]>([]);
const current = ref<ReviewList | null>(null);
const dataCurrent = ref<ReviewList | null>(null);
const flowCurrent = ref<ReviewList | null>(null);
const showEdit = ref(false);
const showDataVisible = ref(false);
const showFlowVisible = ref(false);
const showMove = ref(false);
const loading = ref(true);
const datasource: DatasourceFunction = ({ page, limit, where, orders, filters }) => {
if (filters) {
where.status = filters.status;
}
return pageGroupReviewList({
...where,
...orders,
const datasource: DatasourceFunction = ({
page,
limit
});
};
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90
},
{
title: '模块',
dataIndex: 'moduleName',
key: 'module',
align: 'center'
},
{
title: '名称',
dataIndex: 'businessName',
key: 'businessName',
align: 'center',
ellipsis: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center'
},
{
title: '最新审核意见',
dataIndex: 'content',
key: 'content',
align: 'center',
ellipsis: true
},
{
title: '提交时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '当前审核人',
dataIndex: ['user', 'realName'],
key: 'userId',
align: 'center'
},
{
title: '操作',
key: 'action',
width: 220,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
const reload = (where?: ReviewListParam) => {
selection.value = [];
tableRef?.value?.reload({ where });
};
const openEdit = (row?: ReviewList) => {
current.value = row ?? null;
showEdit.value = true;
};
const openMove = () => {
showMove.value = true;
};
const openDataModal = (row: ReviewList) => {
dataCurrent.value = row;
showDataVisible.value = true;
};
const openFlowModal = (row: ReviewList) => {
flowCurrent.value = row;
showFlowVisible.value = true;
};
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchReviewList(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
}
});
};
return pageGroupReviewList({
...where,
...orders,
page,
limit
});
};
const query = () => {
loading.value = true;
};
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90
},
{
title: '模块',
dataIndex: 'moduleName',
key: 'module',
align: 'center'
},
{
title: '名称',
dataIndex: 'businessName',
key: 'businessName',
align: 'center',
ellipsis: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center'
},
{
title: '最新审核意见',
dataIndex: 'content',
key: 'content',
align: 'center',
ellipsis: true
},
{
title: '提交时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '当前审核人',
dataIndex: ['user', 'realName'],
key: 'userId',
align: 'center'
},
{
title: '操作',
key: 'action',
width: 220,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
query();
const reload = (where?: ReviewListParam) => {
selection.value = [];
tableRef?.value?.reload({ where });
};
const openEdit = (row?: ReviewList) => {
current.value = row ?? null;
showEdit.value = true;
};
const openMove = () => {
showMove.value = true;
};
const openDataModal = (row: ReviewList) => {
dataCurrent.value = row;
showDataVisible.value = true;
};
const openFlowModal = (row: ReviewList) => {
flowCurrent.value = row;
showFlowVisible.value = true;
};
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchReviewList(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
const query = () => {
loading.value = true;
};
query();
</script>
<script lang="ts">
export default {
name: 'ReviewList'
};
export default {
name: 'ReviewList'
};
</script>
<style lang="less" scoped></style>
<style lang="less" scoped></style>
+118
View File
@@ -0,0 +1,118 @@
export interface AttachmentFile {
name: string;
url: string;
}
const FILE_EXT_PATTERN =
/\.(pdf|docx?|xlsx?|pptx?|csv|txt|zip|rar|7z|jpe?g|png|gif|webp|bmp|svg|mp4|mp3|wav)$/i;
/**
* 从文件地址中取出展示用的文件名
*/
export function getAttachmentName(url?: string) {
if (!url) {
return '未命名文件';
}
const cleanUrl = url.split('?')[0];
const name = cleanUrl.split('/').pop() || cleanUrl;
try {
return decodeURIComponent(name);
} catch (e) {
return name;
}
}
/**
* 判断字符串是否像文件地址(http(s)、协议相对地址、带扩展名的相对路径)
*/
export function isFileUrl(value?: string) {
const text = value?.trim();
if (!text) {
return false;
}
if (/^(https?:)?\/\//i.test(text)) {
return true;
}
return FILE_EXT_PATTERN.test(text) && !/\s/.test(text);
}
export function isImageFile(url?: string) {
const text = (url || '').split('?')[0];
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(text);
}
const normalizeEntry = (item: unknown): AttachmentFile | null => {
if (typeof item === 'string') {
return isFileUrl(item)
? { name: getAttachmentName(item), url: item.trim() }
: null;
}
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>;
const rawUrl = [
record.url,
record.fileUrl,
record.downloadUrl,
record.path
].find((value) => typeof value === 'string' && isFileUrl(value as string));
if (typeof rawUrl !== 'string') {
return null;
}
const rawName = [record.name, record.fileName, record.originalName].find(
(value) => typeof value === 'string' && (value as string).trim()
);
return {
name: (rawName as string | undefined) || getAttachmentName(rawUrl),
url: rawUrl.trim()
};
}
return null;
};
/**
* 把后端存储的附件字段解析成文件列表。
* 兼容:单个地址、JSON 数组字符串、对象数组(name/url、fileName/fileUrl 等)
*/
export function parseAttachments(value: unknown): AttachmentFile[] {
if (value === null || value === undefined || value === '') {
return [];
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) {
return [];
}
if (text.startsWith('[') || text.startsWith('{')) {
try {
return parseAttachments(JSON.parse(text));
} catch (e) {
// 解析失败时按普通地址处理
}
}
const single = normalizeEntry(text);
return single ? [single] : [];
}
if (Array.isArray(value)) {
return value
.map((item) => normalizeEntry(item))
.filter((item): item is AttachmentFile => !!item);
}
const single = normalizeEntry(value);
return single ? [single] : [];
}
/**
* 把附件字段解析结果转换成图片地址列表
*/
export function parseAttachmentImages(value: unknown) {
const urls: string[] = [];
parseAttachments(value).forEach((item) => {
if (isImageFile(item.url)) {
urls.push(item.url);
}
});
if (!urls.length && typeof value === 'string' && isImageFile(value)) {
urls.push(value);
}
return urls;
}
+1 -1
View File
@@ -63,7 +63,7 @@ const router = useRouter();
const features = [
{
title: "AI+党建的双向赋能",
title: "AI+团建的双向赋能",
desc: "赋能广西医科大学团委的各项工作。项目遵循“先基础,后智能”的原则,信息化和智能化相结合,确保项目稳步推进,价值逐步释放。"
},
{
@@ -134,7 +134,7 @@
class="ele-btn-icon"
@click="openUrl('https://p.haoii123.com/')"
>
一键排版
公众号排版
</a-button>
</a-space>
</div>