This commit is contained in:
2026-07-20 11:50:47 +08:00
parent 95d43746f6
commit 019ed28d37
401 changed files with 61622 additions and 23869 deletions
@@ -0,0 +1,343 @@
<template>
<a-drawer
placement="right"
width="78%"
:visible="visible"
:title="isUpdate ? '编辑基层团支部工作台帐' : '新增基层团支部工作台帐'"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<a-form :model="form" layout="vertical">
<a-tabs v-model:activeKey="activeKey" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="overview" tab="团支部简况">
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="单位">
<a-input v-model:value="form.unitName" placeholder="请输入单位" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="团支部书记姓名">
<a-input v-model:value="form.branchSecretaryName" placeholder="请输入团支部书记姓名" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="手机号码">
<a-input v-model:value="form.branchSecretaryPhone" placeholder="请输入手机号码" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8" v-for="item in overviewFields" :key="item.key">
<a-form-item :label="item.label">
<a-input v-model:value="form[item.key]" :placeholder="`请输入${item.label}`" />
</a-form-item>
</a-col>
</a-row>
</a-tab-pane>
<a-tab-pane key="branchMembers" tab="团支部班子成员名单">
<editable-table title="团支部班子成员名单" :rows="form.branchCommitteeMembers" :columns="branchCommitteeColumns" :create-row="createBranchCommitteeMember" :scroll-x="1100" />
</a-tab-pane>
<a-tab-pane key="leagueMembers" tab="团员登记表">
<editable-table title="团员登记表" :rows="form.leagueMemberRegisters" :columns="leagueMemberColumns" :create-row="createLeagueMemberRegister" :scroll-x="1380" />
</a-tab-pane>
<a-tab-pane key="youths" tab="青年登记表">
<editable-table title="青年登记表" :rows="form.youthRegisters" :columns="youthColumns" :create-row="createYouthRegister" :scroll-x="1180" />
</a-tab-pane>
<a-tab-pane key="honors" tab="荣誉登记表">
<editable-table title="荣誉登记表" :rows="form.branchHonorRecords" :columns="branchHonorColumns" :create-row="createBranchHonorRecord" :scroll-x="1120" />
</a-tab-pane>
<a-tab-pane key="fees" tab="团费收缴登记表">
<editable-table title="团员团费收缴登记表" :rows="form.memberFeeRecords" :columns="memberFeeColumns" :create-row="createMemberFeeRecord" :scroll-x="1750" />
</a-tab-pane>
<a-tab-pane key="meeting" tab="团支部会议记录">
<editable-table title="团支部有关会议记录" :rows="form.branchMeetingRecords" :columns="meetingColumns" :create-row="createBranchMeetingRecord" :scroll-x="1400" />
</a-tab-pane>
<a-tab-pane key="activity" tab="团支部活动记录">
<editable-table title="团支部有关活动记录" :rows="form.branchActivityRecords" :columns="activityColumns" :create-row="createBranchActivityRecord" :scroll-x="1320" />
</a-tab-pane>
<a-tab-pane key="evaluation" tab="教育评议结果">
<editable-table title="年度团员教育评议结果汇总表" :rows="form.branchEducationEvaluations" :columns="branchEducationColumns" :create-row="createBranchEducationEvaluation" :scroll-x="1080" />
</a-tab-pane>
<a-tab-pane key="summary" tab="年度工作总结">
<a-row :gutter="16">
<a-col :xs="24" :md="8">
<a-form-item label="编号发展团员智慧团建录入率">
<a-input v-model:value="form.smartLeagueEntryRate" placeholder="若无请填无" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="学社衔接率">
<a-input v-model:value="form.schoolSocietyConnectionRate" placeholder="若无请填无" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="对标定级等次">
<a-input v-model:value="form.standardLevel" placeholder="若无请填无" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="团支部年度工作总结">
<a-textarea v-model:value="form.branchAnnualSummary" :rows="18" placeholder="请输入团支部年度工作总结" />
</a-form-item>
</a-tab-pane>
</a-tabs>
</a-form>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { leagueBranchLedgerApi } from '@/api/gxmu/work-ledger';
import type { WorkLedger } from '@/api/gxmu/work-ledger/model';
import EditableTable from '../../workLedger/components/editable-table.vue';
import { useGxmuFormSelectOptions } from '@/views/gxmu/useFormSelectOptions';
const props = defineProps<{
visible: boolean;
data?: WorkLedger | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const loading = ref(false);
const isUpdate = ref(false);
const activeKey = ref('overview');
const { nationOptions, loadNationOptions } = useGxmuFormSelectOptions();
const rowKey = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const withKey = <T extends Record<string, any>>(row: T) => ({ ...row, __rowKey: rowKey() });
const createBranchCommitteeMember = () => withKey({ name: '', gender: '', birthMonth: '', politics: '', leagueDuty: '', partTimeWork: '' });
const createLeagueMemberRegister = () => withKey({ name: '', gender: '', birthMonth: '', nation: '', joinMonth: '', leagueDuty: '', workDuty: '', remark: '' });
const createYouthRegister = () => withKey({ name: '', gender: '', birthMonth: '', nation: '', workDuty: '', remark: '' });
const createBranchHonorRecord = () => withKey({ honorName: '', awardTime: '', winner: '', rewardSituation: '', remark: '' });
const createMemberFeeRecord = () => withKey({
name: '', januaryAmount: '', februaryAmount: '', marchAmount: '', aprilAmount: '', mayAmount: '', juneAmount: '',
julyAmount: '', augustAmount: '', septemberAmount: '', octoberAmount: '', novemberAmount: '', decemberAmount: '', totalAmount: ''
});
const createBranchMeetingRecord = () => withKey({ meetingName: '', meetingTheme: '', time: '', location: '', host: '', attendees: '', absentees: '', content: '', recorder: '' });
const createBranchActivityRecord = () => withKey({ activityName: '', activityTheme: '', activityTime: '', location: '', participants: '', content: '', recorder: '' });
const createBranchEducationEvaluation = () => withKey({ name: '', excellent: '', qualified: '', basicallyQualified: '', unqualified: '', remark: '' });
const overviewFields = [
{ label: '总人数', key: 'totalPeopleCount' },
{ label: '团员数', key: 'memberTotal' },
{ label: '党员数', key: 'partyMemberCount' },
{ label: '青年数', key: 'youthCount' },
{ label: '团青比例', key: 'leagueYouthRatio' },
{ label: '教职工团员数', key: 'facultyMemberCount' },
{ label: '学生团员数', key: 'studentMemberCount' },
{ label: '少数民族团员数', key: 'minorityMemberCount' },
{ label: '女团员数', key: 'femaleMemberCount' }
];
const branchCommitteeColumns = [
{ title: '姓名', key: 'name', width: 140 },
{ title: '性别', key: 'gender', width: 100 },
{ title: '出生年月', key: 'birthMonth', width: 140 },
{ title: '政治面貌', key: 'politics', width: 140 },
{ title: '团内职务', key: 'leagueDuty', width: 140 },
{ title: '兼任工作', key: 'partTimeWork', width: 220 },
{ title: '操作', key: 'action', width: 90 }
];
const leagueMemberColumns = computed(() => [
{ title: '姓名', key: 'name', width: 140 },
{ title: '性别', key: 'gender', width: 90 },
{ title: '出生年月', key: 'birthMonth', width: 130 },
{ title: '民族', key: 'nation', width: 100, options: nationOptions.value },
{ title: '入团年月', key: 'joinMonth', width: 130 },
{ title: '团内职务', key: 'leagueDuty', width: 160 },
{ title: '工作职务', key: 'workDuty', width: 160 },
{ title: '备注', key: 'remark', width: 160 },
{ title: '操作', key: 'action', width: 90 }
]);
const youthColumns = computed(() => [
{ title: '姓名', key: 'name', width: 140 },
{ title: '性别', key: 'gender', width: 90 },
{ title: '出生年月', key: 'birthMonth', width: 130 },
{ title: '民族', key: 'nation', width: 100, options: nationOptions.value },
{ title: '工作职务', key: 'workDuty', width: 180 },
{ title: '备注', key: 'remark', width: 180 },
{ title: '操作', key: 'action', width: 90 }
]);
const branchHonorColumns = [
{ title: '荣誉名称(校级及以上)', key: 'honorName', width: 280 },
{ title: '获奖时间', key: 'awardTime', width: 140 },
{ title: '获奖者(集体或个人)', key: 'winner', width: 220 },
{ title: '奖励情况', key: 'rewardSituation', width: 160 },
{ title: '备注', key: 'remark', width: 160 },
{ title: '操作', key: 'action', width: 90 }
];
const memberFeeColumns = [
{ title: '姓名', key: 'name', width: 140 },
{ title: '一月', key: 'januaryAmount', width: 90 },
{ title: '二月', key: 'februaryAmount', width: 90 },
{ title: '三月', key: 'marchAmount', width: 90 },
{ title: '四月', key: 'aprilAmount', width: 90 },
{ title: '五月', key: 'mayAmount', width: 90 },
{ title: '六月', key: 'juneAmount', width: 90 },
{ title: '七月', key: 'julyAmount', width: 90 },
{ title: '八月', key: 'augustAmount', width: 90 },
{ title: '九月', key: 'septemberAmount', width: 90 },
{ title: '十月', key: 'octoberAmount', width: 90 },
{ title: '十一月', key: 'novemberAmount', width: 90 },
{ title: '十二月', key: 'decemberAmount', width: 90 },
{ title: '小计', key: 'totalAmount', width: 100 },
{ title: '操作', key: 'action', width: 90 }
];
const meetingColumns = [
{ title: '会议名称', key: 'meetingName', width: 180 },
{ title: '会议主题', key: 'meetingTheme', width: 220 },
{ title: '时间', key: 'time', width: 150 },
{ title: '地点', key: 'location', width: 150 },
{ title: '主持人', key: 'host', width: 120 },
{ title: '与会人员', key: 'attendees', width: 220, textarea: true },
{ title: '缺席人员', key: 'absentees', width: 160 },
{ title: '会议记录', key: 'content', width: 320, textarea: true },
{ title: '记录人', key: 'recorder', width: 120 },
{ title: '操作', key: 'action', width: 90 }
];
const activityColumns = [
{ title: '活动名称', key: 'activityName', width: 180 },
{ title: '活动主题', key: 'activityTheme', width: 220 },
{ title: '活动时间', key: 'activityTime', width: 160 },
{ title: '活动地点', key: 'location', width: 160 },
{ title: '参与人员', key: 'participants', width: 220, textarea: true },
{ title: '活动内容', key: 'content', width: 420, textarea: true },
{ title: '记录人', key: 'recorder', width: 120 },
{ title: '操作', key: 'action', width: 90 }
];
const branchEducationColumns = [
{ title: '姓名', key: 'name', width: 180 },
{ title: '优秀', key: 'excellent', width: 120 },
{ title: '合格', key: 'qualified', width: 120 },
{ title: '基本合格', key: 'basicallyQualified', width: 140 },
{ title: '不合格', key: 'unqualified', width: 120 },
{ title: '备注', key: 'remark', width: 180 },
{ title: '操作', key: 'action', width: 90 }
];
const createDefaultForm = (): WorkLedger => ({
id: undefined,
unitName: '',
branchSecretaryName: '',
branchSecretaryPhone: '',
totalPeopleCount: '',
memberTotal: '',
partyMemberCount: '',
youthCount: '',
leagueYouthRatio: '',
facultyMemberCount: '',
studentMemberCount: '',
minorityMemberCount: '',
femaleMemberCount: '',
smartLeagueEntryRate: '',
schoolSocietyConnectionRate: '',
standardLevel: '',
branchAnnualSummary: '',
branchCommitteeMembers: [createBranchCommitteeMember()],
leagueMemberRegisters: [createLeagueMemberRegister()],
youthRegisters: [createYouthRegister()],
branchHonorRecords: [createBranchHonorRecord()],
memberFeeRecords: [createMemberFeeRecord()],
branchMeetingRecords: [createBranchMeetingRecord()],
branchActivityRecords: [createBranchActivityRecord()],
branchEducationEvaluations: [createBranchEducationEvaluation()]
});
const form = reactive<WorkLedger>(createDefaultForm());
const normalizeRows = (rows: any[] | undefined, createRow: () => Record<string, any>) =>
rows?.length ? rows.map((item) => ({ ...createRow(), ...item, __rowKey: rowKey() })) : [createRow()];
const assignForm = (record?: WorkLedger | null) => {
const defaults = createDefaultForm();
Object.assign(form, defaults, record || {});
form.branchCommitteeMembers = normalizeRows(record?.branchCommitteeMembers, createBranchCommitteeMember) as any;
form.leagueMemberRegisters = normalizeRows(record?.leagueMemberRegisters, createLeagueMemberRegister) as any;
form.youthRegisters = normalizeRows(record?.youthRegisters, createYouthRegister) as any;
form.branchHonorRecords = normalizeRows(record?.branchHonorRecords, createBranchHonorRecord) as any;
form.memberFeeRecords = normalizeRows(record?.memberFeeRecords, createMemberFeeRecord) as any;
form.branchMeetingRecords = normalizeRows(record?.branchMeetingRecords, createBranchMeetingRecord) as any;
form.branchActivityRecords = normalizeRows(record?.branchActivityRecords, createBranchActivityRecord) as any;
form.branchEducationEvaluations = normalizeRows(record?.branchEducationEvaluations, createBranchEducationEvaluation) as any;
};
const cleanRows = (rows?: any[]) =>
(rows || [])
.map(({ __rowKey, ...item }) => item)
.filter((item) => Object.values(item).some((value) => String(value || '').trim()));
const save = () => {
loading.value = true;
const payload: WorkLedger = {
...form,
branchCommitteeMembers: cleanRows(form.branchCommitteeMembers) as any,
leagueMemberRegisters: cleanRows(form.leagueMemberRegisters) as any,
youthRegisters: cleanRows(form.youthRegisters) as any,
branchHonorRecords: cleanRows(form.branchHonorRecords) as any,
memberFeeRecords: cleanRows(form.memberFeeRecords) as any,
branchMeetingRecords: cleanRows(form.branchMeetingRecords) as any,
branchActivityRecords: cleanRows(form.branchActivityRecords) as any,
branchEducationEvaluations: cleanRows(form.branchEducationEvaluations) as any
};
const saveOrUpdate = payload.id
? leagueBranchLedgerApi.update(payload)
: leagueBranchLedgerApi.add(payload);
saveOrUpdate
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
await loadNationOptions().catch(() => {});
isUpdate.value = !!props.data?.id;
assignForm(props.data);
} else {
assignForm();
}
activeKey.value = 'overview';
}
);
</script>
<style lang="less" scoped>
@import '../../tag-tabs.less';
.gxmu-tag-tabs();
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space wrap>
<a-button class="gxmu-toolbar-btn gxmu-add-btn" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
<span>添加</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入单位/团支部书记"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<branch-edit
v-model:visible="showEdit"
:data="current"
@done="reload"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import type {
ColumnItem,
DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types';
import { leagueBranchLedgerApi } from '@/api/gxmu/work-ledger';
import type { WorkLedger, WorkLedgerParam } from '@/api/gxmu/work-ledger/model';
import BranchEdit from './components/edit.vue';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const current = ref<WorkLedger | null>(null);
const columns = ref<ColumnItem[]>([
{ title: '单位', dataIndex: 'unitName', key: 'unitName', align: 'center', width: 220, ellipsis: true },
{ title: '团支部书记', dataIndex: 'branchSecretaryName', key: 'branchSecretaryName', align: 'center', width: 120 },
{ title: '手机号码', dataIndex: 'branchSecretaryPhone', key: 'branchSecretaryPhone', align: 'center', width: 140 },
{ title: '团员数', dataIndex: 'memberTotal', key: 'memberTotal', align: 'center', width: 100 },
{ title: '党员数', dataIndex: 'partyMemberCount', key: 'partyMemberCount', align: 'center', width: 100 },
{ title: '青年数', dataIndex: 'youthCount', key: 'youthCount', align: 'center', width: 100 },
{ title: '团青比例', dataIndex: 'leagueYouthRatio', key: 'leagueYouthRatio', align: 'center', width: 120 },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', align: 'center', width: 180 },
{ title: '操作', key: 'action', align: 'center', fixed: 'right', width: 160 }
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return leagueBranchLedgerApi
.userPage({
page,
limit,
keywords: searchText.value.trim() || undefined
} as WorkLedgerParam)
.catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const openEdit = (record?: WorkLedger) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as WorkLedger)
: null;
showEdit.value = true;
};
const remove = (record: WorkLedger) => {
const hide = message.loading('请求中..', 0);
leagueBranchLedgerApi
.remove(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
@@ -0,0 +1,8 @@
<template>
<work-ledger title="基层团委工作台账" :api="leagueCommitteeLedgerApi" />
</template>
<script lang="ts" setup>
import WorkLedger from '../workLedger/index.vue';
import { leagueCommitteeLedgerApi } from '@/api/gxmu/work-ledger';
</script>
+558
View File
@@ -0,0 +1,558 @@
<template>
<a-drawer
placement="right"
width="1180"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<a-form ref="formRef" :model="form">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 240px"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
<a-tabs v-model:activeKey="activeTab" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="base" tab="登记表">
<div class="sheet-wrap">
<!-- <div class="sheet-main-title"-->
<!-- >2025 年广西医科大学青年马克思主义者培养工程培训班学员登记表</div>-->
<table class="sheet-table">
<colgroup>
<col style="width: 14%" />
<col style="width: 15%" />
<col style="width: 10%" />
<col style="width: 14%" />
<col style="width: 8%" />
<col style="width: 17%" />
<col style="width: 22%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">姓名</td>
<td class="sheet-value">
<a-input
v-model:value="form.name"
placeholder="请输入姓名"
/>
</td>
<td class="sheet-label">性别</td>
<td class="sheet-value">
<a-select v-model:value="form.gender" placeholder="请选择" style="width: 120px">
<a-select-option value="男">男</a-select-option>
<a-select-option value="女">女</a-select-option>
</a-select>
</td>
<td class="sheet-label">民族</td>
<td class="sheet-value">
<a-select
allow-clear
show-search
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
v-model:value="form.nation"
style="width: 120px"
/>
</td>
<td class="sheet-photo-cell" rowspan="4">
<div class="sheet-photo-title">电子版照片</div>
<div class="sheet-photo-box">
<a-image
v-if="form.photo"
:src="form.photo"
:width="96"
/>
<div v-else class="sheet-photo-placeholder">未上传</div>
</div>
<upload-file
v-model:value="form.photo"
accept="image/*"
button-text="上传照片"
/>
</td>
</tr>
<tr>
<td class="sheet-label">出生年月</td>
<td class="sheet-value">
<a-date-picker
v-model:value="form.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</td>
<td class="sheet-label">政治面貌</td>
<td class="sheet-value">
<a-select style="width: 120px"
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</td>
<td class="sheet-label">籍贯</td>
<td class="sheet-value">
<a-input style="width: 120px"
v-model:value="form.nativePlace"
placeholder="请输入籍贯"
/>
</td>
</tr>
<tr>
<td class="sheet-label">手机号码</td>
<td class="sheet-value" colspan="2">
<a-input
v-model:value="form.phone"
placeholder="请输入手机号码"
/>
</td>
<td class="sheet-label" colspan="2">微信号</td>
<td class="sheet-value">
<a-input
v-model:value="form.wechat"
placeholder="请输入微信号"
/>
</td>
</tr>
<tr>
<td class="sheet-label">电子邮箱</td>
<td class="sheet-value" colspan="2">
<a-input
v-model:value="form.email"
placeholder="请输入电子邮箱"
/>
</td>
<td class="sheet-label" colspan="2">QQ号</td>
<td class="sheet-value">
<a-input v-model:value="form.qq" placeholder="请输入QQ号" />
</td>
</tr>
<tr>
<td class="sheet-label">身份证号</td>
<td class="sheet-value" colspan="2">
<a-input
v-model:value="form.idCardNo"
placeholder="请输入身份证号"
/>
</td>
<td class="sheet-label" colspan="2">爱好特长</td>
<td class="sheet-value" colspan="2">
<a-input
v-model:value="form.hobby"
placeholder="请输入爱好特长"
/>
</td>
</tr>
<tr>
<td class="sheet-label" colspan="4">
毕业后是否有志报考广西选调生<br />
或志愿到我区基层一线和艰苦边远地区工作
</td>
<td class="sheet-value" colspan="3">
<a-radio-group v-model:value="form.willingToWorkInGuangxi">
<a-radio value="是">是</a-radio>
<a-radio value="否">否</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">学校、院系、年级、专业</td>
<td class="sheet-value left" colspan="6">
<a-input
v-model:value="form.schoolInfo"
placeholder="请输入学校、院系、年级、专业"
/>
</td>
</tr>
<tr>
<td class="sheet-label">担任团学职务情况</td>
<td class="sheet-value left" colspan="6">
<a-input
v-model:value="form.leaguePosition"
placeholder="请输入担任团学职务情况"
/>
</td>
</tr>
<tr>
<td class="sheet-label">个人简历</td>
<td class="sheet-value left" colspan="6">
<a-textarea
v-model:value="form.resume"
:rows="8"
placeholder="请输入个人简历"
/>
</td>
</tr>
<tr>
<td class="sheet-label">奖惩情况</td>
<td class="sheet-value left" colspan="6">
<a-textarea
v-model:value="form.awards"
:rows="7"
placeholder="请输入奖惩情况"
/>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
<!-- <a-tab-pane key="review" tab="综合成绩与意见">-->
<!-- <div class="sheet-wrap">-->
<!-- <table class="sheet-table">-->
<!-- <colgroup>-->
<!-- <col style="width: 15%" />-->
<!-- <col style="width: 85%" />-->
<!-- </colgroup>-->
<!-- <tbody>-->
<!-- <tr>-->
<!-- <td class="sheet-label">-->
<!-- 综合成绩<br />-->
<!-- 情况<br />-->
<!-- (按最近一<br />-->
<!-- 学年填写)-->
<!-- </td>-->
<!-- <td class="sheet-value left">-->
<!-- <a-textarea-->
<!-- v-model:value="form.academicPerformance"-->
<!-- :rows="12"-->
<!-- placeholder="示例:最近学年专业综合排名、补考重修情况、思政课程成绩等"-->
<!-- />-->
<!-- </td>-->
<!-- </tr>-->
<!-- <tr>-->
<!-- <td class="sheet-label">二级团组织意见</td>-->
<!-- <td class="sheet-value left opinion-cell">-->
<!-- <a-textarea-->
<!-- v-model:value="form.secondaryLeagueOpinion"-->
<!-- :rows="6"-->
<!-- placeholder="请输入二级团组织意见"-->
<!-- />-->
<!-- <div class="opinion-date-row">-->
<!-- <span>(盖章)</span>-->
<!-- <a-input-->
<!-- v-model:value="form.secondaryLeagueOpinionDate"-->
<!-- placeholder="如:2025年5月1日"-->
<!-- class="date-input"-->
<!-- />-->
<!-- </div>-->
<!-- </td>-->
<!-- </tr>-->
<!-- <tr>-->
<!-- <td class="sheet-label">二级党组织意见</td>-->
<!-- <td class="sheet-value left opinion-cell">-->
<!-- <a-textarea-->
<!-- v-model:value="form.secondaryPartyOpinion"-->
<!-- :rows="6"-->
<!-- placeholder="请输入二级党组织意见"-->
<!-- />-->
<!-- <div class="opinion-date-row">-->
<!-- <span>(盖章)</span>-->
<!-- <a-input-->
<!-- v-model:value="form.secondaryPartyOpinionDate"-->
<!-- placeholder="如:2025年5月2日"-->
<!-- class="date-input"-->
<!-- />-->
<!-- </div>-->
<!-- </td>-->
<!-- </tr>-->
<!-- <tr>-->
<!-- <td class="sheet-label">学校团委意见</td>-->
<!-- <td class="sheet-value left opinion-cell">-->
<!-- <a-textarea-->
<!-- v-model:value="form.schoolLeagueOpinion"-->
<!-- :rows="6"-->
<!-- placeholder="请输入学校团委意见"-->
<!-- />-->
<!-- <div class="opinion-date-row">-->
<!-- <span>(盖章)</span>-->
<!-- <a-input-->
<!-- v-model:value="form.schoolLeagueOpinionDate"-->
<!-- placeholder="如:2025年5月3日"-->
<!-- class="date-input"-->
<!-- />-->
<!-- </div>-->
<!-- </td>-->
<!-- </tr>-->
<!-- </tbody>-->
<!-- </table>-->
<!-- </div>-->
<!-- </a-tab-pane>-->
</a-tabs>
</a-form>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save"
>保存</a-button
>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import UploadFile from '@/components/UploadFile/index.vue';
import { addQmgcForm, updateQmgcForm } from '@/api/gxmu/qmgc-form';
import type { QmgcForm } from '@/api/gxmu/qmgc-form/model';
import { listDictData } from '@/api/system/dict-data';
const NATION_DICT_ID = 173;
const POLITICS_DICT_ID = 172;
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: QmgcForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const activeTab = ref('base');
const nationOptions = ref<{ label: string; value: string }[]>([]);
const politicsOptions = ref<{ label: string; value: string }[]>([]);
const optionsLoaded = ref(false);
const createDefaultForm = (): QmgcForm => ({
id: undefined,
year: '',
name: '',
gender: '',
nation: '',
photo: '',
birthMonth: '',
politics: '',
nativePlace: '',
phone: '',
wechat: '',
email: '',
qq: '',
idCardNo: '',
hobby: '',
willingToWorkInGuangxi: '',
schoolInfo: '',
leaguePosition: '',
resume: '',
awards: '',
academicPerformance: '',
secondaryLeagueOpinion: '',
secondaryLeagueOpinionDate: '',
secondaryPartyOpinion: '',
secondaryPartyOpinionDate: '',
schoolLeagueOpinion: '',
schoolLeagueOpinionDate: ''
});
const form = reactive<QmgcForm>(createDefaultForm());
const filterSelectOption = (input: string, option: any) => {
return String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase());
};
const loadNationOptions = async () => {
const result = await listDictData({ dictId: NATION_DICT_ID });
nationOptions.value = result
.filter((item) => !!item.dictDataName)
.map((item) => ({
label: item.dictDataName as string,
value: item.dictDataName as string
}));
optionsLoaded.value = true;
};
const loadPoliticsOptions = async () => {
const result = await listDictData({ dictId: POLITICS_DICT_ID });
politicsOptions.value = result
.filter((item) => !!item.dictDataName)
.map((item) => ({
label: item.dictDataName as string,
value: item.dictDataName as string
}));
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
if (!optionsLoaded.value) {
try {
await Promise.all([loadNationOptions(), loadPoliticsOptions()]);
} catch (e: any) {
message.error(e.message || '民族字典加载失败');
}
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
activeTab.value = 'base';
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateQmgcForm : addQmgcForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
@import '../../tag-tabs.less';
.gxmu-field-theme();
.gxmu-tag-tabs();
.sheet-wrap {
border: 1px solid #d9d9d9;
padding: 12px;
background: #fff;
}
.sheet-main-title {
font-size: 28px;
font-weight: 700;
text-align: center;
line-height: 1.5;
margin-bottom: 16px;
}
.sheet-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.sheet-table td {
border: 1px solid #3b3b3b;
padding: 8px 6px;
font-size: 13px;
text-align: center;
vertical-align: middle;
}
.sheet-label {
font-weight: 600;
}
.sheet-value.left {
text-align: left;
}
.sheet-photo-cell {
vertical-align: top !important;
}
.sheet-photo-title {
writing-mode: vertical-rl;
text-orientation: upright;
margin: 0 auto 12px;
font-weight: 600;
letter-spacing: 4px;
}
.sheet-photo-box {
min-height: 108px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.sheet-photo-placeholder {
color: #999;
}
.sheet-table .sheet-value :deep(.ant-input),
.sheet-table .sheet-value :deep(.ant-select-selector),
.sheet-table .sheet-value :deep(.ant-radio-group),
.sheet-table .sheet-value :deep(.ant-input-textarea) {
border: 0;
box-shadow: none;
padding: 0;
background: transparent;
}
.sheet-table .sheet-value :deep(textarea.ant-input) {
resize: none;
}
.opinion-cell {
padding: 12px 12px 18px !important;
}
.opinion-date-row {
margin-top: 16px;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
}
.date-input {
width: 220px;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
@media (max-width: 768px) {
.sheet-main-title {
font-size: 18px;
}
}
</style>
@@ -0,0 +1,108 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="青马工程"
file-name="青马工程导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addQmgcForm } from '@/api/gxmu/qmgc-form';
import type { QmgcForm } from '@/api/gxmu/qmgc-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '姓名', key: 'name', required: true },
{ header: '性别', key: 'gender' },
{ header: '民族', key: 'nation' },
{ header: '出生年月', key: 'birthMonth' },
{ header: '政治面貌', key: 'politics' },
{ header: '籍贯', key: 'nativePlace' },
{ header: '手机号', key: 'phone' },
{ header: '微信号', key: 'wechat' },
{ header: '邮箱', key: 'email' },
{ header: 'QQ', key: 'qq' },
{ header: '身份证号', key: 'idCardNo' },
{ header: '兴趣爱好', key: 'hobby' },
{ header: '是否愿意留桂工作', key: 'willingToWorkInGuangxi' },
{ header: '学校信息', key: 'schoolInfo' },
{ header: '团内职务', key: 'leaguePosition' },
{ header: '个人简历', key: 'resume' },
{ header: '获得奖励', key: 'awards' },
{ header: '学习成绩', key: 'academicPerformance' },
{ header: '二级团组织意见', key: 'secondaryLeagueOpinion' },
{ header: '二级团组织意见日期', key: 'secondaryLeagueOpinionDate' },
{ header: '二级党组织意见', key: 'secondaryPartyOpinion' },
{ header: '二级党组织意见日期', key: 'secondaryPartyOpinionDate' },
{ header: '学校团组织意见', key: 'schoolLeagueOpinion' },
{ header: '学校团组织意见日期', key: 'schoolLeagueOpinionDate' }
] as const;
const sampleRow = {
年份: '2025',
姓名: '孙七',
性别: '男',
民族: '汉族',
出生年月: '2001-01',
政治面貌: '中共党员',
籍贯: '广西南宁',
手机号: '13800138000',
微信号: 'sunqi2025',
邮箱: 'sunqi@example.com',
QQ: '12345678',
身份证号: '450101200101011234',
兴趣爱好: '篮球',
是否愿意留桂工作: '是',
学校信息: '广西医科大学 第一临床医学院',
团内职务: '学生会骨干',
个人简历: '2022年至今担任学生干部',
获得奖励: '校级奖学金',
学习成绩: '专业前10%',
二级团组织意见: '',
二级团组织意见日期: '',
二级党组织意见: '',
二级党组织意见日期: '',
学校团组织意见: '',
学校团组织意见日期: ''
};
const createPayload = (row: Record<string, any>): QmgcForm => ({
year: String(row['年份'] || '').trim(),
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
nation: String(row['民族'] || '').trim(),
birthMonth: String(row['出生年月'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
nativePlace: String(row['籍贯'] || '').trim(),
phone: String(row['手机号'] || '').trim(),
wechat: String(row['微信号'] || '').trim(),
email: String(row['邮箱'] || '').trim(),
qq: String(row['QQ'] || '').trim(),
idCardNo: String(row['身份证号'] || '').trim(),
hobby: String(row['兴趣爱好'] || '').trim(),
willingToWorkInGuangxi: String(row['是否愿意留桂工作'] || '').trim(),
schoolInfo: String(row['学校信息'] || '').trim(),
leaguePosition: String(row['团内职务'] || '').trim(),
resume: String(row['个人简历'] || '').trim(),
awards: String(row['获得奖励'] || '').trim(),
academicPerformance: String(row['学习成绩'] || '').trim(),
secondaryLeagueOpinion: String(row['二级团组织意见'] || '').trim(),
secondaryLeagueOpinionDate: String(row['二级团组织意见日期'] || '').trim(),
secondaryPartyOpinion: String(row['二级党组织意见'] || '').trim(),
secondaryPartyOpinionDate: String(row['二级党组织意见日期'] || '').trim(),
schoolLeagueOpinion: String(row['学校团组织意见'] || '').trim(),
schoolLeagueOpinionDate: String(row['学校团组织意见日期'] || '').trim()
});
const saveItem = (item: QmgcForm) => addQmgcForm(item);
</script>
+366
View File
@@ -0,0 +1,366 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 2200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/手机号/学校院系"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'photo'">
<a-image
v-if="record.photo"
:src="record.photo"
:width="44"
:height="58"
style="object-fit: cover"
/>
<span v-else>-</span>
</template>
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(record)">审核历史</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="青马工程"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportQmgcForm,
userPageQmgcForm,
removeQmgcForm
} from '@/api/gxmu/qmgc-form';
import type { QmgcForm, QmgcFormParam } from '@/api/gxmu/qmgc-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<QmgcForm>();
const detailCurrent = ref<QmgcForm | null>(null);
const REVIEW_MODULE = 'gxmu_qmgc_form';
const showReviewHistory = ref(false);
const reviewCurrent = ref<QmgcForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '照片',
dataIndex: 'photo',
key: 'photo',
align: 'center',
width: 88
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 100
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 90
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '手机号码',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140
},
{
title: '微信号',
dataIndex: 'wechat',
key: 'wechat',
align: 'center',
width: 140
},
{
title: '学校、院系、年级、专业',
dataIndex: 'schoolInfo',
key: 'schoolInfo',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '担任团学职务情况',
dataIndex: 'leaguePosition',
key: 'leaguePosition',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '是否有志到基层一线工作',
dataIndex: 'willingToWorkInGuangxi',
key: 'willingToWorkInGuangxi',
align: 'center',
width: 190
},
{
title: '综合成绩情况',
dataIndex: 'academicPerformance',
key: 'academicPerformance',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '奖惩情况',
dataIndex: 'awards',
key: 'awards',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 200
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageQmgcForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as QmgcFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const buildQueryParams = (): QmgcFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as QmgcFormParam;
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(payload?.url || payload?.downloadUrl || payload?.path || '');
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportQmgcForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: QmgcForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as QmgcForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: QmgcForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: QmgcForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: QmgcForm) => {
const hide = message.loading('请求中..', 0);
removeQmgcForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'QMGCForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
+276
View File
@@ -0,0 +1,276 @@
<template>
<a-modal
:width="900"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="申报类别"
name="applyType"
:rules="[{ required: true, message: '请输入申报类别' }]"
>
<a-input
v-model:value="form.applyType"
placeholder="请输入申报类别"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="性别" name="gender">
<a-select
v-model:value="form.gender"
:options="genderOptions"
placeholder="请选择性别"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="出生年月" name="birthMonth">
<a-date-picker
v-model:value="form.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="民族" name="nation">
<a-select
allow-clear
show-search
v-model:value="form.nation"
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="政治面貌" name="politics">
<a-select
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="学历" name="education">
<a-input v-model:value="form.education" placeholder="请输入学历" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="职务" name="position">
<a-input v-model:value="form.position" placeholder="请输入职务" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="职称" name="title">
<a-input v-model:value="form.title" placeholder="请输入职称" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所在单位" name="unit">
<a-input v-model:value="form.unit" placeholder="请输入所在单位" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="近三年曾获校级及以上奖励" name="awards">
<a-textarea
v-model:value="form.awards"
:rows="3"
placeholder="请输入近三年曾获校级及以上奖励"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="工作(学习)经历" name="experience">
<a-textarea
v-model:value="form.experience"
:rows="3"
placeholder="请输入工作(学习)经历"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="主要事迹" name="mainStory">
<a-textarea
v-model:value="form.mainStory"
:rows="3"
placeholder="请输入主要事迹"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在二级团组织意见" name="leagueOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.leagueOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在二级团组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位党组织意见" name="partyOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.partyOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在单位党组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="校团委意见" name="schoolOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.schoolOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入校团委意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addSjqnForm, updateSjqnForm } from '@/api/gxmu/sjqn-form';
import type { SjqnForm } from '@/api/gxmu/sjqn-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: SjqnForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const { nationOptions, politicsOptions, genderOptions, loadNationOptions, loadPoliticsOptions } =
useGxmuFormSelectOptions();
const createDefaultForm = (): SjqnForm => ({
id: undefined,
year: '',
applyType: '',
name: '',
gender: '',
birthMonth: '',
nation: '',
politics: '',
education: '',
position: '',
title: '',
unit: '',
awards: '',
experience: '',
mainStory: '',
leagueOpinion: '',
partyOpinion: '',
schoolOpinion: ''
});
const form = reactive<SjqnForm>(createDefaultForm());
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await Promise.all([loadNationOptions(), loadPoliticsOptions()]);
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateSjqnForm : addSjqnForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,84 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="十佳青年"
file-name="十佳青年导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addSjqnForm } from '@/api/gxmu/sjqn-form';
import type { SjqnForm } from '@/api/gxmu/sjqn-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '申报类别', key: 'applyType', required: true },
{ header: '姓名', key: 'name', required: true },
{ header: '性别', key: 'gender' },
{ header: '出生年月', key: 'birthMonth' },
{ header: '民族', key: 'nation' },
{ header: '政治面貌', key: 'politics' },
{ header: '学历', key: 'education' },
{ header: '职务', key: 'position' },
{ header: '职称', key: 'title' },
{ header: '所在单位', key: 'unit' },
{ header: '近三年曾获校级及以上奖励', key: 'awards' },
{ header: '工作(学习)经历', key: 'experience' },
{ header: '主要事迹', key: 'mainStory' },
{ header: '所在二级团组织意见', key: 'leagueOpinion' },
{ header: '所在二级党组织意见', key: 'partyOpinion' },
{ header: '学校团委意见', key: 'schoolOpinion' }
] as const;
const sampleRow = {
年份: '2025',
申报类别: '教师',
姓名: '张三',
性别: '男',
出生年月: '1998-05',
民族: '汉族',
政治面貌: '中共党员',
学历: '本科',
职务: '团支书',
职称: '讲师',
所在单位: '第一临床医学院',
近三年曾获校级及以上奖励: '校级优秀团干',
'工作(学习)经历': '2020年至今任职',
主要事迹: '长期组织志愿服务活动',
所在二级团组织意见: '',
所在二级党组织意见: '',
学校团委意见: ''
};
const createPayload = (row: Record<string, any>): SjqnForm => ({
year: String(row['年份'] || '').trim(),
applyType: String(row['申报类别'] || '').trim(),
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
birthMonth: String(row['出生年月'] || '').trim(),
nation: String(row['民族'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
education: String(row['学历'] || '').trim(),
position: String(row['职务'] || '').trim(),
title: String(row['职称'] || '').trim(),
unit: String(row['所在单位'] || '').trim(),
awards: String(row['近三年曾获校级及以上奖励'] || '').trim(),
experience: String(row['工作(学习)经历'] || '').trim(),
mainStory: String(row['主要事迹'] || '').trim(),
leagueOpinion: String(row['所在二级团组织意见'] || '').trim(),
partyOpinion: String(row['所在二级党组织意见'] || '').trim(),
schoolOpinion: String(row['学校团委意见'] || '').trim()
});
const saveItem = (item: SjqnForm) => addSjqnForm(item);
</script>
+389
View File
@@ -0,0 +1,389 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 2400 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/单位/申报类别"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(record)">审核历史</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="十佳青年岗位能手"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportSjqnForm,
userPageSjqnForm,
removeSjqnForm
} from '@/api/gxmu/sjqn-form';
import type { SjqnForm, SjqnFormParam } from '@/api/gxmu/sjqn-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<SjqnForm>();
const detailCurrent = ref<SjqnForm | null>(null);
const REVIEW_MODULE = 'gxmu_sjqn';
const showReviewHistory = ref(false);
const reviewCurrent = ref<SjqnForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '申报类别',
dataIndex: 'applyType',
key: 'applyType',
align: 'center',
width: 120
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 100
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '出生年月',
dataIndex: 'birthMonth',
key: 'birthMonth',
align: 'center',
width: 120
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 100
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '学历',
dataIndex: 'education',
key: 'education',
align: 'center',
width: 100
},
{
title: '职务',
dataIndex: 'position',
key: 'position',
align: 'center',
width: 120
},
{
title: '职称',
dataIndex: 'title',
key: 'title',
align: 'center',
width: 120
},
{
title: '所在单位',
dataIndex: 'unit',
key: 'unit',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '近三年曾获校级及以上奖励',
dataIndex: 'awards',
key: 'awards',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '工作(学习)经历',
dataIndex: 'experience',
key: 'experience',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '主要事迹',
dataIndex: 'mainStory',
key: 'mainStory',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '所在二级团组织意见',
dataIndex: 'leagueOpinion',
key: 'leagueOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '所在单位党组织意见',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '校团委意见',
dataIndex: 'schoolOpinion',
key: 'schoolOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 220
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageSjqnForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as SjqnFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): SjqnFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as SjqnFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.url || payload?.downloadUrl || payload?.path || ''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportSjqnForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: SjqnForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as SjqnForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: SjqnForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: SjqnForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: SjqnForm) => {
const hide = message.loading('请求中..', 0);
removeSjqnForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'Gxmu'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
@@ -0,0 +1,273 @@
<template>
<a-modal
:width="900"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="性别" name="gender">
<a-select
v-model:value="form.gender"
:options="genderOptions"
placeholder="请选择性别"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="出生年月" name="birthMonth">
<a-date-picker
v-model:value="form.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="民族" name="nation">
<a-select
allow-clear
show-search
v-model:value="form.nation"
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="政治面貌" name="politics">
<a-select
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="学院班级/单位科室" name="collegeClass">
<class-cascader
v-model:value="form.collegeClass"
mode="collegeClass"
placeholder="请选择学院班级/单位科室"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所在团支部" name="branch">
<a-input
v-model:value="form.branch"
placeholder="请输入所在团支部"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度团员教育评议等次" name="eduEval">
<a-input
v-model:value="form.eduEval"
placeholder="请输入上一年度团员教育评议等次"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="近三年曾获校级及以上奖励" name="awards">
<a-textarea
v-model:value="form.awards"
:rows="3"
placeholder="请输入近三年曾获校级及以上奖励"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="工作(学习)经历" name="experience">
<a-textarea
v-model:value="form.experience"
:rows="3"
placeholder="请输入工作(学习)经历"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="主要事迹" name="mainStory">
<a-textarea
v-model:value="form.mainStory"
:rows="3"
placeholder="请输入主要事迹"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在二级团组织意见" name="leagueOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.leagueOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在二级团组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位党组织意见" name="partyOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.partyOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在单位党组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="校团委意见" name="schoolOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.schoolOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入校团委意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import {
addSjtbzbsjForm,
updateSjtbzbsjForm
} from '@/api/gxmu/sjtbzbsj-form';
import type { SjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form/model';
import ClassCascader from '@/views/gxmu/components/class-cascader.vue';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: SjtbzbsjForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const { nationOptions, politicsOptions, genderOptions, loadAll } =
useGxmuFormSelectOptions();
const createDefaultForm = (): SjtbzbsjForm => ({
id: undefined,
year: '',
name: '',
gender: '',
birthMonth: '',
nation: '',
politics: '',
collegeClass: '',
branch: '',
eduEval: '',
awards: '',
experience: '',
mainStory: '',
leagueOpinion: '',
partyOpinion: '',
schoolOpinion: ''
});
const form = reactive<SjtbzbsjForm>(createDefaultForm());
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await loadAll();
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value
? updateSjtbzbsjForm
: addSjtbzbsjForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,78 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="十佳团支部书记"
file-name="十佳团支部书记导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addSjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form';
import type { SjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '姓名', key: 'name', required: true },
{ header: '性别', key: 'gender' },
{ header: '出生年月', key: 'birthMonth' },
{ header: '民族', key: 'nation' },
{ header: '政治面貌', key: 'politics' },
{ header: '学院班级', key: 'collegeClass' },
{ header: '团支部名称', key: 'branch' },
{ header: '教育评议情况', key: 'eduEval' },
{ header: '近三年曾获校级及以上奖励', key: 'awards' },
{ header: '工作(学习)经历', key: 'experience' },
{ header: '主要事迹', key: 'mainStory' },
{ header: '所在二级团组织意见', key: 'leagueOpinion' },
{ header: '所在二级党组织意见', key: 'partyOpinion' },
{ header: '学校团委意见', key: 'schoolOpinion' }
] as const;
const sampleRow = {
年份: '2025',
姓名: '李四',
性别: '女',
出生年月: '2001-03',
民族: '汉族',
政治面貌: '共青团员',
学院班级: '护理学院2022级1班',
团支部名称: '护理学院2022级1班团支部',
教育评议情况: '优秀',
近三年曾获校级及以上奖励: '校级优秀团支书',
'工作(学习)经历': '2022年起任团支书',
主要事迹: '组织主题团日活动成效显著',
所在二级团组织意见: '',
所在二级党组织意见: '',
学校团委意见: ''
};
const createPayload = (row: Record<string, any>): SjtbzbsjForm => ({
year: String(row['年份'] || '').trim(),
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
birthMonth: String(row['出生年月'] || '').trim(),
nation: String(row['民族'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
collegeClass: String(row['学院班级'] || '').trim(),
branch: String(row['团支部名称'] || '').trim(),
eduEval: String(row['教育评议情况'] || '').trim(),
awards: String(row['近三年曾获校级及以上奖励'] || '').trim(),
experience: String(row['工作(学习)经历'] || '').trim(),
mainStory: String(row['主要事迹'] || '').trim(),
leagueOpinion: String(row['所在二级团组织意见'] || '').trim(),
partyOpinion: String(row['所在二级党组织意见'] || '').trim(),
schoolOpinion: String(row['学校团委意见'] || '').trim()
});
const saveItem = (item: SjtbzbsjForm) => addSjtbzbsjForm(item);
</script>
+379
View File
@@ -0,0 +1,379 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 2600 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/学院班级/团支部"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(record)">审核历史</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="十佳团支部书记"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportSjtbzbsjForm,
userPageSjtbzbsjForm,
removeSjtbzbsjForm
} from '@/api/gxmu/sjtbzbsj-form';
import type {
SjtbzbsjForm,
SjtbzbsjFormParam
} from '@/api/gxmu/sjtbzbsj-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<SjtbzbsjForm>();
const detailCurrent = ref<SjtbzbsjForm | null>(null);
const REVIEW_MODULE = 'gxmu_sjtbzbsj';
const showReviewHistory = ref(false);
const reviewCurrent = ref<SjtbzbsjForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 100
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '出生年月',
dataIndex: 'birthMonth',
key: 'birthMonth',
align: 'center',
width: 120
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 100
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '学院班级/单位科室',
dataIndex: 'collegeClass',
key: 'collegeClass',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '所在团支部',
dataIndex: 'branch',
key: 'branch',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '上一年度团员教育评议等次',
dataIndex: 'eduEval',
key: 'eduEval',
align: 'center',
width: 220
},
{
title: '近三年曾获校级及以上奖励',
dataIndex: 'awards',
key: 'awards',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '工作(学习)经历',
dataIndex: 'experience',
key: 'experience',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '主要事迹',
dataIndex: 'mainStory',
key: 'mainStory',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '所在二级团组织意见',
dataIndex: 'leagueOpinion',
key: 'leagueOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '所在单位党组织意见',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '校团委意见',
dataIndex: 'schoolOpinion',
key: 'schoolOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 220
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageSjtbzbsjForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as SjtbzbsjFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): SjtbzbsjFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as SjtbzbsjFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.url || payload?.downloadUrl || payload?.path || ''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportSjtbzbsjForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: SjtbzbsjForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as SjtbzbsjForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: SjtbzbsjForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: SjtbzbsjForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: SjtbzbsjForm) => {
const hide = message.loading('请求中..', 0);
removeSjtbzbsjForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'SJTBZBSJForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
+408
View File
@@ -0,0 +1,408 @@
<template>
<a-modal
:width="920"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="性别" name="gender">
<a-select
v-model:value="form.gender"
:options="genderOptions"
placeholder="请选择性别"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="民族" name="nation">
<a-select
allow-clear
show-search
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
v-model:value="form.nation"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="政治面貌"
name="politics"
:rules="[{ required: true, message: '请选择政治面貌' }]"
>
<a-select
allow-clear
show-search
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
v-model:value="form.politics"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="学院"
name="college"
:rules="[{ required: true, message: '请选择学院' }]"
>
<a-input
:value="form.college || ''"
readonly
placeholder="选择班级后自动带出"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="班级"
name="className"
:rules="[{ required: true, message: '请选择班级' }]"
>
<class-cascader
v-model:value="form.className"
mode="class"
placeholder="请选择班级"
@change="handleClassChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="手机号码" name="phone">
<a-input v-model:value="form.phone" placeholder="请输入手机号码" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="团内职务" name="leaguePosition">
<a-select
allow-clear
show-search
:options="leaguePositionOptions"
:filter-option="filterSelectOption"
placeholder="请选择团内职务"
v-model:value="form.leaguePosition"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所属团支部" name="branchOrganizationId">
<a-tree-select
allow-clear
tree-default-expand-all
:tree-data="organizationOptions"
placeholder="请选择所属团支部"
:value="form.branchOrganizationId"
:dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
@update:value="handleBranchChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="身份证号"
name="idCardNo"
:rules="[{ required: true, message: '请输入身份证号' }]"
>
<a-input
v-model:value="form.idCardNo"
placeholder="请输入身份证号"
@blur="syncBirthDateFromIdCard"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="出生日期" name="birthDate">
<a-input
:value="form.birthDate || ''"
readonly
placeholder="将根据身份证号自动读取"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="团籍是否在本组织" name="archiveInCurrentOrg">
<a-select
v-model:value="form.archiveInCurrentOrg"
placeholder="请选择"
>
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="入团年月" name="joinMonth">
<a-date-picker
v-model:value="form.joinMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择入团年月"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import { toTreeData } from 'ele-admin-pro/es';
import { addTyglForm, updateTyglForm } from '@/api/gxmu/tygl-form';
import type { TyglForm } from '@/api/gxmu/tygl-form/model';
import { listDictData } from '@/api/system/dict-data';
import { listOrganizations } from '@/api/system/organization';
import type { Organization } from '@/api/system/organization/model';
import ClassCascader from '@/views/gxmu/components/class-cascader.vue';
import {
useGxmuFormSelectOptions,
type ClassCascadeSelection
} from '@/views/gxmu/useFormSelectOptions';
dayjs.extend(customParseFormat);
interface TreeOption {
label: string;
value: number;
children?: TreeOption[];
}
const POLITICS_DICT_ID = 172;
const NATION_DICT_ID = 173;
const LEAGUE_POSITION_DICT_ID = 174;
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: TyglForm | null;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const nationOptions = ref<{ label: string; value: string }[]>([]);
const politicsOptions = ref<{ label: string; value: string }[]>([]);
const leaguePositionOptions = ref<{ label: string; value: string }[]>([]);
const { genderOptions, loadClassCascaderOptions, findClassCascadeSelection } =
useGxmuFormSelectOptions();
const organizationOptions = ref<TreeOption[]>([]);
const organizationNameMap = new Map<number, string>();
const optionsLoaded = ref(false);
const createDefaultForm = (): TyglForm => ({
id: undefined,
serialNo: undefined,
name: '',
gender: '',
nation: '',
college: '',
className: '',
politics: '',
phone: '',
leaguePosition: '',
branchOrganizationId: undefined,
branchOrganizationName: '',
idCardNo: '',
birthDate: '',
archiveInCurrentOrg: '',
joinMonth: '',
memberRecords: [],
growthArchives: []
});
const form = reactive<TyglForm>(createDefaultForm());
const filterSelectOption = (input: string, option: any) => {
return String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase());
};
const normalizeDictOptions = async (dictId: number) => {
const result = await listDictData({ dictId });
return result
.filter((item) => !!item.dictDataName)
.map((item) => ({
label: item.dictDataName as string,
value: item.dictDataName as string
}));
};
const buildOrganizationOptions = (list: Organization[]): TreeOption[] => {
return list.map((item) => {
if (item.organizationId) {
organizationNameMap.set(
item.organizationId,
item.organizationName || ''
);
}
return {
label: item.organizationName || '',
value: item.organizationId as number,
children: item.children?.length
? buildOrganizationOptions(item.children)
: undefined
};
});
};
const loadOrganizations = async () => {
const result = await listOrganizations();
const tree = toTreeData({
data: result,
idField: 'organizationId',
parentIdField: 'parentId'
}) as Organization[];
organizationNameMap.clear();
organizationOptions.value = buildOrganizationOptions(tree);
};
const ensureOptionsLoaded = async () => {
if (optionsLoaded.value) {
return;
}
const [politicsList, nationList, leaguePositionList] = await Promise.all([
normalizeDictOptions(POLITICS_DICT_ID),
normalizeDictOptions(NATION_DICT_ID),
normalizeDictOptions(LEAGUE_POSITION_DICT_ID),
loadClassCascaderOptions(),
loadOrganizations()
]);
politicsOptions.value = politicsList;
nationOptions.value = nationList;
leaguePositionOptions.value = leaguePositionList;
optionsLoaded.value = true;
};
const parseBirthDateFromIdCard = (idCardNo?: string) => {
const value = String(idCardNo || '')
.trim()
.toUpperCase();
let rawBirth = '';
if (/^\d{17}[\dX]$/.test(value)) {
rawBirth = value.slice(6, 14);
} else if (/^\d{15}$/.test(value)) {
rawBirth = `19${value.slice(6, 12)}`;
} else {
return '';
}
const date = dayjs(rawBirth, 'YYYYMMDD', true);
return date.isValid() ? date.format('YYYY-MM-DD') : '';
};
const syncBirthDateFromIdCard = () => {
form.birthDate = parseBirthDateFromIdCard(form.idCardNo);
};
const handleBranchChange = (value?: string | number) => {
const organizationId = value ? Number(value) : undefined;
if (!organizationId) {
form.branchOrganizationId = undefined;
form.branchOrganizationName = '';
return;
}
form.branchOrganizationId = organizationId;
form.branchOrganizationName = organizationNameMap.get(organizationId) || '';
};
const handleClassChange = (value?: ClassCascadeSelection) => {
form.college = value?.collegeName || '';
};
watch(
() => form.idCardNo,
() => {
syncBirthDateFromIdCard();
}
);
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await ensureOptionsLoaded();
} catch (e: any) {
message.error(e.message || '表单选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (form.className) {
const classSelection = findClassCascadeSelection(
form.className,
'class'
);
if (classSelection) {
form.college = classSelection.collegeName;
}
}
syncBirthDateFromIdCard();
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
syncBirthDateFromIdCard();
if (!form.birthDate) {
message.error('身份证号格式不正确,无法识别出生日期');
return;
}
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateTyglForm : addTyglForm;
saveOrUpdate({
...form,
birthDate: form.birthDate
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,177 @@
<template>
<a-drawer
placement="right"
width="760"
:visible="visible"
title="成长档案"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<div class="archive-header">
<div class="archive-title">{{ props.data?.name || '未命名团员' }}</div>
<div class="archive-subtitle">
手机:{{ props.data?.phone || '-' }} | 入团年月:{{ props.data?.joinMonth || '-' }}
</div>
</div>
<div class="archive-card">
<div
v-for="(item, index) in growthArchives"
:key="`archive-${index}`"
class="archive-item"
>
<a-form layout="vertical">
<a-form-item label="时间">
<a-date-picker
v-model:value="item.archiveTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择时间"
/>
</a-form-item>
<a-form-item label="内容">
<a-textarea
v-model:value="item.content"
:rows="5"
placeholder="请输入成长档案内容"
/>
</a-form-item>
</a-form>
<a class="remove-link" @click="removeArchive(index)">删除</a>
</div>
<a-button block @click="addArchive">新增成长档案</a-button>
</div>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { updateTyglGrowthArchives } from '@/api/gxmu/tygl-form';
import type { TyglForm, TyglGrowthArchive } from '@/api/gxmu/tygl-form/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: TyglForm | null;
}>();
const loading = ref(false);
const growthArchives = ref<TyglGrowthArchive[]>([]);
const createArchive = (): TyglGrowthArchive => ({
content: '',
archiveTime: ''
});
watch(
() => props.visible,
(visible) => {
if (visible) {
growthArchives.value = (props.data?.growthArchives || []).map((item) => ({
content: item.content || '',
archiveTime: item.archiveTime || ''
}));
} else {
growthArchives.value = [];
}
},
{ immediate: true }
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const addArchive = () => {
growthArchives.value.push(createArchive());
};
const removeArchive = (index: number) => {
growthArchives.value.splice(index, 1);
};
const save = () => {
if (!props.data?.id) {
message.error('请先保存团员基础信息');
return;
}
loading.value = true;
updateTyglGrowthArchives(props.data.id, {
growthArchives: growthArchives.value.filter(
(item) => (item.content || '').trim() || (item.archiveTime || '').trim()
)
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
.archive-header {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: linear-gradient(135deg, #f7fbff 0%, #edf5ff 100%);
}
.archive-title {
font-size: 18px;
font-weight: 600;
}
.archive-subtitle {
margin-top: 8px;
color: rgba(0, 0, 0, 0.65);
}
.archive-card {
padding: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
}
.archive-item {
position: relative;
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: #fafafa;
}
.remove-link {
position: absolute;
top: 16px;
right: 16px;
color: #ff4d4f;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
</style>
@@ -0,0 +1,293 @@
<template>
<ele-modal
:width="560"
:footer="null"
title="导入人员管理"
:visible="visible"
@update:visible="updateVisible"
>
<a-spin :spinning="loading">
<a-space direction="vertical" style="width: 100%" :size="16">
<a-alert
type="info"
show-icon
message="模板说明"
description="请先下载模板,按模板表头填写后上传。人员记录、成长档案不在本次导入范围内。"
/>
<a-button block @click="downloadTemplate">下载模板</a-button>
<a-upload-dragger
accept=".xls,.xlsx"
:show-upload-list="false"
:customRequest="doUpload"
style="padding: 24px 0"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
</a-upload-dragger>
</a-space>
</a-spin>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { CloudUploadOutlined } from '@ant-design/icons-vue';
import { utils, writeFile, read } from 'xlsx';
import dayjs from 'dayjs';
import { toTreeData } from 'ele-admin-pro/es';
import { addTyglForm } from '@/api/gxmu/tygl-form';
import type { TyglForm } from '@/api/gxmu/tygl-form/model';
import { listOrganizations } from '@/api/system/organization';
import type { Organization } from '@/api/system/organization/model';
const TEMPLATE_HEADERS = [
'姓名',
'性别',
'民族',
'学院',
'班级',
'政治面貌',
'手机号码',
'团内职务',
'所属团支部',
'身份证号',
'出生日期',
'团籍是否在本组织',
'入团年月'
];
const TEMPLATE_SAMPLE = [
'张三',
'男',
'汉族',
'第一临床医学院',
'2022级临床1班',
'共青团员',
'13800138000',
'团支书',
'第一临床医学院团支部',
'450101200201011234',
'2002-01-01',
'是',
'2020-05'
];
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
defineProps<{
visible: boolean;
}>();
const loading = ref(false);
const readFileBuffer = (file: File) => {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
};
const parseBirthDateFromIdCard = (idCardNo?: string) => {
const value = String(idCardNo || '')
.trim()
.toUpperCase();
let rawBirth = '';
if (/^\d{17}[\dX]$/.test(value)) {
rawBirth = value.slice(6, 14);
} else if (/^\d{15}$/.test(value)) {
rawBirth = `19${value.slice(6, 12)}`;
} else {
return '';
}
return dayjs(rawBirth, 'YYYYMMDD', true).isValid()
? dayjs(rawBirth, 'YYYYMMDD').format('YYYY-MM-DD')
: '';
};
const downloadTemplate = () => {
const sheet = utils.aoa_to_sheet([
TEMPLATE_HEADERS,
TEMPLATE_SAMPLE
]);
sheet['!cols'] = TEMPLATE_HEADERS.map((title) => ({
wch: Math.max(title.length + 4, 16)
}));
writeFile(
{
SheetNames: ['人员管理导入模板'],
Sheets: {
人员管理导入模板: sheet
}
},
'人员管理导入模板.xlsx'
);
};
const buildOrganizationMap = async () => {
const list = await listOrganizations();
const tree = toTreeData({
data: list,
idField: 'organizationId',
parentIdField: 'parentId'
}) as Organization[];
const result = new Map<string, { id?: number; name?: string }>();
const walk = (items: Organization[]) => {
items.forEach((item) => {
const key = String(item.organizationName || '').trim();
if (key) {
result.set(key, {
id: item.organizationId,
name: item.organizationName
});
}
if (item.children?.length) {
walk(item.children);
}
});
};
walk(tree);
return result;
};
const toRowObject = (row: Record<string, any>): TyglForm => {
const idCardNo = String(row['身份证号'] || '').trim();
const birthDate =
String(row['出生日期'] || '').trim() || parseBirthDateFromIdCard(idCardNo);
return {
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
nation: String(row['民族'] || '').trim(),
college: String(row['学院'] || '').trim(),
className: String(row['班级'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
phone: String(row['手机号码'] || '').trim(),
leaguePosition: String(row['团内职务'] || '').trim(),
branchOrganizationName: String(row['所属团支部'] || '').trim(),
idCardNo,
birthDate,
archiveInCurrentOrg: String(row['团籍是否在本组织'] || '').trim(),
joinMonth: String(row['入团年月'] || '').trim(),
memberRecords: [],
growthArchives: []
};
};
const validateRows = (rows: TyglForm[]) => {
const errors: string[] = [];
rows.forEach((row, index) => {
const rowNo = index + 2;
if (!row.name) {
errors.push(`第 ${rowNo} 行缺少姓名`);
}
if (!row.college) {
errors.push(`第 ${rowNo} 行缺少学院`);
}
if (!row.className) {
errors.push(`第 ${rowNo} 行缺少班级`);
}
if (!row.politics) {
errors.push(`第 ${rowNo} 行缺少政治面貌`);
}
if (!row.idCardNo) {
errors.push(`第 ${rowNo} 行缺少身份证号`);
}
if (!row.birthDate) {
errors.push(`第 ${rowNo} 行身份证号格式不正确,无法识别出生日期`);
}
// if (
// row.branchOrganizationName &&
// !String(row.branchOrganizationName).trim()
// ) {
// errors.push(`第 ${rowNo} 行所属团支部格式不正确`);
// }
});
return errors;
};
const doUpload = async ({ file }: { file: File }) => {
if (
![
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
].includes(file.type) &&
!file.name.endsWith('.xls') &&
!file.name.endsWith('.xlsx')
) {
message.error('只能选择 Excel 文件');
return false;
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
return false;
}
loading.value = true;
try {
const [buffer, organizationMap] = await Promise.all([
readFileBuffer(file),
buildOrganizationMap()
]);
const workbook = read(buffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const rows = utils.sheet_to_json<Record<string, any>>(sheet, {
defval: ''
});
if (!rows.length) {
throw new Error('导入文件内容为空');
}
const data = rows
.map(toRowObject)
.filter((item) =>
[
item.name,
item.gender,
item.nation,
item.college,
item.className,
item.idCardNo
].some((value) => !!String(value || '').trim())
);
if (!data.length) {
throw new Error('未识别到有效数据');
}
const errors = validateRows(data);
if (errors.length) {
throw new Error(errors.slice(0, 5).join(';'));
}
for (let index = 0; index < data.length; index += 1) {
const item = data[index];
const branch = organizationMap.get(item.branchOrganizationName || '');
// if (item.branchOrganizationName && !branch?.id) {
// throw new Error(
// `第 ${index + 2} 行所属团支部“${item.branchOrganizationName}”未匹配到系统组织`
// );
// }
if (branch?.id) {
item.branchOrganizationId = branch.id;
item.branchOrganizationName = branch.name;
}
await addTyglForm(item);
}
message.success(`成功导入 ${data.length} 条人员数据`);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e.message || '导入失败');
} finally {
loading.value = false;
}
return false;
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>
@@ -0,0 +1,270 @@
<template>
<a-drawer
placement="right"
width="780"
:visible="visible"
title="团员记录"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<div class="record-header">
<div class="record-title">{{ props.data?.name || '未命名团员' }}</div>
<div class="record-subtitle">
手机:{{ props.data?.phone || '-' }} | 团内职务:{{ props.data?.leaguePosition || '-' }}
</div>
</div>
<a-tabs v-model:activeKey="activeKey" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="团员转进" tab="团员转进">
<div class="section-card">
<div
v-for="(item, index) in transferInRecords"
:key="`transfer-in-${index}`"
class="record-item"
>
<a-form layout="vertical">
<a-form-item label="时间">
<a-date-picker
v-model:value="item.recordTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择时间"
/>
</a-form-item>
<a-form-item label="内容">
<a-textarea
v-model:value="item.content"
:rows="4"
placeholder="请输入团员转进记录内容"
/>
</a-form-item>
</a-form>
<a class="remove-link" @click="removeRecord('团员转进', index)">删除</a>
</div>
<a-button block @click="addRecord('团员转进')">新增转进记录</a-button>
</div>
</a-tab-pane>
<a-tab-pane key="团员转出" tab="团员转出">
<div class="section-card">
<div
v-for="(item, index) in transferOutRecords"
:key="`transfer-out-${index}`"
class="record-item"
>
<a-form layout="vertical">
<a-form-item label="时间">
<a-date-picker
v-model:value="item.recordTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择时间"
/>
</a-form-item>
<a-form-item label="内容">
<a-textarea
v-model:value="item.content"
:rows="4"
placeholder="请输入团员转出记录内容"
/>
</a-form-item>
</a-form>
<a class="remove-link" @click="removeRecord('团员转出', index)">删除</a>
</div>
<a-button block @click="addRecord('团员转出')">新增转出记录</a-button>
</div>
</a-tab-pane>
<a-tab-pane key="离校记录" tab="离校记录">
<div class="section-card">
<div
v-for="(item, index) in leaveSchoolRecords"
:key="`leave-school-${index}`"
class="record-item"
>
<a-form layout="vertical">
<a-form-item label="时间">
<a-date-picker
v-model:value="item.recordTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择时间"
/>
</a-form-item>
<a-form-item label="内容">
<a-textarea
v-model:value="item.content"
:rows="4"
placeholder="请输入离校记录内容"
/>
</a-form-item>
</a-form>
<a class="remove-link" @click="removeRecord('离校记录', index)">删除</a>
</div>
<a-button block @click="addRecord('离校记录')">新增离校记录</a-button>
</div>
</a-tab-pane>
</a-tabs>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { updateTyglMemberRecords } from '@/api/gxmu/tygl-form';
import type { TyglForm, TyglMemberRecord } from '@/api/gxmu/tygl-form/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: TyglForm | null;
}>();
const loading = ref(false);
const activeKey = ref('团员转进');
const memberRecords = ref<TyglMemberRecord[]>([]);
const createRecord = (type: string): TyglMemberRecord => ({
type,
content: '',
recordTime: ''
});
const normalizeRecord = (item?: TyglMemberRecord | null): TyglMemberRecord => ({
type: item?.type || '',
content: item?.content || '',
recordTime: item?.recordTime || ''
});
watch(
() => props.visible,
(visible) => {
if (visible) {
memberRecords.value = (props.data?.memberRecords || []).map((item) =>
normalizeRecord(item)
);
activeKey.value = '团员转进';
} else {
memberRecords.value = [];
}
},
{ immediate: true }
);
const transferInRecords = computed(() =>
memberRecords.value.filter((item) => item.type === '团员转进')
);
const transferOutRecords = computed(() =>
memberRecords.value.filter((item) => item.type === '团员转出')
);
const leaveSchoolRecords = computed(() =>
memberRecords.value.filter((item) => item.type === '离校记录')
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const addRecord = (type: string) => {
memberRecords.value.push(createRecord(type));
};
const removeRecord = (type: string, index: number) => {
const currentIndexes = memberRecords.value
.map((item, currentIndex) => ({ item, currentIndex }))
.filter(({ item }) => item.type === type);
const target = currentIndexes[index];
if (target) {
memberRecords.value.splice(target.currentIndex, 1);
}
};
const save = () => {
if (!props.data?.id) {
message.error('请先保存团员基础信息');
return;
}
loading.value = true;
updateTyglMemberRecords(props.data.id, {
memberRecords: memberRecords.value.filter(
(item) =>
(item.type || '').trim() ||
(item.content || '').trim() ||
(item.recordTime || '').trim()
)
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
@import '../../tag-tabs.less';
.gxmu-tag-tabs();
.record-header {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: linear-gradient(135deg, #f7fbff 0%, #edf5ff 100%);
}
.record-title {
font-size: 18px;
font-weight: 600;
}
.record-subtitle {
margin-top: 8px;
color: rgba(0, 0, 0, 0.65);
}
.section-card {
padding: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
}
.record-item {
position: relative;
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: #fafafa;
}
.remove-link {
position: absolute;
top: 16px;
right: 16px;
color: #ff4d4f;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
</style>
+513
View File
@@ -0,0 +1,513 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1900 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space wrap>
<a-button
v-if="canShowAddButton"
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>添加</span>
</a-button>
<a-button
v-if="hasPermission('gxmu:TYGLForm:import')"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="hasPermission('gxmu:TYGLForm:import')"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/手机号/学院/班级/身份证号"
@search="reload"
@pressEnter="reload"
/>
<a-select
allow-clear
v-model:value="nationFilter"
:options="nationOptions"
placeholder="全部民族"
style="width: 160px"
@change="reload"
/>
<a-select
allow-clear
v-model:value="leaguePositionFilter"
:options="leaguePositionOptions"
placeholder="全部团内职务"
style="width: 180px"
@change="reload"
/>
<a-button @click="resetFilters">重置筛选</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<div class="action-links">
<a @click="openDetail(record)">详情</a>
<a
v-if="hasPermission('gxmu:TYGLForm:edit')"
@click="openEdit(record)"
>
编辑
</a>
<a @click="openMemberRecords(record)">团员记录</a>
<a @click="openGrowthArchives(record)">成长档案</a>
<a-popconfirm
v-if="hasPermission('gxmu:TYGLForm:remove')"
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</div>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal v-model:visible="showEdit" :data="current" @done="reload" />
<import-modal v-model:visible="showImport" @done="reload" />
<member-records-modal
v-model:visible="showMemberRecords"
:data="recordCurrent"
@done="reload"
/>
<growth-archives-modal
v-model:visible="showGrowthArchives"
:data="archiveCurrent"
@done="reload"
/>
<ModuleDataModal
v-model:visible="showDetail"
module="gxmu_tygl_form"
module-name="团员管理"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import MemberRecordsModal from './components/memberRecords.vue';
import GrowthArchivesModal from './components/growthArchives.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportTyglForm,
getTyglForm,
pageTyglForm,
removeTyglForm
} from '@/api/gxmu/tygl-form';
import type { TyglForm, TyglFormParam } from '@/api/gxmu/tygl-form/model';
import { listDictData } from '@/api/system/dict-data';
import { hasAnyRole, hasPermission } from '@/utils/permission';
import { useUserStore } from '@/store/modules/user';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const userStore = useUserStore();
const addRoleCodes = [
'superAdmin',
'SchoolYouthLeagueCommittee',
'PartyCommitteesOfSecondaryColleges',
'YouthLeagueCommitteeOfSecondaryColleges',
'YouthLeagueBranchesOfSecondaryColleges'
];
const studentRoleCodes = ['sutdent', 'student'];
const searchText = ref('');
const nationFilter = ref<string>();
const leaguePositionFilter = ref<string>();
const nationOptions = ref<{ label: string; value: string }[]>([]);
const leaguePositionOptions = ref<{ label: string; value: string }[]>([]);
const showEdit = ref(false);
const showImport = ref(false);
const current = ref<TyglForm>();
const showMemberRecords = ref(false);
const recordCurrent = ref<TyglForm>();
const showGrowthArchives = ref(false);
const archiveCurrent = ref<TyglForm>();
const showDetail = ref(false);
const detailCurrent = ref<TyglForm | null>(null);
const REVIEW_MODULE: string | undefined = undefined;
const showReviewHistory = ref(false);
const reviewCurrent = ref<TyglForm | null>(null);
const exporting = ref(false);
const studentRecordCount = ref(0);
const isStudentOnlyUser = computed(() => {
const roles = (userStore.roles ?? []).filter(
(role): role is string => !!role
);
return roles.length === 1 && studentRoleCodes.includes(roles[0]);
});
const canShowAddButton = computed(() => {
if (hasAnyRole(addRoleCodes)) {
return true;
}
return isStudentOnlyUser.value && studentRecordCount.value === 0;
});
const columns = ref<ColumnItem[]>([
{
title: '序号',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 120
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 100
},
{
title: '学院',
dataIndex: 'college',
key: 'college',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '班级',
dataIndex: 'className',
key: 'className',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 130
},
{
title: '手机号码',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140
},
{
title: '团内职务',
dataIndex: 'leaguePosition',
key: 'leaguePosition',
align: 'center',
width: 160,
ellipsis: true
},
{
title: '所属团支部',
dataIndex: 'branchOrganizationName',
key: 'branchOrganizationName',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '出生日期',
dataIndex: 'birthDate',
key: 'birthDate',
align: 'center',
width: 120
},
{
title: '团籍是否在本组织',
dataIndex: 'archiveInCurrentOrg',
key: 'archiveInCurrentOrg',
align: 'center',
width: 170
},
{
title: '入团年月',
dataIndex: 'joinMonth',
key: 'joinMonth',
align: 'center',
width: 120
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 260
}
]);
const buildQueryParams = (): TyglFormParam => {
return {
keywords: searchText.value.trim() || undefined,
nation: nationFilter.value,
leaguePosition: leaguePositionFilter.value
} as TyglFormParam;
};
const datasource: DatasourceFunction = ({ page, limit }) => {
return pageTyglForm({
page,
limit,
...buildQueryParams()
} as TyglFormParam)
.then((result) => {
if (isStudentOnlyUser.value) {
studentRecordCount.value = Number(result?.count ?? 0);
}
return result;
})
.catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
loadStudentRecordCount();
tableRef.value?.reload();
};
const loadStudentRecordCount = async () => {
if (!isStudentOnlyUser.value) {
studentRecordCount.value = 0;
return;
}
try {
const result = await pageTyglForm({
page: 1,
limit: 1
} as TyglFormParam);
studentRecordCount.value = Number(result?.count ?? 0);
} catch (e: any) {
message.error(e.message || '获取团员数据失败');
}
};
const loadDictOptions = async (dictId: number) => {
const result = await listDictData({ dictId });
return result
.filter((item) => !!item.dictDataName)
.map((item) => ({
label: item.dictDataName as string,
value: item.dictDataName as string
}));
};
const loadFilterOptions = async () => {
try {
const [nationList, leaguePositionList] = await Promise.all([
loadDictOptions(173),
loadDictOptions(174)
]);
nationOptions.value = nationList;
leaguePositionOptions.value = leaguePositionList;
} catch (e: any) {
message.error(e.message || '筛选字典加载失败');
}
};
const resetFilters = () => {
searchText.value = '';
nationFilter.value = undefined;
leaguePositionFilter.value = undefined;
reload();
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.data?.url ||
payload?.data?.downloadUrl ||
payload?.data?.path ||
payload?.url ||
payload?.downloadUrl ||
payload?.path ||
''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportTyglForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openImport = () => {
showImport.value = true;
};
const openEdit = (record?: TyglForm) => {
if (!record && isStudentOnlyUser.value && studentRecordCount.value > 0) {
message.warning('当前学生用户已有团员数据,不能重复新增');
return;
}
current.value = record
? (JSON.parse(JSON.stringify(record)) as TyglForm)
: undefined;
showEdit.value = true;
};
const loadTyglDetail = (
record: TyglForm,
callback: (data: TyglForm) => void
) => {
if (!record.id) {
message.error('缺少团员记录ID');
return;
}
const hide = message.loading('加载中..', 0);
getTyglForm(record.id)
.then((data) => {
hide();
callback(JSON.parse(JSON.stringify(data)) as TyglForm);
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const openMemberRecords = (record: TyglForm) => {
loadTyglDetail(record, (data) => {
recordCurrent.value = data;
showMemberRecords.value = true;
});
};
const openGrowthArchives = (record: TyglForm) => {
loadTyglDetail(record, (data) => {
archiveCurrent.value = data;
showGrowthArchives.value = true;
});
};
const openDetail = (record: TyglForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const openReviewHistory = (record: TyglForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const remove = (record: TyglForm) => {
const hide = message.loading('请求中..', 0);
removeTyglForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
onMounted(() => {
loadStudentRecordCount();
loadFilterOptions();
});
</script>
<script lang="ts">
export default {
name: 'TYGLForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
.action-links {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px 12px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,930 @@
<template>
<a-drawer
placement="right"
width="80%"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<a-form ref="formRef" :model="form">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 240px"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
<a-tabs v-model:activeKey="activeTab" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="base" tab="项目申报表">
<div class="sheet-wrap">
<div class="sheet-header">
<div class="sheet-tag">附件 1</div>
<div class="sheet-main-title"
>{{ title || '' }}参赛项目申报表</div
>
</div>
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 32%" />
<col style="width: 18%" />
<col style="width: 32%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">所在省<br />(区、市)</td>
<td class="sheet-value">
<a-input
v-model:value="form.provinceCity"
placeholder="请输入所在省/市"
/>
</td>
<td class="sheet-label">学校名称<br />(全称)</td>
<td class="sheet-value">
<a-input
v-model:value="form.schoolName"
placeholder="请输入学校名称"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目名称</td>
<td class="sheet-value left" colspan="3">
<a-input
v-model:value="form.projectName"
placeholder="请输入项目名称"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目类型</td>
<td class="sheet-value left" colspan="3">
<a-radio-group
v-model:value="form.projectType"
class="inline-radio"
>
<a-radio
v-for="item in formProjectTypeOptions"
:key="`base-type-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">
项目分组
<div class="sheet-note"
>(仅可选择1个组别,须在此栏中删除其他组别内容)</div
>
</td>
<td class="sheet-value left" colspan="3">
<a-radio-group
v-model:value="form.projectGroup"
class="group-radio"
>
<a-radio
v-for="item in formProjectGroupOptions"
:key="`base-group-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
</tbody>
</table>
<table class="sheet-table">
<colgroup>
<col style="width: 14%" />
<col style="width: 12%" />
<col style="width: 10%" />
<col style="width: 16%" />
<col style="width: 18%" />
<col style="width: 12%" />
<col style="width: 18%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label" :rowspan="teamMembersCount + 1"
>团队成员<br />(最多15人)</td
>
<td class="sheet-label">姓名</td>
<td class="sheet-label">性别</td>
<td class="sheet-label">学院</td>
<td class="sheet-label">年级、专业</td>
<td class="sheet-label">手机</td>
<td class="sheet-label"
>备注(负责人及各成员学历-本/硕/博)</td
>
</tr>
<tr
v-for="(item, index) in form.teamMembers"
:key="`member-${index}`"
>
<td class="sheet-value"
><a-input v-model:value="item.name"
/></td>
<td class="sheet-value">
<a-select style="width: 120px"
v-model:value="item.gender"
:options="genderOptions"
placeholder="请选择"
/>
</td>
<td class="sheet-value">
<a-select style="width: 120px"
allow-clear
show-search
v-model:value="item.college"
:options="collegeOptions"
:filter-option="filterSelectOption"
placeholder="请选择"
/>
</td>
<td class="sheet-value"
><a-input v-model:value="item.gradeMajor"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.phone"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.remark"
/></td>
</tr>
</tbody>
</table>
<table class="sheet-table">
<colgroup>
<col style="width: 14%" />
<col style="width: 12%" />
<col style="width: 10%" />
<col style="width: 16%" />
<col style="width: 18%" />
<col style="width: 16%" />
<col style="width: 14%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label" :rowspan="advisorCount + 1"
>指导教师<br />(最多5人)</td
>
<td class="sheet-label">姓名</td>
<td class="sheet-label">性别</td>
<td class="sheet-label">学院</td>
<td class="sheet-label">职称</td>
<td class="sheet-label">职务</td>
<td class="sheet-label">手机</td>
</tr>
<tr
v-for="(item, index) in form.advisors"
:key="`advisor-${index}`"
>
<td class="sheet-value"
><a-input v-model:value="item.name"
/></td>
<td class="sheet-value">
<a-select style="width: 120px"
v-model:value="item.gender"
:options="genderOptions"
placeholder="请选择"
/>
</td>
<td class="sheet-value">
<a-select style="width: 120px"
allow-clear
show-search
v-model:value="item.college"
:options="collegeOptions"
:filter-option="filterSelectOption"
placeholder="请选择"
/>
</td>
<td class="sheet-value"
><a-input v-model:value="item.title"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.duty"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.phone"
/></td>
</tr>
</tbody>
</table>
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 82%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">项目简介<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.projectBrief"
:rows="4"
placeholder="请输入项目简介"
/>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
<a-tab-pane key="detail" tab="项目说明">
<div class="sheet-wrap">
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 82%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">社会价值<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.socialValue"
:rows="4"
placeholder="请输入社会价值"
/>
</td>
</tr>
<tr>
<td class="sheet-label">实践过程<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.practiceProcess"
:rows="4"
placeholder="请输入实践过程"
/>
</td>
</tr>
<tr>
<td class="sheet-label">创新意义<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.innovationMeaning"
:rows="4"
placeholder="请输入创新意义"
/>
</td>
</tr>
<tr>
<td class="sheet-label">发展前景<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.developmentProspect"
:rows="4"
placeholder="请输入发展前景"
/>
</td>
</tr>
<tr>
<td class="sheet-label">团队协作<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.teamCooperation"
:rows="4"
placeholder="请输入团队协作"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目介绍材料</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.projectMaterials"
:rows="3"
placeholder="请输入项目介绍材料"
/>
</td>
</tr>
<tr>
<td class="sheet-label">其他相关证明材料</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.otherProofs"
:rows="3"
placeholder="选报,所有其他相关证明材料需扫描在同一 PDF 文档中"
/>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
<a-tab-pane key="public" tab="公开展示信息表">
<div class="sheet-wrap">
<div class="public-title">
<div class="public-tag">附件 2</div>
<div class="public-main-title"
>{{ title || '' }}参赛项目公开展示信息表</div
>
</div>
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 82%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">项目名称</td>
<td class="sheet-value left">
<a-input
v-model:value="form.projectName"
placeholder="请输入项目名称"
/>
</td>
</tr>
<tr>
<td class="sheet-label">参赛学校</td>
<td class="sheet-value left">
<a-input
v-model:value="form.schoolName"
placeholder="请输入参赛学校"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目类型</td>
<td class="sheet-value left">
<a-radio-group
v-model:value="form.publicProjectType"
class="inline-radio"
>
<a-radio
v-for="item in publicProjectTypeOptions"
:key="`public-type-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">项目分组</td>
<td class="sheet-value left">
<a-radio-group
v-model:value="form.publicProjectGroup"
class="group-radio"
>
<a-radio
v-for="item in publicProjectGroupOptions"
:key="`public-group-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">我们的项目</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.projectSummary"
:rows="3"
placeholder="150字以内,阐述项目的实践来源、社会背景、基本情况等"
/>
</td>
</tr>
<tr>
<td class="sheet-label">我们的团队</td>
<td class="sheet-value left">
<div class="media-upload-block">
<div v-if="form.teamIntro" class="media-preview-wrap">
<img
class="media-image"
:src="form.teamIntro"
alt="团队图片"
/>
</div>
<div v-else class="media-empty">请上传团队图片</div>
<div class="media-actions">
<a-upload
:show-upload-list="false"
accept="image/*"
:customRequest="uploadTeamImage"
>
<a-button type="primary">上传图片</a-button>
</a-upload>
<a-button v-if="form.teamIntro" @click="form.teamIntro = ''">
移除
</a-button>
</div>
</div>
</td>
</tr>
<tr>
<td class="sheet-label">我们的口号</td>
<td class="sheet-value left">
<a-input
v-model:value="form.teamSlogan"
placeholder="20字以内"
/>
</td>
</tr>
<tr>
<td class="sheet-label">我们的实践日志(选填)</td>
<td class="sheet-value left">
<div class="media-upload-block">
<div v-if="form.practiceLog" class="media-preview-wrap media-preview-wrap--video">
<video class="media-video" :src="form.practiceLog" controls></video>
</div>
<div v-else class="media-empty">请上传实践日志视频</div>
<div class="media-actions">
<a-upload
:show-upload-list="false"
accept="video/*"
:customRequest="uploadPracticeVideo"
>
<a-button type="primary">上传视频</a-button>
</a-upload>
<a-button v-if="form.practiceLog" @click="form.practiceLog = ''">
移除
</a-button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
</a-tabs>
</a-form>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save"
>保存</a-button
>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addTzbcyForm, updateTzbcyForm } from '@/api/gxmu/tzbcy-form';
import { uploadFile } from '@/api/system/file';
import type {
TzbcyAdvisor,
TzbcyForm,
TzbcyTeamMember
} from '@/api/gxmu/tzbcy-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
import {
TZBCY_PROJECT_GROUP_OPTIONS,
TZBCY_PROJECT_TYPE_OPTIONS
} from '../options';
interface ChoiceOption {
label: string;
value: string;
}
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: TzbcyForm | null;
title?: string;
fixedYear?: string | number;
declareFormProjectTypes?: string[];
declareFormProjectGroups?: string[];
declarePublicProjectTypes?: string[];
declarePublicProjectGroups?: string[];
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const activeTab = ref('base');
const { genderOptions, collegeOptions, loadCollegeOptions } =
useGxmuFormSelectOptions();
const createTeamMember = (): TzbcyTeamMember => ({
name: '',
gender: '',
college: '',
gradeMajor: '',
phone: '',
remark: ''
});
const createAdvisor = (): TzbcyAdvisor => ({
name: '',
gender: '',
college: '',
title: '',
duty: '',
phone: ''
});
const createDefaultForm = (): TzbcyForm => ({
id: undefined,
year: '',
provinceCity: '',
schoolName: '广西医科大学',
projectName: '',
projectType: '',
projectGroup: '',
publicProjectType: '',
publicProjectGroup: '',
leader: '',
phone: '',
teamMembers: Array.from({ length: 15 }).map(() => createTeamMember()),
advisors: Array.from({ length: 5 }).map(() => createAdvisor()),
projectBrief: '',
socialValue: '',
practiceProcess: '',
innovationMeaning: '',
developmentProspect: '',
teamCooperation: '',
projectMaterials: '',
otherProofs: '',
projectSummary: '',
teamIntro: '',
teamSlogan: '',
practiceLog: ''
});
const fillList = <T extends Record<string, unknown>>(
list: T[] | undefined,
count: number,
createItem: () => T
) => {
const source = list?.map((item) => ({ ...createItem(), ...item })) ?? [];
return Array.from({ length: count }).map(
(_, index) => source[index] ?? createItem()
);
};
const normalizeForm = (record?: TzbcyForm | null): TzbcyForm => {
const defaults = createDefaultForm();
if (!record) {
return defaults;
}
const normalizedProjectType = record.projectType || '';
const normalizedProjectGroup = record.projectGroup || '';
return {
...defaults,
...record,
projectType: normalizedProjectType,
projectGroup: normalizedProjectGroup,
publicProjectType: record.publicProjectType || normalizedProjectType,
publicProjectGroup: record.publicProjectGroup || normalizedProjectGroup,
teamMembers: fillList(record.teamMembers, 15, createTeamMember),
advisors: fillList(record.advisors, 5, createAdvisor)
};
};
const form = reactive<TzbcyForm>(createDefaultForm());
const teamMembersCount = computed(() => form.teamMembers?.length || 0);
const advisorCount = computed(() => form.advisors?.length || 0);
const buildChoiceOptions = (
customValues: string[] | undefined,
fallback: readonly ChoiceOption[],
currentValue?: string
): ChoiceOption[] => {
const values = (customValues?.length
? customValues
: fallback.map((item) => item.value)
)
.map((item) => item.trim())
.filter((item, index, arr) => !!item && arr.indexOf(item) === index);
if (currentValue?.trim() && !values.includes(currentValue.trim())) {
values.push(currentValue.trim());
}
return values.map((value) => {
const matched = fallback.find((item) => item.value === value);
return {
label: matched?.label ?? value,
value
};
});
};
const formProjectTypeOptions = computed(() =>
buildChoiceOptions(
props.declareFormProjectTypes,
TZBCY_PROJECT_TYPE_OPTIONS,
form.projectType
)
);
const formProjectGroupOptions = computed(() =>
buildChoiceOptions(
props.declareFormProjectGroups,
TZBCY_PROJECT_GROUP_OPTIONS,
form.projectGroup
)
);
const publicProjectTypeOptions = computed(() =>
buildChoiceOptions(
props.declarePublicProjectTypes,
TZBCY_PROJECT_TYPE_OPTIONS,
form.publicProjectType
)
);
const publicProjectGroupOptions = computed(() =>
buildChoiceOptions(
props.declarePublicProjectGroups,
TZBCY_PROJECT_GROUP_OPTIONS,
form.publicProjectGroup
)
);
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await loadCollegeOptions();
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, normalizeForm(props.data));
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
activeTab.value = 'base';
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const syncLeaderInfo = () => {
const firstMember = form.teamMembers?.find(
(item) => item.name?.trim() || item.phone?.trim()
);
form.leader = firstMember?.name?.trim() || '';
form.phone = firstMember?.phone?.trim() || '';
};
const save = () => {
syncLeaderInfo();
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateTzbcyForm : addTzbcyForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
const uploadTeamImage = (item) => {
const file = item.file as File;
if (!file.type.startsWith('image/')) {
message.error('请上传图片文件');
return;
}
uploadFile(file)
.then((data) => {
form.teamIntro = String(data.url || '');
message.success('图片上传成功');
})
.catch((e) => {
message.error(e.message);
});
};
const uploadPracticeVideo = (item) => {
const file = item.file as File;
if (!file.type.startsWith('video/')) {
message.error('请上传视频文件');
return;
}
uploadFile(file)
.then((data) => {
form.practiceLog = String(data.url || '');
message.success('视频上传成功');
})
.catch((e) => {
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
@import '../../tag-tabs.less';
.gxmu-field-theme();
.gxmu-tag-tabs();
.sheet-wrap {
border: 1px solid #d9d9d9;
padding: 12px;
background: #fff;
}
.sheet-header {
margin-bottom: 16px;
}
.sheet-tag {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.sheet-main-title {
font-size: 28px;
font-weight: 700;
text-align: center;
line-height: 1.4;
}
.sheet-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 12px;
}
.sheet-table td {
border: 1px solid #3b3b3b;
padding: 8px 6px;
font-size: 13px;
text-align: center;
vertical-align: middle;
}
.sheet-label {
font-weight: 600;
text-align: center;
}
.sheet-note {
font-weight: 400;
font-size: 12px;
color: #444;
margin-top: 6px;
line-height: 1.8;
}
.sheet-value {
text-align: center;
}
.sheet-value.left {
text-align: left;
}
.sheet-table .sheet-value :deep(.ant-input),
.sheet-table .sheet-value :deep(.ant-select-selector),
.sheet-table .sheet-value :deep(.ant-radio-group),
.sheet-table .sheet-value :deep(.ant-input-textarea),
.sheet-table .sheet-value :deep(.ant-input-affix-wrapper) {
border: 0;
box-shadow: none;
padding: 0;
background: transparent;
}
.sheet-table .sheet-value :deep(.ant-input) {
text-align: center;
}
.sheet-table .sheet-value.left :deep(.ant-input),
.sheet-table .sheet-value.left :deep(.ant-input-textarea) {
text-align: left;
}
.sheet-table .sheet-value :deep(textarea.ant-input) {
resize: none;
}
.inline-radio :deep(.ant-radio) {
margin-right: 12px;
}
.group-radio {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.media-upload-block {
display: flex;
flex-direction: column;
gap: 12px;
}
.media-preview-wrap {
width: 100%;
border-radius: 8px;
overflow: hidden;
background: #f5f7fa;
}
.media-preview-wrap--video {
background: #111827;
}
.media-image {
width: 100%;
max-height: 220px;
object-fit: cover;
display: block;
}
.media-video {
width: 100%;
max-height: 260px;
display: block;
}
.media-empty {
padding: 24px 12px;
border-radius: 8px;
background: #f5f7fa;
color: #909399;
text-align: center;
}
.media-actions {
display: flex;
gap: 12px;
align-items: center;
}
.public-title {
margin-bottom: 16px;
}
.public-tag {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.public-main-title {
font-size: 28px;
font-weight: 700;
text-align: center;
line-height: 1.4;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
@media (max-width: 768px) {
.sheet-main-title,
.public-main-title {
font-size: 16px;
}
}
</style>
@@ -0,0 +1,144 @@
<template>
<a-modal
:visible="visible"
:title="form.id ? '编辑材料' : '新增材料'"
:confirm-loading="loading"
:mask-closable="false"
@ok="save"
@cancel="close"
@update:visible="updateVisible"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 19 }"
>
<a-form-item label="材料类型" name="materialType">
<a-input
v-model:value="form.materialType"
allow-clear
placeholder="请输入材料类型"
/>
</a-form-item>
<a-form-item label="上传材料" name="fileId">
<a-upload
:show-upload-list="false"
:customRequest="handleUpload"
>
<a-button :loading="uploading">
<template #icon>
<UploadOutlined />
</template>
上传文件
</a-button>
</a-upload>
<div v-if="form.fileName" class="file-name">
{{ form.fileName }}
</div>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import type { FormInstance } from 'ant-design-vue';
import { uploadFile } from '@/api/system/file';
import { addTzbcyMaterial, updateTzbcyMaterial } from '@/api/gxmu/tzbcy-material';
import type { TzbcyMaterial } from '@/api/gxmu/tzbcy-material/model';
const props = defineProps<{
visible: boolean;
formId?: number;
data?: TzbcyMaterial | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'done'): void;
}>();
const formRef = ref<FormInstance>();
const loading = ref(false);
const uploading = ref(false);
const form = reactive<TzbcyMaterial>({});
const rules = {
materialType: [{ required: true, message: '请输入材料类型', trigger: 'blur' }],
fileId: [{ required: true, message: '请上传材料文件', trigger: 'change' }]
};
watch(
() => props.visible,
(visible) => {
if (!visible) {
return;
}
Object.assign(form, {
id: props.data?.id,
formId: props.data?.formId || props.formId,
materialType: props.data?.materialType,
fileId: props.data?.fileId,
fileName: props.data?.fileName
});
},
{ immediate: true }
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const close = () => {
emit('update:visible', false);
};
const handleUpload = async (options: any) => {
const rawFile = options.file as File;
uploading.value = true;
const hide = message.loading('上传中..', 0);
try {
const result = await uploadFile(rawFile);
form.fileId = result.id;
form.fileName = result.name || rawFile.name;
options.onSuccess?.(result);
formRef.value?.validateFields(['fileId']);
message.success('上传成功');
} catch (e: any) {
options.onError?.(e);
message.error(e.message || '上传失败');
} finally {
hide();
uploading.value = false;
}
};
const save = async () => {
await formRef.value?.validate();
loading.value = true;
try {
const submitData = { ...form, formId: form.formId || props.formId };
const msg = submitData.id
? await updateTzbcyMaterial(submitData)
: await addTzbcyMaterial(submitData);
message.success(msg);
emit('done');
close();
} catch (e: any) {
message.error(e.message || '提交失败');
} finally {
loading.value = false;
}
};
</script>
<style lang="less" scoped>
.file-name {
margin-top: 8px;
color: #4b5563;
word-break: break-all;
}
</style>
@@ -0,0 +1,326 @@
<template>
<a-modal
:visible="visible"
title="材料上传"
:footer="null"
:destroy-on-close="true"
:width="1000"
@cancel="close"
@update:visible="updateVisible"
>
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1100 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-select
v-model:value="materialStatus"
allow-clear
placeholder="请选择审核状态"
style="width: 160px"
@change="reload"
>
<a-select-option
v-for="item in statusOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
<a-button type="primary" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
新增材料
</a-button>
<a-button :loading="exporting" @click="exportMaterials">
整体导出
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="download(record)">下载</a>
<template v-if="record.status !== 1">
<a-divider type="vertical" />
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a @click="audit(record, 1)">通过</a>
<a-divider type="vertical" />
<a @click="openReject(record)">驳回</a>
<a-divider type="vertical" />
<a-popconfirm title="确定要删除此材料吗?" @confirm="remove(record)">
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</template>
</a-space>
</template>
</template>
</ele-pro-table>
<material-edit
v-model:visible="showEdit"
:form-id="record?.id"
:data="current"
@done="reload"
/>
<a-modal
v-model:visible="showReject"
title="驳回材料"
:confirm-loading="rejecting"
@ok="submitReject"
>
<a-textarea
v-model:value="rejectReason"
:rows="4"
placeholder="请输入驳回原因"
/>
</a-modal>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import type {
ColumnItem,
DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types';
import MaterialEdit from './materialEdit.vue';
import {
auditTzbcyMaterial,
exportTzbcyMaterial,
pageTzbcyMaterial,
removeTzbcyMaterial
} from '@/api/gxmu/tzbcy-material';
import type { TzbcyMaterial } from '@/api/gxmu/tzbcy-material/model';
import type { TzbcyForm } from '@/api/gxmu/tzbcy-form/model';
const props = defineProps<{
visible: boolean;
record?: TzbcyForm | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const showEdit = ref(false);
const showReject = ref(false);
const rejecting = ref(false);
const exporting = ref(false);
const current = ref<TzbcyMaterial | null>(null);
const rejectCurrent = ref<TzbcyMaterial | null>(null);
const rejectReason = ref('');
const materialStatus = ref<number>();
const statusOptions = [
{ label: '待审核', value: 0 },
{ label: '通过', value: 1 },
{ label: '驳回', value: 2 }
];
const columns = ref<ColumnItem[]>([
{
title: '材料类型',
dataIndex: 'materialType',
key: 'materialType',
align: 'center',
width: 180
},
{
title: '文件名称',
dataIndex: 'fileName',
key: 'fileName',
align: 'center',
width: 260,
ellipsis: true
},
{
title: '审核状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 110
},
{
title: '驳回原因',
dataIndex: 'rejectReason',
key: 'rejectReason',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '上传时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 170
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 260
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
if (!props.record?.id) {
return Promise.resolve({ list: [], count: 0 });
}
return pageTzbcyMaterial({
page,
limit,
formId: props.record.id,
status: materialStatus.value
}).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
setTimeout(() => reload());
} else {
materialStatus.value = undefined;
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const close = () => {
emit('update:visible', false);
};
const reload = () => {
tableRef.value?.reload();
};
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 'processing';
};
const openEdit = (record?: TzbcyMaterial) => {
current.value = record ? { ...record } : null;
showEdit.value = true;
};
const download = (record: TzbcyMaterial) => {
const url = record.downloadUrl || record.fileUrl;
if (!url) {
message.warning('暂无下载地址');
return;
}
window.open(url, '_blank');
};
const audit = async (record: TzbcyMaterial, status: number, reason?: string) => {
try {
const msg = await auditTzbcyMaterial({
id: record.id,
status,
rejectReason: reason
});
message.success(msg);
reload();
} catch (e: any) {
message.error(e.message || '审核失败');
}
};
const openReject = (record: TzbcyMaterial) => {
rejectCurrent.value = record;
rejectReason.value = record.rejectReason || '';
showReject.value = true;
};
const submitReject = async () => {
if (!rejectReason.value.trim()) {
message.warning('请输入驳回原因');
return;
}
if (!rejectCurrent.value) {
return;
}
rejecting.value = true;
try {
await audit(rejectCurrent.value, 2, rejectReason.value.trim());
showReject.value = false;
} finally {
rejecting.value = false;
}
};
const remove = async (record: TzbcyMaterial) => {
try {
const msg = await removeTzbcyMaterial(record.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e.message || '删除失败');
}
};
const exportMaterials = async () => {
if (!props.record?.id) {
return;
}
exporting.value = true;
try {
const result = await exportTzbcyMaterial({
formId: props.record.id,
status: materialStatus.value
});
const url = result.url || result.downloadUrl || result.path;
if (!url) {
message.warning('未获取到导出文件地址');
return;
}
window.open(url, '_blank');
} catch (e: any) {
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
</script>
+292
View File
@@ -0,0 +1,292 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1400 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入项目名称/负责人/学校"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openMaterials(record)">材料上传</a>
<a-divider type="vertical" />
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(record)">审核历史</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:title="title"
:fixed-year="props.fixedYear"
:declare-form-project-types="props.declareFormProjectTypes"
:declare-form-project-groups="props.declareFormProjectGroups"
:declare-public-project-types="props.declarePublicProjectTypes"
:declare-public-project-groups="props.declarePublicProjectGroups"
@done="reload"
/>
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="挑战杯"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
<materials-modal
v-model:visible="showMaterials"
:record="materialCurrent"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined, PlusOutlined } from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import MaterialsModal from './components/materials.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { userPageTzbcyForm, removeTzbcyForm } from '@/api/gxmu/tzbcy-form';
import type { TzbcyForm, TzbcyFormParam } from '@/api/gxmu/tzbcy-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
title?: string;
declareFormProjectTypes?: string[];
declareFormProjectGroups?: string[];
declarePublicProjectTypes?: string[];
declarePublicProjectGroups?: string[];
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showDetail = ref(false);
const current = ref<TzbcyForm>();
const detailCurrent = ref<TzbcyForm | null>(null);
const REVIEW_MODULE = 'gxmu_tzbcy_form';
const showReviewHistory = ref(false);
const reviewCurrent = ref<TzbcyForm | null>(null);
const showMaterials = ref(false);
const materialCurrent = ref<TzbcyForm | null>(null);
const getLeaderInfo = (record: TzbcyForm) => {
const firstMember = record.teamMembers?.find(
(item) => item.name?.trim() || item.phone?.trim()
);
return {
leader: record.leader || firstMember?.name || '',
phone: record.phone || firstMember?.phone || ''
};
};
const columns = ref<ColumnItem[]>([
{
title: '项目名称',
dataIndex: 'projectName',
key: 'projectName',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '项目类型',
dataIndex: 'projectType',
key: 'projectType',
align: 'center',
width: 140
},
{
title: '项目分组',
dataIndex: 'projectGroup',
key: 'projectGroup',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '学校名称',
dataIndex: 'schoolName',
key: 'schoolName',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '负责人',
dataIndex: 'leader',
key: 'leader',
align: 'center',
width: 120,
customRender: ({ record }) => getLeaderInfo(record as TzbcyForm).leader
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140,
customRender: ({ record }) => getLeaderInfo(record as TzbcyForm).phone
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 280
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageTzbcyForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as TzbcyFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
message.info('已触发导入操作(请接入实际导入逻辑)');
};
const openEdit = (record?: TzbcyForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as TzbcyForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: TzbcyForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: TzbcyForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const openMaterials = (record: TzbcyForm) => {
materialCurrent.value = record;
showMaterials.value = true;
};
const remove = (record: TzbcyForm) => {
const hide = message.loading('请求中..', 0);
removeTzbcyForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'TZBCYForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
+13
View File
@@ -0,0 +1,13 @@
export const TZBCY_PROJECT_TYPE_OPTIONS = [
{ label: 'I. 普通高校', value: '普通高校' },
{ label: 'II. 职业院校', value: '职业院校' },
{ label: 'III. 东盟留学生专项', value: '东盟留学生专项' }
] as const;
export const TZBCY_PROJECT_GROUP_OPTIONS = [
{ label: 'A. 科技创新和未来产业', value: 'A. 科技创新和未来产业' },
{ label: 'B. 乡村振兴和农业农村现代化', value: 'B. 乡村振兴和农业农村现代化' },
{ label: 'C. 社会治理和公共服务', value: 'C. 社会治理和公共服务' },
{ label: 'D. 生态环保和可持续发展', value: 'D. 生态环保和可持续发展' },
{ label: 'E. 文化创意和区域合作', value: 'E. 文化创意和区域合作' }
] as const;
@@ -0,0 +1,343 @@
<template>
<a-modal
:width="1000"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="二级团组织全称"
name="orgName"
:rules="[{ required: true, message: '请输入二级团组织全称' }]"
>
<a-input
v-model:value="form.orgName"
placeholder="请输入二级团组织全称"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="负责人" name="leader">
<a-input v-model:value="form.leader" placeholder="请输入负责人" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="form.phone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="现有团员总数" name="memberTotal">
<a-input
v-model:value="form.memberTotal"
placeholder="请输入现有团员总数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度发展团员人数" name="memberDeveloped2024">
<a-input
v-model:value="form.memberDeveloped2024"
placeholder="请输入上一年度发展团员人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否已登录智慧团建系统" name="smartSystemLogin">
<a-select
v-model:value="form.smartSystemLogin"
placeholder="请选择"
>
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="团委委员人数" name="committeeCount">
<a-input
v-model:value="form.committeeCount"
placeholder="请输入团委委员人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="专职团干部数" name="fulltimeCadreCount">
<a-input
v-model:value="form.fulltimeCadreCount"
placeholder="请输入专职团干部数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="兼职团干部数" name="parttimeCadreCount">
<a-input
v-model:value="form.parttimeCadreCount"
placeholder="请输入兼职团干部数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="团委最近一次换届时间" name="lastElectionTime">
<a-date-picker
v-model:value="form.lastElectionTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择团委最近一次换届时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度应收团费" name="feeReceivable2024">
<a-input
v-model:value="form.feeReceivable2024"
placeholder="请输入上一年度应收团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度实收团费" name="feeReceived2024">
<a-input
v-model:value="form.feeReceived2024"
placeholder="请输入上一年度实收团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度应上缴团费" name="feePayable2024">
<a-input
v-model:value="form.feePayable2024"
placeholder="请输入上一年度应上缴团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度实际上缴团费" name="feePaid2024">
<a-input
v-model:value="form.feePaid2024"
placeholder="请输入上一年度实际上缴团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="团支部数" name="branchCount">
<a-input
v-model:value="form.branchCount"
placeholder="请输入团支部数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="是否开展规范化建设工作"
name="standardizedWork2024"
>
<a-select
v-model:value="form.standardizedWork2024"
placeholder="请选择"
>
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="推荐入党积极分子人数"
name="recommendActivist2024"
>
<a-input
v-model:value="form.recommendActivist2024"
placeholder="请输入推荐入党积极分子人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="确定为入党积极分子数" name="activistConfirmed">
<a-input
v-model:value="form.activistConfirmed"
placeholder="请输入确定为入党积极分子数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="推荐党的发展对象人数"
name="recommendDevTarget2024"
>
<a-input
v-model:value="form.recommendDevTarget2024"
placeholder="请输入推荐党的发展对象人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="确定为党的发展对象数" name="devTargetConfirmed">
<a-input
v-model:value="form.devTargetConfirmed"
placeholder="请输入确定为党的发展对象数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="近五年获得校级及以上荣誉情况"
name="honorsFiveYears"
>
<a-textarea
v-model:value="form.honorsFiveYears"
:rows="3"
placeholder="请输入近五年获得校级及以上荣誉情况"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="近三年开展的主要工作及取得的效果"
name="workSummaryThreeYears"
>
<a-textarea
v-model:value="form.workSummaryThreeYears"
:rows="3"
placeholder="请输入近三年开展的主要工作及取得的效果"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位党组织意见" name="partyOpinion">-->
<!-- <a-textarea v-model:value="form.partyOpinion" :rows="2" placeholder="请输入所在单位党组织意见" />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="校团委意见" name="schoolOpinion">-->
<!-- <a-textarea v-model:value="form.schoolOpinion" :rows="2" placeholder="请输入校团委意见" />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addWshqtwForm, updateWshqtwForm } from '@/api/gxmu/wshqtw-form';
import type { WshqtwForm } from '@/api/gxmu/wshqtw-form/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: WshqtwForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const createDefaultForm = (): WshqtwForm => ({
id: undefined,
year: '',
orgName: '',
leader: '',
phone: '',
memberTotal: '',
memberDeveloped2024: '',
smartSystemLogin: '',
committeeCount: '',
fulltimeCadreCount: '',
parttimeCadreCount: '',
lastElectionTime: '',
feeReceivable2024: '',
feeReceived2024: '',
feePayable2024: '',
feePaid2024: '',
branchCount: '',
standardizedWork2024: '',
recommendActivist2024: '',
activistConfirmed: '',
recommendDevTarget2024: '',
devTargetConfirmed: '',
honorsFiveYears: '',
workSummaryThreeYears: '',
partyOpinion: '',
schoolOpinion: ''
});
const form = reactive<WshqtwForm>(createDefaultForm());
watch(
() => props.visible,
(visible) => {
if (visible) {
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateWshqtwForm : addWshqtwForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,104 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="五四红旗团委"
file-name="五四红旗团委导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addWshqtwForm } from '@/api/gxmu/wshqtw-form';
import type { WshqtwForm } from '@/api/gxmu/wshqtw-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '二级团组织全称', key: 'orgName', required: true },
{ header: '负责人', key: 'leader' },
{ header: '联系电话', key: 'phone' },
{ header: '现有团员总数', key: 'memberTotal' },
{ header: '上一年度发展团员人数', key: 'memberDeveloped2024' },
{ header: '是否已登录智慧团建系统', key: 'smartSystemLogin' },
{ header: '团委委员人数', key: 'committeeCount' },
{ header: '专职团干部数', key: 'fulltimeCadreCount' },
{ header: '兼职团干部数', key: 'parttimeCadreCount' },
{ header: '团委最近一次换届时间', key: 'lastElectionTime' },
{ header: '上一年度应收团费', key: 'feeReceivable2024' },
{ header: '上一年度实收团费', key: 'feeReceived2024' },
{ header: '上一年度应上缴团费', key: 'feePayable2024' },
{ header: '上一年度实际上缴团费', key: 'feePaid2024' },
{ header: '团支部数', key: 'branchCount' },
{ header: '是否开展规范化建设工作', key: 'standardizedWork2024' },
{ header: '推荐入党积极分子人数', key: 'recommendActivist2024' },
{ header: '确定为入党积极分子数', key: 'activistConfirmed' },
{ header: '推荐党的发展对象人数', key: 'recommendDevTarget2024' },
{ header: '确定为党的发展对象数', key: 'devTargetConfirmed' },
{ header: '近五年获得校级及以上荣誉情况', key: 'honorsFiveYears' },
{ header: '近三年开展的主要工作及取得的效果', key: 'workSummaryThreeYears' }
] as const;
const sampleRow = {
年份: '2025',
二级团组织全称: '第一临床医学院团委',
负责人: '王老师',
联系电话: '13800138000',
现有团员总数: '560',
上一年度发展团员人数: '120',
是否已登录智慧团建系统: '是',
团委委员人数: '9',
专职团干部数: '2',
兼职团干部数: '8',
团委最近一次换届时间: '2024-06-01',
上一年度应收团费: '5000',
上一年度实收团费: '4800',
上一年度应上缴团费: '3000',
上一年度实际上缴团费: '3000',
团支部数: '24',
是否开展规范化建设工作: '是',
推荐入党积极分子人数: '30',
确定为入党积极分子数: '28',
推荐党的发展对象人数: '12',
确定为党的发展对象数: '10',
近五年获得校级及以上荣誉情况: '校五四红旗团委',
近三年开展的主要工作及取得的效果: '组织青年志愿活动成效显著'
};
const createPayload = (row: Record<string, any>): WshqtwForm => ({
year: String(row['年份'] || '').trim(),
orgName: String(row['二级团组织全称'] || '').trim(),
leader: String(row['负责人'] || '').trim(),
phone: String(row['联系电话'] || '').trim(),
memberTotal: String(row['现有团员总数'] || '').trim(),
memberDeveloped2024: String(row['上一年度发展团员人数'] || '').trim(),
smartSystemLogin: String(row['是否已登录智慧团建系统'] || '').trim(),
committeeCount: String(row['团委委员人数'] || '').trim(),
fulltimeCadreCount: String(row['专职团干部数'] || '').trim(),
parttimeCadreCount: String(row['兼职团干部数'] || '').trim(),
lastElectionTime: String(row['团委最近一次换届时间'] || '').trim(),
feeReceivable2024: String(row['上一年度应收团费'] || '').trim(),
feeReceived2024: String(row['上一年度实收团费'] || '').trim(),
feePayable2024: String(row['上一年度应上缴团费'] || '').trim(),
feePaid2024: String(row['上一年度实际上缴团费'] || '').trim(),
branchCount: String(row['团支部数'] || '').trim(),
standardizedWork2024: String(row['是否开展规范化建设工作'] || '').trim(),
recommendActivist2024: String(row['推荐入党积极分子人数'] || '').trim(),
activistConfirmed: String(row['确定为入党积极分子数'] || '').trim(),
recommendDevTarget2024: String(row['推荐党的发展对象人数'] || '').trim(),
devTargetConfirmed: String(row['确定为党的发展对象数'] || '').trim(),
honorsFiveYears: String(row['近五年获得校级及以上荣誉情况'] || '').trim(),
workSummaryThreeYears: String(
row['近三年开展的主要工作及取得的效果'] || ''
).trim()
});
const saveItem = (item: WshqtwForm) => addWshqtwForm(item);
</script>
+447
View File
@@ -0,0 +1,447 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 3200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入团组织/负责人/联系电话"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(record)">审核历史</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="五四红旗团委"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportWshqtwForm,
userPageWshqtwForm,
removeWshqtwForm
} from '@/api/gxmu/wshqtw-form';
import type {
WshqtwForm,
WshqtwFormParam
} from '@/api/gxmu/wshqtw-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<WshqtwForm>();
const detailCurrent = ref<WshqtwForm | null>(null);
const REVIEW_MODULE = 'gxmu_wshqtw';
const showReviewHistory = ref(false);
const reviewCurrent = ref<WshqtwForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '二级团组织全称',
dataIndex: 'orgName',
key: 'orgName',
align: 'center',
width: 180
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '负责人',
dataIndex: 'leader',
key: 'leader',
align: 'center',
width: 100
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140
},
{
title: '现有团员总数',
dataIndex: 'memberTotal',
key: 'memberTotal',
align: 'center',
width: 130
},
{
title: '上一年度发展团员人数',
dataIndex: 'memberDeveloped2024',
key: 'memberDeveloped2024',
align: 'center',
width: 170
},
{
title: '本级是否已登录“智慧团建”系统',
dataIndex: 'smartSystemLogin',
key: 'smartSystemLogin',
align: 'center',
width: 200
},
{
title: '团委委员人数',
dataIndex: 'committeeCount',
key: 'committeeCount',
align: 'center',
width: 130
},
{
title: '专职团干部数(其中教师/学生)',
dataIndex: 'fulltimeCadreCount',
key: 'fulltimeCadreCount',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '兼职团干部数(其中教师/学生)',
dataIndex: 'parttimeCadreCount',
key: 'parttimeCadreCount',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '团委最近一次换届时间',
dataIndex: 'lastElectionTime',
key: 'lastElectionTime',
align: 'center',
width: 170
},
{
title: '上一年度应收团费',
dataIndex: 'feeReceivable2024',
key: 'feeReceivable2024',
align: 'center',
width: 130
},
{
title: '上一年度实收团费',
dataIndex: 'feeReceived2024',
key: 'feeReceived2024',
align: 'center',
width: 130
},
{
title: '上一年度应上缴团费',
dataIndex: 'feePayable2024',
key: 'feePayable2024',
align: 'center',
width: 130
},
{
title: '上一年度实际上缴团费',
dataIndex: 'feePaid2024',
key: 'feePaid2024',
align: 'center',
width: 150
},
{
title: '团支部数',
dataIndex: 'branchCount',
key: 'branchCount',
align: 'center',
width: 110
},
{
title: '上一年度是否开展基层团组织规范化建设工作',
dataIndex: 'standardizedWork2024',
key: 'standardizedWork2024',
align: 'center',
width: 240
},
{
title: '上一年度推荐优秀团员作为入党积极分子人数',
dataIndex: 'recommendActivist2024',
key: 'recommendActivist2024',
align: 'center',
width: 240
},
{
title: '其中被党组织确定为入党积极分子数',
dataIndex: 'activistConfirmed',
key: 'activistConfirmed',
align: 'center',
width: 220
},
{
title: '上一年度推荐优秀团员作为党的发展对象人数',
dataIndex: 'recommendDevTarget2024',
key: 'recommendDevTarget2024',
align: 'center',
width: 240
},
{
title: '其中被党组织确定为党的发展对象数',
dataIndex: 'devTargetConfirmed',
key: 'devTargetConfirmed',
align: 'center',
width: 220
},
{
title: '近五年获得校级及以上荣誉情况',
dataIndex: 'honorsFiveYears',
key: 'honorsFiveYears',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '近三年开展的主要工作及取得的效果',
dataIndex: 'workSummaryThreeYears',
key: 'workSummaryThreeYears',
align: 'center',
width: 260,
ellipsis: true
},
{
title: '所在单位党组织意见',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '校团委意见',
dataIndex: 'schoolOpinion',
key: 'schoolOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 220
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageWshqtwForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WshqtwFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): WshqtwFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WshqtwFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.url || payload?.downloadUrl || payload?.path || ''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportWshqtwForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: WshqtwForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as WshqtwForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: WshqtwForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: WshqtwForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: WshqtwForm) => {
const hide = message.loading('请求中..', 0);
removeWshqtwForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'WSHQTWForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
@@ -0,0 +1,409 @@
<template>
<a-modal
:width="1000"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="团支部全称"
name="branchName"
:rules="[{ required: true, message: '请输入团支部全称' }]"
>
<a-input
v-model:value="form.branchName"
placeholder="请输入团支部全称"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所属二级团组织" name="secondOrg">
<a-input
v-model:value="form.secondOrg"
placeholder="请输入所属二级团组织"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="团支部书记" name="secretary">
<a-input
v-model:value="form.secretary"
placeholder="请输入团支部书记"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="政治面貌" name="politics">
<a-select
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="联系方式" name="contact">
<a-input
v-model:value="form.contact"
placeholder="请输入联系方式"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="成立时间" name="establishTime">
<a-date-picker
v-model:value="form.establishTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择成立时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="最近一次换届时间" name="lastElectionTime">
<a-date-picker
v-model:value="form.lastElectionTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择最近一次换届时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否已登录智慧团建系统" name="smartSystemLogin">
<a-select
v-model:value="form.smartSystemLogin"
placeholder="请选择"
>
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="现有团员总数" name="memberTotal">
<a-input
v-model:value="form.memberTotal"
placeholder="请输入现有团员总数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度发展团员数" name="memberDeveloped2024">
<a-input
v-model:value="form.memberDeveloped2024"
placeholder="请输入上一年度发展团员数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度应收团费" name="feeReceivable2024">
<a-input
v-model:value="form.feeReceivable2024"
placeholder="请输入上一年度应收团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度实收团费" name="feeReceived2024">
<a-input
v-model:value="form.feeReceived2024"
placeholder="请输入上一年度实收团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度应上缴团费" name="feePayable2024">
<a-input
v-model:value="form.feePayable2024"
placeholder="请输入上一年度应上缴团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度实际上缴团费" name="feePaid2024">
<a-input
v-model:value="form.feePaid2024"
placeholder="请输入上一年度实际上缴团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="推荐入党积极分子人数"
name="recommendActivist2024"
>
<a-input
v-model:value="form.recommendActivist2024"
placeholder="请输入推荐入党积极分子人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="确定为入党积极分子数" name="activistConfirmed">
<a-input
v-model:value="form.activistConfirmed"
placeholder="请输入确定为入党积极分子数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="推荐党的发展对象人数"
name="recommendDevTarget2024"
>
<a-input
v-model:value="form.recommendDevTarget2024"
placeholder="请输入推荐党的发展对象人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="确定为党的发展对象数" name="devTargetConfirmed">
<a-input
v-model:value="form.devTargetConfirmed"
placeholder="请输入确定为党的发展对象数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="团支部委员会会议召开次数"
name="branchCommitteeMeetingCount"
>
<a-input
v-model:value="form.branchCommitteeMeetingCount"
placeholder="请输入团支部委员会会议召开次数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="团支部团员大会召开次数"
name="branchMemberMeetingCount"
>
<a-input
v-model:value="form.branchMemberMeetingCount"
placeholder="请输入团支部团员大会召开次数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否开展团员教育评议" name="eduEvalDone">
<a-select v-model:value="form.eduEvalDone" placeholder="请选择">
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否开展团员年度团籍注册" name="annualRegDone">
<a-select v-model:value="form.annualRegDone" placeholder="请选择">
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="开展团课次数" name="classCount">
<a-input
v-model:value="form.classCount"
placeholder="请输入开展团课次数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否100%录入智慧团建" name="smartSystem100">
<a-select v-model:value="form.smartSystem100" placeholder="请选择">
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="近五年获得院级及以上荣誉情况"
name="honorsFiveYears"
>
<a-textarea
v-model:value="form.honorsFiveYears"
:rows="3"
placeholder="请输入近五年获得院级及以上荣誉情况"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="近三年开展的主要工作及取得的效果"
name="workSummaryThreeYears"
>
<a-textarea
v-model:value="form.workSummaryThreeYears"
:rows="3"
placeholder="请输入近三年开展的主要工作及取得的效果"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在二级团组织意见" name="leagueOpinion">-->
<!-- <a-textarea v-model:value="form.leagueOpinion" :rows="2" placeholder="请输入所在二级团组织意见" />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位党组织意见" name="partyOpinion">-->
<!-- <a-textarea v-model:value="form.partyOpinion" :rows="2" placeholder="请输入所在单位党组织意见" />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="校团委意见" name="schoolOpinion">-->
<!-- <a-textarea v-model:value="form.schoolOpinion" :rows="2" placeholder="请输入校团委意见" />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addWshqtzbForm, updateWshqtzbForm } from '@/api/gxmu/wshqtzb-form';
import type { WshqtzbForm } from '@/api/gxmu/wshqtzb-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: WshqtzbForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const { politicsOptions, loadPoliticsOptions } = useGxmuFormSelectOptions();
const createDefaultForm = (): WshqtzbForm => ({
id: undefined,
year: '',
branchName: '',
secondOrg: '',
secretary: '',
politics: '',
contact: '',
establishTime: '',
lastElectionTime: '',
smartSystemLogin: '',
memberTotal: '',
memberDeveloped2024: '',
feeReceivable2024: '',
feeReceived2024: '',
feePayable2024: '',
feePaid2024: '',
recommendActivist2024: '',
activistConfirmed: '',
recommendDevTarget2024: '',
devTargetConfirmed: '',
branchCommitteeMeetingCount: '',
branchMemberMeetingCount: '',
eduEvalDone: '',
annualRegDone: '',
classCount: '',
smartSystem100: '',
honorsFiveYears: '',
workSummaryThreeYears: '',
leagueOpinion: '',
partyOpinion: '',
schoolOpinion: ''
});
const form = reactive<WshqtzbForm>(createDefaultForm());
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await loadPoliticsOptions();
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value
? updateWshqtzbForm
: addWshqtzbForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,116 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="五四红旗团支部"
file-name="五四红旗团支部导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addWshqtzbForm } from '@/api/gxmu/wshqtzb-form';
import type { WshqtzbForm } from '@/api/gxmu/wshqtzb-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '团支部全称', key: 'branchName', required: true },
{ header: '所属二级党组织', key: 'secondOrg' },
{ header: '团支部书记', key: 'secretary' },
{ header: '政治面貌', key: 'politics' },
{ header: '联系方式', key: 'contact' },
{ header: '成立时间', key: 'establishTime' },
{ header: '最近一次换届时间', key: 'lastElectionTime' },
{ header: '智慧团建系统登录情况', key: 'smartSystemLogin' },
{ header: '现有团员总数', key: 'memberTotal' },
{ header: '上一年度发展团员人数', key: 'memberDeveloped2024' },
{ header: '上一年度应收团费', key: 'feeReceivable2024' },
{ header: '上一年度实收团费', key: 'feeReceived2024' },
{ header: '上一年度应上缴团费', key: 'feePayable2024' },
{ header: '上一年度实际上缴团费', key: 'feePaid2024' },
{ header: '推荐入党积极分子人数', key: 'recommendActivist2024' },
{ header: '确定为入党积极分子数', key: 'activistConfirmed' },
{ header: '推荐党的发展对象人数', key: 'recommendDevTarget2024' },
{ header: '确定为党的发展对象数', key: 'devTargetConfirmed' },
{ header: '支委会次数', key: 'branchCommitteeMeetingCount' },
{ header: '支部大会次数', key: 'branchMemberMeetingCount' },
{ header: '教育评议是否完成', key: 'eduEvalDone' },
{ header: '年度团籍注册是否完成', key: 'annualRegDone' },
{ header: '班级数', key: 'classCount' },
{ header: '智慧团建系统覆盖率', key: 'smartSystem100' },
{ header: '近五年获得校级及以上荣誉情况', key: 'honorsFiveYears' },
{ header: '近三年开展的主要工作及取得的效果', key: 'workSummaryThreeYears' }
] as const;
const sampleRow = {
年份: '2025',
团支部全称: '护理学院2022级1班团支部',
所属二级党组织: '护理学院党委',
团支部书记: '张同学',
政治面貌: '共青团员',
联系方式: '13800138000',
成立时间: '2022-09-01',
最近一次换届时间: '2024-09-01',
智慧团建系统登录情况: '是',
现有团员总数: '38',
上一年度发展团员人数: '12',
上一年度应收团费: '380',
上一年度实收团费: '380',
上一年度应上缴团费: '200',
上一年度实际上缴团费: '200',
推荐入党积极分子人数: '8',
确定为入党积极分子数: '7',
推荐党的发展对象人数: '3',
确定为党的发展对象数: '2',
支委会次数: '12',
支部大会次数: '8',
教育评议是否完成: '是',
年度团籍注册是否完成: '是',
班级数: '1',
智慧团建系统覆盖率: '100%',
近五年获得校级及以上荣誉情况: '校五四红旗团支部',
近三年开展的主要工作及取得的效果: '主题团日活动开展扎实'
};
const createPayload = (row: Record<string, any>): WshqtzbForm => ({
year: String(row['年份'] || '').trim(),
branchName: String(row['团支部全称'] || '').trim(),
secondOrg: String(row['所属二级党组织'] || '').trim(),
secretary: String(row['团支部书记'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
contact: String(row['联系方式'] || '').trim(),
establishTime: String(row['成立时间'] || '').trim(),
lastElectionTime: String(row['最近一次换届时间'] || '').trim(),
smartSystemLogin: String(row['智慧团建系统登录情况'] || '').trim(),
memberTotal: String(row['现有团员总数'] || '').trim(),
memberDeveloped2024: String(row['上一年度发展团员人数'] || '').trim(),
feeReceivable2024: String(row['上一年度应收团费'] || '').trim(),
feeReceived2024: String(row['上一年度实收团费'] || '').trim(),
feePayable2024: String(row['上一年度应上缴团费'] || '').trim(),
feePaid2024: String(row['上一年度实际上缴团费'] || '').trim(),
recommendActivist2024: String(row['推荐入党积极分子人数'] || '').trim(),
activistConfirmed: String(row['确定为入党积极分子数'] || '').trim(),
recommendDevTarget2024: String(row['推荐党的发展对象人数'] || '').trim(),
devTargetConfirmed: String(row['确定为党的发展对象数'] || '').trim(),
branchCommitteeMeetingCount: String(row['支委会次数'] || '').trim(),
branchMemberMeetingCount: String(row['支部大会次数'] || '').trim(),
eduEvalDone: String(row['教育评议是否完成'] || '').trim(),
annualRegDone: String(row['年度团籍注册是否完成'] || '').trim(),
classCount: String(row['班级数'] || '').trim(),
smartSystem100: String(row['智慧团建系统覆盖率'] || '').trim(),
honorsFiveYears: String(row['近五年获得校级及以上荣誉情况'] || '').trim(),
workSummaryThreeYears: String(
row['近三年开展的主要工作及取得的效果'] || ''
).trim()
});
const saveItem = (item: WshqtzbForm) => addWshqtzbForm(item);
</script>
+481
View File
@@ -0,0 +1,481 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 3600 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入团支部/书记/联系方式"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(record)">审核历史</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="五四红旗团支部"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportWshqtzbForm,
userPageWshqtzbForm,
removeWshqtzbForm
} from '@/api/gxmu/wshqtzb-form';
import type {
WshqtzbForm,
WshqtzbFormParam
} from '@/api/gxmu/wshqtzb-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<WshqtzbForm>();
const detailCurrent = ref<WshqtzbForm | null>(null);
const REVIEW_MODULE = 'gxmu_wshqtzb';
const showReviewHistory = ref(false);
const reviewCurrent = ref<WshqtzbForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '团支部全称',
dataIndex: 'branchName',
key: 'branchName',
align: 'center',
width: 180
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '所属二级团组织',
dataIndex: 'secondOrg',
key: 'secondOrg',
align: 'center',
width: 180
},
{
title: '团支部书记',
dataIndex: 'secretary',
key: 'secretary',
align: 'center',
width: 120
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '联系方式',
dataIndex: 'contact',
key: 'contact',
align: 'center',
width: 140
},
{
title: '成立时间',
dataIndex: 'establishTime',
key: 'establishTime',
align: 'center',
width: 120
},
{
title: '最近一次换届时间',
dataIndex: 'lastElectionTime',
key: 'lastElectionTime',
align: 'center',
width: 160
},
{
title: '本级是否已登录“智慧团建”系统',
dataIndex: 'smartSystemLogin',
key: 'smartSystemLogin',
align: 'center',
width: 200
},
{
title: '现有团员总数',
dataIndex: 'memberTotal',
key: 'memberTotal',
align: 'center',
width: 130
},
{
title: '上一年度发展团员数',
dataIndex: 'memberDeveloped2024',
key: 'memberDeveloped2024',
align: 'center',
width: 150
},
{
title: '上一年度应收团费',
dataIndex: 'feeReceivable2024',
key: 'feeReceivable2024',
align: 'center',
width: 130
},
{
title: '上一年度实收团费',
dataIndex: 'feeReceived2024',
key: 'feeReceived2024',
align: 'center',
width: 130
},
{
title: '上一年度应上缴团费',
dataIndex: 'feePayable2024',
key: 'feePayable2024',
align: 'center',
width: 140
},
{
title: '上一年度实际上缴团费',
dataIndex: 'feePaid2024',
key: 'feePaid2024',
align: 'center',
width: 150
},
{
title: '推荐优秀团员作入党积极分子人数(上一年度)',
dataIndex: 'recommendActivist2024',
key: 'recommendActivist2024',
align: 'center',
width: 240
},
{
title: '其中被党组织确定为入党积极分子数',
dataIndex: 'activistConfirmed',
key: 'activistConfirmed',
align: 'center',
width: 220
},
{
title: '推荐优秀团员作党的发展对象人数(上一年度)',
dataIndex: 'recommendDevTarget2024',
key: 'recommendDevTarget2024',
align: 'center',
width: 240
},
{
title: '其中被党组织确定为党的发展对象数',
dataIndex: 'devTargetConfirmed',
key: 'devTargetConfirmed',
align: 'center',
width: 220
},
{
title: '团支部委员会会议召开次数',
dataIndex: 'branchCommitteeMeetingCount',
key: 'branchCommitteeMeetingCount',
align: 'center',
width: 200
},
{
title: '团支部团员大会召开次数',
dataIndex: 'branchMemberMeetingCount',
key: 'branchMemberMeetingCount',
align: 'center',
width: 200
},
{
title: '是否开展团员教育评议',
dataIndex: 'eduEvalDone',
key: 'eduEvalDone',
align: 'center',
width: 170
},
{
title: '是否开展团员年度团籍注册',
dataIndex: 'annualRegDone',
key: 'annualRegDone',
align: 'center',
width: 200
},
{
title: '开展团课次数',
dataIndex: 'classCount',
key: 'classCount',
align: 'center',
width: 130
},
{
title: '是否100%录入“智慧团建”',
dataIndex: 'smartSystem100',
key: 'smartSystem100',
align: 'center',
width: 200
},
{
title: '近五年获得院级及以上荣誉情况',
dataIndex: 'honorsFiveYears',
key: 'honorsFiveYears',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '近三年开展的主要工作及取得的效果',
dataIndex: 'workSummaryThreeYears',
key: 'workSummaryThreeYears',
align: 'center',
width: 260,
ellipsis: true
},
{
title: '所在二级团组织意见',
dataIndex: 'leagueOpinion',
key: 'leagueOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '所在单位党组织意见',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '校团委意见',
dataIndex: 'schoolOpinion',
key: 'schoolOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 220
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageWshqtzbForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WshqtzbFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): WshqtzbFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WshqtzbFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.url || payload?.downloadUrl || payload?.path || ''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportWshqtzbForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: WshqtzbForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as WshqtzbForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: WshqtzbForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: WshqtzbForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: WshqtzbForm) => {
const hide = message.loading('请求中..', 0);
removeWshqtzbForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'WSHQTZBForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
@@ -0,0 +1,706 @@
<template>
<ele-modal
:width="960"
:footer="null"
title="项目申报书文件校验"
:visible="visible"
@update:visible="updateVisible"
>
<a-spin :spinning="loading">
<div class="validator-layout">
<div class="upload-panel">
<div class="validator-intro">
上传项目申报书后,系统将调用项目书质量初审模型,从创新性、可行性、应用前景、文本规范性等维度生成初审报告。
</div>
<div class="upload-label">项目申报书</div>
<a-upload-dragger
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
:file-list="projectFileList"
:before-upload="beforeProjectUpload"
:max-count="1"
@remove="removeProjectFile"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-text">点击或拖拽上传待审项目申报书</p>
<p class="ant-upload-hint">支持 PDF、DOC、DOCX,大小不超过 50MB</p>
</a-upload-dragger>
<div class="upload-label optional-label">自定义评分细则</div>
<a-upload
accept=".pdf,.doc,.docx,.csv,.xls,.xlsx,.txt,.md"
:file-list="rubricFileList"
:before-upload="beforeRubricUpload"
:max-count="1"
@remove="removeRubricFile"
>
<a-button>
<template #icon>
<upload-outlined />
</template>
上传评分细则
</a-button>
</a-upload>
<div class="action-row">
<a-button
type="primary"
:disabled="!projectFile || loading"
@click="startReview"
>
开始校验
</a-button>
<a-button :disabled="!loading" @click="stopReview">停止</a-button>
<a-button :disabled="loading && !reportMarkdown" @click="clearResult">
清空
</a-button>
</div>
</div>
<div class="result-panel">
<div class="result-toolbar">
<div>
<div class="result-title">AI 初审报告</div>
<div class="result-subtitle">
{{ statusText || '报告生成后将在此展示' }}
</div>
</div>
<a-space>
<a-tag :color="loading ? 'processing' : reportMarkdown ? 'success' : 'default'">
{{ loading ? '生成中' : reportMarkdown ? '已完成' : '待开始' }}
</a-tag>
<a-button size="small" :disabled="!reportMarkdown" @click="exportReport">
导出
</a-button>
<a-button
size="small"
:disabled="!reportMarkdown"
@click="fullscreenVisible = true"
>
全屏查看
</a-button>
<a-button size="small" :disabled="!reportMarkdown" @click="copyReport">
复制
</a-button>
</a-space>
</div>
<div ref="reportShellRef" class="report-shell">
<div v-if="loading && !reportMarkdown" class="report-streaming">
AI 正在分析项目申报书...
</div>
<ByteMdViewer
v-else-if="reportMarkdown"
class="report-viewer"
:value="reportMarkdown"
:plugins="plugins"
/>
<a-empty v-else description="请先上传项目申报书并开始校验" />
</div>
</div>
</div>
</a-spin>
</ele-modal>
<ele-modal
:width="'92vw'"
:footer="null"
title="AI 初审报告"
:visible="fullscreenVisible"
@update:visible="fullscreenVisible = $event"
>
<div class="fullscreen-toolbar">
<a-space>
<a-button :disabled="!reportMarkdown" @click="exportReport">
导出
</a-button>
<a-button :disabled="!reportMarkdown" @click="copyReport">
复制
</a-button>
</a-space>
</div>
<div class="fullscreen-report-shell">
<ByteMdViewer
v-if="reportMarkdown"
class="report-viewer"
:value="reportMarkdown"
:plugins="plugins"
/>
<a-empty v-else description="暂无初审报告" />
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import 'github-markdown-css/github-markdown-light.css';
import gfm from '@bytemd/plugin-gfm';
import zhHansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import highlight from '@bytemd/plugin-highlight-ssr';
import 'highlight.js/styles/default.css';
import { computed, ref, watch, onBeforeUnmount, nextTick } from 'vue';
import { message } from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { CloudUploadOutlined, UploadOutlined } from '@ant-design/icons-vue';
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { reviewWxxzxProjectBookQuality } from '@/api/gxmu/wxxzx-form';
import { stopMessage } from '@/api/oa/chatgpt';
import { API_BASE_URL } from '@/config/setting';
import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common';
import type { WxxzxProjectQualityReviewResult } from '@/api/gxmu/wxxzx-form/model';
const STREAM_END_TEXT = '__END__';
const SOCKET_CONNECTED_TEXT = '连接成功';
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
}>();
const loginUser = computed(() => useUserStore().info ?? {});
const plugins = ref([
gfm({
locale: zhHansGfm
}),
highlight()
]);
const loading = ref(false);
const statusText = ref('');
const projectFile = ref<File>();
const rubricFile = ref<File>();
const reportMarkdown = ref('');
const activeTaskId = ref<string>();
const streamStarted = ref(false);
const fullscreenVisible = ref(false);
const reportShellRef = ref<HTMLElement | null>(null);
const webSocketRef = ref<WebSocket | null>(null);
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
const streamResolverRef = ref<{
resolve: () => void;
reject: (error: Error) => void;
} | null>(null);
const projectFileList = computed(() =>
projectFile.value
? [
{
uid: 'project',
name: projectFile.value.name,
status: 'done' as const
}
]
: []
);
const rubricFileList = computed(() =>
rubricFile.value
? [
{
uid: 'rubric',
name: rubricFile.value.name,
status: 'done' as const
}
]
: []
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const beforeProjectUpload: UploadProps['beforeUpload'] = (file) => {
if (file.size / 1024 / 1024 > 50) {
message.error('项目申报书不能超过 50MB');
return false;
}
projectFile.value = file as File;
return false;
};
const beforeRubricUpload: UploadProps['beforeUpload'] = (file) => {
if (file.size / 1024 / 1024 > 20) {
message.error('评分细则不能超过 20MB');
return false;
}
rubricFile.value = file as File;
return false;
};
const removeProjectFile = () => {
projectFile.value = undefined;
return true;
};
const removeRubricFile = () => {
rubricFile.value = undefined;
return true;
};
const scrollReportToBottom = () => {
nextTick(() => {
const el = reportShellRef.value;
if (el) {
el.scrollTop = el.scrollHeight;
}
});
};
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/chat/${userId}`;
};
const clearStreamResolver = () => {
streamResolverRef.value = null;
};
const resolveStream = () => {
streamResolverRef.value?.resolve();
clearStreamResolver();
};
const rejectStream = (error: Error) => {
streamResolverRef.value?.reject(error);
clearStreamResolver();
};
const createStreamWaiter = () => {
streamStarted.value = false;
return new Promise<void>((resolve, reject) => {
streamResolverRef.value = { resolve, reject };
});
};
const disconnectWebSocket = () => {
wsConnectPromiseRef.value = null;
const socket = webSocketRef.value;
webSocketRef.value = null;
if (
socket &&
(socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING)
) {
socket.close();
}
};
const pickReportText = (payload: any): string => {
if (!payload) {
return '';
}
if (typeof payload === 'string') {
return payload;
}
const outputs = payload.outputs || payload.data?.outputs || payload.raw?.data?.outputs;
const direct =
payload.answer ||
payload.result ||
payload.output ||
payload.content ||
outputs?.answer ||
outputs?.result ||
outputs?.output ||
outputs?.content ||
outputs?.report ||
outputs?.markdown;
if (typeof direct === 'string') {
return direct;
}
if (outputs) {
const firstText = Object.values(outputs).find(
(item) => typeof item === 'string' && item.trim()
);
if (typeof firstText === 'string') {
return firstText;
}
}
return '';
};
const appendReportChunk = (chunk: string, taskId?: string) => {
if (!chunk) {
return;
}
if (taskId && taskId !== '0') {
activeTaskId.value = taskId;
}
if (!streamStarted.value) {
streamStarted.value = true;
reportMarkdown.value = '';
}
reportMarkdown.value += chunk;
statusText.value = '正在通过 WebSocket 接收流式报告';
scrollReportToBottom();
};
const finishStream = (taskId?: string) => {
if (taskId && taskId !== '0') {
activeTaskId.value = taskId;
}
loading.value = false;
statusText.value = '项目书质量初审完成';
scrollReportToBottom();
resolveStream();
disconnectWebSocket();
};
const handleSocketMessage = (raw: string) => {
if (!raw) {
return;
}
try {
const data = JSON.parse(raw);
if (data?.stopType && data.stopType !== 'projectQualityReview') {
return;
}
if (data?.answer === STREAM_END_TEXT) {
finishStream(data?.taskId);
return;
}
if (data?.taskId && data.taskId !== '0') {
activeTaskId.value = data.taskId;
}
if (data?.loading === 'true' || data?.loading === true) {
statusText.value = 'AI 正在检索历史案例并组织评审意见...';
return;
}
if (typeof data?.answer === 'string') {
appendReportChunk(data.answer, data?.taskId);
}
} catch (e) {
appendReportChunk(raw);
}
};
const connectWebSocket = async () => {
const userId = loginUser.value.userId;
if (!userId) {
return Promise.reject(new Error('未获取到当前登录用户'));
}
const currentSocket = webSocketRef.value;
if (currentSocket?.readyState === WebSocket.OPEN) {
return;
}
if (
(currentSocket?.readyState === WebSocket.OPEN ||
currentSocket?.readyState === WebSocket.CONNECTING) &&
wsConnectPromiseRef.value
) {
return wsConnectPromiseRef.value;
}
let cleanupPending = () => undefined;
const connectPromise = new Promise<void>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(buildWebSocketUrl(userId));
cleanupPending = () => {
if (wsConnectPromiseRef.value === connectPromise) {
wsConnectPromiseRef.value = null;
}
};
socket.onmessage = (event) => {
const raw = String(event.data || '');
if (raw === SOCKET_CONNECTED_TEXT) {
if (!settled) {
settled = true;
cleanupPending();
resolve();
}
return;
}
handleSocketMessage(raw);
};
socket.onclose = () => {
if (webSocketRef.value === socket) {
webSocketRef.value = null;
}
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('项目书质量初审连接已关闭'));
return;
}
if (loading.value) {
rejectStream(new Error('项目书质量初审连接已关闭'));
loading.value = false;
}
cleanupPending();
};
socket.onerror = () => {
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('项目书质量初审连接失败'));
return;
}
if (loading.value) {
rejectStream(new Error('项目书质量初审连接失败'));
loading.value = false;
}
};
webSocketRef.value = socket;
});
wsConnectPromiseRef.value = connectPromise;
return connectPromise;
};
const clearResult = () => {
reportMarkdown.value = '';
statusText.value = '';
activeTaskId.value = undefined;
streamStarted.value = false;
};
const startReview = async () => {
if (!projectFile.value) {
message.warning('请先上传项目申报书');
return;
}
loading.value = true;
clearResult();
statusText.value = '正在建立 WebSocket 连接...';
try {
await connectWebSocket();
statusText.value = '连接成功,已提交项目书质量初审任务...';
const waitStream = createStreamWaiter();
const result = (await reviewWxxzxProjectBookQuality(
projectFile.value,
rubricFile.value
)) as WxxzxProjectQualityReviewResult;
const directReport = pickReportText(result);
if (!streamStarted.value && directReport) {
reportMarkdown.value = directReport;
loading.value = false;
statusText.value = '项目书质量初审完成';
scrollReportToBottom();
resolveStream();
disconnectWebSocket();
} else {
await waitStream;
}
if (!reportMarkdown.value.trim()) {
message.warning('接口已返回,但未解析到初审报告内容');
return;
}
message.success('项目书质量初审完成');
} catch (e: any) {
disconnectWebSocket();
clearStreamResolver();
loading.value = false;
statusText.value = '';
message.error(e.message || '项目书质量初审失败');
}
};
const stopReview = async () => {
try {
await stopMessage({
taskId: activeTaskId.value,
mode: 'projectQualityReview' as any
});
loading.value = false;
statusText.value = '已停止项目书质量初审';
resolveStream();
disconnectWebSocket();
message.success('已停止');
} catch (e: any) {
message.error(e.message || '停止失败');
}
};
const copyReport = () => {
if (!reportMarkdown.value.trim()) {
message.warning('暂无可复制内容');
return;
}
copyText(reportMarkdown.value);
};
const exportReport = () => {
if (!reportMarkdown.value.trim()) {
message.warning('暂无可导出内容');
return;
}
const baseName =
projectFile.value?.name?.replace(/\.[^.]+$/, '') || '项目书质量初审报告';
const safeName = baseName.replace(/[\\/:*?"<>|]/g, '_');
const blob = new Blob([reportMarkdown.value], {
type: 'text/markdown;charset=utf-8'
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${safeName}-AI初审报告.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('导出成功');
};
watch(
() => props.visible,
(value) => {
if (value) {
clearResult();
} else {
disconnectWebSocket();
clearStreamResolver();
loading.value = false;
}
}
);
onBeforeUnmount(() => {
disconnectWebSocket();
clearStreamResolver();
});
</script>
<style lang="less" scoped>
.validator-layout {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
align-items: start;
gap: 20px;
max-height: calc(100vh - 220px);
overflow: auto;
}
.upload-panel {
min-width: 0;
align-self: start;
}
.validator-intro {
margin-bottom: 16px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.7;
}
.upload-label {
margin: 14px 0 8px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.optional-label {
margin-top: 16px;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
}
.result-panel {
min-width: 0;
align-self: start;
border: 1px solid #edf0f5;
border-radius: 8px;
background: #fff;
}
.result-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid #edf0f5;
}
.result-title {
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.result-subtitle {
margin-top: 4px;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
.report-shell {
height: 360px;
overflow: auto;
padding: 18px;
}
.report-streaming {
color: rgba(0, 0, 0, 0.65);
line-height: 1.8;
white-space: pre-wrap;
}
.report-viewer :deep(.markdown-body) {
font-size: 14px;
line-height: 1.8;
}
.fullscreen-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.fullscreen-report-shell {
height: calc(100vh - 210px);
overflow: auto;
padding: 20px 24px;
border: 1px solid #edf0f5;
border-radius: 8px;
background: #fff;
}
.fullscreen-report-shell :deep(.markdown-body) {
max-width: 980px;
margin: 0 auto;
font-size: 15px;
line-height: 1.9;
}
@media (max-width: 860px) {
.validator-layout {
grid-template-columns: 1fr;
max-height: calc(100vh - 180px);
}
.report-shell {
height: 320px;
}
}
:deep(.ant-upload.ant-upload-drag) {
height: 180px;
}
:deep(.ant-upload.ant-upload-drag .ant-upload) {
padding: 24px 16px;
}
:deep(.ant-upload.ant-upload-drag p.ant-upload-drag-icon) {
margin-bottom: 10px;
}
:deep(.ant-upload.ant-upload-drag p.ant-upload-text) {
margin-bottom: 6px;
font-size: 15px;
}
:deep(.ant-upload-list) {
max-height: 74px;
overflow: auto;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="未来学术之星"
file-name="未来学术之星导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:import-description="description"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addWxxzxForm } from '@/api/gxmu/wxxzx-form';
import type { WxxzxForm } from '@/api/gxmu/wxxzx-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const description =
'本模板仅导入未来学术之星主表字段。指导教师、申请人、预算、评审成员、过程材料与归档记录请在详情中继续维护。';
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '课题名称', key: 'topicName', required: true },
{ header: '课题负责人', key: 'leader' },
{ header: '学院、年级、班别', key: 'collegeGradeClass' },
{ header: '联系电话', key: 'phone' },
{ header: '指导老师', key: 'advisor' },
{ header: '课题类别', key: 'topicType' },
{ header: '立项依据', key: 'rationale' },
{ header: '指导教师意见', key: 'teacherOpinion' },
{ header: '学院意见', key: 'collegeOpinion' },
{ header: '学校意见', key: 'schoolOpinion' },
{ header: '承诺签字人', key: 'promiseSigner' },
{ header: '评审名单-学院', key: 'reviewCollege' },
{ header: '评审名单-汇报人', key: 'reviewReporter' },
{ header: '评审名单-日期', key: 'reviewDate' }
] as const;
const sampleRow = {
年份: '2025',
课题名称: '医学生科研素养提升路径研究',
课题负责人: '周八',
'学院、年级、班别': '第一临床医学院 2022级 1班',
联系电话: '13800138000',
指导老师: '李教授',
课题类别: '重点课题',
立项依据: '围绕医学生科研训练现状展开研究',
指导教师意见: '',
学院意见: '',
学校意见: '',
承诺签字人: '周八',
'评审名单-学院': '',
'评审名单-汇报人': '',
'评审名单-日期': ''
};
const createPayload = (row: Record<string, any>): WxxzxForm => ({
year: String(row['年份'] || '').trim(),
topicName: String(row['课题名称'] || '').trim(),
leader: String(row['课题负责人'] || '').trim(),
collegeGradeClass: String(row['学院、年级、班别'] || '').trim(),
phone: String(row['联系电话'] || '').trim(),
advisor: String(row['指导老师'] || '').trim(),
topicType: String(row['课题类别'] || '').trim(),
rationale: String(row['立项依据'] || '').trim(),
teacherOpinion: String(row['指导教师意见'] || '').trim(),
collegeOpinion: String(row['学院意见'] || '').trim(),
schoolOpinion: String(row['学校意见'] || '').trim(),
promiseSigner: String(row['承诺签字人'] || '').trim(),
reviewCollege: String(row['评审名单-学院'] || '').trim(),
reviewReporter: String(row['评审名单-汇报人'] || '').trim(),
reviewDate: String(row['评审名单-日期'] || '').trim(),
guidanceTeachers: [],
applicants: [],
budgets: [],
reviewMembers: [],
archiveRecords: []
});
const saveItem = (item: WxxzxForm) => addWxxzxForm(item);
</script>
@@ -0,0 +1,383 @@
<template>
<a-drawer
placement="right"
width="760"
:visible="visible"
title="项目过程材料"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<div class="material-header">
<div class="material-title">{{ props.data?.topicName || '未命名课题' }}</div>
<div class="material-subtitle">
负责人:{{ props.data?.leader || '-' }} | 指导老师:{{ props.data?.advisor || '-' }}
</div>
</div>
<a-tabs v-model:activeKey="activeKey" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="funding" tab="经费管理">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="请分别上传经费预算表和经费划拨明细表,支持覆盖更新。"
/>
<a-form layout="vertical" class="stage-form">
<a-form-item label="经费预算表">
<stage-file-upload
v-model:value="form.fundingBudgetFile"
button-text="上传经费预算表"
:multiple="false"
/>
</a-form-item>
<a-form-item label="经费划拨明细表">
<stage-file-upload
v-model:value="form.fundingAllocationFile"
button-text="上传经费划拨明细表"
:multiple="false"
/>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane key="midterm" tab="中期检查">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="学生团队可在此提交中期检查所需的阶段性材料。"
/>
<a-form layout="vertical" class="stage-form">
<a-form-item label="补充说明">
<a-textarea
v-model:value="form.midtermRemark"
:rows="4"
placeholder="请输入中期检查说明,如材料清单、阶段进展等"
/>
</a-form-item>
<a-form-item label="中期检查材料">
<stage-file-upload
v-model:value="form.midtermMaterials"
button-text="上传中期检查材料"
:multiple="true"
/>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane key="final" tab="结题验收">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="学生团队可上传结题验收阶段的最终材料。"
/>
<a-form layout="vertical" class="stage-form">
<a-form-item label="补充说明">
<a-textarea
v-model:value="form.finalRemark"
:rows="4"
placeholder="请输入结题说明,如成果概述、材料目录等"
/>
</a-form-item>
<a-form-item label="结题材料">
<stage-file-upload
v-model:value="form.finalMaterials"
button-text="上传结题材料"
:multiple="true"
/>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane key="archive" tab="成果归档">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="请以表格形式维护成果归档,可添加多条获奖记录并分别上传凭证。"
/>
<div class="archive-toolbar">
<a-button type="dashed" @click="addArchiveRecord">新增一条</a-button>
</div>
<a-table
row-key="__rowKey"
:columns="archiveColumns"
:data-source="form.archiveRecords"
:pagination="false"
:scroll="{ x: 980 }"
class="archive-table"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'awardName'">
<a-input
v-model:value="form.archiveRecords[index].awardName"
placeholder="请输入获奖名称"
/>
</template>
<template v-else-if="column.key === 'awardTime'">
<a-date-picker
v-model:value="form.archiveRecords[index].awardTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择获奖时间"
/>
</template>
<template v-else-if="column.key === 'remark'">
<a-textarea
v-model:value="form.archiveRecords[index].remark"
:rows="2"
placeholder="请输入备注说明"
/>
</template>
<template v-else-if="column.key === 'proofFile'">
<stage-file-upload
v-model:value="form.archiveRecords[index].proofFile"
button-text="上传凭证"
:multiple="false"
:limit="1"
/>
</template>
<template v-else-if="column.key === 'action'">
<a class="ele-text-danger" @click="removeArchiveRecord(index)">
删除
</a>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { updateWxxzxMaterials } from '@/api/gxmu/wxxzx-form';
import type {
WxxzxArchiveRecord,
WxxzxForm
} from '@/api/gxmu/wxxzx-form/model';
import StageFileUpload from './stageFileUpload.vue';
type MaterialStageKey = 'funding' | 'midterm' | 'final' | 'archive';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = withDefaults(
defineProps<{
visible: boolean;
data?: WxxzxForm | null;
initialStage?: MaterialStageKey;
}>(),
{
initialStage: 'funding'
}
);
const loading = ref(false);
const activeKey = ref<MaterialStageKey>('funding');
const createArchiveRecord = (): WxxzxArchiveRecord & { __rowKey: string } => ({
id: undefined,
year: '',
awardName: '',
awardTime: '',
remark: '',
proofFile: '',
__rowKey: `${Date.now()}-${Math.random().toString(16).slice(2)}`
});
const normalizeArchiveRecord = (record?: WxxzxArchiveRecord | null) => ({
...createArchiveRecord(),
...record,
__rowKey:
(record as any)?.__rowKey ||
`${Date.now()}-${Math.random().toString(16).slice(2)}`
});
const archiveColumns = [
{
title: '获奖名称',
dataIndex: 'awardName',
key: 'awardName',
width: 180
},
{
title: '获奖时间',
dataIndex: 'awardTime',
key: 'awardTime',
width: 160
},
{
title: '备注说明',
dataIndex: 'remark',
key: 'remark',
width: 280
},
{
title: '上传凭证',
dataIndex: 'proofFile',
key: 'proofFile',
width: 280
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right'
}
];
const createDefaultForm = () => ({
fundingBudgetFile: '',
fundingAllocationFile: '',
midtermRemark: '',
midtermMaterials: '',
finalRemark: '',
finalMaterials: '',
archiveAwards: '',
archiveProofs: '',
archiveRecords: [createArchiveRecord()]
});
const form = reactive(createDefaultForm());
watch(
() => props.visible,
(visible) => {
if (visible) {
Object.assign(form, createDefaultForm(), {
fundingBudgetFile: props.data?.fundingBudgetFile || '',
fundingAllocationFile: props.data?.fundingAllocationFile || '',
midtermRemark: props.data?.midtermRemark || '',
midtermMaterials: props.data?.midtermMaterials || '',
finalRemark: props.data?.finalRemark || '',
finalMaterials: props.data?.finalMaterials || '',
archiveAwards: props.data?.archiveAwards || '',
archiveProofs: props.data?.archiveProofs || '',
archiveRecords:
props.data?.archiveRecords?.length
? props.data.archiveRecords.map((item) =>
normalizeArchiveRecord(item)
)
: [createArchiveRecord()]
});
activeKey.value = props.initialStage;
} else {
Object.assign(form, createDefaultForm());
}
},
{ immediate: true }
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
if (!props.data?.id) {
message.error('请先保存课题申报信息');
return;
}
loading.value = true;
updateWxxzxMaterials(props.data.id, {
...form,
archiveRecords: form.archiveRecords
.map(({ __rowKey, ...item }: any) => item)
.filter(
(item) =>
item.awardName ||
item.awardTime ||
item.remark ||
item.proofFile
)
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
const addArchiveRecord = () => {
form.archiveRecords.push(createArchiveRecord());
};
const removeArchiveRecord = (index: number) => {
form.archiveRecords.splice(index, 1);
if (!form.archiveRecords.length) {
form.archiveRecords.push(createArchiveRecord());
}
};
</script>
<style lang="less" scoped>
@import '../../tag-tabs.less';
.gxmu-tag-tabs();
.material-header {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: linear-gradient(135deg, #f7fbff 0%, #edf5ff 100%);
}
.material-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.material-subtitle {
margin-top: 8px;
color: rgba(0, 0, 0, 0.65);
}
.stage-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
background: #fff;
}
.stage-form {
margin-top: 16px;
}
.archive-toolbar {
display: flex;
justify-content: flex-end;
margin: 16px 0 12px;
}
.archive-table :deep(.ant-table-cell) {
vertical-align: top;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
</style>
@@ -0,0 +1,224 @@
<template>
<div class="stage-file-upload">
<a-upload
:accept="accept"
:multiple="multiple"
:showUploadList="false"
:customRequest="handleUpload"
>
<a-button class="ele-btn-icon">
<template #icon>
<UploadOutlined />
</template>
<span>{{ buttonText }}</span>
</a-button>
</a-upload>
<div v-if="fileItems.length" class="file-list">
<div
v-for="(item, index) in fileItems"
:key="`${item.url}-${index}`"
class="file-item"
>
<a :href="item.url" target="_blank" rel="noreferrer">
{{ item.name || getFileName(item.url) }}
</a>
<a class="file-remove" @click="removeFile(index)">删除</a>
</div>
</div>
<div v-else class="empty-text">{{ placeholder }}</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { uploadFile } from '@/api/system/file';
interface StageUploadFileItem {
name: string;
url: string;
}
const emit = defineEmits<{
(e: 'update:value', value: string): void;
}>();
const props = withDefaults(
defineProps<{
value?: string;
multiple?: boolean;
accept?: string;
buttonText?: string;
placeholder?: string;
limit?: number;
}>(),
{
value: '',
multiple: false,
accept:
'.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar,.jpg,.jpeg,.png',
buttonText: '上传文件',
placeholder: '暂未上传材料',
limit: 20
}
);
const fileItems = ref<StageUploadFileItem[]>([]);
const getFileName = (url?: string) => {
if (!url) {
return '未命名文件';
}
const cleanUrl = url.split('?')[0];
const fileName = cleanUrl.split('/').pop() || cleanUrl;
return decodeURIComponent(fileName);
};
const normalizeItem = (item: unknown) => {
if (typeof item === 'string') {
const url = item.trim();
if (!url) {
return null;
}
return {
name: getFileName(url),
url
};
}
if (
item &&
typeof item === 'object' &&
typeof (item as StageUploadFileItem).url === 'string'
) {
const url = (item as StageUploadFileItem).url.trim();
if (!url) {
return null;
}
return {
name:
(item as StageUploadFileItem).name?.trim() || getFileName(url),
url
};
}
return null;
};
const parseValue = (value?: string) => {
const text = value?.trim();
if (!text) {
return [];
}
if (!props.multiple) {
return normalizeItem(text) ? [normalizeItem(text)!] : [];
}
try {
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) {
return normalizeItem(text) ? [normalizeItem(text)!] : [];
}
return parsed
.map((item) => normalizeItem(item))
.filter((item): item is StageUploadFileItem => !!item);
} catch (e) {
return normalizeItem(text) ? [normalizeItem(text)!] : [];
}
};
const emitValue = (list: StageUploadFileItem[]) => {
if (!props.multiple) {
emit('update:value', list[0]?.url || '');
return;
}
emit('update:value', list.length ? JSON.stringify(list) : '');
};
const syncFileItems = (value?: string) => {
fileItems.value = parseValue(value);
};
watch(
() => props.value,
(value) => {
syncFileItems(value);
},
{ immediate: true }
);
const handleUpload = async (options: any) => {
const rawFile = options.file as File;
if (rawFile.size / 1024 / 1024 > 100) {
message.error('大小不能超过 100MB');
options.onError?.(new Error('文件过大'));
return;
}
if (props.multiple && fileItems.value.length >= props.limit) {
message.warning(`最多上传 ${props.limit} 个文件`);
options.onError?.(new Error('超出上传数量限制'));
return;
}
const hide = message.loading('上传中..', 0);
try {
const result = await uploadFile(rawFile);
const nextItem = {
name: result.name || rawFile.name,
url: String(result.url || result.downloadUrl || result.path || '')
};
const nextList = props.multiple ? [...fileItems.value, nextItem] : [nextItem];
fileItems.value = nextList;
emitValue(nextList);
message.success('上传成功');
options.onSuccess?.(result);
} catch (e: any) {
message.error(e.message || '上传失败');
options.onError?.(e);
} finally {
hide();
}
};
const removeFile = (index: number) => {
const nextList = fileItems.value.filter((_, currentIndex) => currentIndex !== index);
fileItems.value = nextList;
emitValue(nextList);
};
</script>
<style lang="less" scoped>
.file-list {
margin-top: 12px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fafafa;
}
.file-item + .file-item {
margin-top: 8px;
}
.file-item a:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-remove {
color: #ff4d4f;
flex-shrink: 0;
}
.empty-text {
margin-top: 8px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+378
View File
@@ -0,0 +1,378 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openDocValidate"
>
<template #icon>
<FileSearchOutlined />
</template>
<span>文件校验</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入课题名称/负责人/学院"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<div class="action-links">
<a @click="openDetail(record)">详情</a>
<a v-if="!shouldHideEditByReviewList(record.reviewList)" @click="openEdit(record)">编辑</a>
<a @click="openMaterial(record, 'funding')">经费管理</a>
<a @click="openMaterial(record, 'midterm')">中期检查</a>
<a @click="openMaterial(record, 'final')">结题验收</a>
<a @click="openMaterial(record, 'archive')">成果归档</a>
<a @click="openReviewHistory(record)">审核历史</a>
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</div>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<materials-modal
v-model:visible="showMaterials"
:data="materialCurrent"
:initial-stage="materialStage"
@done="reload"
/>
<doc-validate-modal v-model:visible="showDocValidate" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="未来学术之星"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined,
FileSearchOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import DocValidateModal from './components/docValidate.vue';
import MaterialsModal from './components/materials.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportWxxzxForm,
getWxxzxForm,
userPageWxxzxForm,
removeWxxzxForm
} from '@/api/gxmu/wxxzx-form';
import type { WxxzxForm, WxxzxFormParam } from '@/api/gxmu/wxxzx-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const current = ref<WxxzxForm>();
const showDetail = ref(false);
const detailCurrent = ref<WxxzxForm | null>(null);
const showMaterials = ref(false);
const materialCurrent = ref<WxxzxForm>();
const materialStage = ref<'funding' | 'midterm' | 'final' | 'archive'>(
'funding'
);
const showDocValidate = ref(false);
const REVIEW_MODULE = 'gxmu_wxxzx_project';
const showReviewHistory = ref(false);
const reviewCurrent = ref<WxxzxForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '课题名称',
dataIndex: 'topicName',
key: 'topicName',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '课题负责人',
dataIndex: 'leader',
key: 'leader',
align: 'center',
width: 120
},
{
title: '学院、年级、班别',
dataIndex: 'collegeGradeClass',
key: 'collegeGradeClass',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140
},
{
title: '指导老师',
dataIndex: 'advisor',
key: 'advisor',
align: 'center',
width: 140
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 420
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageWxxzxForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WxxzxFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const buildQueryParams = (): WxxzxFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WxxzxFormParam;
};
const openImport = () => {
showImport.value = true;
};
const openDocValidate = () => {
showDocValidate.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(payload?.url || payload?.downloadUrl || payload?.path || '');
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportWxxzxForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: WxxzxForm) => {
if (!record?.id) {
current.value = undefined;
showEdit.value = true;
return;
}
const hide = message.loading('加载中..', 0);
getWxxzxForm(record.id)
.then((data) => {
hide();
current.value = JSON.parse(JSON.stringify(data)) as WxxzxForm;
showEdit.value = true;
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const openReviewHistory = (record: WxxzxForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: WxxzxForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const openMaterial = (
record: WxxzxForm,
stage: 'funding' | 'midterm' | 'final' | 'archive'
) => {
if (!record.id) {
message.error('缺少课题记录ID');
return;
}
materialStage.value = stage;
const hide = message.loading('加载中..', 0);
getWxxzxForm(record.id)
.then((data) => {
hide();
materialCurrent.value = JSON.parse(JSON.stringify(data)) as WxxzxForm;
showMaterials.value = true;
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const remove = (record: WxxzxForm) => {
const hide = message.loading('请求中..', 0);
removeWxxzxForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'WXXZXForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
.action-links {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px 12px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,318 @@
<template>
<a-modal
:width="900"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="性别" name="gender">
<a-select
v-model:value="form.gender"
:options="genderOptions"
placeholder="请选择性别"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="民族" name="nation">
<a-select
allow-clear
show-search
v-model:value="form.nation"
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="出生年月" name="birthMonth">
<a-date-picker
v-model:value="form.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="政治面貌" name="politics">
<a-select
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="职务" name="position">
<a-input v-model:value="form.position" placeholder="请输入职务" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="身份" name="identity">
<a-input v-model:value="form.identity" placeholder="请输入身份" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所在团组织" name="organization">
<a-input
v-model:value="form.organization"
placeholder="请输入所在团组织"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="联系方式" name="contact">
<a-input
v-model:value="form.contact"
placeholder="请输入联系方式"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="发展团员编号" name="memberNo">
<a-input
v-model:value="form.memberNo"
placeholder="请输入发展团员编号"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="任现团内职务时间" name="currentDutyTime">
<a-date-picker
v-model:value="form.currentDutyTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择任现团内职务时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="担任团干部年限" name="cadreYears">
<a-input
v-model:value="form.cadreYears"
placeholder="请输入担任团干部年限"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度工作考核结果" name="assessment2024">
<a-input
v-model:value="form.assessment2024"
placeholder="请输入上一年度工作考核结果"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="成为注册志愿者时间" name="volunteerRegTime">
<a-date-picker
v-model:value="form.volunteerRegTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择成为注册志愿者时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="从事团干部经历" name="cadreExperience">
<a-textarea
v-model:value="form.cadreExperience"
:rows="3"
placeholder="请输入从事团干部经历"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="近五年获得校级及以上荣誉情况"
name="honorsFiveYears"
>
<a-textarea
v-model:value="form.honorsFiveYears"
:rows="3"
placeholder="请输入近五年获得校级及以上荣誉情况"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="主要事迹" name="mainStory">
<a-textarea
v-model:value="form.mainStory"
:rows="3"
placeholder="请输入主要事迹"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位团组织意见" name="leagueOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.leagueOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在单位团组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位党组织意见" name="partyOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.partyOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在单位党组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import {
addYxgqtdgbForm,
updateYxgqtdgbForm
} from '@/api/gxmu/yxgqtdgb-form';
import type { YxgqtdgbForm } from '@/api/gxmu/yxgqtdgb-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: YxgqtdgbForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const { nationOptions, politicsOptions, genderOptions, loadNationOptions, loadPoliticsOptions } =
useGxmuFormSelectOptions();
const createDefaultForm = (): YxgqtdgbForm => ({
id: undefined,
year: '',
name: '',
gender: '',
nation: '',
birthMonth: '',
politics: '',
position: '',
identity: '',
organization: '',
contact: '',
memberNo: '',
currentDutyTime: '',
cadreYears: '',
assessment2024: '',
volunteerRegTime: '',
cadreExperience: '',
honorsFiveYears: '',
mainStory: '',
leagueOpinion: '',
partyOpinion: ''
});
const form = reactive<YxgqtdgbForm>(createDefaultForm());
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await Promise.all([loadNationOptions(), loadPoliticsOptions()]);
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value
? updateYxgqtdgbForm
: addYxgqtdgbForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,93 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="优秀共青团干部"
file-name="优秀共青团干部导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addYxgqtdgbForm } from '@/api/gxmu/yxgqtdgb-form';
import type { YxgqtdgbForm } from '@/api/gxmu/yxgqtdgb-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '姓名', key: 'name', required: true },
{ header: '性别', key: 'gender' },
{ header: '民族', key: 'nation' },
{ header: '出生年月', key: 'birthMonth' },
{ header: '政治面貌', key: 'politics' },
{ header: '职务', key: 'position' },
{ header: '身份类别', key: 'identity' },
{ header: '所在团组织', key: 'organization' },
{ header: '联系方式', key: 'contact' },
{ header: '团员编号', key: 'memberNo' },
{ header: '现任职务时间', key: 'currentDutyTime' },
{ header: '从事团的工作年限', key: 'cadreYears' },
{ header: '2024年度考核等次', key: 'assessment2024' },
{ header: '志愿汇注册时间', key: 'volunteerRegTime' },
{ header: '团干部经历', key: 'cadreExperience' },
{ header: '近五年获得校级及以上荣誉情况', key: 'honorsFiveYears' },
{ header: '主要事迹', key: 'mainStory' },
{ header: '二级团组织意见', key: 'leagueOpinion' },
{ header: '二级党组织意见', key: 'partyOpinion' }
] as const;
const sampleRow = {
年份: '2025',
姓名: '王五',
性别: '男',
民族: '汉族',
出生年月: '1999-04',
政治面貌: '中共党员',
职务: '团委副书记',
身份类别: '学生',
所在团组织: '第一临床医学院团委',
联系方式: '13800138000',
团员编号: 'TW2025001',
现任职务时间: '2023-09',
从事团的工作年限: '2',
'2024年度考核等次': '优秀',
志愿汇注册时间: '2021-03',
团干部经历: '连续两年担任学院团学骨干',
近五年获得校级及以上荣誉情况: '校优秀团干部',
主要事迹: '组织多场品牌志愿服务活动',
二级团组织意见: '',
二级党组织意见: ''
};
const createPayload = (row: Record<string, any>): YxgqtdgbForm => ({
year: String(row['年份'] || '').trim(),
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
nation: String(row['民族'] || '').trim(),
birthMonth: String(row['出生年月'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
position: String(row['职务'] || '').trim(),
identity: String(row['身份类别'] || '').trim(),
organization: String(row['所在团组织'] || '').trim(),
contact: String(row['联系方式'] || '').trim(),
memberNo: String(row['团员编号'] || '').trim(),
currentDutyTime: String(row['现任职务时间'] || '').trim(),
cadreYears: String(row['从事团的工作年限'] || '').trim(),
assessment2024: String(row['2024年度考核等次'] || '').trim(),
volunteerRegTime: String(row['志愿汇注册时间'] || '').trim(),
cadreExperience: String(row['团干部经历'] || '').trim(),
honorsFiveYears: String(row['近五年获得校级及以上荣誉情况'] || '').trim(),
mainStory: String(row['主要事迹'] || '').trim(),
leagueOpinion: String(row['二级团组织意见'] || '').trim(),
partyOpinion: String(row['二级党组织意见'] || '').trim()
});
const saveItem = (item: YxgqtdgbForm) => addYxgqtdgbForm(item);
</script>
+412
View File
@@ -0,0 +1,412 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 3000 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/所在团组织/联系方式"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(record)">审核历史</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="优秀共青团干部"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportYxgqtdgbForm,
userPageYxgqtdgbForm,
removeYxgqtdgbForm
} from '@/api/gxmu/yxgqtdgb-form';
import type {
YxgqtdgbForm,
YxgqtdgbFormParam
} from '@/api/gxmu/yxgqtdgb-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const current = ref<YxgqtdgbForm>();
const showDetail = ref(false);
const detailCurrent = ref<YxgqtdgbForm | null>(null);
const REVIEW_MODULE = 'gxmu_yxgqtdgb';
const showReviewHistory = ref(false);
const reviewCurrent = ref<YxgqtdgbForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 100
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 100
},
{
title: '出生年月',
dataIndex: 'birthMonth',
key: 'birthMonth',
align: 'center',
width: 120
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '职务',
dataIndex: 'position',
key: 'position',
align: 'center',
width: 120
},
{
title: '身份',
dataIndex: 'identity',
key: 'identity',
align: 'center',
width: 120
},
{
title: '所在团组织',
dataIndex: 'organization',
key: 'organization',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '联系方式',
dataIndex: 'contact',
key: 'contact',
align: 'center',
width: 140
},
{
title: '发展团员编号',
dataIndex: 'memberNo',
key: 'memberNo',
align: 'center',
width: 160
},
{
title: '任现团内职务时间',
dataIndex: 'currentDutyTime',
key: 'currentDutyTime',
align: 'center',
width: 160
},
{
title: '担任团干部年限',
dataIndex: 'cadreYears',
key: 'cadreYears',
align: 'center',
width: 140
},
{
title: '上一年度工作考核结果',
dataIndex: 'assessment2024',
key: 'assessment2024',
align: 'center',
width: 180
},
{
title: '成为注册志愿者时间',
dataIndex: 'volunteerRegTime',
key: 'volunteerRegTime',
align: 'center',
width: 180
},
{
title: '从事团干部经历',
dataIndex: 'cadreExperience',
key: 'cadreExperience',
align: 'center',
width: 260,
ellipsis: true
},
{
title: '近五年获得校级及以上荣誉情况',
dataIndex: 'honorsFiveYears',
key: 'honorsFiveYears',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '主要事迹',
dataIndex: 'mainStory',
key: 'mainStory',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '所在单位团组织意见',
dataIndex: 'leagueOpinion',
key: 'leagueOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '所在单位党组织意见',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 220
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageYxgqtdgbForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as YxgqtdgbFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): YxgqtdgbFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as YxgqtdgbFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.url || payload?.downloadUrl || payload?.path || ''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportYxgqtdgbForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: YxgqtdgbForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as YxgqtdgbForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: YxgqtdgbForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: YxgqtdgbForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: YxgqtdgbForm) => {
const hide = message.loading('请求中..', 0);
removeYxgqtdgbForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'YXGQTDGBForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
@@ -0,0 +1,330 @@
<template>
<a-modal
:width="980"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="性别" name="gender">
<a-select
v-model:value="form.gender"
:options="genderOptions"
placeholder="请选择性别"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="民族" name="nation">
<a-select
allow-clear
show-search
v-model:value="form.nation"
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="政治面貌" name="politics">
<a-select
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="出生年月" name="birthMonth">
<a-date-picker
v-model:value="form.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="入团时间" name="joinTime">
<a-date-picker
v-model:value="form.joinTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择入团时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所在学院、专业、班级" name="collegeMajorClass">
<class-cascader
v-model:value="form.collegeMajorClass"
mode="collegeClass"
placeholder="请先选择学院,再选择班级"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="职务" name="position">
<a-input v-model:value="form.position" placeholder="请输入职务" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="成为注册志愿者时间" name="volunteerRegTime">
<a-date-picker
v-model:value="form.volunteerRegTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择成为注册志愿者时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度团员教育评议等次" name="eduEval2024">
<a-input
v-model:value="form.eduEval2024"
placeholder="请输入上一年度团员教育评议等次"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否已登录智慧团建系统" name="smartSystem">
<a-select v-model:value="form.smartSystem" placeholder="请选择">
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="联系电话" name="contact">
<a-input
v-model:value="form.contact"
placeholder="请输入联系电话"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="累计志愿服务时长" name="totalVolunteerHours">
<a-input
v-model:value="form.totalVolunteerHours"
placeholder="请输入累计志愿服务时长"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度志愿服务时长" name="volunteerHours2024">
<a-input
v-model:value="form.volunteerHours2024"
placeholder="请输入上一年度志愿服务时长"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="发展团员编号" name="memberNo">
<a-input
v-model:value="form.memberNo"
placeholder="请输入发展团员编号"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="近五年获得荣誉情况" name="honorsFiveYears">
<a-textarea
v-model:value="form.honorsFiveYears"
:rows="3"
placeholder="请输入近五年获得荣誉情况"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="主要事迹" name="mainStory">
<a-textarea
v-model:value="form.mainStory"
:rows="3"
placeholder="请输入主要事迹"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item-->
<!-- label="学生社团指导教师意见/所在单位团委"-->
<!-- name="leagueOpinion"-->
<!-- >-->
<!-- <a-textarea-->
<!-- v-model:value="form.leagueOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item-->
<!-- label="业务指导单位意见/所在单位党组织"-->
<!-- name="partyOpinion"-->
<!-- >-->
<!-- <a-textarea-->
<!-- v-model:value="form.partyOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="校团委意见" name="schoolOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.schoolOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入校团委意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addYxgqtyForm, updateYxgqtyForm } from '@/api/gxmu/yxgqty-form';
import type { YxgqtyForm } from '@/api/gxmu/yxgqty-form/model';
import ClassCascader from '@/views/gxmu/components/class-cascader.vue';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: YxgqtyForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const { nationOptions, politicsOptions, genderOptions, loadAll } =
useGxmuFormSelectOptions();
const createDefaultForm = (): YxgqtyForm => ({
id: undefined,
year: '',
name: '',
gender: '',
nation: '',
politics: '',
birthMonth: '',
joinTime: '',
collegeMajorClass: '',
position: '',
volunteerRegTime: '',
eduEval2024: '',
smartSystem: '',
contact: '',
totalVolunteerHours: '',
volunteerHours2024: '',
memberNo: '',
honorsFiveYears: '',
mainStory: '',
leagueOpinion: '',
partyOpinion: '',
schoolOpinion: ''
});
const form = reactive<YxgqtyForm>(createDefaultForm());
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await loadAll();
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateYxgqtyForm : addYxgqtyForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,96 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="优秀共青团员"
file-name="优秀共青团员导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addYxgqtyForm } from '@/api/gxmu/yxgqty-form';
import type { YxgqtyForm } from '@/api/gxmu/yxgqty-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '姓名', key: 'name', required: true },
{ header: '性别', key: 'gender' },
{ header: '民族', key: 'nation' },
{ header: '政治面貌', key: 'politics' },
{ header: '出生年月', key: 'birthMonth' },
{ header: '入团时间', key: 'joinTime' },
{ header: '所在学院、专业、班级', key: 'collegeMajorClass' },
{ header: '职务', key: 'position' },
{ header: '志愿汇注册时间', key: 'volunteerRegTime' },
{ header: '2024年度教育评议等次', key: 'eduEval2024' },
{ header: '智慧团建系统录入情况', key: 'smartSystem' },
{ header: '联系方式', key: 'contact' },
{ header: '累计志愿服务时长', key: 'totalVolunteerHours' },
{ header: '2024年度志愿服务时长', key: 'volunteerHours2024' },
{ header: '团员编号', key: 'memberNo' },
{ header: '近五年获得校级及以上荣誉情况', key: 'honorsFiveYears' },
{ header: '主要事迹', key: 'mainStory' },
{ header: '二级团组织意见', key: 'leagueOpinion' },
{ header: '二级党组织意见', key: 'partyOpinion' },
{ header: '学校团委意见', key: 'schoolOpinion' }
] as const;
const sampleRow = {
年份: '2025',
姓名: '赵六',
性别: '女',
民族: '汉族',
政治面貌: '共青团员',
出生年月: '2002-06',
入团时间: '2018-05',
'所在学院、专业、班级': '护理学院 护理学 2022级1班',
职务: '班长',
志愿汇注册时间: '2020-09',
'2024年度教育评议等次': '优秀',
智慧团建系统录入情况: '是',
联系方式: '13800138000',
累计志愿服务时长: '120',
'2024年度志愿服务时长': '40',
团员编号: 'TY2025001',
近五年获得校级及以上荣誉情况: '校优秀共青团员',
主要事迹: '积极参与志愿服务和学风建设',
二级团组织意见: '',
二级党组织意见: '',
学校团委意见: ''
};
const createPayload = (row: Record<string, any>): YxgqtyForm => ({
year: String(row['年份'] || '').trim(),
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
nation: String(row['民族'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
birthMonth: String(row['出生年月'] || '').trim(),
joinTime: String(row['入团时间'] || '').trim(),
collegeMajorClass: String(row['所在学院、专业、班级'] || '').trim(),
position: String(row['职务'] || '').trim(),
volunteerRegTime: String(row['志愿汇注册时间'] || '').trim(),
eduEval2024: String(row['2024年度教育评议等次'] || '').trim(),
smartSystem: String(row['智慧团建系统录入情况'] || '').trim(),
contact: String(row['联系方式'] || '').trim(),
totalVolunteerHours: String(row['累计志愿服务时长'] || '').trim(),
volunteerHours2024: String(row['2024年度志愿服务时长'] || '').trim(),
memberNo: String(row['团员编号'] || '').trim(),
honorsFiveYears: String(row['近五年获得校级及以上荣誉情况'] || '').trim(),
mainStory: String(row['主要事迹'] || '').trim(),
leagueOpinion: String(row['二级团组织意见'] || '').trim(),
partyOpinion: String(row['二级党组织意见'] || '').trim(),
schoolOpinion: String(row['学校团委意见'] || '').trim()
});
const saveItem = (item: YxgqtyForm) => addYxgqtyForm(item);
</script>
+870
View File
@@ -0,0 +1,870 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="list" tab="名单列表">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 3800 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/学院/联系方式"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<a @click="openSheet(record)">申报表</a>
<a-divider type="vertical" />
<a @click="openReviewHistory(record)">审核历史</a>
<a-divider type="vertical" />
<template
v-if="!shouldHideEditByReviewList(record.reviewList)"
>
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-tab-pane>
<a-tab-pane key="form" tab="申报表">
<div class="sheet-wrap">
<div class="sheet-title">
广西医科大学2025年“优秀共青团员”申报表
</div>
<a-form layout="vertical">
<div class="sheet-table-wrap">
<table class="sheet-table">
<colgroup>
<col style="width: 10%" />
<col style="width: 15%" />
<col style="width: 10%" />
<col style="width: 15%" />
<col style="width: 12%" />
<col style="width: 18%" />
<col style="width: 10%" />
<col style="width: 10%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">姓名</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.name"
placeholder="请输入姓名"
/>
</td>
<td class="sheet-label">性别</td>
<td class="sheet-value">
<a-select
v-model:value="sheetForm.gender"
:options="genderOptions"
placeholder="请选择"
/>
</td>
<td class="sheet-label">民族</td>
<td class="sheet-value">
<a-select
allow-clear
show-search
v-model:value="sheetForm.nation"
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
/>
</td>
<td class="sheet-label">政治面貌</td>
<td class="sheet-value">
<a-select
allow-clear
show-search
v-model:value="sheetForm.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</td>
</tr>
<tr>
<td class="sheet-label">出生年月</td>
<td class="sheet-value">
<a-date-picker
v-model:value="sheetForm.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</td>
<td class="sheet-label">入团时间</td>
<td class="sheet-value">
<a-date-picker
v-model:value="sheetForm.joinTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择入团时间"
/>
</td>
<td class="sheet-label">所在学院、专业、班级</td>
<td class="sheet-value left" colspan="2">
<class-cascader
v-model:value="sheetForm.collegeMajorClass"
mode="collegeClass"
placeholder="请先选择学院,再选择班级"
/>
</td>
<td class="sheet-label">职务</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.position"
placeholder="请输入职务"
/>
</td>
</tr>
<tr>
<td class="sheet-label">成为注册志愿者时间</td>
<td class="sheet-value">
<a-date-picker
v-model:value="sheetForm.volunteerRegTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择成为注册志愿者时间"
/>
</td>
<td class="sheet-label">上一年度团员教育评议等次</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.eduEval2024"
placeholder="如:优秀"
/>
</td>
<td class="sheet-label">是否已登录“智慧团建”系统</td>
<td class="sheet-value">
<a-select
v-model:value="sheetForm.smartSystem"
placeholder="请选择"
>
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</td>
<td class="sheet-label">联系电话</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.contact"
placeholder="请输入联系电话"
/>
</td>
</tr>
<tr>
<td class="sheet-label">累计志愿服务时长</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.totalVolunteerHours"
placeholder="如:200小时"
/>
</td>
<td class="sheet-label">上一年度志愿服务时长</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.volunteerHours2024"
placeholder="如:40小时"
/>
</td>
<td class="sheet-label">发展团员编号</td>
<td class="sheet-value left" colspan="3">
<a-input
v-model:value="sheetForm.memberNo"
placeholder="请输入发展团员编号"
/>
</td>
</tr>
<tr>
<td class="sheet-label">近五年获得荣誉情况</td>
<td class="sheet-value sheet-textarea" colspan="7">
<div class="sheet-tip"
>(2020年1月1日以后,所获荣誉填3-5项)</div
>
<a-textarea
v-model:value="sheetForm.honorsFiveYears"
:rows="4"
placeholder="如:2021年10月 被评为校级优秀团员"
/>
</td>
</tr>
<tr>
<td class="sheet-label">主要事迹</td>
<td class="sheet-value sheet-textarea" colspan="7">
<div class="sheet-tip"
>(围绕参评条件,突出重点,简明扼要,不超过300字)</div
>
<a-textarea
v-model:value="sheetForm.mainStory"
:rows="5"
placeholder="请输入主要事迹"
/>
</td>
</tr>
<tr>
<td class="sheet-label">意见栏</td>
<td class="sheet-value" colspan="7">
<div class="opinion-grid">
<div class="opinion-box">
<div class="opinion-title"
>学生社团指导教师意见 / 所在单位团委</div
>
<a-textarea
v-model:value="sheetForm.leagueOpinion"
:rows="5"
placeholder="请输入意见"
/>
<div class="opinion-sign">(签章) 年 月 日</div>
</div>
<div class="opinion-box">
<div class="opinion-title"
>业务指导单位意见 / 所在单位党组织</div
>
<a-textarea
v-model:value="sheetForm.partyOpinion"
:rows="5"
placeholder="请输入意见"
/>
<div class="opinion-sign">(签章) 年 月 日</div>
</div>
</div>
</td>
</tr>
<tr>
<td class="sheet-label">校团委意见</td>
<td class="sheet-value sheet-textarea" colspan="7">
<a-textarea
v-model:value="sheetForm.schoolOpinion"
:rows="4"
placeholder="请输入校团委意见"
/>
<div class="opinion-sign align-right"
>(盖章) 年 月 日</div
>
</td>
</tr>
</tbody>
</table>
</div>
<div class="sheet-actions">
<a-space>
<a-button type="primary" @click="saveSheet"
>保存表单</a-button
>
<a-button @click="resetSheet">重置</a-button>
</a-space>
</div>
</a-form>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="优秀共青团员"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import ClassCascader from '@/views/gxmu/components/class-cascader.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
addYxgqtyForm,
exportYxgqtyForm,
userPageYxgqtyForm,
removeYxgqtyForm,
updateYxgqtyForm
} from '@/api/gxmu/yxgqty-form';
import type {
YxgqtyForm,
YxgqtyFormParam
} from '@/api/gxmu/yxgqty-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<YxgqtyForm>();
const detailCurrent = ref<YxgqtyForm | null>(null);
const activeKey = ref('list');
const REVIEW_MODULE = 'gxmu_yxgqty';
const showReviewHistory = ref(false);
const reviewCurrent = ref<YxgqtyForm | null>(null);
const exporting = ref(false);
const { genderOptions, nationOptions, politicsOptions, loadAll } =
useGxmuFormSelectOptions();
const columns = ref<ColumnItem[]>([
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 100
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 100
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '出生年月',
dataIndex: 'birthMonth',
key: 'birthMonth',
align: 'center',
width: 120
},
{
title: '入团时间',
dataIndex: 'joinTime',
key: 'joinTime',
align: 'center',
width: 120
},
{
title: '所在学院、专业、班级',
dataIndex: 'collegeMajorClass',
key: 'collegeMajorClass',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '职务',
dataIndex: 'position',
key: 'position',
align: 'center',
width: 120
},
{
title: '成为注册志愿者时间',
dataIndex: 'volunteerRegTime',
key: 'volunteerRegTime',
align: 'center',
width: 180
},
{
title: '上一年度团员教育评议等次',
dataIndex: 'eduEval2024',
key: 'eduEval2024',
align: 'center',
width: 200
},
{
title: '是否已登录智慧团建系统',
dataIndex: 'smartSystem',
key: 'smartSystem',
align: 'center',
width: 200
},
{
title: '联系电话',
dataIndex: 'contact',
key: 'contact',
align: 'center',
width: 140
},
{
title: '累计志愿服务时长',
dataIndex: 'totalVolunteerHours',
key: 'totalVolunteerHours',
align: 'center',
width: 160
},
{
title: '上一年度志愿服务时长',
dataIndex: 'volunteerHours2024',
key: 'volunteerHours2024',
align: 'center',
width: 180
},
{
title: '发展团员编号',
dataIndex: 'memberNo',
key: 'memberNo',
align: 'center',
width: 160
},
{
title: '近五年获得荣誉情况',
dataIndex: 'honorsFiveYears',
key: 'honorsFiveYears',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '主要事迹',
dataIndex: 'mainStory',
key: 'mainStory',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '学生社团指导教师意见/所在单位团委',
dataIndex: 'leagueOpinion',
key: 'leagueOpinion',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '业务指导单位意见/所在单位党组织',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '校团委意见',
dataIndex: 'schoolOpinion',
key: 'schoolOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 260
}
]);
const createSheetDefault = (): YxgqtyForm => ({
id: undefined,
name: '',
gender: '',
nation: '',
politics: '',
birthMonth: '',
joinTime: '',
collegeMajorClass: '',
position: '',
volunteerRegTime: '',
eduEval2024: '',
smartSystem: '',
contact: '',
totalVolunteerHours: '',
volunteerHours2024: '',
memberNo: '',
honorsFiveYears: '',
mainStory: '',
leagueOpinion: '',
partyOpinion: '',
schoolOpinion: ''
});
const sheetForm = reactive<YxgqtyForm>(createSheetDefault());
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageYxgqtyForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as YxgqtyFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): YxgqtyFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as YxgqtyFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(payload?.url || payload?.downloadUrl || payload?.path || '');
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportYxgqtyForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openReviewHistory = (record: YxgqtyForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: YxgqtyForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const openEdit = (record?: YxgqtyForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as YxgqtyForm)
: undefined;
showEdit.value = true;
};
const openSheet = (record: YxgqtyForm) => {
loadAll().catch((e: any) => {
message.error(e.message || '下拉选项加载失败');
});
current.value = JSON.parse(JSON.stringify(record)) as YxgqtyForm;
Object.assign(sheetForm, createSheetDefault(), current.value);
activeKey.value = 'form';
};
const remove = (record: YxgqtyForm) => {
const hide = message.loading('请求中..', 0);
removeYxgqtyForm(record.id)
.then((msg) => {
hide();
message.success(msg);
if (sheetForm.id === record.id) {
Object.assign(sheetForm, createSheetDefault());
activeKey.value = 'list';
}
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const saveSheet = () => {
const hide = message.loading('请求中..', 0);
const saveOrUpdate = sheetForm.id ? updateYxgqtyForm : addYxgqtyForm;
saveOrUpdate(sheetForm)
.then((msg) => {
hide();
message.success(msg);
activeKey.value = 'list';
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const resetSheet = () => {
Object.assign(sheetForm, createSheetDefault(), current.value ?? {});
};
</script>
<script lang="ts">
export default {
name: 'YXGQTYForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
.sheet-wrap {
background: #fff;
border: 1px solid #d9d9d9;
padding: 16px 18px 20px;
}
.sheet-title {
text-align: center;
font-size: 20px;
font-weight: 600;
letter-spacing: 1px;
margin-bottom: 12px;
}
.sheet-table-wrap {
overflow-x: auto;
}
.sheet-table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
table-layout: fixed;
}
.sheet-table td {
border: 1px solid #3b3b3b;
padding: 8px 6px;
font-size: 13px;
line-height: 1.4;
vertical-align: middle;
}
.sheet-label {
text-align: center;
font-weight: 600;
}
.sheet-value {
text-align: center;
}
.sheet-value.left {
text-align: left;
}
.sheet-value :deep(.ant-input),
.sheet-value :deep(.ant-select-selector) {
border: 0;
box-shadow: none;
padding: 0;
background: transparent;
}
.sheet-value :deep(.ant-input) {
text-align: center;
}
.sheet-value.left :deep(.ant-input) {
text-align: left;
}
.sheet-value :deep(.ant-select-selector) {
height: 24px;
min-height: 24px;
align-items: center;
}
.sheet-value :deep(.ant-select-selection-item),
.sheet-value :deep(.ant-select-selection-placeholder) {
text-align: center;
}
.sheet-textarea {
text-align: left;
}
.sheet-textarea :deep(.ant-input) {
border: 0;
box-shadow: none;
padding: 4px 6px;
background: transparent;
resize: none;
}
.sheet-tip {
color: #606266;
font-size: 12px;
margin-bottom: 6px;
}
.opinion-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.opinion-box {
border: 1px dashed #6b6b6b;
padding: 8px;
min-height: 180px;
display: flex;
flex-direction: column;
}
.opinion-title {
font-weight: 600;
margin-bottom: 8px;
}
.opinion-sign {
text-align: right;
margin-top: 8px;
color: #444;
}
.opinion-sign.align-right {
text-align: right;
}
.sheet-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@media (max-width: 768px) {
.sheet-wrap {
padding: 12px;
}
.sheet-title {
font-size: 18px;
}
.opinion-grid {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large Load Diff
+564
View File
@@ -0,0 +1,564 @@
<template>
<div class="challenge-stats-page">
<a-card :bordered="false" class="hero-card">
<div class="hero-wrap">
<div>
<div class="hero-kicker">CHALLENGE CUP ANALYTICS</div>
<div class="hero-title">挑战杯数据统计</div>
<div class="hero-desc">
基于挑战杯申报表汇总申报作品、参与人数、指导老师和审核通过作品等关键指标,支持按年份查看趋势与结构分布。
</div>
</div>
<a-space wrap>
<a-select
v-model:value="selectedYear"
allow-clear
style="width: 180px"
placeholder="全部年份"
@change="loadStatistics"
>
<a-select-option
v-for="item in yearOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
<a-button :loading="loading" @click="loadStatistics"
>刷新数据</a-button
>
</a-space>
</div>
</a-card>
<a-spin :spinning="loading">
<a-alert
v-if="stats.awardCountDescription"
class="summary-alert"
type="info"
show-icon
:message="stats.awardCountDescription"
/>
<a-row :gutter="[16, 16]" class="stats-row">
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card">
<div class="metric-label">申报作品数量</div>
<div class="metric-value">{{ summary.declarationCount }}</div>
<div class="metric-foot">当前筛选范围内的项目总数</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card accent">
<div class="metric-label">参与申报人数</div>
<div class="metric-value">{{ summary.participantCount }}</div>
<div class="metric-foot">按团队成员去重统计</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card warm">
<div class="metric-label">指导老师数量</div>
<div class="metric-value">{{ summary.advisorCount }}</div>
<div class="metric-foot">按指导老师去重统计</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card success">
<div class="metric-label">获奖数量</div>
<div class="metric-value">{{ summary.awardCount }}</div>
<div class="metric-foot">当前按审核通过作品数统计</div>
</div>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="stats-row secondary-row">
<a-col :xs="24" :sm="12" :xl="8">
<div class="mini-metric-card">
<span>申报学校数量</span>
<b>{{ summary.schoolCount }}</b>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="8">
<div class="mini-metric-card">
<span>项目类型数</span>
<b>{{ stats.typeDistribution.length }}</b>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="8">
<div class="mini-metric-card">
<span>项目分组数</span>
<b>{{ stats.groupDistribution.length }}</b>
</div>
</a-col>
</a-row>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :xl="14">
<a-card :bordered="false" class="section-card" title="年度申报趋势">
<v-chart :option="yearChartOption" class="chart-box" autoresize />
</a-card>
</a-col>
<a-col :xs="24" :xl="10">
<a-card :bordered="false" class="section-card" title="项目类型分布">
<v-chart :option="typeChartOption" class="chart-box" autoresize />
</a-card>
</a-col>
<a-col :xs="24" :xl="12">
<a-card :bordered="false" class="section-card" title="项目分组分布">
<v-chart :option="groupChartOption" class="chart-box" autoresize />
</a-card>
</a-col>
<a-col :xs="24" :xl="12">
<a-card :bordered="false" class="section-card" title="学校申报排行">
<v-chart :option="schoolChartOption" class="chart-box" autoresize />
</a-card>
</a-col>
</a-row>
<a-card :bordered="false" class="section-card table-card">
<template #title>
<div class="table-title">
<span>分布明细</span>
<a-tag color="processing">
{{ selectedYear ? `${selectedYear} 年` : '全部年份' }}
</a-tag>
</div>
</template>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :xl="12">
<a-table
row-key="label"
size="small"
:pagination="false"
:columns="distributionColumns"
:data-source="stats.typeDistribution"
/>
</a-col>
<a-col :xs="24" :xl="12">
<a-table
row-key="label"
size="small"
:pagination="false"
:columns="distributionColumns"
:data-source="stats.groupDistribution"
/>
</a-col>
</a-row>
</a-card>
</a-spin>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { use } from 'echarts/core';
import type { EChartsCoreOption } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart, PieChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TooltipComponent
} from 'echarts/components';
import VChart from 'vue-echarts';
import { getTzbcyStatistics } from '@/api/gxmu/tzbcy-form';
import type {
TzbcyStatisticItem,
TzbcyStatisticsResult
} from '@/api/gxmu/tzbcy-form/model';
use([
CanvasRenderer,
BarChart,
PieChart,
GridComponent,
LegendComponent,
TooltipComponent
]);
const loading = ref(false);
const selectedYear = ref<number>();
const stats = reactive<TzbcyStatisticsResult>({
availableYears: [],
summary: {
declarationCount: 0,
participantCount: 0,
advisorCount: 0,
awardCount: 0,
schoolCount: 0
},
awardCountDescription: '',
yearDistribution: [],
typeDistribution: [],
groupDistribution: [],
schoolDistribution: []
});
const distributionColumns = [
{
title: '分类',
dataIndex: 'label',
key: 'label'
},
{
title: '数量',
dataIndex: 'value',
key: 'value',
width: 120
}
];
const summary = computed(() => stats.summary);
const yearOptions = computed(() =>
stats.availableYears.map((item) => ({
label: `${item} 年`,
value: item
}))
);
const createEmptyOption = (title: string): EChartsCoreOption => ({
title: {
text: title,
left: 'center',
top: 'middle',
textStyle: {
color: '#94a3b8',
fontSize: 14,
fontWeight: 500
}
}
});
const yearChartOption = computed<EChartsCoreOption>(() => {
if (!stats.yearDistribution.length) {
return createEmptyOption('暂无年度数据');
}
return {
tooltip: { trigger: 'axis' },
grid: {
left: 40,
right: 20,
top: 30,
bottom: 30
},
xAxis: {
type: 'category',
data: stats.yearDistribution.map((item) => item.label),
axisLine: {
lineStyle: { color: '#cbd5e1' }
}
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: { color: '#e2e8f0' }
}
},
series: [
{
type: 'bar',
barWidth: 36,
data: stats.yearDistribution.map((item) => item.value),
itemStyle: {
borderRadius: [10, 10, 0, 0],
color: '#2563eb'
}
}
]
};
});
const typeChartOption = computed<EChartsCoreOption>(() => {
if (!stats.typeDistribution.length) {
return createEmptyOption('暂无类型分布');
}
return {
tooltip: { trigger: 'item' },
legend: {
bottom: 0,
itemWidth: 10,
itemHeight: 10
},
series: [
{
type: 'pie',
radius: ['42%', '68%'],
center: ['50%', '42%'],
data: stats.typeDistribution.map((item) => ({
name: item.label,
value: item.value
})),
label: {
formatter: '{b}\n{c}'
}
}
]
};
});
const groupChartOption = computed<EChartsCoreOption>(() => {
if (!stats.groupDistribution.length) {
return createEmptyOption('暂无分组分布');
}
const rows = [...stats.groupDistribution].reverse();
return {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: {
left: 120,
right: 20,
top: 20,
bottom: 20
},
xAxis: {
type: 'value',
splitLine: {
lineStyle: { color: '#e2e8f0' }
}
},
yAxis: {
type: 'category',
data: rows.map((item) => item.label),
axisLine: {
lineStyle: { color: '#cbd5e1' }
}
},
series: [
{
type: 'bar',
data: rows.map((item) => item.value),
barWidth: 20,
itemStyle: {
borderRadius: [0, 10, 10, 0],
color: '#f59e0b'
}
}
]
};
});
const schoolChartOption = computed<EChartsCoreOption>(() => {
if (!stats.schoolDistribution.length) {
return createEmptyOption('暂无学校排行');
}
const rows = [...stats.schoolDistribution].reverse();
return {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: {
left: 110,
right: 20,
top: 20,
bottom: 20
},
xAxis: {
type: 'value',
splitLine: {
lineStyle: { color: '#e2e8f0' }
}
},
yAxis: {
type: 'category',
data: rows.map((item) => item.label),
axisLine: {
lineStyle: { color: '#cbd5e1' }
}
},
series: [
{
type: 'bar',
data: rows.map((item) => item.value),
barWidth: 18,
itemStyle: {
borderRadius: [0, 10, 10, 0],
color: '#10b981'
}
}
]
};
});
const assignItems = (
target: TzbcyStatisticItem[],
source?: TzbcyStatisticItem[]
) => {
target.splice(0, target.length, ...(source ?? []));
};
const loadStatistics = async () => {
loading.value = true;
try {
const data = await getTzbcyStatistics({
year: selectedYear.value
});
stats.year = data.year;
stats.availableYears = data.availableYears ?? [];
stats.summary = data.summary ?? stats.summary;
stats.awardCountDescription = data.awardCountDescription || '';
assignItems(stats.yearDistribution, data.yearDistribution);
assignItems(stats.typeDistribution, data.typeDistribution);
assignItems(stats.groupDistribution, data.groupDistribution);
assignItems(stats.schoolDistribution, data.schoolDistribution);
} catch (e: any) {
message.error(e.message || '获取挑战杯统计数据失败');
} finally {
loading.value = false;
}
};
onMounted(() => {
loadStatistics();
});
</script>
<script lang="ts">
export default {
name: 'GxmuChallengeCupStats'
};
</script>
<style scoped lang="less">
.challenge-stats-page {
min-height: 100%;
padding: 24px;
background: radial-gradient(
circle at top left,
rgba(37, 99, 235, 0.12),
transparent 24%
),
linear-gradient(180deg, #f7fbff 0%, #fffdf8 100%);
}
.hero-card,
.section-card {
border-radius: 22px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.08);
}
.hero-wrap {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.hero-kicker {
margin-bottom: 8px;
color: #5b7cc7;
font-size: 12px;
letter-spacing: 0.22em;
}
.hero-title {
color: #102a43;
font-size: 32px;
font-weight: 700;
line-height: 1.2;
}
.hero-desc {
max-width: 760px;
margin-top: 12px;
color: #52637a;
line-height: 1.8;
}
.summary-alert {
margin-top: 16px;
border-radius: 16px;
}
.stats-row {
margin-top: 16px;
}
.secondary-row {
margin-top: 0;
}
.metric-card {
height: 100%;
padding: 22px 24px;
border-radius: 20px;
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
border: 1px solid #dbeafe;
}
.metric-card.accent {
background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%);
border-color: #bbf7d0;
}
.metric-card.warm {
background: linear-gradient(135deg, #fff7ed 0%, #ffffff 100%);
border-color: #fed7aa;
}
.metric-card.success {
background: linear-gradient(135deg, #ecfeff 0%, #ffffff 100%);
border-color: #a5f3fc;
}
.metric-label {
color: #64748b;
font-size: 14px;
}
.metric-value {
margin-top: 10px;
color: #0f172a;
font-size: 34px;
font-weight: 700;
line-height: 1;
}
.metric-foot {
margin-top: 10px;
color: #94a3b8;
font-size: 13px;
}
.mini-metric-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-radius: 18px;
background: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
}
.mini-metric-card b {
color: #0f172a;
font-size: 24px;
}
.chart-box {
height: 320px;
}
.table-card {
margin-top: 16px;
}
.table-title {
display: flex;
align-items: center;
gap: 10px;
}
@media (max-width: 992px) {
.challenge-stats-page {
padding: 16px;
}
.hero-wrap {
flex-direction: column;
}
.hero-title {
font-size: 28px;
}
}
</style>
@@ -0,0 +1,273 @@
<template>
<div class="class-table">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1450 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space style="flex-wrap: wrap">
<a-button type="primary" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
<span>新增班级</span>
</a-button>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 220px"
placeholder="班级名称/编码"
@search="reload"
@pressEnter="reload"
/>
<a-select
v-if="showOrganizationFilter"
v-model:value="selectedOrganizationId"
allow-clear
show-search
option-filter-prop="label"
style="width: 220px"
placeholder="所属机构"
:options="organizationOptions"
/>
<a-select
v-model:value="status"
allow-clear
style="width: 120px"
placeholder="状态"
:options="statusOptions"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '启用' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此班级吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
<edit-modal
v-model:visible="showEdit"
:data="current"
:default-college-id="currentDefaultOrganizationId"
:lock-college-id="lockOrganization"
@done="reload"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } 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 EditModal from './edit.vue';
import { pageClassInfo, removeClassInfo } from '@/api/gxmu/class';
import { listOrganizations } from '@/api/system/organization';
import type { ClassInfo, ClassInfoParam } from '@/api/gxmu/class/model';
const props = withDefaults(
defineProps<{
organizationId?: number;
showOrganizationFilter?: boolean;
lockOrganization?: boolean;
}>(),
{
showOrganizationFilter: true,
lockOrganization: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const selectedOrganizationId = ref<number | undefined>(props.organizationId);
const status = ref<number>();
const showEdit = ref(false);
const current = ref<ClassInfo>();
const organizationOptions = ref<{ label: string; value: number }[]>([]);
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 }
];
const columns = ref<ColumnItem[]>([
{
title: '班级名称',
dataIndex: 'className',
key: 'className',
width: 200
},
{
title: '班级编码',
dataIndex: 'classCode',
key: 'classCode',
width: 160
},
{
title: '所属机构',
dataIndex: 'collegeName',
key: 'collegeName',
width: 220
},
{
title: '年级',
dataIndex: 'gradeYear',
key: 'gradeYear',
align: 'center',
width: 100
},
{
title: '辅导员',
dataIndex: 'counselorName',
key: 'counselorName',
width: 120
},
{
title: '联系电话',
dataIndex: 'counselorPhone',
key: 'counselorPhone',
width: 150
},
{
title: '学生人数',
dataIndex: 'studentCount',
key: 'studentCount',
align: 'center',
width: 100
},
{
title: '排序',
dataIndex: 'sortNumber',
key: 'sortNumber',
align: 'center',
width: 90,
sorter: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 100
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 180,
sorter: true,
customRender: ({ text }) => (text ? toDateString(text) : '-')
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 140
}
]);
const currentDefaultOrganizationId = computed(
() => props.organizationId ?? selectedOrganizationId.value
);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageClassInfo({
...(where || {}),
...orders,
page,
limit,
keywords: searchText.value.trim() || undefined,
collegeId: currentDefaultOrganizationId.value,
status: status.value
} as ClassInfoParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const loadOrganizations = () => {
listOrganizations()
.then((list) => {
organizationOptions.value = list.map((item) => ({
label: item.organizationFullName || item.organizationName || '',
value: item.organizationId || 0
}));
})
.catch((e) => {
message.error(e.message);
});
};
const reload = () => {
tableRef.value?.reload();
};
const resetSearch = () => {
searchText.value = '';
selectedOrganizationId.value = props.organizationId;
status.value = undefined;
reload();
};
const openEdit = (record?: ClassInfo) => {
current.value = record ? { ...record } : void 0;
showEdit.value = true;
};
const remove = (record: ClassInfo) => {
const hide = message.loading('请求中..', 0);
removeClassInfo(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
watch(
() => props.organizationId,
(value) => {
selectedOrganizationId.value = value;
reload();
}
);
onMounted(() => {
loadOrganizations();
});
defineExpose({
reload
});
</script>
@@ -0,0 +1,234 @@
<template>
<a-modal
:width="700"
:visible="visible"
:title="isUpdate ? '编辑班级' : '新增班级'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="所属机构"
name="collegeId"
:rules="[{ required: true, message: '请选择所属机构' }]"
>
<a-select
v-model:value="form.collegeId"
:disabled="lockCollegeId"
show-search
option-filter-prop="label"
placeholder="请选择所属机构"
:options="organizationOptions"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="班级名称"
name="className"
:rules="[{ required: true, message: '请输入班级名称' }]"
>
<a-input
v-model:value="form.className"
placeholder="请输入班级名称"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="班级编码" name="classCode">
<a-input
v-model:value="form.classCode"
placeholder="请输入班级编码"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="年级" name="gradeYear">
<a-input-number
v-model:value="form.gradeYear"
:min="2000"
:max="2100"
:precision="0"
style="width: 100%"
placeholder="请输入年级"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="辅导员" name="counselorName">
<a-input
v-model:value="form.counselorName"
placeholder="请输入辅导员"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话" name="counselorPhone">
<a-input
v-model:value="form.counselorPhone"
placeholder="请输入联系电话"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="学生人数" name="studentCount">
<a-input-number
v-model:value="form.studentCount"
:min="0"
:precision="0"
style="width: 100%"
placeholder="请输入学生人数"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序" name="sortNumber">
<a-input-number
v-model:value="form.sortNumber"
:min="0"
:precision="0"
style="width: 100%"
placeholder="请输入排序"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">停用</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="form.remark"
:rows="3"
placeholder="请输入备注"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addClassInfo, updateClassInfo } from '@/api/gxmu/class';
import { listOrganizations } from '@/api/system/organization';
import type { ClassInfo } from '@/api/gxmu/class/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: ClassInfo | null;
defaultCollegeId?: number;
lockCollegeId?: boolean;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const organizationOptions = ref<{ label: string; value: number }[]>([]);
const form = reactive<ClassInfo>({
id: undefined,
collegeId: undefined,
classCode: '',
className: '',
gradeYear: undefined,
counselorName: '',
counselorPhone: '',
studentCount: 0,
sortNumber: 0,
status: 1,
remark: ''
});
const resetForm = () => {
form.id = undefined;
form.collegeId = props.defaultCollegeId;
form.classCode = '';
form.className = '';
form.gradeYear = undefined;
form.counselorName = '';
form.counselorPhone = '';
form.studentCount = 0;
form.sortNumber = 0;
form.status = 1;
form.remark = '';
};
const loadOrganizationOptions = () => {
listOrganizations()
.then((list) => {
organizationOptions.value = list.map((item) => ({
label: item.organizationFullName || item.organizationName || '',
value: item.organizationId || 0
}));
})
.catch((e) => {
message.error(e.message);
});
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const request = isUpdate.value ? updateClassInfo : addClassInfo;
request({ ...form })
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
loadOrganizationOptions();
if (props.data) {
isUpdate.value = true;
Object.assign(form, {
...props.data
});
if (props.lockCollegeId && props.defaultCollegeId != null) {
form.collegeId = props.defaultCollegeId;
}
} else {
isUpdate.value = false;
resetForm();
}
} else {
resetForm();
}
}
);
</script>
@@ -0,0 +1,216 @@
<template>
<ele-modal
:width="560"
:footer="null"
title="批量导入班级"
:visible="visible"
@update:visible="updateVisible"
>
<a-spin :spinning="loading">
<a-space direction="vertical" style="width: 100%" :size="16">
<a-alert
type="info"
show-icon
message="导入说明"
description="请先下载模板,按模板填写后上传。导入时会校验所属学院是否存在,不存在则自动创建,上级ID固定为24。"
/>
<a-button block @click="downloadTemplate">下载模板</a-button>
<a-upload-dragger
accept=".xls,.xlsx"
:show-upload-list="false"
:customRequest="doUpload"
style="padding: 24px 0"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
</a-upload-dragger>
</a-space>
</a-spin>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { CloudUploadOutlined } from '@ant-design/icons-vue';
import { read, utils, writeFile } from 'xlsx';
import { importClassInfo } from '@/api/gxmu/class';
import type { ClassImportItem } from '@/api/gxmu/class/model';
const TEMPLATE_HEADERS = [
'所属学院',
'班级名称',
'班级编码',
'年级',
'辅导员',
'联系电话',
'学生人数',
'排序',
'状态',
'备注'
];
const TEMPLATE_SAMPLE = [
'第一临床医学院',
'2022级临床1班',
'2022LC01',
'2022',
'张老师',
'13800138000',
'45',
'1',
'启用',
'示例数据'
];
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
defineProps<{
visible: boolean;
}>();
const loading = ref(false);
const readFileBuffer = (file: File) => {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
};
const toNumber = (value: unknown) => {
const text = String(value ?? '').trim();
if (!text) {
return void 0;
}
const result = Number(text);
return Number.isFinite(result) ? result : void 0;
};
const toStatus = (value: unknown) => {
const text = String(value ?? '').trim();
if (!text) {
return 1;
}
if (['1', '启用', '是', '正常'].includes(text)) {
return 1;
}
if (['0', '停用', '否', '禁用'].includes(text)) {
return 0;
}
return 1;
};
const downloadTemplate = () => {
const sheet = utils.aoa_to_sheet([TEMPLATE_HEADERS, TEMPLATE_SAMPLE]);
sheet['!cols'] = TEMPLATE_HEADERS.map((title) => ({
wch: Math.max(String(title).length + 4, 16)
}));
writeFile(
{
SheetNames: ['班级导入模板'],
Sheets: {
班级导入模板: sheet
}
},
'班级导入模板.xlsx'
);
};
const buildRows = (rows: Record<string, any>[]) => {
return rows
.map<ClassImportItem>((row) => ({
collegeName: String(row['所属学院'] || '').trim(),
className: String(row['班级名称'] || '').trim(),
classCode: String(row['班级编码'] || '').trim(),
gradeYear: toNumber(row['年级']),
counselorName: String(row['辅导员'] || '').trim(),
counselorPhone: String(row['联系电话'] || '').trim(),
studentCount: toNumber(row['学生人数']),
sortNumber: toNumber(row['排序']) ?? 0,
status: toStatus(row['状态']),
remark: String(row['备注'] || '').trim()
}))
.filter((item) =>
[
item.collegeName,
item.className,
item.classCode,
item.counselorName,
item.remark
].some((value) => !!String(value || '').trim())
);
};
const validateRows = (rows: ClassImportItem[]) => {
const errors: string[] = [];
rows.forEach((item, index) => {
const rowNo = index + 2;
if (!item.collegeName) {
errors.push(`第 ${rowNo} 行缺少所属学院`);
}
if (!item.className) {
errors.push(`第 ${rowNo} 行缺少班级名称`);
}
});
return errors;
};
const doUpload = async ({ file }: { file: File }) => {
if (
![
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
].includes(file.type) &&
!file.name.endsWith('.xls') &&
!file.name.endsWith('.xlsx')
) {
message.error('只能选择 Excel 文件');
return false;
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
return false;
}
loading.value = true;
try {
const buffer = await readFileBuffer(file);
const workbook = read(buffer, { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = utils.sheet_to_json<Record<string, any>>(sheet, {
defval: ''
});
if (!rows.length) {
throw new Error('导入文件内容为空');
}
const data = buildRows(rows);
if (!data.length) {
throw new Error('未识别到有效数据');
}
const errors = validateRows(data);
if (errors.length) {
throw new Error(errors.slice(0, 5).join(';'));
}
const msg = await importClassInfo(data);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e.message || '导入失败');
} finally {
loading.value = false;
}
return false;
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<class-table />
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import ClassTable from './components/class-table.vue';
</script>
<script lang="ts">
export default {
name: 'ClassManagement'
};
</script>
@@ -0,0 +1,181 @@
<template>
<a-modal
:width="640"
:visible="visible"
:title="isUpdate ? '编辑学院' : '新增学院'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="学院名称"
name="collegeName"
:rules="[{ required: true, message: '请输入学院名称' }]"
>
<a-input
v-model:value="form.collegeName"
placeholder="请输入学院名称"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="学院编码"
name="collegeCode"
>
<a-input
v-model:value="form.collegeCode"
placeholder="请输入学院编码"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="学院简称" name="shortName">
<a-input
v-model:value="form.shortName"
placeholder="请输入学院简称"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="负责人" name="leaderName">
<a-input
v-model:value="form.leaderName"
placeholder="请输入负责人"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话" name="leaderPhone">
<a-input
v-model:value="form.leaderPhone"
placeholder="请输入联系电话"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序" name="sortNumber">
<a-input-number
v-model:value="form.sortNumber"
:min="0"
:precision="0"
style="width: 100%"
placeholder="请输入排序"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">停用</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="form.remark"
:rows="3"
placeholder="请输入备注"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addCollege, updateCollege } from '@/api/gxmu/college';
import type { College } from '@/api/gxmu/college/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: College | null;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const form = reactive<College>({
id: undefined,
collegeCode: '',
collegeName: '',
shortName: '',
leaderName: '',
leaderPhone: '',
sortNumber: 0,
status: 1,
remark: ''
});
const resetForm = () => {
form.id = undefined;
form.collegeCode = '';
form.collegeName = '';
form.shortName = '';
form.leaderName = '';
form.leaderPhone = '';
form.sortNumber = 0;
form.status = 1;
form.remark = '';
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const request = isUpdate.value ? updateCollege : addCollege;
request({ ...form })
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
isUpdate.value = true;
Object.assign(form, {
...props.data
});
} else {
isUpdate.value = false;
resetForm();
}
} else {
resetForm();
}
}
);
</script>
+210
View File
@@ -0,0 +1,210 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space style="flex-wrap: wrap">
<a-button type="primary" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
<span>新增学院</span>
</a-button>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="学院名称/编码"
@search="reload"
@pressEnter="reload"
/>
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="状态"
:options="statusOptions"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '启用' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此学院吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } 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 EditModal from './components/edit.vue';
import { pageCollege, removeCollege } from '@/api/gxmu/college';
import type { College, CollegeParam } from '@/api/gxmu/college/model';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const status = ref<number>();
const showEdit = ref(false);
const current = ref<College>();
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 }
];
const columns = ref<ColumnItem[]>([
{
title: '学院名称',
dataIndex: 'collegeName',
key: 'collegeName',
width: 220
},
{
title: '学院编码',
dataIndex: 'collegeCode',
key: 'collegeCode',
width: 160
},
{
title: '学院简称',
dataIndex: 'shortName',
key: 'shortName',
width: 160
},
{
title: '负责人',
dataIndex: 'leaderName',
key: 'leaderName',
width: 120
},
{
title: '联系电话',
dataIndex: 'leaderPhone',
key: 'leaderPhone',
width: 150
},
{
title: '班级数',
dataIndex: 'classCount',
key: 'classCount',
align: 'center',
width: 100
},
{
title: '排序',
dataIndex: 'sortNumber',
key: 'sortNumber',
align: 'center',
width: 90,
sorter: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 100
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 180,
sorter: true,
customRender: ({ text }) => (text ? toDateString(text) : '-')
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 140
}
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageCollege({
...(where || {}),
...orders,
page,
limit,
keywords: searchText.value.trim() || undefined,
status: status.value
} as CollegeParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const resetSearch = () => {
searchText.value = '';
status.value = undefined;
reload();
};
const openEdit = (record?: College) => {
current.value = record;
showEdit.value = true;
};
const remove = (record: College) => {
const hide = message.loading('请求中..', 0);
removeCollege(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'CollegeManagement'
};
</script>
@@ -0,0 +1,460 @@
<template>
<div class="page">
<div class="ele-body">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="9">
<a-card :bordered="false" :body-style="{ padding: '18px' }">
<div class="panel-title">历史竞赛文件入库</div>
<div class="panel-desc">
批量上传历史优秀项目申报书,系统将完成解析、脱敏和向量化入库。
</div>
<a-upload-dragger
multiple
accept=".pdf,.doc,.docx"
:file-list="historyFileList"
:before-upload="beforeHistoryUpload"
@remove="removeHistoryFile"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-text">上传历史项目书文件</p>
<p class="ant-upload-hint">支持 PDF、DOC、DOCX,可多选</p>
</a-upload-dragger>
<div class="field-label">历史评分/评语 CSV</div>
<a-upload
accept=".csv,.xls,.xlsx"
:file-list="scoreFileList"
:before-upload="beforeScoreUpload"
:max-count="1"
@remove="removeScoreFile"
>
<a-button>
<template #icon>
<upload-outlined />
</template>
上传评分文件
</a-button>
</a-upload>
<div class="field-label">手动评分和评价指导</div>
<a-textarea
v-model:value="manualText"
:rows="5"
allow-clear
placeholder="可输入评分标准、评语参考或入库补充说明"
/>
<div class="action-row">
<a-button
type="primary"
:loading="submitting"
:disabled="!historyFiles.length"
@click="submitPreprocess"
>
开始入库
</a-button>
<a-button :disabled="submitting" @click="resetUpload">重置</a-button>
</div>
<div v-if="preprocessText" class="preprocess-result">
<div class="result-head">
<span>处理结果</span>
<a-button size="small" type="link" @click="copyPreprocessResult">
复制
</a-button>
</div>
<pre>{{ preprocessText }}</pre>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="15">
<a-card :bordered="false" :body-style="{ padding: '18px' }">
<div class="table-toolbar">
<div>
<div class="panel-title">挑战杯历史案例知识库</div>
<div class="panel-desc">查看已入库文档,可按文档名称搜索。</div>
</div>
<a-space>
<a-input-search
v-model:value="keyword"
allow-clear
placeholder="搜索文档名称"
style="width: 220px"
@search="reload"
@pressEnter="reload"
/>
<a-button :loading="loading" @click="reload">
<template #icon>
<reload-outlined />
</template>
刷新
</a-button>
</a-space>
</div>
<a-table
row-key="id"
:loading="loading"
:columns="columns"
:data-source="records"
:pagination="pagination"
:scroll="{ x: 900 }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="doc-name">{{ record.name || '-' }}</div>
<div class="doc-id">{{ record.id }}</div>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record)">
{{ getStatusText(record) }}
</a-tag>
</template>
<template v-else-if="column.key === 'enabled'">
<a-tag :color="record.enabled === false ? 'default' : 'success'">
{{ record.enabled === false ? '停用' : '启用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'word_count'">
{{ formatNumber(record.word_count) }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { TablePaginationConfig, UploadProps } from 'ant-design-vue';
import {
CloudUploadOutlined,
UploadOutlined,
ReloadOutlined
} from '@ant-design/icons-vue';
import {
listChallengeCupDocuments,
preprocessCompetitionHistoryFiles
} from '@/api/ai/competitionHistory';
import type {
ChallengeCupDocument,
CompetitionHistoryPreprocessResult
} from '@/api/ai/competitionHistory/model';
import { copyText } from '@/utils/common';
const historyFiles = ref<File[]>([]);
const scoreFile = ref<File>();
const manualText = ref('');
const submitting = ref(false);
const loading = ref(false);
const keyword = ref('');
const records = ref<ChallengeCupDocument[]>([]);
const preprocessResult = ref<CompetitionHistoryPreprocessResult | null>(null);
const pagination = reactive<TablePaginationConfig>({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`
});
const columns = [
{
title: '文档',
dataIndex: 'name',
key: 'name',
width: 320,
ellipsis: true
},
{
title: '状态',
key: 'status',
align: 'center',
width: 120
},
{
title: '启用状态',
key: 'enabled',
align: 'center',
width: 110
},
{
title: '字数',
dataIndex: 'word_count',
key: 'word_count',
align: 'center',
width: 110
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
align: 'center',
width: 180
}
];
const historyFileList = computed(() =>
historyFiles.value.map((file, index) => ({
uid: `${index}-${file.name}`,
name: file.name,
status: 'done' as const
}))
);
const scoreFileList = computed(() =>
scoreFile.value
? [
{
uid: 'score',
name: scoreFile.value.name,
status: 'done' as const
}
]
: []
);
const preprocessText = computed(() => {
const result = preprocessResult.value;
if (!result) {
return '';
}
if (result.answer) {
return result.answer;
}
if (result.outputs) {
return JSON.stringify(result.outputs, null, 2);
}
return JSON.stringify(result, null, 2);
});
const beforeHistoryUpload: UploadProps['beforeUpload'] = (file) => {
if (file.size / 1024 / 1024 > 100) {
message.error('单个历史项目书文件不能超过 100MB');
return false;
}
historyFiles.value = [...historyFiles.value, file as File];
return false;
};
const beforeScoreUpload: UploadProps['beforeUpload'] = (file) => {
if (file.size / 1024 / 1024 > 50) {
message.error('评分文件不能超过 50MB');
return false;
}
scoreFile.value = file as File;
return false;
};
const removeHistoryFile = (file: any) => {
historyFiles.value = historyFiles.value.filter(
(item, index) => `${index}-${item.name}` !== file.uid
);
return true;
};
const removeScoreFile = () => {
scoreFile.value = undefined;
return true;
};
const resetUpload = () => {
historyFiles.value = [];
scoreFile.value = undefined;
manualText.value = '';
preprocessResult.value = null;
};
const submitPreprocess = async () => {
if (!historyFiles.value.length) {
message.warning('请先上传历史项目书文件');
return;
}
submitting.value = true;
preprocessResult.value = null;
try {
const result = await preprocessCompetitionHistoryFiles(
historyFiles.value,
scoreFile.value,
manualText.value
);
preprocessResult.value = result || null;
message.success('历史竞赛文件预处理已完成');
reload();
} catch (e: any) {
message.error(e.message || '历史竞赛文件预处理失败');
} finally {
submitting.value = false;
}
};
const reload = async () => {
loading.value = true;
try {
const result = await listChallengeCupDocuments({
page: Number(pagination.current || 1),
limit: Number(pagination.pageSize || 20),
keyword: keyword.value.trim() || undefined
});
records.value = result?.data || [];
pagination.total = result?.total || records.value.length;
pagination.current = result?.page || pagination.current || 1;
pagination.pageSize = result?.limit || pagination.pageSize || 20;
} catch (e: any) {
message.error(e.message || '获取知识库文档失败');
} finally {
loading.value = false;
}
};
const handleTableChange = (page: TablePaginationConfig) => {
pagination.current = page.current || 1;
pagination.pageSize = page.pageSize || 20;
reload();
};
const getStatusText = (record: ChallengeCupDocument) => {
return (
record.display_status ||
record.indexing_status ||
(record.archived ? '已归档' : '正常')
);
};
const getStatusColor = (record: ChallengeCupDocument) => {
const text = getStatusText(record);
if (/error|fail|失败|错误/.test(text)) {
return 'error';
}
if (/indexing|processing|处理中|解析中/.test(text)) {
return 'processing';
}
if (/completed|available|正常|完成|成功/.test(text)) {
return 'success';
}
return 'default';
};
const formatNumber = (value?: number) => {
return typeof value === 'number' ? value.toLocaleString() : '-';
};
const formatDate = (value?: number) => {
if (!value) {
return '-';
}
return new Date(value * 1000).toLocaleString();
};
const copyPreprocessResult = () => {
if (!preprocessText.value) {
message.warning('暂无可复制内容');
return;
}
copyText(preprocessText.value);
};
onMounted(() => {
reload();
});
</script>
<script lang="ts">
export default {
name: 'CompetitionHistoryFiles'
};
</script>
<style lang="less" scoped>
.panel-title {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.panel-desc {
margin: 6px 0 16px;
color: rgba(0, 0, 0, 0.55);
line-height: 1.7;
}
.field-label {
margin: 18px 0 8px;
font-weight: 600;
color: rgba(0, 0, 0, 0.82);
}
.action-row {
display: flex;
gap: 10px;
margin-top: 18px;
}
.preprocess-result {
margin-top: 18px;
border: 1px solid #edf0f5;
border-radius: 8px;
background: #fafafa;
}
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid #edf0f5;
font-weight: 600;
}
.preprocess-result pre {
max-height: 260px;
margin: 0;
padding: 12px;
overflow: auto;
color: rgba(0, 0, 0, 0.78);
white-space: pre-wrap;
word-break: break-all;
}
.table-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.doc-name {
font-weight: 600;
color: rgba(0, 0, 0, 0.84);
}
.doc-id {
margin-top: 4px;
color: rgba(0, 0, 0, 0.42);
font-size: 12px;
}
@media (max-width: 768px) {
.table-toolbar {
display: block;
}
.table-toolbar :deep(.ant-space) {
margin-top: 12px;
width: 100%;
}
}
</style>
@@ -0,0 +1,98 @@
<template>
<a-cascader
:value="innerValue"
:options="classCascaderOptions"
:allow-clear="allowClear"
:show-search="showSearch"
:placeholder="placeholder"
:disabled="disabled"
style="width: 100%"
@update:value="updateValue"
/>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import type { ValueType } from 'ant-design-vue/es/vc-cascader/Cascader';
import {
useGxmuFormSelectOptions,
type ClassCascadeSelection
} from '@/views/gxmu/useFormSelectOptions';
const props = withDefaults(
defineProps<{
value?: string;
mode?: 'class' | 'collegeClass';
placeholder?: string;
allowClear?: boolean;
showSearch?: boolean;
disabled?: boolean;
}>(),
{
mode: 'collegeClass',
allowClear: true,
showSearch: true
}
);
const emit = defineEmits<{
(e: 'update:value', value?: string): void;
(e: 'change', value?: ClassCascadeSelection): void;
}>();
const innerValue = ref<string[]>();
const {
classCascaderOptions,
loadClassCascaderOptions,
findClassCascadeSelection,
getClassCascadeSelectionByPath
} = useGxmuFormSelectOptions();
const syncInnerValue = () => {
const selection = findClassCascadeSelection(props.value, props.mode);
innerValue.value = selection?.path;
};
const ensureLoaded = async () => {
await loadClassCascaderOptions();
syncInnerValue();
};
const updateValue = (value: ValueType) => {
const path = (value as string[] | undefined)?.filter(Boolean);
if (!path?.length) {
innerValue.value = void 0;
emit('update:value', void 0);
emit('change', void 0);
return;
}
innerValue.value = path;
const selection = getClassCascadeSelectionByPath(path);
if (!selection) {
emit('update:value', void 0);
emit('change', void 0);
return;
}
emit(
'update:value',
props.mode === 'class' ? selection.className : selection.combinedLabel
);
emit('change', selection);
};
watch(
() => props.value,
() => {
syncInnerValue();
},
{
immediate: true
}
);
ensureLoaded().catch(() => {
innerValue.value = void 0;
});
</script>
+175
View File
@@ -0,0 +1,175 @@
<template>
<ele-modal
:width="560"
:footer="null"
:title="`导入${moduleName}`"
:visible="visible"
@update:visible="updateVisible"
>
<a-spin :spinning="loading">
<a-space direction="vertical" style="width: 100%" :size="16">
<a-alert
type="info"
show-icon
:message="`${moduleName}导入说明`"
:description="description"
/>
<a-button block @click="downloadTemplate">下载模板</a-button>
<a-upload-dragger
accept=".xls,.xlsx"
:show-upload-list="false"
:customRequest="doUpload"
style="padding: 24px 0"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
</a-upload-dragger>
</a-space>
</a-spin>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { CloudUploadOutlined } from '@ant-design/icons-vue';
import { read, utils, writeFile } from 'xlsx';
type ImportItem = Record<string, any>;
export interface ImportFieldConfig {
header: string;
key: string;
required?: boolean;
}
const props = defineProps<{
visible: boolean;
moduleName: string;
fileName: string;
templateFields: ImportFieldConfig[];
sampleRow: Record<string, any>;
importDescription?: string;
createPayload: (row: Record<string, any>, rowIndex: number) => ImportItem;
saveItem: (item: ImportItem) => Promise<unknown>;
validateItem?: (item: ImportItem, rowIndex: number) => string[];
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const loading = ref(false);
const description = computed(
() =>
props.importDescription ||
`请先下载模板,按模板表头填写后上传。必填列请勿留空。`
);
const readFileBuffer = (file: File) => {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
};
const downloadTemplate = () => {
const headers = props.templateFields.map((item) => item.header);
const sample = headers.map((header) => props.sampleRow[header] ?? '');
const sheet = utils.aoa_to_sheet([headers, sample]);
sheet['!cols'] = headers.map((title) => ({
wch: Math.max(String(title).length + 4, 18)
}));
writeFile(
{
SheetNames: ['导入模板'],
Sheets: {
导入模板: sheet
}
},
props.fileName
);
};
const validateRows = (rows: ImportItem[]) => {
const errors: string[] = [];
rows.forEach((item, index) => {
const rowNo = index + 2;
props.templateFields.forEach((field) => {
if (field.required && !String(item[field.key] ?? '').trim()) {
errors.push(`第 ${rowNo} 行缺少${field.header}`);
}
});
if (props.validateItem) {
errors.push(...props.validateItem(item, rowNo));
}
});
return errors;
};
const doUpload = async ({ file }: { file: File }) => {
if (
![
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
].includes(file.type) &&
!file.name.endsWith('.xls') &&
!file.name.endsWith('.xlsx')
) {
message.error('只能选择 Excel 文件');
return false;
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
return false;
}
loading.value = true;
try {
const buffer = await readFileBuffer(file);
const workbook = read(buffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const rows = utils.sheet_to_json<Record<string, any>>(sheet, {
defval: ''
});
if (!rows.length) {
throw new Error('导入文件内容为空');
}
const data = rows
.map((row, index) => props.createPayload(row, index + 2))
.filter((item) =>
props.templateFields.some((field) =>
!!String(item[field.key] ?? '').trim()
)
);
if (!data.length) {
throw new Error('未识别到有效数据');
}
const errors = validateRows(data);
if (errors.length) {
throw new Error(errors.slice(0, 5).join(';'));
}
for (const item of data) {
await props.saveItem(item);
}
message.success(`成功导入 ${data.length} 条${props.moduleName}数据`);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e.message || '导入失败');
} finally {
loading.value = false;
}
return false;
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>
@@ -0,0 +1,133 @@
<template>
<ele-modal
:width="860"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="title || '审核历史'"
:body-style="{ paddingBottom: '20px' }"
@update:visible="updateVisible"
okText="关闭"
@ok="updateVisible(false)"
>
<a-space style="margin-bottom: 12px">
<span>当前审核状态:</span>
<ReviewStatusTag :review-list="list" />
<a-button size="small" :loading="loading" @click="refresh">
刷新
</a-button>
</a-space>
<div style="overflow-y: auto; padding: 10px 0" class="ele-scrollbar-hover">
<a-timeline v-if="displayList.length > 0">
<a-timeline-item
v-for="item in displayList"
:key="item.id"
:color="item.status === 1 ? 'green' : item.status === 0 ? 'red' : 'gray'"
>
<div style="margin-bottom: 8px">
<span style="color: rgba(0, 0, 0, 0.45)">
{{ toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss') }}
</span>
<a-tag
:color="item.status === 1 ? 'success' : item.status === 2 ? 'error' : 'default'"
style="margin-left: 8px"
>
{{ item.status === 1 ? '通过' : item.status === 2 ? '不通过' : '待审核' }}
</a-tag>
</div>
<div v-if="item.user?.realName" style="margin-bottom: 4px">
<span style="font-weight: 500">审核人:</span>
<span>{{ item.user.realName }}</span>
</div>
<div v-if="item.content" style="color: rgba(0, 0, 0, 0.85)">
{{ item.content }}
</div>
</a-timeline-item>
</a-timeline>
<a-empty v-else description="暂无审核记录" :image="simpleImage" />
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { message, Empty } from 'ant-design-vue';
import { toDateString } from 'ele-admin-pro';
import { listReviewList } from '@/api/gxmu/reviewList';
import type { ReviewList } from '@/api/gxmu/reviewList/model';
import ReviewStatusTag from './reviewStatusTag.vue';
const props = defineProps<{
visible: boolean;
title?: string;
module?: string;
pk?: number;
reviewList?: ReviewList[];
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const loading = ref(false);
const maxable = ref(true);
const list = ref<ReviewList[]>([]);
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const displayList = computed(() => {
const getTime = (value?: string) => {
const t = Date.parse(value || '');
return Number.isFinite(t) ? t : 0;
};
return [...(list.value ?? [])].sort((a, b) => {
const diff = getTime(b.createTime) - getTime(a.createTime);
if (diff !== 0) return diff;
return (b.id || 0) - (a.id || 0);
});
});
const refresh = () => {
if (!props.module || !props.pk) {
list.value = props.reviewList ? [...props.reviewList] : [];
return;
}
loading.value = true;
listReviewList({
module: props.module,
pk: props.pk
})
.then((data) => {
loading.value = false;
list.value = data || [];
})
.catch((e) => {
loading.value = false;
message.error(e.message);
list.value = props.reviewList ? [...props.reviewList] : [];
});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
list.value = props.reviewList ? [...props.reviewList] : [];
refresh();
} else {
list.value = [];
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.ele-scrollbar-hover :deep(.ant-timeline-item-last > .ant-timeline-item-content) {
min-height: auto;
}
</style>
@@ -0,0 +1,25 @@
<template>
<template v-if="latest?.status === 1">
<a-tag color="green">通过</a-tag>
</template>
<template v-else-if="latest?.status === 2">
<a-tag color="red">不通过</a-tag>
</template>
<template v-else>
<a-tag>待审核</a-tag>
</template>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import type { ReviewList } from '@/api/gxmu/reviewList/model';
const props = defineProps<{
reviewList?: ReviewList[];
}>();
const latest = computed(() => {
const list = props.reviewList ?? [];
return [...list].sort((a, b) => Number(b.id || 0) - Number(a.id || 0))[0];
});
</script>
+999
View File
@@ -0,0 +1,999 @@
<template>
<div class="creative-page">
<div class="hero-panel">
<div>
<div class="hero-kicker">AI CREATIVE ASSISTANT</div>
<h1>AI创意助手</h1>
<p>
支持文本指令与多类型附件联合驱动工作流,自动返回视频、脚本 Markdown 或图像/排版结果。
</p>
</div>
</div>
<a-row :gutter="[24, 24]">
<a-col :xs="24" :xl="9">
<div class="quick-actions-panel">
<div class="quick-actions-title">快捷模板</div>
<div class="quick-actions">
<a-button block @click="fillExample('video')">短视频创意</a-button>
<a-button block @click="fillExample('script')">脚本创作</a-button>
<a-button block @click="fillExample('image')">海报排版</a-button>
</div>
</div>
<a-card :bordered="false" class="panel-card">
<template #title>输入配置</template>
<a-form layout="vertical">
<a-form-item label="文本指令">
<a-textarea
v-model:value="content"
:auto-size="{ minRows: 10, maxRows: 16 }"
placeholder="请输入用户的文本指令或需求描述,例如:根据活动照片和通知文档,生成一条公众号推文封面及 60 秒短视频脚本。"
/>
</a-form-item>
<a-form-item label="上传附件">
<a-upload
:multiple="true"
:show-upload-list="false"
:accept="acceptTypes"
:customRequest="handleUpload"
>
<a-button>
<template #icon>
<UploadOutlined />
</template>
添加附件
</a-button>
</a-upload>
<div class="upload-tip">
支持图片、视频、文档、音频。
</div>
<div v-if="uploadedFiles.length" class="file-list">
<div
v-for="(item, index) in uploadedFiles"
:key="`${item.url || item.path || item.name}-${index}`"
class="file-item"
>
<div class="file-main">
<div class="file-badge">{{ getFileTypeLabel(item) }}</div>
<div class="file-text">
<div class="file-name">{{ item.name || getFileName(item) }}</div>
<div class="file-url">{{ resolveFileUrl(item) || '-' }}</div>
</div>
</div>
<a class="file-remove" @click="removeUploadedFile(index)">移除</a>
</div>
</div>
</a-form-item>
</a-form>
<div class="submit-bar">
<a-button :disabled="loading" @click="resetAll">重置</a-button>
<a-button type="primary" :loading="loading" :disabled="!canSubmit" @click="runWorkflow">
生成
</a-button>
</div>
</a-card>
</a-col>
<a-col :xs="24" :xl="15">
<a-card :bordered="false" class="panel-card result-card">
<template #title>
<div class="result-head">
<span>工作流结果</span>
<!-- <a-tag :color="resultTagColor">{{ resultTagText }}</a-tag>-->
</div>
</template>
<template #extra>
<a-space>
<a-button :disabled="!scriptMarkdown" @click="copyScript">
复制脚本
</a-button>
<a-button :disabled="!hasAnyResult" @click="clearResult">清空结果</a-button>
</a-space>
</template>
<a-empty
v-if="!loading && !hasAnyResult"
description="填写文本指令并上传附件后执行工作流"
/>
<div v-else class="result-body">
<div v-if="loading" class="loading-panel">
<div class="loading-visual">
<div class="loading-orbit orbit-one"></div>
<div class="loading-orbit orbit-two"></div>
<div class="loading-core">
<span class="loading-core-dot"></span>
<span class="loading-core-dot"></span>
<span class="loading-core-dot"></span>
</div>
</div>
<div class="loading-copy">
<div class="loading-kicker">WORKFLOW RUNNING</div>
<div class="loading-title">{{ loadingTitle }}</div>
<div class="loading-desc">{{ loadingDescription }}</div>
</div>
<div class="loading-steps">
<div
v-for="(item, index) in loadingSteps"
:key="item.title"
class="loading-step"
:class="{ 'is-active': index === 1 }"
>
<div class="loading-step-index">0{{ index + 1 }}</div>
<div class="loading-step-main">
<div class="loading-step-title">{{ item.title }}</div>
<div class="loading-step-desc">{{ item.desc }}</div>
</div>
</div>
</div>
</div>
<a-alert
v-if="statusMessage"
class="status-alert"
type="info"
show-icon
:message="statusMessage"
/>
<div v-if="videoFiles.length" class="result-section">
<div class="section-title">视频生成结果</div>
<div class="video-grid">
<div
v-for="(item, index) in videoFiles"
:key="`${resolveFileUrl(item)}-${index}`"
class="video-card"
>
<video
v-if="isVideoFile(item)"
class="video-player"
controls
:src="resolveFileUrl(item)"
></video>
<div class="result-meta">
<div class="result-name">{{ item.name || getFileName(item) }}</div>
<a :href="resolveFileUrl(item)" target="_blank" rel="noreferrer">
打开文件
</a>
</div>
</div>
</div>
</div>
<div v-if="scriptMarkdown" class="result-section">
<div class="section-title">脚本创作结果</div>
<ByteMdViewer
class="markdown-viewer"
:value="scriptMarkdown"
:plugins="plugins"
/>
</div>
<div v-if="photoFiles.length" class="result-section">
<div class="section-title">图像 / 排版结果</div>
<div class="photo-grid">
<div
v-for="(item, index) in photoFiles"
:key="`${resolveFileUrl(item)}-${index}`"
class="photo-card"
>
<a-image
v-if="isImageFile(item)"
:src="resolveFileUrl(item)"
:preview="true"
class="photo-image"
/>
<div v-else class="photo-fallback">
<div class="file-badge">{{ getFileTypeLabel(item) }}</div>
<a :href="resolveFileUrl(item)" target="_blank" rel="noreferrer">
查看结果文件
</a>
</div>
<div class="result-meta">
<div class="result-name">{{ item.name || getFileName(item) }}</div>
<a :href="resolveFileUrl(item)" target="_blank" rel="noreferrer">
打开文件
</a>
</div>
</div>
</div>
</div>
<!-- <div v-if="rawOutputsText" class="result-section">-->
<!-- <div class="section-title">原始输出</div>-->
<!-- <pre class="raw-output">{{ rawOutputsText }}</pre>-->
<!-- </div>-->
</div>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import 'bytemd/dist/index.min.css';
import 'github-markdown-css/github-markdown-light.css';
import gfm from '@bytemd/plugin-gfm';
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, ref } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { messageLoading } from 'ele-admin-pro';
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { creativeAssistant, uploadAiFile } from '@/api/ai/creativeAssistant';
import type { FileRecord } from '@/api/system/file/model';
import type {
CreativeAssistantOutputs,
CreativeAssistantResponse
} from '@/api/ai/creativeAssistant/model';
import { copyText } from '@/utils/common';
type ExampleType = 'video' | 'script' | 'image';
const acceptTypes =
'image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md';
const plugins = ref([
gfm({
locale: zh_HansGfm
}),
highlight()
]);
const loading = ref(false);
const content = ref('');
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
const workflowResponse = ref<CreativeAssistantResponse | null>(null);
const workflowOutputs = ref<CreativeAssistantOutputs | null>(null);
const canSubmit = computed(() => {
return !!content.value.trim() || uploadedFiles.value.length > 0;
});
const videoFiles = computed(() => workflowOutputs.value?.video_result || []);
const scriptMarkdown = computed(() => workflowOutputs.value?.Script_result || '');
const photoFiles = computed(() => workflowOutputs.value?.photo_files || []);
const statusMessage = computed(() => {
return (
workflowOutputs.value?.video_message ||
workflowOutputs.value?.photo_message ||
''
);
});
const hasAnyResult = computed(() => {
return !!(
videoFiles.value.length ||
scriptMarkdown.value.trim() ||
photoFiles.value.length
);
});
const rawOutputsText = computed(() => {
if (!workflowOutputs.value) {
return '';
}
try {
return JSON.stringify(workflowOutputs.value, null, 2);
} catch (e) {
return '';
}
});
const resultTagText = computed(() => {
if (loading.value) {
return '执行中';
}
if (videoFiles.value.length) {
return '视频生成';
}
if (scriptMarkdown.value.trim()) {
return '脚本创作';
}
if (photoFiles.value.length) {
return '图像/排版';
}
return '待执行';
});
const resultTagColor = computed(() => {
if (loading.value) {
return 'processing';
}
if (hasAnyResult.value) {
return 'success';
}
return 'default';
});
const loadingTitle = computed(() => {
if (uploadedFiles.value.length && content.value.trim()) {
return '正在分析指令并处理附件';
}
if (uploadedFiles.value.length) {
return '正在解析附件并生成结果';
}
return '正在根据文本指令生成内容';
});
const loadingDescription = computed(() => {
return '该工作流可能持续数分钟,请保持页面开启。系统正在后台完成素材理解、内容生成与结果整理。';
});
const loadingSteps = [
{
title: '输入理解',
desc: '解析文本指令、识别目标输出和执行路径'
},
{
title: '内容生成',
desc: '调用工作流处理视频、脚本或图像排版任务'
},
{
title: '结果整理',
desc: '回收文件链接并整理结构化输出'
}
];
const normalizeOutputPayload = (payload: CreativeAssistantResponse | null) => {
if (!payload) {
return {};
}
if (payload.outputs && typeof payload.outputs === 'object') {
return payload.outputs;
}
if ((payload as any).data?.outputs && typeof (payload as any).data.outputs === 'object') {
return (payload as any).data.outputs;
}
return payload as CreativeAssistantOutputs;
};
const resolveFileUrl = (file?: Partial<FileRecord> | null) => {
if (!file) {
return '';
}
return String(file.url || file.downloadUrl || file.thumbnail || file.path || '');
};
const getFileName = (file?: Partial<FileRecord> | null) => {
const value = resolveFileUrl(file);
if (!value) {
return '未命名文件';
}
return decodeURIComponent(value.split('?')[0].split('/').pop() || value);
};
const getFileExt = (file?: Partial<FileRecord> | null) => {
const name = String(file?.name || getFileName(file)).toLowerCase();
return name.split('.').pop() || '';
};
const DOCUMENT_FILE_EXTS = new Set([
'TXT',
'MD',
'MARKDOWN',
'PDF',
'HTML',
'XLSX',
'XLS',
'DOCX',
'CSV',
'EML',
'MSG',
'PPTX',
'PPT',
'XML',
'EPUB'
]);
const AUDIO_FILE_EXTS = new Set(['MP3', 'M4A', 'WAV', 'WEBM', 'AMR']);
const VIDEO_FILE_EXTS = new Set(['MP4', 'MOV', 'MPEG', 'MPGA']);
const isImageFile = (file?: Partial<FileRecord> | null) => {
const contentType = String(file?.contentType || '').toLowerCase();
if (contentType.startsWith('image/')) {
return true;
}
return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(getFileExt(file));
};
const isVideoFile = (file?: Partial<FileRecord> | null) => {
const contentType = String(file?.contentType || '').toLowerCase();
if (contentType.startsWith('video/')) {
return true;
}
return ['mp4', 'mov', 'm4v', 'webm', 'avi'].includes(getFileExt(file));
};
const isAudioFile = (file?: Partial<FileRecord> | null) => {
const contentType = String(file?.contentType || '').toLowerCase();
if (contentType.startsWith('audio/')) {
return true;
}
return ['mp3', 'wav', 'aac', 'm4a', 'ogg'].includes(getFileExt(file));
};
const getUploadFileType = (
file?: Partial<FileRecord> | null
): 'image' | 'document' | 'audio' | 'video' => {
const contentType = String(file?.contentType || '').toLowerCase();
const ext = getFileExt(file).toUpperCase();
if (contentType.startsWith('image/')) {
return 'image';
}
if (AUDIO_FILE_EXTS.has(ext)) {
return 'audio';
}
if (VIDEO_FILE_EXTS.has(ext)) {
return 'video';
}
if (DOCUMENT_FILE_EXTS.has(ext)) {
return 'document';
}
return 'document';
};
const getFileTypeLabel = (file?: Partial<FileRecord> | null) => {
if (isImageFile(file)) {
return '图片';
}
if (isVideoFile(file)) {
return '视频';
}
if (isAudioFile(file)) {
return '音频';
}
return '文档';
};
const clearResult = () => {
workflowResponse.value = null;
workflowOutputs.value = null;
};
const resetAll = () => {
content.value = '';
uploadedFiles.value = [];
clearResult();
};
const removeUploadedFile = (index: number) => {
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
};
const handleUpload = async (options: any) => {
const rawFile = options.file as File;
if (rawFile.size / 1024 / 1024 > 100) {
message.error('单个附件不能超过 100MB');
options.onError?.(new Error('文件过大'));
return;
}
const hide = messageLoading({
content: '上传中..',
duration: 0,
mask: true
});
try {
const result = await uploadAiFile(rawFile);
uploadedFiles.value = [
...uploadedFiles.value,
{
id: result.id,
name: result.name || rawFile.name,
path: result.path,
url: result.url,
downloadUrl: result.downloadUrl,
contentType: result.contentType
}
];
options.onSuccess?.(result);
message.success('附件上传成功');
} catch (e: any) {
message.error(e.message || '上传失败');
options.onError?.(e);
} finally {
hide();
}
};
const buildRequestPayload = () => {
const payload: any = {
user_content: content.value.trim(),
user_contentString: content.value.trim()
};
if (uploadedFiles.value.length) {
payload.user_files = uploadedFiles.value.map((item) => ({
transfer_method: 'local_file',
type: getUploadFileType(item),
upload_file_id: item.id,
url: ''
}));
}
return payload;
};
const runWorkflow = async () => {
if (!canSubmit.value) {
message.warning('请先输入文本指令或上传附件');
return;
}
loading.value = true;
try {
const result = await creativeAssistant(buildRequestPayload());
workflowResponse.value = result || null;
workflowOutputs.value = normalizeOutputPayload(result || null);
if (
!videoFiles.value.length &&
!scriptMarkdown.value.trim() &&
!photoFiles.value.length
) {
message.warning('接口已返回,但未识别到文档约定的输出字段');
} else {
message.success('工作流执行完成');
}
} catch (e: any) {
message.error(e.message || '工作流执行失败');
} finally {
loading.value = false;
}
};
const copyScript = () => {
if (!scriptMarkdown.value.trim()) {
message.warning('暂无脚本结果');
return;
}
copyText(scriptMarkdown.value);
};
const fillExample = (type: ExampleType) => {
if (type === 'video') {
content.value =
'请根据我上传的活动照片和通知文档,生成一条 45 秒校园活动宣传短视频,画面节奏明快,配一段适合配音的解说文案,并输出视频结果。';
return;
}
if (type === 'script') {
content.value =
'围绕“青年志愿者进社区”主题创作一份短视频分镜脚本,要求包含开场钩子、3 个主要场景和结尾号召,输出为 Markdown 脚本。';
return;
}
content.value =
'请根据上传的活动照片、LOGO 和文案素材,生成一套公众号头图与活动海报排版,风格青春、明亮,输出图片文件。';
};
</script>
<script lang="ts">
export default {
name: 'OaCreativeAssistant'
};
</script>
<style scoped lang="less">
.creative-page {
min-height: 100%;
padding: 24px;
background:
radial-gradient(circle at top right, rgba(255, 154, 93, 0.16), transparent 24%),
linear-gradient(180deg, #fff8f3 0%, #f7fbff 100%);
}
.hero-panel {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
margin-bottom: 24px;
border-radius: 24px;
background: linear-gradient(135deg, #3b0f14 0%, #8f1f2d 48%, #d6404f 100%);
color: #f5fbff;
box-shadow: 0 18px 40px rgba(143, 31, 45, 0.2);
}
.hero-kicker {
margin-bottom: 8px;
font-size: 12px;
letter-spacing: 0.22em;
color: rgba(255, 235, 237, 0.72);
}
.hero-panel h1 {
margin: 0;
font-size: 32px;
line-height: 1.2;
color: #fff8f8;
}
.hero-panel p {
max-width: 760px;
margin: 14px 0 0;
line-height: 1.8;
color: rgba(255, 239, 241, 0.88);
}
.panel-card {
border-radius: 20px;
box-shadow: 0 14px 34px rgba(87, 54, 30, 0.08);
}
.quick-actions-panel {
margin-bottom: 14px;
padding: 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 34px rgba(87, 54, 30, 0.08);
}
.quick-actions-title {
margin-bottom: 12px;
color: #4d3828;
font-size: 14px;
font-weight: 700;
}
.quick-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.upload-tip {
margin-top: 10px;
color: #88715f;
font-size: 12px;
line-height: 1.7;
}
.file-list {
margin-top: 14px;
display: grid;
gap: 10px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: #faf5ef;
border: 1px solid #c7e8ff;
}
.file-main {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.file-badge {
flex-shrink: 0;
min-width: 48px;
padding: 4px 10px;
border-radius: 999px;
background: #f2e1d3;
color: #8a4f1d;
text-align: center;
font-size: 12px;
font-weight: 600;
}
.file-text {
flex: 1;
min-width: 0;
}
.file-name,
.file-url,
.result-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-name {
color: #3b2c20;
font-weight: 600;
}
.file-url {
margin-top: 4px;
color: #8c7462;
font-size: 12px;
}
.file-remove {
flex-shrink: 0;
color: #ff4d4f;
}
.submit-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.result-card {
min-height: 100%;
}
.result-head {
display: flex;
align-items: center;
gap: 10px;
}
.result-body {
display: grid;
gap: 18px;
}
.status-alert {
border-radius: 14px;
}
.loading-panel {
display: grid;
gap: 18px;
padding: 22px;
border-radius: 20px;
background:
radial-gradient(circle at top right, rgba(255, 182, 122, 0.24), transparent 30%),
linear-gradient(135deg, #fff7ef 0%, #fffdfb 100%);
border: 1px solid #c7e8ff;
overflow: hidden;
}
.loading-visual {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 132px;
height: 132px;
margin: 0 auto;
}
.loading-orbit {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(214, 117, 44, 0.18);
}
.orbit-one {
animation: orbitPulse 2.4s ease-in-out infinite;
}
.orbit-two {
inset: 14px;
border-color: rgba(214, 117, 44, 0.28);
animation: orbitPulse 2.4s ease-in-out infinite 0.7s;
}
.loading-core {
display: flex;
align-items: center;
gap: 8px;
padding: 18px 24px;
border-radius: 999px;
background: linear-gradient(135deg, #1590ff 0%, #32bfff 100%);
box-shadow: 0 14px 30px rgba(160, 83, 29, 0.2);
}
.loading-core-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff8f1;
animation: dotBounce 1.2s ease-in-out infinite;
}
.loading-core-dot:nth-child(2) {
animation-delay: 0.18s;
}
.loading-core-dot:nth-child(3) {
animation-delay: 0.36s;
}
.loading-copy {
text-align: center;
}
.loading-kicker {
margin-bottom: 8px;
color: #b37545;
font-size: 12px;
letter-spacing: 0.24em;
}
.loading-title {
color: #4e311c;
font-size: 22px;
font-weight: 700;
line-height: 1.4;
}
.loading-desc {
max-width: 680px;
margin: 10px auto 0;
color: #876650;
line-height: 1.8;
}
.loading-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.loading-step {
display: flex;
gap: 12px;
padding: 14px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid #f1e3d7;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
}
.loading-step.is-active {
transform: translateY(-2px);
border-color: #edb27f;
box-shadow: 0 10px 24px rgba(214, 117, 44, 0.12);
}
.loading-step-index {
flex-shrink: 0;
color: #ce8348;
font-size: 12px;
font-weight: 700;
line-height: 1.8;
}
.loading-step-title {
color: #4f3320;
font-weight: 600;
}
.loading-step-desc {
margin-top: 4px;
color: #8c705d;
font-size: 12px;
line-height: 1.7;
}
.result-section {
padding: 18px;
border-radius: 18px;
background: #faf8f5;
}
.section-title {
margin-bottom: 14px;
color: #4d3828;
font-size: 16px;
font-weight: 700;
}
.video-grid,
.photo-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.video-card,
.photo-card {
padding: 12px;
border-radius: 14px;
background: #fff;
border: 1px solid #efe6dc;
}
.video-player {
display: block;
//width: 100%;
height: 500px;
object-fit: cover;
border-radius: 10px;
background: #000;
}
.photo-image :deep(img) {
width: 100%;
height: 220px;
object-fit: cover;
border-radius: 10px;
}
.photo-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 220px;
border-radius: 10px;
background: linear-gradient(135deg, #f7f1ea 0%, #fff 100%);
}
.result-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
}
.result-meta a {
flex-shrink: 0;
}
.markdown-viewer :deep(.markdown-body) {
padding: 18px;
border-radius: 14px;
background: #fff;
}
.raw-output {
margin: 0;
padding: 14px;
border-radius: 12px;
background: #1f1a17;
color: #f7ede2;
overflow: auto;
font-size: 12px;
line-height: 1.7;
}
@keyframes orbitPulse {
0% {
transform: scale(0.9);
opacity: 0.25;
}
50% {
transform: scale(1.04);
opacity: 0.7;
}
100% {
transform: scale(0.9);
opacity: 0.25;
}
}
@keyframes dotBounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.55;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
@media (max-width: 991px) {
.creative-page {
padding: 16px;
}
.hero-panel {
flex-direction: column;
padding: 22px 20px;
}
.hero-panel h1 {
font-size: 28px;
}
.video-grid,
.photo-grid {
grid-template-columns: 1fr;
}
.loading-steps {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,887 @@
<template>
<div class="ele-body cross-board-page">
<section class="hero-panel">
<div class="hero-copy">
<div class="hero-kicker">跨校活动情报看板</div>
<h1>把兄弟高校的活动打法,整理成一眼可读的案例板</h1>
<p>
以信息流和案例卡片形式集中展示同类活动的公开线索,方便快速判断选题趋势、包装方式和传播动作。
</p>
<div class="hero-actions">
<a-button type="primary" size="large" @click="selectedHeat = '高热'">
本周热点
</a-button>
<a-button size="large" @click="keyword = '活动'">拟办活动对标</a-button>
</div>
</div>
<div class="hero-stats">
<div class="hero-stat stat-primary">
<div class="stat-label">案例总量</div>
<div class="stat-value">{{ totalCount }}</div>
<div class="stat-desc">来自公开新闻站点抓取入库</div>
</div>
<div class="hero-stat">
<div class="stat-label">高热案例</div>
<div class="stat-value">{{ highHeatCount }}</div>
<div class="stat-desc">适合优先拆解传播路径</div>
</div>
<div class="hero-stat">
<div class="stat-label">覆盖类型</div>
<div class="stat-value">{{ categoryCount }}</div>
<div class="stat-desc">品牌活动 / 实践 / 科创 / 文体</div>
</div>
<div class="hero-stat">
<div class="stat-label">来源院校</div>
<div class="stat-value">{{ schoolCount }}</div>
<div class="stat-desc">按院校公开渠道聚合</div>
</div>
</div>
</section>
<section class="filter-panel">
<div class="filter-group">
<span class="filter-label">活动类型</span>
<button
v-for="item in categoryOptions"
:key="item"
type="button"
class="filter-chip"
:class="{ active: selectedCategory === item }"
@click="selectedCategory = item"
>
{{ item }}
</button>
</div>
<div class="filter-group">
<span class="filter-label">热度等级</span>
<button
v-for="item in heatOptions"
:key="item"
type="button"
class="filter-chip"
:class="{ active: selectedHeat === item }"
@click="selectedHeat = item"
>
{{ item }}
</button>
</div>
<a-input-search
v-model:value="keyword"
allow-clear
class="keyword-search"
placeholder="搜索标题、院校、标签"
/>
</section>
<a-card :bordered="false" class="stream-card" :loading="loading">
<template #title>情报信息流</template>
<div class="stream-list">
<article
v-for="item in streamItems"
:key="item.id"
class="stream-item"
>
<img
:src="item.cover"
:alt="item.title"
class="stream-cover"
@error="handleCoverError"
/>
<div class="stream-content">
<div class="stream-top">
<div>
<div class="stream-title">{{ item.title }}</div>
<div class="stream-meta">
<span>{{ item.school }}</span>
<span>{{ item.source }}</span>
<span>{{ item.publishAt }}</span>
</div>
</div>
<a-tag :color="heatColorMap[item.heat]">{{ item.heat }}</a-tag>
</div>
<div class="stream-summary">{{ item.summary }}</div>
<div class="stream-tags">
<span v-for="tag in item.tags" :key="tag" class="stream-tag">
{{ tag }}
</span>
</div>
</div>
</article>
</div>
<a-empty
v-if="!streamItems.length"
class="cases-empty"
description="暂无情报数据"
/>
</a-card>
<a-card :bordered="false" class="cases-card" :loading="loading">
<template #title>
<div class="case-title-wrap">
<span>案例卡片墙</span>
<span class="case-count">共 {{ filteredCases.length }} 条</span>
</div>
</template>
<div class="cases-grid">
<article
v-for="item in filteredCases"
:key="item.id"
class="case-card"
>
<div class="case-cover-wrap">
<img
:src="item.cover"
:alt="item.title"
class="case-cover"
@error="handleCoverError"
/>
<div class="case-school">{{ item.school }}</div>
</div>
<div class="case-body">
<div class="case-head">
<a-tag>{{ item.category }}</a-tag>
<a-tag :color="heatColorMap[item.heat]">{{ item.heat }}</a-tag>
</div>
<h3 class="case-title">{{ item.title }}</h3>
<p class="case-summary">{{ item.summary }}</p>
<div class="case-info">
<span>来源:{{ item.source }}</span>
<span>发布时间:{{ item.publishAt }}</span>
</div>
<div class="case-tags">
<span v-for="tag in item.tags" :key="tag" class="case-tag">
# {{ tag }}
</span>
</div>
<div class="case-footer">
<div class="signal-badge">
<span class="signal-badge-label">可借鉴动作</span>
<span class="signal-badge-value">{{ item.highlight }}</span>
</div>
<a
class="origin-link"
:href="item.link"
target="_blank"
rel="noreferrer"
>
查看原文
</a>
</div>
</div>
</article>
</div>
<a-empty
v-if="!filteredCases.length"
class="cases-empty"
description="暂无匹配的案例"
/>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from "vue";
import { message } from "ant-design-vue";
import {
listCrossSchoolActivityArticle,
syncCrossSchoolActivityArticle
} from "@/api/gxmu/cross-school-activity-article";
import type { CrossSchoolActivityArticle } from "@/api/gxmu/cross-school-activity-article/model";
import defaultCover from "@/assets/暂无图片.png";
interface ActivityCase {
id: number;
title: string;
school: string;
category: string;
heat: "高热" | "跟进" | "观察";
source: string;
publishAt: string;
summary: string;
tags: string[];
cover: string;
link: string;
highlight: string;
}
const cases = ref<ActivityCase[]>([]);
const loading = ref(false);
const syncing = ref(false);
const lastSyncTime = ref("");
const categoryOptions = computed(() => [
"全部",
...Array.from(new Set(cases.value.map((item) => item.category).filter(Boolean)))
]);
const heatOptions = ["全部", "高热", "跟进", "观察"];
const selectedCategory = ref("全部");
const selectedHeat = ref("全部");
const keyword = ref("");
const heatColorMap: Record<ActivityCase["heat"], string> = {
高热: "error",
跟进: "processing",
观察: "default"
};
const parseTags = (value?: string): string[] => {
if (!value) {
return [];
}
try {
const result = JSON.parse(value);
return Array.isArray(result) ? result.filter(Boolean) : [];
} catch (e) {
return value
.split(/[,,#\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
};
const formatTime = (value?: string) => {
if (!value) {
return "未知时间";
}
return value.replace("T", " ").slice(0, 16);
};
const handleCoverError = (event: Event) => {
const image = event.target as HTMLImageElement | null;
if (image && image.src !== defaultCover) {
image.src = defaultCover;
}
};
const mapArticle = (
item: CrossSchoolActivityArticle,
index: number
): ActivityCase => ({
id: item.id || index + 1,
title: item.title || "未命名文章",
school: item.schoolName || "未知院校",
category: item.category || "校园要闻",
heat: (item.heatLevel as ActivityCase["heat"]) || "观察",
source: item.sourceName || "学校官网",
publishAt: formatTime(item.publishTime),
summary: item.summary || "来源站点暂未提供摘要,可点击原文查看完整内容。",
tags: parseTags(item.tags),
cover: item.coverImage?.trim() || defaultCover,
link: item.detailUrl || item.sourceUrl || "#",
highlight: item.highlight || "选题跟踪 + 案例复盘"
});
const loadData = async () => {
loading.value = true;
try {
const list = await listCrossSchoolActivityArticle({
limit: 80,
sort: "publishTime",
order: "desc"
});
cases.value = list.map(mapArticle);
const latest = list.find((item) => item.lastSyncTime)?.lastSyncTime;
lastSyncTime.value = latest ? formatTime(latest) : "";
} catch (e: any) {
message.error(e?.message || "跨校活动情报加载失败");
} finally {
loading.value = false;
}
};
const handleSync = async () => {
syncing.value = true;
try {
const count = await syncCrossSchoolActivityArticle();
message.success(`同步完成,写入/更新 ${count} 条`);
await loadData();
} catch (e: any) {
message.error(e?.message || "同步失败");
} finally {
syncing.value = false;
}
};
const filteredCases = computed(() => {
const text = keyword.value.trim().toLowerCase();
return cases.value.filter((item) => {
const matchCategory =
selectedCategory.value === "全部" || item.category === selectedCategory.value;
const matchHeat =
selectedHeat.value === "全部" || item.heat === selectedHeat.value;
const searchText = [item.title, item.school, item.summary, item.tags.join(" ")]
.join(" ")
.toLowerCase();
const matchKeyword = !text || searchText.includes(text);
return matchCategory && matchHeat && matchKeyword;
});
});
const totalCount = computed(() => cases.value.length);
const highHeatCount = computed(
() => cases.value.filter((item) => item.heat === "高热").length
);
const categoryCount = computed(
() => new Set(cases.value.map((item) => item.category)).size
);
const schoolCount = computed(
() => new Set(cases.value.map((item) => item.school)).size
);
const streamItems = computed(() => filteredCases.value.slice(0, 4));
const insightSignals = computed(() => [
{
title: "高频选题集中在交流、竞赛与专题学习",
desc: `当前抓取到 ${highHeatCount.value} 条高热线索,可优先拆解标题包装、组织协同和后续传播动作。`
},
{
title: "兄弟高校要闻适合做快速对标池",
desc: "列表按发布时间汇总,能快速观察近期高校关注的活动主题、承办单位和报道角度。"
},
{
title: "建议保留原文入口做复核",
desc: "抓取结果保留来源链接,便于在策划复盘时回到原文确认细节和图片素材。"
}
]);
const watchList = [
"活动是否配置统一 hashtag 与二创任务",
"是否提供报名后自动领取的素材包",
"是否能把案例沉淀为往届经验库",
"是否区分预热、爆发、回顾三个传播阶段"
];
onMounted(() => {
loadData();
});
</script>
<script lang="ts">
export default {
name: "GxmuCrossSchoolActivityBoard"
};
</script>
<style lang="less" scoped>
.cross-board-page {
--board-bg: #f5fbff;
--board-card: rgba(255, 255, 255, 0.92);
--board-line: rgba(21, 144, 255, 0.12);
--board-text: #1f2d3d;
--board-subtext: #5f7f9f;
--board-primary: @primary-color;
--board-primary-soft: #e8f4ff;
--board-shadow: 0 16px 48px rgba(21, 144, 255, 0.1);
min-height: calc(100vh - 120px);
background: radial-gradient(circle at top left, rgba(80, 180, 255, 0.18), transparent 28%),
linear-gradient(180deg, #f5fbff 0%, #f5fbff 36%, #fff 100%);
}
.hero-panel {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(320px, 420px);
gap: 20px;
margin-bottom: 16px;
padding: 28px;
border-radius: 28px;
overflow: hidden;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(245, 251, 255, 0.94)),
repeating-linear-gradient(90deg,
rgba(21, 144, 255, 0.04) 0,
rgba(21, 144, 255, 0.04) 1px,
transparent 1px,
transparent 24px);
box-shadow: var(--board-shadow);
border: 1px solid rgba(21, 144, 255, 0.14);
}
.hero-panel::after {
content: '';
position: absolute;
right: -80px;
top: -60px;
width: 260px;
height: 260px;
border-radius: 50%;
background: radial-gradient(circle, rgba(21, 144, 255, 0.18), transparent 68%);
pointer-events: none;
}
.hero-copy {
position: relative;
z-index: 1;
}
.hero-kicker {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: rgba(21, 144, 255, 0.08);
color: var(--board-primary);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
}
.hero-copy h1 {
max-width: 620px;
margin: 18px 0 12px;
color: var(--board-text);
font-size: 34px;
line-height: 1.25;
font-weight: 700;
}
.hero-copy p {
max-width: 600px;
margin: 0;
color: var(--board-subtext);
font-size: 15px;
line-height: 1.8;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 22px;
}
.hero-stats {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.hero-stat {
padding: 18px;
border-radius: 22px;
background: var(--board-card);
border: 1px solid rgba(21, 144, 255, 0.12);
box-shadow: 0 10px 28px rgba(21, 144, 255, 0.07);
backdrop-filter: blur(10px);
}
.stat-primary {
background: linear-gradient(160deg, #32bfff 0%, @primary-color 100%);
color: #fff;
}
.stat-primary .stat-label,
.stat-primary .stat-desc {
color: rgba(255, 255, 255, 0.8);
}
.stat-label {
color: var(--board-subtext);
font-size: 13px;
}
.stat-value {
margin: 10px 0 6px;
font-size: 32px;
line-height: 1;
font-weight: 700;
color: inherit;
}
.stat-desc {
color: var(--board-subtext);
font-size: 12px;
line-height: 1.6;
}
.demo-alert {
margin-bottom: 16px;
border-radius: 18px;
border: 1px solid rgba(21, 144, 255, 0.18);
background: rgba(255, 251, 248, 0.95);
}
.filter-panel {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
margin-bottom: 16px;
padding: 18px 20px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(21, 144, 255, 0.12);
box-shadow: 0 10px 30px rgba(21, 144, 255, 0.06);
}
.filter-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.filter-label {
color: var(--board-subtext);
font-size: 13px;
font-weight: 600;
}
.filter-chip {
padding: 7px 14px;
border: 1px solid rgba(21, 144, 255, 0.16);
border-radius: 999px;
background: #f5fbff;
color: var(--board-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-chip.active,
.filter-chip:hover {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, #32bfff, @primary-color);
box-shadow: 0 8px 20px rgba(21, 144, 255, 0.22);
}
.keyword-search {
width: 280px;
margin-left: auto;
}
.overview-row {
margin-bottom: 16px;
}
.stream-card,
.signal-card,
.cases-card {
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 16px 40px rgba(21, 144, 255, 0.08);
margin-top: 12px;
}
.stream-list {
display: grid;
gap: 14px;
}
.stream-item {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 16px;
padding: 16px;
border-radius: 20px;
background: linear-gradient(180deg, #f5fbff, #fff);
border: 1px solid rgba(21, 144, 255, 0.1);
}
.stream-cover {
width: 100%;
height: 128px;
object-fit: cover;
border-radius: 16px;
background: #e8f4ff;
}
.stream-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.stream-title {
color: var(--board-text);
font-size: 18px;
font-weight: 700;
line-height: 1.5;
}
.stream-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 6px;
color: #5f7f9f;
font-size: 12px;
}
.stream-summary {
margin-top: 12px;
color: #1f2d3d;
font-size: 14px;
line-height: 1.8;
}
.stream-tags,
.case-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.stream-tag,
.case-tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
background: var(--board-primary-soft);
color: #1590ff;
font-size: 12px;
}
.signal-list {
display: grid;
gap: 14px;
}
.signal-item {
padding: 16px;
border-radius: 18px;
background: linear-gradient(180deg, #f5fbff, #fff);
border: 1px solid rgba(21, 144, 255, 0.1);
}
.signal-title {
color: var(--board-text);
font-size: 15px;
font-weight: 700;
line-height: 1.5;
}
.signal-title.secondary {
margin-bottom: 12px;
}
.signal-desc {
margin-top: 8px;
color: #5f7f9f;
font-size: 13px;
line-height: 1.8;
}
.signal-divider {
height: 1px;
margin: 18px 0;
background: linear-gradient(90deg, rgba(21, 144, 255, 0.18), transparent);
}
.watch-list {
display: grid;
gap: 10px;
}
.watch-item {
position: relative;
padding-left: 14px;
color: #5f7f9f;
font-size: 13px;
line-height: 1.7;
}
.watch-item::before {
content: '';
position: absolute;
left: 0;
top: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #1590ff;
}
.case-title-wrap {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.case-count {
color: #5f7f9f;
font-size: 13px;
font-weight: 400;
}
.cases-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 18px;
}
.case-card {
overflow: hidden;
border-radius: 22px;
border: 1px solid rgba(21, 144, 255, 0.12);
background: linear-gradient(180deg, #f5fbff 0%, #fff 45%);
box-shadow: 0 12px 30px rgba(21, 144, 255, 0.08);
transition: transform 0.24s ease,
box-shadow 0.24s ease;
}
.case-card:hover {
transform: translateY(-4px);
box-shadow: 0 18px 40px rgba(21, 144, 255, 0.12);
}
.case-cover-wrap {
position: relative;
}
.case-cover {
width: 100%;
height: 190px;
object-fit: cover;
display: block;
background: #e8f4ff;
}
.case-school {
position: absolute;
left: 16px;
bottom: 14px;
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: rgba(31, 45, 61, 0.72);
color: #fff;
font-size: 12px;
backdrop-filter: blur(8px);
}
.case-body {
padding: 18px;
}
.case-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.case-title {
margin: 14px 0 10px;
color: var(--board-text);
font-size: 19px;
line-height: 1.5;
font-weight: 700;
}
.case-summary {
min-height: 88px;
margin: 0;
color: #5f7f9f;
font-size: 14px;
line-height: 1.85;
}
.case-info {
display: grid;
gap: 6px;
margin-top: 14px;
color: #5f7f9f;
font-size: 12px;
}
.case-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
}
.signal-badge {
display: inline-flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(245, 251, 255, 0.95);
}
.signal-badge-label {
color: #1590ff;
font-size: 11px;
}
.signal-badge-value {
color: #1590ff;
font-size: 13px;
font-weight: 700;
}
.origin-link {
color: var(--board-primary);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.cases-empty {
margin-top: 20px;
}
@media (max-width: 1200px) {
.hero-panel {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.cross-board-page {
padding-bottom: 10px;
}
.hero-panel {
padding: 22px;
border-radius: 22px;
}
.hero-copy h1 {
font-size: 28px;
}
.hero-stats {
grid-template-columns: 1fr 1fr;
}
.filter-panel {
padding: 16px;
}
.keyword-search {
width: 100%;
margin-left: 0;
}
.stream-item {
grid-template-columns: 1fr;
}
.stream-cover {
height: 180px;
}
}
@media (max-width: 576px) {
.hero-stats {
grid-template-columns: 1fr;
}
.case-footer,
.case-title-wrap {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,474 @@
<!-- 编辑弹窗 -->
<template>
<ele-modal
:width="800"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑申报管理' : '添加申报管理'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 4, sm: 5, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="名称" name="title">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.title"
/>
</a-form-item>
<a-form-item label="年份" name="year">
<a-date-picker
allow-clear
picker="year"
value-format="YYYY"
class="ele-fluid"
placeholder="请选择年份"
v-model:value="yearValue"
/>
</a-form-item>
<a-form-item label="模块" name="module">
<a-select
allow-clear
show-search
:options="DECLARE_MODULE_OPTIONS"
option-filter-prop="label"
placeholder="请选择"
v-model:value="form.module"
/>
</a-form-item>
<template v-if="isTzbcyModule">
<a-form-item label="项目申报表配置">
<div class="declare-config-block">
<div class="declare-config-title">项目类型</div>
<div
v-for="(item, index) in form.formProjectType"
:key="`form-project-type-${index}`"
class="declare-config-row"
>
<a-input
v-model:value="form.formProjectType[index]"
placeholder="请输入项目申报表项目类型"
/>
<a-button danger @click="removeConfigRow('formProjectType', index)"
>删除</a-button
>
</div>
<a-button type="dashed" block @click="addConfigRow('formProjectType')"
>新增项目类型</a-button
>
</div>
<div class="declare-config-block">
<div class="declare-config-title">项目分组</div>
<div
v-for="(item, index) in form.formProjectGroup"
:key="`form-project-group-${index}`"
class="declare-config-row"
>
<a-input
v-model:value="form.formProjectGroup[index]"
placeholder="请输入项目申报表项目分组"
/>
<a-button
danger
@click="removeConfigRow('formProjectGroup', index)"
>
删除
</a-button>
</div>
<a-button
type="dashed"
block
@click="addConfigRow('formProjectGroup')"
>
新增项目分组
</a-button>
</div>
</a-form-item>
<a-form-item label="公开展示信息表配置">
<div class="declare-config-block">
<div class="declare-config-title">项目类型</div>
<div
v-for="(item, index) in form.publicProjectType"
:key="`public-project-type-${index}`"
class="declare-config-row"
>
<a-input
v-model:value="form.publicProjectType[index]"
placeholder="请输入公开展示信息表项目类型"
/>
<a-button
danger
@click="removeConfigRow('publicProjectType', index)"
>
删除
</a-button>
</div>
<a-button
type="dashed"
block
@click="addConfigRow('publicProjectType')"
>
新增项目类型
</a-button>
</div>
<div class="declare-config-block">
<div class="declare-config-title">项目分组</div>
<div
v-for="(item, index) in form.publicProjectGroup"
:key="`public-project-group-${index}`"
class="declare-config-row"
>
<a-input
v-model:value="form.publicProjectGroup[index]"
placeholder="请输入公开展示信息表项目分组"
/>
<a-button
danger
@click="removeConfigRow('publicProjectGroup', index)"
>
删除
</a-button>
</div>
<a-button
type="dashed"
block
@click="addConfigRow('publicProjectGroup')"
>
新增项目分组
</a-button>
</div>
</a-form-item>
</template>
<a-form-item label="开始时间" name="startTime">
<a-date-picker
allow-clear
show-time
value-format="YYYY-MM-DD HH:mm:ss"
class="ele-fluid"
placeholder="请选择开始时间"
v-model:value="form.startTime"
/>
</a-form-item>
<a-form-item label="结束时间" name="endTime">
<a-date-picker
allow-clear
show-time
value-format="YYYY-MM-DD HH:mm:ss"
class="ele-fluid"
placeholder="请选择结束时间"
v-model:value="form.endTime"
/>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { addDeclare, updateDeclare } from '@/api/gxmu/declare';
import type { Declare } from '@/api/gxmu/declare/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
import { DECLARE_MODULE_OPTIONS } from '../moduleOptions';
import {
normalizeDeclareRecord,
stringifyDeclareMultiValueField
} from '../projectFieldUtils';
interface DeclareEditForm
extends Omit<
Declare,
| 'projectType'
| 'projectGroup'
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup'
> {
projectType: string[];
projectGroup: string[];
formProjectType: string[];
formProjectGroup: string[];
publicProjectType: string[];
publicProjectGroup: string[];
}
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: Declare | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 表格选中数据
const formRef = ref<FormInstance | null>(null);
const yearValue = ref<string>();
const createDefaultForm = (): DeclareEditForm => ({
id: undefined,
title: undefined,
year: undefined,
module: undefined,
projectType: [],
projectGroup: [],
formProjectType: [''],
formProjectGroup: [''],
publicProjectType: [''],
publicProjectGroup: [''],
startTime: undefined,
endTime: undefined,
userId: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
deleted: undefined
});
// 用户信息
const form = reactive<DeclareEditForm>(createDefaultForm());
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
module: [
{
required: true,
message: '请选择模块',
trigger: 'change'
}
],
});
const { resetFields } = useForm(form, rules);
const isTzbcyModule = computed(() => form.module === 'gxmu_tzbcy_form');
const normalizeEditableList = (list?: string[]) => {
const values = (list || []).map((item) => item?.trim() || '').filter(Boolean);
return values.length ? values : [''];
};
const addConfigRow = (
key:
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup'
) => {
form[key].push('');
};
const removeConfigRow = (
key:
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup',
index: number
) => {
form[key].splice(index, 1);
if (!form[key].length) {
form[key].push('');
}
};
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
form.year = yearValue.value ? Number(yearValue.value) : undefined;
formRef.value
.validate()
.then(() => {
loading.value = true;
const normalizedFormProjectType = normalizeEditableList(
form.formProjectType
);
const normalizedFormProjectGroup = normalizeEditableList(
form.formProjectGroup
);
const normalizedPublicProjectType = normalizeEditableList(
form.publicProjectType
);
const normalizedPublicProjectGroup = normalizeEditableList(
form.publicProjectGroup
);
if (
isTzbcyModule.value &&
!normalizedFormProjectType.length
) {
loading.value = false;
message.error('请至少配置一个项目申报表项目类型');
return;
}
if (
isTzbcyModule.value &&
!normalizedFormProjectGroup.length
) {
loading.value = false;
message.error('请至少配置一个项目申报表项目分组');
return;
}
if (
isTzbcyModule.value &&
!normalizedPublicProjectType.length
) {
loading.value = false;
message.error('请至少配置一个公开展示信息表项目类型');
return;
}
if (
isTzbcyModule.value &&
!normalizedPublicProjectGroup.length
) {
loading.value = false;
message.error('请至少配置一个公开展示信息表项目分组');
return;
}
const formData = {
...form,
projectType: stringifyDeclareMultiValueField(normalizedFormProjectType),
projectGroup: stringifyDeclareMultiValueField(normalizedFormProjectGroup),
formProjectType: stringifyDeclareMultiValueField(
normalizedFormProjectType
),
formProjectGroup: stringifyDeclareMultiValueField(
normalizedFormProjectGroup
),
publicProjectType: stringifyDeclareMultiValueField(
normalizedPublicProjectType
),
publicProjectGroup: stringifyDeclareMultiValueField(
normalizedPublicProjectGroup
)
};
if (!isTzbcyModule.value) {
formData.projectType = undefined;
formData.projectGroup = undefined;
formData.formProjectType = undefined;
formData.formProjectGroup = undefined;
formData.publicProjectType = undefined;
formData.publicProjectGroup = undefined;
}
const saveOrUpdate = isUpdate.value ? updateDeclare : addDeclare;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
const record = normalizeDeclareRecord(props.data);
Object.assign(form, createDefaultForm(), record ?? {});
form.formProjectType = normalizeEditableList(
record?.formProjectType as string[]
);
form.formProjectGroup = normalizeEditableList(
record?.formProjectGroup as string[]
);
form.publicProjectType = normalizeEditableList(
record?.publicProjectType as string[]
);
form.publicProjectGroup = normalizeEditableList(
record?.publicProjectGroup as string[]
);
yearValue.value = props.data.year ? String(props.data.year) : undefined;
isUpdate.value = true;
} else {
Object.assign(form, createDefaultForm());
isUpdate.value = false;
yearValue.value = undefined;
}
} else {
resetFields();
Object.assign(form, createDefaultForm());
yearValue.value = undefined;
}
},
{ immediate: true }
);
watch(
() => form.module,
(value) => {
if (value !== 'gxmu_tzbcy_form') {
form.projectType = [];
form.projectGroup = [];
form.formProjectType = [''];
form.formProjectGroup = [''];
form.publicProjectType = [''];
form.publicProjectGroup = [''];
}
}
);
</script>
<style lang="less" scoped>
.declare-config-block {
margin-bottom: 16px;
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fafafa;
}
.declare-config-block:last-child {
margin-bottom: 0;
}
.declare-config-title {
margin-bottom: 10px;
font-weight: 600;
color: #262626;
}
.declare-config-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
</style>
@@ -0,0 +1,42 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon>
<PlusOutlined />
</template>
<span>添加</span>
</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue';
import type { GradeParam } from '@/api/user/grade/model';
import { watch } from 'vue';
const props = withDefaults(
defineProps<{
// 选中的角色
selection?: [];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: GradeParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
}>();
// 新增
const add = () => {
emit('add');
};
watch(
() => props.selection,
() => {}
);
</script>
+286
View File
@@ -0,0 +1,286 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<a-image :src="record.image" :width="50" />
</template>
<template v-else-if="column.key === 'module'">
{{ getDeclareModuleLabel(record.module) }}
</template>
<template v-else-if="column.key === 'formProjectType'">
{{ formatDeclareMultiValueField(record.formProjectType || record.projectType) }}
</template>
<template v-else-if="column.key === 'formProjectGroup'">
{{ formatDeclareMultiValueField(record.formProjectGroup || record.projectGroup) }}
</template>
<template v-else-if="column.key === 'publicProjectType'">
{{ formatDeclareMultiValueField(record.publicProjectType || record.projectType) }}
</template>
<template v-else-if="column.key === 'publicProjectGroup'">
{{ formatDeclareMultiValueField(record.publicProjectGroup || record.projectGroup) }}
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<DeclareEdit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</div>
</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 DeclareEdit from './components/declareEdit.vue';
import { pageDeclare, removeDeclare, removeBatchDeclare } from '@/api/gxmu/declare';
import type { Declare, DeclareParam } from '@/api/gxmu/declare/model';
import { getDeclareModuleLabel } from './moduleOptions';
import { formatDeclareMultiValueField } from './projectFieldUtils';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<Declare[]>([]);
// 当前编辑数据
const current = ref<Declare | null>(null);
// 是否显示编辑弹窗
const showEdit = 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 pageDeclare({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90,
},
{
title: '名称',
dataIndex: 'title',
key: 'title',
align: 'center',
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
},
{
title: '模块',
dataIndex: 'module',
key: 'module',
align: 'center',
},
// {
// title: '申报表项目类型',
// dataIndex: 'formProjectType',
// key: 'formProjectType',
// align: 'center',
// },
// {
// title: '申报表项目分组',
// dataIndex: 'formProjectGroup',
// key: 'formProjectGroup',
// align: 'center',
// },
// {
// title: '公开展示项目类型',
// dataIndex: 'publicProjectType',
// key: 'publicProjectType',
// align: 'center',
// },
// {
// title: '公开展示项目分组',
// dataIndex: 'publicProjectGroup',
// key: 'publicProjectGroup',
// align: 'center',
// },
{
title: '开始时间',
dataIndex: 'startTime',
key: 'startTime',
align: 'center',
},
{
title: '结束时间',
dataIndex: 'endTime',
key: 'endTime',
align: 'center',
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: DeclareParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: Declare) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/* 删除单个 */
const remove = (row: Declare) => {
const hide = message.loading('请求中..', 0);
removeDeclare(row.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchDeclare(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;
};
/* 自定义行属性 */
const customRow = (record: Declare) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
query();
</script>
<script lang="ts">
export default {
name: 'Declare'
};
</script>
<style lang="less" scoped></style>
+94
View File
@@ -0,0 +1,94 @@
export const DECLARE_MODULE_OPTIONS = [
{ label: '十佳青年岗位能手', value: 'gxmu_sjqn' },
{ label: '十佳团支部书记', value: 'gxmu_sjtbzbsj' },
{ label: '五四红旗团委', value: 'gxmu_wshqtw' },
{ label: '五四红旗团支部', value: 'gxmu_wshqtzb' },
{ label: '优秀共青团干部', value: 'gxmu_yxgqtdgb' },
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
{ label: '青马工程', value: 'gxmu_qmgc_form' },
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' }
] as const;
export const DECLARE_MODULE_GROUP_OPTIONS = [
{
label: '五四模块',
value: 'wusi',
modules: [
'gxmu_sjqn',
'gxmu_sjtbzbsj',
'gxmu_wshqtw',
'gxmu_wshqtzb',
'gxmu_yxgqtdgb',
'gxmu_yxgqty'
]
},
{
label: '挑战杯模块',
value: 'challengeCup',
modules: ['gxmu_tzbcy_form']
},
{
label: '未来学术之星',
value: 'futureAcademicStar',
modules: ['gxmu_wxxzx_project']
},
{
label: '青马工程',
value: 'qingma',
modules: ['gxmu_qmgc_form']
}
] as const;
const DECLARE_MODULE_LABEL_MAP = DECLARE_MODULE_OPTIONS.reduce<
Record<string, string>
>((result, item) => {
result[item.value] = item.label;
return result;
}, {});
const DECLARE_MODULE_GROUP_LABEL_MAP = DECLARE_MODULE_GROUP_OPTIONS.reduce<
Record<string, string>
>((result, item) => {
item.modules.forEach((module) => {
result[module] = item.label;
});
return result;
}, {});
const DECLARE_MODULE_ROUTE_MAP: Record<string, string> = {
gxmu_sjqn: '/gxmu/SJQNForm',
gxmu_sjtbzbsj: '/gxmu/SJTBZBSJForm',
gxmu_wshqtw: '/gxmu/WSHQTWForm',
gxmu_wshqtzb: '/gxmu/WSHQTZBForm',
gxmu_yxgqtdgb: '/gxmu/YXGQTDGBForm',
gxmu_yxgqty: '/gxmu/YXGQTYForm',
gxmu_tzbcy_form: '/gxmu/TZBCYForm',
gxmu_qmgc_form: '/gxmu/QMGCForm',
gxmu_wxxzx_project: '/gxmu/WXXZXForm'
};
export const getDeclareModuleLabel = (value?: string) => {
if (!value) {
return '-';
}
return DECLARE_MODULE_LABEL_MAP[value] ?? value;
};
export const getDeclareModuleGroupLabel = (value?: string) => {
if (!value) {
return '-';
}
return DECLARE_MODULE_GROUP_LABEL_MAP[value] ?? '其他模块';
};
export const getDeclareModuleRoute = (value?: string) => {
if (!value) {
return undefined;
}
return DECLARE_MODULE_ROUTE_MAP[value];
};
export const isChallengeCupModule = (value?: string) => {
return value === 'gxmu_tzbcy_form';
};
@@ -0,0 +1,80 @@
import type { Declare } from '@/api/gxmu/declare/model';
type MultiValueField = string | string[] | null | undefined;
const normalizeList = (list: unknown[]): string[] => {
const result: string[] = [];
list.forEach((item) => {
const value = typeof item === 'string' ? item.trim() : '';
if (value && !result.includes(value)) {
result.push(value);
}
});
return result;
};
export const parseDeclareMultiValueField = (value: MultiValueField): string[] => {
if (Array.isArray(value)) {
return normalizeList(value);
}
if (typeof value !== 'string') {
return [];
}
const trimmed = value.trim();
if (!trimmed) {
return [];
}
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return normalizeList(parsed);
}
} catch (e) {
// 兼容历史单值字符串
}
return [trimmed];
};
export const stringifyDeclareMultiValueField = (
value: MultiValueField
): string | undefined => {
const list = parseDeclareMultiValueField(value);
return list.length ? JSON.stringify(list) : undefined;
};
export const formatDeclareMultiValueField = (value: MultiValueField): string => {
const list = parseDeclareMultiValueField(value);
return list.length ? list.join('、') : '-';
};
export const normalizeDeclareRecord = (
record?: Declare | null
): Declare | null => {
if (!record) {
return null;
}
return {
...record,
projectType: parseDeclareMultiValueField(record.projectType),
projectGroup: parseDeclareMultiValueField(record.projectGroup),
formProjectType: parseDeclareMultiValueField(
record.formProjectType ?? record.projectType
),
formProjectGroup: parseDeclareMultiValueField(
record.formProjectGroup ?? record.projectGroup
),
publicProjectType: parseDeclareMultiValueField(
record.publicProjectType ?? record.projectType
),
publicProjectGroup: parseDeclareMultiValueField(
record.publicProjectGroup ?? record.projectGroup
)
};
};
+502
View File
@@ -0,0 +1,502 @@
<template>
<div class="page">
<div class="ele-body declare-history-page">
<a-card :bordered="false" :body-style="{ padding: '20px' }">
<div class="page-header">
<div>
<div class="page-title">我的申报历史</div>
<div class="page-desc">展示当前账号在各申报模块中的历史申报记录</div>
</div>
<a-space>
<a-button @click="goDeclareList">返回申报列表</a-button>
<a-button :loading="historyLoading" @click="loadHistoryData">刷新</a-button>
</a-space>
</div>
<div class="filter-bar">
<div class="filter-left">
<div class="filter-label">模块筛选</div>
<div class="filter-tag-groups">
<div class="filter-tag-row">
<a-tag
class="filter-tag"
:class="{ 'filter-tag-active': !selectedModule }"
@click="setSelectedModule()"
>
全部
</a-tag>
</div>
<div
v-for="group in DECLARE_MODULE_GROUP_OPTIONS"
:key="group.value"
class="filter-tag-group"
>
<div class="filter-group-name">{{ group.label }}</div>
<div class="filter-tag-row">
<a-tag
v-for="module in group.modules"
:key="module"
class="filter-tag"
:class="{ 'filter-tag-active': selectedModule === module }"
@click="setSelectedModule(module)"
>
{{ getDeclareModuleLabel(module) }}
</a-tag>
</div>
</div>
</div>
</div>
</div>
<a-table
row-key="key"
:loading="historyLoading"
:columns="historyColumns"
:data-source="filteredHistoryList"
:pagination="historyPagination"
:scroll="{ x: 1180 }"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'module'">
{{ getDeclareModuleLabel(record.module) }}
</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>待审核</a-tag>
</template>
<template v-else-if="column.key === 'submitTime'">
{{ formatDateTime(record.submitTime) }}
</template>
<template v-else-if="column.key === 'latestReviewTime'">
{{ formatDateTime(record.latestReviewTime) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">查看详情</a>
<a-divider type="vertical" />
<a @click="openReviewFlow(record)">审核流列表</a>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无申报历史" />
</template>
</a-table>
</a-card>
</div>
<ModuleDataModal
v-model:visible="showDetail"
:module="detailCurrent?.module"
:module-name="getDeclareModuleLabel(detailCurrent?.module)"
:pk="detailCurrent?.id"
/>
<ReviewFlowModal
v-model:visible="showReviewFlow"
:module="reviewFlowCurrent?.module"
:module-name="getDeclareModuleLabel(reviewFlowCurrent?.module)"
:pk="reviewFlowCurrent?.id"
@done="loadHistoryData"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { message } from "ant-design-vue";
import { toDateString } from "ele-admin-pro";
import type { TablePaginationConfig } from "ant-design-vue";
import type { ColumnType } from "ant-design-vue/es/table";
import type { PageResult } from "@/api";
import type { ReviewList } from "@/api/gxmu/reviewList/model";
import { userPageSjqnForm } from "@/api/gxmu/sjqn-form";
import { userPageSjtbzbsjForm } from "@/api/gxmu/sjtbzbsj-form";
import { userPageWshqtwForm } from "@/api/gxmu/wshqtw-form";
import { userPageWshqtzbForm } from "@/api/gxmu/wshqtzb-form";
import { userPageYxgqtdgbForm } from "@/api/gxmu/yxgqtdgb-form";
import { userPageYxgqtyForm } from "@/api/gxmu/yxgqty-form";
import { userPageTzbcyForm } from "@/api/gxmu/tzbcy-form";
import { userPageQmgcForm } from "@/api/gxmu/qmgc-form";
import { userPageWxxzxForm } from "@/api/gxmu/wxxzx-form";
import ModuleDataModal from "../reviewList/components/moduleDataModal.vue";
import ReviewFlowModal from "../reviewList/components/reviewFlowModal.vue";
import {
DECLARE_MODULE_GROUP_OPTIONS,
getDeclareModuleLabel
} from "../declare/moduleOptions";
interface DeclareHistoryRecord {
key: string;
id?: number;
module: string;
title: string;
year?: string | number;
applicant?: string;
submitTime?: string;
latestReviewTime?: string;
status?: number;
reviewList?: ReviewList[];
}
const { push } = useRouter();
const selectedModule = ref<string>();
const historyLoading = ref(false);
const historyList = ref<DeclareHistoryRecord[]>([]);
const showDetail = ref(false);
const detailCurrent = ref<DeclareHistoryRecord | null>(null);
const showReviewFlow = ref(false);
const reviewFlowCurrent = ref<DeclareHistoryRecord | null>(null);
const historyColumns = computed<ColumnType<DeclareHistoryRecord>[]>(() => [
{
title: "模块",
dataIndex: "module",
key: "module",
width: 160
},
{
title: "申报名称",
dataIndex: "title",
key: "title",
ellipsis: true
},
{
title: "年份",
dataIndex: "year",
key: "year",
width: 100,
align: "center"
},
{
title: "申报人",
dataIndex: "applicant",
key: "applicant",
width: 140,
ellipsis: true
},
{
title: "提交时间",
dataIndex: "submitTime",
key: "submitTime",
width: 180
},
{
title: "最近审核时间",
dataIndex: "latestReviewTime",
key: "latestReviewTime",
width: 180
},
{
title: "审核状态",
dataIndex: "status",
key: "status",
width: 120,
align: "center"
},
{
title: "操作",
key: "action",
width: 180,
align: "center",
fixed: "right"
}
]);
const historyPagination = computed<TablePaginationConfig>(() => ({
pageSize: 10,
hideOnSinglePage: historyList.value.length <= 10,
showSizeChanger: false,
showTotal: (total) => `共 ${total} 条`
}));
const filteredHistoryList = computed(() => {
if (!selectedModule.value) {
return historyList.value;
}
return historyList.value.filter((item) => item.module === selectedModule.value);
});
const setSelectedModule = (module?: string) => {
selectedModule.value = module;
};
const goDeclareList = () => {
push("/gxmu/declareList");
};
const openDetail = (record: DeclareHistoryRecord) => {
if (!record.id || !record.module) {
message.error("当前记录缺少详情参数");
return;
}
detailCurrent.value = record;
showDetail.value = true;
};
const openReviewFlow = (record: DeclareHistoryRecord) => {
if (!record.id || !record.module) {
message.error("当前记录缺少审核流参数");
return;
}
reviewFlowCurrent.value = record;
showReviewFlow.value = true;
};
const formatDateTime = (value?: string) => {
if (!value) {
return "-";
}
return toDateString(value, "yyyy-MM-dd HH:mm:ss");
};
const getLatestReview = (reviewList?: ReviewList[]) => {
const list = reviewList ?? [];
return [...list].sort((a, b) => Number(b.id || 0) - Number(a.id || 0))[0];
};
const mapHistoryRecord = (
module: string,
record: Record<string, any>,
options: {
title: (item: Record<string, any>) => string;
applicant: (item: Record<string, any>) => string;
}
): DeclareHistoryRecord => {
const latestReview = getLatestReview(record.reviewList as ReviewList[] | undefined);
return {
key: `${module}_${record.id}`,
id: record.id,
module,
title: options.title(record) || getDeclareModuleLabel(module),
year: record.year,
applicant: options.applicant(record) || "-",
submitTime: record.createTime ?? record.updateTime,
latestReviewTime: latestReview?.updateTime ?? latestReview?.createTime,
status: latestReview?.status,
reviewList: record.reviewList
};
};
const loadHistoryPage = async <T extends Record<string, any>>(
module: string,
requestFn: (params: { page: number; limit: number }) => Promise<PageResult<T>>,
options: {
title: (item: T) => string;
applicant: (item: T) => string;
}
) => {
const firstPage = await requestFn({ page: 1, limit: 100 });
const list = [...(firstPage.list ?? [])];
const total = Number(firstPage.count ?? list.length);
const pageSize = 100;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages > 1) {
const tasks: Promise<PageResult<T>>[] = [];
for (let page = 2; page <= totalPages; page += 1) {
tasks.push(requestFn({ page, limit: pageSize }));
}
const restPages = await Promise.all(tasks);
restPages.forEach((pageData) => {
list.push(...(pageData.list ?? []));
});
}
return list.map((item) => mapHistoryRecord(module, item, options));
};
const loadHistoryData = async () => {
historyLoading.value = true;
try {
const results = await Promise.all([
loadHistoryPage("gxmu_sjqn", userPageSjqnForm, {
title: (item) => item.name || "十佳青年岗位能手",
applicant: (item) => item.name || "-"
}),
loadHistoryPage("gxmu_sjtbzbsj", userPageSjtbzbsjForm, {
title: (item) => item.name || "十佳团支部书记",
applicant: (item) => item.name || "-"
}),
loadHistoryPage("gxmu_wshqtw", userPageWshqtwForm, {
title: (item) => item.organizationName || item.schoolName || "五四红旗团委",
applicant: (item) => item.secretaryName || item.organizationName || "-"
}),
loadHistoryPage("gxmu_wshqtzb", userPageWshqtzbForm, {
title: (item) => item.organizationName || item.schoolName || "五四红旗团支部",
applicant: (item) => item.secretaryName || item.organizationName || "-"
}),
loadHistoryPage("gxmu_yxgqtdgb", userPageYxgqtdgbForm, {
title: (item) => item.name || "优秀共青团干部",
applicant: (item) => item.name || "-"
}),
loadHistoryPage("gxmu_yxgqty", userPageYxgqtyForm, {
title: (item) => item.name || "优秀共青团员",
applicant: (item) => item.name || "-"
}),
loadHistoryPage("gxmu_tzbcy_form", userPageTzbcyForm, {
title: (item) => item.projectName || "挑战杯",
applicant: (item) => item.leader || "-"
}),
loadHistoryPage("gxmu_qmgc_form", userPageQmgcForm, {
title: (item) => item.name || "青马工程",
applicant: (item) => item.name || "-"
}),
loadHistoryPage("gxmu_wxxzx_project", userPageWxxzxForm, {
title: (item) => item.topicName || "未来学术之星",
applicant: (item) => item.leader || "-"
})
]);
historyList.value = results
.flat()
.sort((a, b) => {
const timeA = new Date(a.submitTime || a.latestReviewTime || 0).getTime();
const timeB = new Date(b.submitTime || b.latestReviewTime || 0).getTime();
return timeB - timeA;
});
} catch (e) {
message.error((e as Error).message);
} finally {
historyLoading.value = false;
}
};
onMounted(() => {
loadHistoryData();
});
</script>
<script lang="ts">
export default {
name: "GxmuDeclareHistory"
};
</script>
<style lang="less" scoped>
.declare-history-page {
min-height: calc(100vh - 132px);
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
gap: 16px;
}
.page-title {
font-size: 22px;
font-weight: 600;
color: #1f2329;
line-height: 1.2;
}
.page-desc {
margin-top: 6px;
color: #8c8c8c;
font-size: 13px;
}
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 24px;
padding: 16px 18px;
border-radius: 14px;
background: linear-gradient(135deg, #f7fbff 0%, #f4f8f6 100%);
}
.filter-left {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.filter-label {
flex-shrink: 0;
color: #1f2329;
font-size: 14px;
font-weight: 600;
line-height: 32px;
}
.filter-tag-groups {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.filter-tag-group {
display: flex;
align-items: flex-start;
gap: 10px;
}
.filter-group-name {
flex-shrink: 0;
min-width: 88px;
color: #333333;
font-size: 15px;
font-weight: bold;
line-height: 32px;
}
.filter-tag-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.filter-tag {
margin: 0;
padding: 6px 16px;
border: 1px solid #d9d9d9;
border-radius: 999px;
background: #fff;
color: #5f6b7a;
font-size: 13px;
line-height: 18px;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-tag:hover {
border-color: #1677ff;
}
.filter-tag-active {
border-color: #1677ff;
background: linear-gradient(90deg, #4096ff 0%, #0958d9 100%);
color: #fff;
}
@media (max-width: 768px) {
.page-header,
.filter-bar {
align-items: flex-start;
flex-direction: column;
}
.filter-left {
width: 100%;
align-items: flex-start;
flex-direction: column;
}
.filter-tag-group {
width: 100%;
flex-direction: column;
gap: 6px;
}
.filter-group-name {
min-width: auto;
line-height: 1.5;
}
}
</style>
+579
View File
@@ -0,0 +1,579 @@
<template>
<div class="page">
<div class="ele-body declare-list-page">
<a-card :bordered="false" :body-style="{ padding: '20px' }">
<div class="page-header">
<div>
<div class="page-title">申报列表</div>
<div class="page-desc">当前仅展示可申报项目</div>
</div>
<a-space>
<a-button @click="goHistoryPage">我的申报历史</a-button>
<a-button :loading="loading" @click="loadData">刷新</a-button>
</a-space>
</div>
<div class="filter-bar">
<div class="filter-left">
<div class="filter-label">模块筛选</div>
<div class="filter-tag-groups">
<div class="filter-tag-row">
<a-tag
class="filter-tag"
:class="{ 'filter-tag-active': !selectedModule }"
@click="setSelectedModule()"
>
全部
</a-tag>
</div>
<div
v-for="group in DECLARE_MODULE_GROUP_OPTIONS"
:key="group.value"
class="filter-tag-group"
>
<div class="filter-group-name">{{ group.label }}</div>
<div class="filter-tag-row">
<a-tag
v-for="module in group.modules"
:key="module"
class="filter-tag"
:class="{ 'filter-tag-active': selectedModule === module }"
@click="setSelectedModule(module)"
>
{{ getDeclareModuleLabel(module) }}
</a-tag>
</div>
</div>
</div>
</div>
<a-button type="link" class="toggle-all-btn" @click="toggleAllGroups">
{{ allGroupsCollapsed ? "全部展开" : "全部折叠" }}
</a-button>
</div>
<a-spin :spinning="loading">
<a-empty v-if="!loading && !filteredDeclareGroups.length" description="当前暂无可申报项目" />
<div v-else class="declare-group-list">
<section
v-for="group in filteredDeclareGroups"
:key="group.value"
class="declare-group-section"
>
<div class="group-header">
<div>
<div class="group-title">{{ group.label }}</div>
<div class="group-desc">共 {{ group.items.length }} 个可申报项目</div>
</div>
<a-button type="link" class="group-toggle-btn" @click="toggleGroup(group.value)">
{{ isGroupCollapsed(group.value) ? "展开" : "折叠" }}
</a-button>
</div>
<div v-if="!isGroupCollapsed(group.value)" class="declare-grid">
<a-card
v-for="item in group.items"
:key="item.id"
hoverable
class="declare-card"
:bordered="false"
>
<div class="card-top">
<div>
<div class="card-title">
{{ item.title || getDeclareModuleLabel(item.module) }}
</div>
<div class="card-subtitle">
{{ getDeclareModuleLabel(item.module) }}
</div>
</div>
<a-tag color="processing">可申报</a-tag>
</div>
<div class="card-year">
{{ item.year ? `${item.year} 年` : "未设置年度" }}
</div>
<div class="card-meta">
<div class="meta-item">
<span class="meta-label">开始时间</span>
<span class="meta-value">{{ formatDateTime(item.startTime) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">结束时间</span>
<span class="meta-value">{{ formatDateTime(item.endTime) }}</span>
</div>
</div>
<div class="card-footer">
<a-button
type="primary"
block
:disabled="!item.module || !DECLARE_MODULE_COMPONENT_MAP[item.module]"
@click="goApply(item)"
>
进入申报
</a-button>
</div>
</a-card>
</div>
</section>
</div>
</a-spin>
</a-card>
</div>
<a-modal
:visible="showApplyModal"
:title="currentDeclare?.title || getDeclareModuleLabel(currentDeclare?.module)"
:footer="null"
:mask-closable="false"
:destroy-on-close="true"
:width="'96vw'"
wrap-class-name="declare-apply-modal"
:body-style="{ padding: '0', maxHeight: '80vh', overflow: 'auto' }"
@update:visible="updateApplyModal"
>
<component
:is="currentComponent"
v-if="currentComponent && currentDeclare"
:popup="true"
:title="currentDeclare?.title"
:fixed-year="currentDeclare?.year"
:declare-form-project-types="currentDeclareFormProjectTypes"
:declare-form-project-groups="currentDeclareFormProjectGroups"
:declare-public-project-types="currentDeclarePublicProjectTypes"
:declare-public-project-groups="currentDeclarePublicProjectGroups"
class="apply-modal-content"
/>
<a-empty v-else description="当前申报项目未配置展示页面" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, markRaw, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { message } from "ant-design-vue";
import { toDateString } from "ele-admin-pro";
import { listDeclare } from "@/api/gxmu/declare";
import type { Declare } from "@/api/gxmu/declare/model";
import {
DECLARE_MODULE_GROUP_OPTIONS,
getDeclareModuleLabel
} from "../declare/moduleOptions";
import SJQNForm from "../SJQNForm/index.vue";
import SJTBZBSJForm from "../SJTBZBSJForm/index.vue";
import WSHQTWForm from "../WSHQTWForm/index.vue";
import WSHQTZBForm from "../WSHQTZBForm/index.vue";
import YXGQTDGBForm from "../YXGQTDGBForm/index.vue";
import YXGQTYForm from "../YXGQTYForm/index.vue";
import TZBCYForm from "../TZBCYForm/index.vue";
import QMGCForm from "../QMGCForm/index.vue";
import WXXZXForm from "../WXXZXForm/index.vue";
import { parseDeclareMultiValueField } from "../declare/projectFieldUtils";
const { push } = useRouter();
const loading = ref(false);
const declareList = ref<Declare[]>([]);
const selectedModule = ref<string>();
const collapsedGroups = ref<string[]>([]);
const showApplyModal = ref(false);
const currentDeclare = ref<Declare | null>(null);
const DECLARE_MODULE_COMPONENT_MAP: Record<string, any> = {
gxmu_sjqn: markRaw(SJQNForm),
gxmu_sjtbzbsj: markRaw(SJTBZBSJForm),
gxmu_wshqtw: markRaw(WSHQTWForm),
gxmu_wshqtzb: markRaw(WSHQTZBForm),
gxmu_yxgqtdgb: markRaw(YXGQTDGBForm),
gxmu_yxgqty: markRaw(YXGQTYForm),
gxmu_tzbcy_form: markRaw(TZBCYForm),
gxmu_qmgc_form: markRaw(QMGCForm),
gxmu_wxxzx_project: markRaw(WXXZXForm)
};
const currentComponent = computed(() => {
const module = currentDeclare.value?.module;
return module ? DECLARE_MODULE_COMPONENT_MAP[module] : undefined;
});
const currentDeclareFormProjectTypes = computed(() =>
parseDeclareMultiValueField(
currentDeclare.value?.formProjectType ?? currentDeclare.value?.projectType
)
);
const currentDeclareFormProjectGroups = computed(() =>
parseDeclareMultiValueField(
currentDeclare.value?.formProjectGroup ?? currentDeclare.value?.projectGroup
)
);
const currentDeclarePublicProjectTypes = computed(() =>
parseDeclareMultiValueField(
currentDeclare.value?.publicProjectType ?? currentDeclare.value?.projectType
)
);
const currentDeclarePublicProjectGroups = computed(() =>
parseDeclareMultiValueField(
currentDeclare.value?.publicProjectGroup ?? currentDeclare.value?.projectGroup
)
);
const visibleGroupValues = computed(() =>
filteredDeclareGroups.value.map((group) => group.value)
);
const allGroupsCollapsed = computed(() => {
return (
visibleGroupValues.value.length > 0 &&
visibleGroupValues.value.every((value) => collapsedGroups.value.includes(value))
);
});
const filteredDeclareGroups = computed(() => {
return DECLARE_MODULE_GROUP_OPTIONS.map((group) => {
const items = declareList.value.filter((item) => {
if (!item.module || !group.modules.includes(item.module)) {
return false;
}
if (selectedModule.value && item.module !== selectedModule.value) {
return false;
}
return true;
});
return {
...group,
items
};
}).filter((group) => group.items.length > 0);
});
const isGroupCollapsed = (groupValue: string) => {
return collapsedGroups.value.includes(groupValue);
};
const setSelectedModule = (module?: string) => {
selectedModule.value = module;
};
const formatDateTime = (value?: string) => {
if (!value) {
return "-";
}
return toDateString(value, "yyyy-MM-dd HH:mm:ss");
};
const loadData = async () => {
loading.value = true;
try {
const data = await listDeclare({ usable: true });
declareList.value = [...data].sort((a, b) => {
const yearA = Number(a.year ?? 0);
const yearB = Number(b.year ?? 0);
return yearB - yearA;
});
} catch (e) {
message.error((e as Error).message);
} finally {
loading.value = false;
}
};
const goHistoryPage = () => {
push("/gxmu/declareHistory");
};
const updateApplyModal = (value: boolean) => {
showApplyModal.value = value;
if (!value) {
currentDeclare.value = null;
}
};
const toggleGroup = (groupValue: string) => {
if (isGroupCollapsed(groupValue)) {
collapsedGroups.value = collapsedGroups.value.filter((item) => item !== groupValue);
return;
}
collapsedGroups.value = [...collapsedGroups.value, groupValue];
};
const toggleAllGroups = () => {
if (allGroupsCollapsed.value) {
collapsedGroups.value = collapsedGroups.value.filter(
(item) => !visibleGroupValues.value.includes(item)
);
return;
}
collapsedGroups.value = Array.from(
new Set([...collapsedGroups.value, ...visibleGroupValues.value])
);
};
const goApply = (item: Declare) => {
if (!item.module || !DECLARE_MODULE_COMPONENT_MAP[item.module]) {
message.warning("当前申报项目未配置跳转页面");
return;
}
currentDeclare.value = item;
showApplyModal.value = true;
};
onMounted(() => {
loadData();
});
</script>
<script lang="ts">
export default {
name: "GxmuDeclareList"
};
</script>
<style lang="less" scoped>
.declare-list-page {
min-height: calc(100vh - 132px);
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
gap: 16px;
}
.page-title {
font-size: 22px;
font-weight: 600;
color: #1f2329;
line-height: 1.2;
}
.page-desc {
margin-top: 6px;
color: #8c8c8c;
font-size: 13px;
}
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 24px;
padding: 16px 18px;
border-radius: 14px;
background: linear-gradient(135deg, #f7fbff 0%, #f4f8f6 100%);
}
.filter-left {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.filter-label {
flex-shrink: 0;
color: #1f2329;
font-size: 14px;
font-weight: 600;
line-height: 32px;
}
.filter-tag-groups {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.filter-tag-group {
display: flex;
align-items: flex-start;
gap: 10px;
}
.filter-group-name {
flex-shrink: 0;
min-width: 88px;
color: #333333;
font-size: 15px;
font-weight: bold;
line-height: 32px;
}
.filter-tag-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.filter-tag {
margin: 0;
padding: 6px 16px;
border: 1px solid #d9d9d9;
border-radius: 999px;
background: #fff;
color: #5f6b7a;
font-size: 13px;
line-height: 18px;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-tag:hover {
border-color: #1677ff;
}
.filter-tag-active {
border-color: #1677ff;
background: linear-gradient(90deg, #4096ff 0%, #0958d9 100%);
color: #fff;
}
.toggle-all-btn,
.group-toggle-btn {
padding: 0;
height: auto;
}
.declare-group-list {
display: flex;
flex-direction: column;
gap: 28px;
}
.declare-group-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.group-title {
color: #1f2329;
font-size: 18px;
font-weight: 600;
line-height: 1.4;
}
.group-desc {
margin-top: 4px;
color: #8c8c8c;
font-size: 13px;
}
.declare-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.declare-card {
border-radius: 14px;
background: linear-gradient(180deg, rgba(240, 247, 255, 0.95) 0%, #ffffff 42%),
#ffffff;
box-shadow: 0 10px 24px rgba(31, 35, 41, 0.06);
}
.card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.card-title {
color: #1f2329;
font-size: 18px;
font-weight: 600;
line-height: 1.5;
}
.card-subtitle {
color: #5f6b7a;
font-size: 13px;
line-height: 1.5;
}
.card-year {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 12px;
margin-bottom: 16px;
border-radius: 999px;
background: #eef5ff;
color: #1554ad;
font-weight: 500;
}
.card-meta {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.meta-item {
display: flex;
flex-direction: column;
padding: 12px;
border-radius: 10px;
background: rgba(246, 248, 250, 0.9);
}
.meta-label {
color: #8c8c8c;
font-size: 12px;
margin-bottom: 4px;
}
.meta-value {
color: #1f2329;
font-size: 14px;
word-break: break-all;
}
.apply-modal-content {
min-height: 360px;
}
:deep(.declare-apply-modal .ant-modal) {
top: 24px;
padding-bottom: 24px;
}
:deep(.declare-apply-modal .ant-modal-content) {
overflow: hidden;
}
@media (max-width: 768px) {
.page-header,
.filter-bar,
.group-header {
align-items: flex-start;
flex-direction: column;
}
.filter-left {
width: 100%;
align-items: flex-start;
flex-direction: column;
}
.filter-tag-group {
width: 100%;
flex-direction: column;
gap: 6px;
}
.filter-group-name {
min-width: auto;
line-height: 1.5;
}
}
</style>
+45
View File
@@ -0,0 +1,45 @@
.gxmu-field-theme() {
:deep(.ant-input),
:deep(.ant-input-affix-wrapper),
:deep(.ant-select-selector),
:deep(.ant-picker),
:deep(.ant-input-number),
:deep(.ant-input-search .ant-input),
:deep(.ant-input-search .ant-input-affix-wrapper) {
border-color: @primary-color !important;
border-radius: 10px !important;
transition: all 0.2s ease;
}
:deep(.ant-input:hover),
:deep(.ant-input-affix-wrapper:hover),
:deep(.ant-select:not(.ant-select-disabled):hover .ant-select-selector),
:deep(.ant-picker:hover),
:deep(.ant-input-number:hover),
:deep(.ant-input-search:hover .ant-input),
:deep(.ant-input-search:hover .ant-input-affix-wrapper) {
border-color: darken(@primary-color, 5%) !important;
}
:deep(.ant-input:focus),
:deep(.ant-input-focused),
:deep(.ant-input-affix-wrapper-focused),
:deep(.ant-select-focused .ant-select-selector),
:deep(.ant-picker-focused),
:deep(.ant-input-number-focused),
:deep(.ant-input-search .ant-input:focus),
:deep(.ant-input-search .ant-input-affix-wrapper-focused) {
border-color: darken(@primary-color, 10%) !important;
box-shadow: 0 0 0 2px fade(@primary-color, 14%) !important;
}
:deep(.ant-input-group-addon),
:deep(.ant-input-search-button) {
border-color: @primary-color !important;
border-radius: 10px !important;
}
:deep(textarea.ant-input) {
border-radius: 12px !important;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,344 @@
<template>
<div class="topic-search-page">
<a-card :bordered="false" class="hero-card">
<div class="hero-wrap">
<div>
<div class="hero-kicker">PROJECT TOPIC RETRIEVAL</div>
<div class="hero-title">项目主题语义检索</div>
<div class="hero-desc">
输入主题关键字后,从未来学术之星项目库中检索课题名称、课题类型和立论依据相关内容,并以卡片方式展示。
</div>
</div>
<div class="hero-search">
<a-select
v-model:value="selectedModule"
class="module-select"
size="large"
:options="moduleOptions"
@change="handleModuleChange"
/>
<a-input-search
v-model:value="keyword"
size="large"
allow-clear
placeholder="请输入项目主题关键字,例如:乡村振兴、医学科普、心理健康"
enter-button="搜索项目"
:loading="loading"
@search="handleSearch"
/>
</div>
</div>
</a-card>
<a-card :bordered="false" class="result-card">
<template #title>
<div class="result-head">
<span>检索结果</span>
<a-tag color="processing">
{{ hasSearched ? `共 ${results.length} 条` : '待搜索' }}
</a-tag>
</div>
</template>
<template #extra>
<a-button :loading="loading" @click="handleSearch">刷新</a-button>
</template>
<a-spin :spinning="loading">
<a-empty
v-if="!hasSearched && !loading"
description="输入关键字后开始检索项目主题"
/>
<a-empty
v-else-if="hasSearched && !results.length && !loading"
description="未检索到相关项目"
/>
<div v-else class="card-grid">
<div
v-for="item in results"
:key="item.id"
class="topic-card"
@click="openDetail(item)"
>
<div class="topic-card-head">
<div class="topic-tags">
<a-tag color="geekblue">{{ item.moduleName || getDeclareModuleLabel(item.module) }}</a-tag>
<a-tag color="blue">{{ item.topicType || '未分类' }}</a-tag>
</div>
<span class="score-text">相关度 {{ item.score ?? 0 }}</span>
</div>
<div class="topic-title">{{ item.topicName || '未命名项目' }}</div>
<div class="topic-snippet">{{ item.snippet || '暂无摘要' }}</div>
<div class="topic-action">点击查看详情</div>
</div>
</div>
</a-spin>
</a-card>
<a-modal
:visible="showDetail"
width="760px"
title="项目详情"
:footer="null"
@update:visible="updateDetailVisible"
>
<template v-if="current">
<div class="detail-meta">
<a-tag color="geekblue">{{ current.moduleName || getDeclareModuleLabel(current.module) }}</a-tag>
<a-tag color="blue">{{ current.topicType || '未分类' }}</a-tag>
<a-tag color="geekblue">相关度 {{ current.score ?? 0 }}</a-tag>
</div>
<div class="detail-title">{{ current.topicName || '未命名项目' }}</div>
<div class="detail-section">
<div class="detail-label">立论依据</div>
<div class="detail-content">{{ current.rationale || '暂无立论依据' }}</div>
</div>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { searchProjectTopics } from '@/api/gxmu/project-topic-semantic-search';
import type { WxxzxTopicSemanticSearchItem } from '@/api/gxmu/project-topic-semantic-search/model';
import {
DECLARE_MODULE_OPTIONS,
getDeclareModuleLabel
} from '@/views/gxmu/declare/moduleOptions';
const keyword = ref('');
const selectedModule = ref('gxmu_wxxzx_project');
const loading = ref(false);
const hasSearched = ref(false);
const results = ref<WxxzxTopicSemanticSearchItem[]>([]);
const showDetail = ref(false);
const current = ref<WxxzxTopicSemanticSearchItem>();
const moduleOptions = DECLARE_MODULE_OPTIONS.map((item) => ({
label: item.label,
value: item.value
}));
const handleSearch = async () => {
const searchValue = keyword.value.trim();
hasSearched.value = true;
if (!searchValue) {
results.value = [];
message.warning('请输入检索关键字');
return;
}
loading.value = true;
try {
results.value = await searchProjectTopics(searchValue, selectedModule.value);
} catch (e: any) {
message.error(e.message || '检索失败');
} finally {
loading.value = false;
}
};
const handleModuleChange = () => {
if (keyword.value.trim()) {
handleSearch();
} else if (hasSearched.value) {
results.value = [];
}
};
const openDetail = (item: WxxzxTopicSemanticSearchItem) => {
current.value = item;
showDetail.value = true;
};
const updateDetailVisible = (value: boolean) => {
showDetail.value = value;
};
</script>
<script lang="ts">
export default {
name: 'ProjectTopicSemanticSearch'
};
</script>
<style lang="less" scoped>
.topic-search-page {
padding: 0;
}
.hero-card,
.result-card {
border-radius: 20px;
overflow: hidden;
}
.hero-card {
margin-bottom: 20px;
background:
radial-gradient(circle at top left, rgba(22, 119, 255, 0.18), transparent 34%),
linear-gradient(135deg, #f3f8ff 0%, #ffffff 52%, #f7fbf7 100%);
}
.hero-wrap {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 520px);
gap: 24px;
align-items: center;
}
.hero-kicker {
font-size: 12px;
letter-spacing: 0.24em;
color: #1677ff;
font-weight: 700;
}
.hero-title {
margin-top: 12px;
font-size: 34px;
line-height: 1.2;
font-weight: 800;
color: #1f2937;
}
.hero-desc {
margin-top: 12px;
max-width: 760px;
line-height: 1.85;
color: rgba(31, 41, 55, 0.78);
}
.hero-search {
display: grid;
gap: 12px;
padding: 18px;
border: 1px solid rgba(22, 119, 255, 0.12);
border-radius: 18px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 18px 44px rgba(31, 41, 55, 0.08);
}
.module-select {
width: 100%;
}
.result-head {
display: flex;
align-items: center;
gap: 10px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.topic-card {
padding: 18px;
border: 1px solid #e5edf7;
border-radius: 18px;
background:
linear-gradient(180deg, rgba(243, 248, 255, 0.92), #ffffff 72%),
#ffffff;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.topic-card:hover {
transform: translateY(-4px);
border-color: rgba(22, 119, 255, 0.28);
box-shadow: 0 16px 32px rgba(22, 119, 255, 0.12);
}
.topic-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.topic-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.score-text {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.topic-title {
margin-top: 14px;
font-size: 18px;
line-height: 1.5;
font-weight: 700;
color: #111827;
}
.topic-snippet {
margin-top: 12px;
min-height: 72px;
line-height: 1.8;
color: rgba(17, 24, 39, 0.74);
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.topic-action {
margin-top: 16px;
color: #1677ff;
font-weight: 600;
}
.detail-meta {
margin-bottom: 12px;
}
.detail-title {
font-size: 24px;
font-weight: 700;
line-height: 1.5;
color: #111827;
}
.detail-section {
margin-top: 20px;
padding: 18px;
border-radius: 16px;
background: #f8fafc;
}
.detail-label {
margin-bottom: 10px;
font-size: 14px;
font-weight: 700;
color: #1677ff;
}
.detail-content {
line-height: 1.9;
color: rgba(17, 24, 39, 0.82);
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 991px) {
.hero-wrap {
grid-template-columns: 1fr;
}
.hero-title {
font-size: 28px;
}
}
</style>
@@ -0,0 +1,509 @@
<!-- 编辑弹窗 -->
<template>
<ele-modal
:width="800"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:confirm-loading="loading"
:title="isUpdate ? '编辑审核流' : '添加审核流'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 4, sm: 5, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="名称" name="title">
<a-input
allow-clear
placeholder="请输入名称"
v-model:value="form.title"
/>
</a-form-item>
<a-form-item label="机构" name="organizationIds">
<a-tree-select
multiple
allow-clear
tree-default-expand-all
:tree-data="organizationTreeData"
:value="form.organizationIds"
:loading="organizationLoading"
:dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
placeholder="请选择审核机构"
@update:value="updateOrganizationIds"
/>
</a-form-item>
<a-form-item label="审核顺序">
<div class="flow-step-list">
<div
v-for="(item, index) in form.flowItems"
:key="item.organizationId"
class="flow-step-item"
>
<div class="flow-step-info">
<a-tag color="blue">第{{ index + 1 }}级</a-tag>
<span>{{ getOrganizationName(item.organizationId) }}</span>
</div>
<div class="flow-step-reviewer">
<a-select
allow-clear
show-search
:value="item.reviewUserId"
:loading="reviewUserLoadingMap[item.organizationId || 0]"
:options="getReviewUserOptions(item.organizationId)"
:filter-option="filterReviewUserOption"
placeholder="请选择审核人"
@focus="loadReviewUsers(item.organizationId)"
@update:value="updateReviewUser(index, $event)"
/>
</div>
<a-space :size="4">
<a-button
size="small"
:disabled="index === 0"
@click="moveOrganization(index, 'up')"
>
上移
</a-button>
<a-button
size="small"
:disabled="index === form.flowItems.length - 1"
@click="moveOrganization(index, 'down')"
>
下移
</a-button>
<a-button size="small" danger @click="removeOrganization(index)">
移除
</a-button>
</a-space>
</div>
<a-empty v-if="!form.flowItems.length" description="请选择审核机构" />
</div>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject } from 'ele-admin-pro';
import { toTreeData } from 'ele-admin-pro/es';
import { addReviewFlow, removeReviewFlow } from '@/api/gxmu/reviewFlow';
import { ReviewFlow } from '@/api/gxmu/reviewFlow/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
import { listOrganizations } from '@/api/system/organization';
import type { Organization } from '@/api/system/organization/model';
import { listUsers } from '@/api/system/user';
import type { User } from '@/api/system/user/model';
interface ReviewFlowItem {
organizationId?: number;
reviewUserId?: number;
}
interface ReviewFlowForm extends Omit<ReviewFlow, 'reviewUserId'> {
organizationIds: number[];
flowItems: ReviewFlowItem[];
}
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: ReviewFlow | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 表格选中数据
const formRef = ref<FormInstance | null>(null);
// 机构树数据
const organizationTreeData = ref<Organization[]>([]);
const organizationLoading = ref(false);
const organizationNameMap = ref<Record<number, string>>({});
const reviewUserOptionsMap = ref<Record<number, User[]>>({});
const reviewUserLoadingMap = ref<Record<number, boolean>>({});
const reviewUserNameMap = ref<Record<number, string>>({});
// 用户信息
const form = reactive<ReviewFlowForm>({
id: undefined,
title: undefined,
organizationId: undefined,
organizationIds: [],
flowItems: [],
level: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
userId: undefined
});
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
title: [
{
required: true,
type: 'string',
whitespace: true,
message: '请输入名称',
trigger: 'blur'
}
],
organizationIds: [
{
required: true,
type: 'array',
min: 1,
message: '请选择至少一个机构',
trigger: 'change'
},
{
validator: async () => {
const invalid = form.flowItems.some((item) => !item.reviewUserId);
if (invalid) {
return Promise.reject(new Error('请为每一级选择审核人'));
}
return Promise.resolve();
},
trigger: 'change'
}
]
});
const { resetFields } = useForm(form, rules);
const syncOrganizationIds = () => {
form.organizationIds = form.flowItems
.map((item) => item.organizationId)
.filter((item): item is number => typeof item === 'number');
};
const loadOrganizations = async () => {
organizationLoading.value = true;
try {
const result = await listOrganizations();
organizationNameMap.value = result.reduce<Record<number, string>>(
(map, item) => {
if (item.organizationId) {
map[item.organizationId] =
item.organizationName || `机构${item.organizationId}`;
}
return map;
},
{}
);
organizationTreeData.value = toTreeData({
data: result.map((item) => ({
...item,
key: item.organizationId,
value: item.organizationId,
title: item.organizationName
})),
idField: 'organizationId',
parentIdField: 'parentId'
}) as Organization[];
} catch (e: any) {
message.error(e.message || '机构列表加载失败');
} finally {
organizationLoading.value = false;
}
};
const updateOrganizationIds = (values?: number[]) => {
const selectedIds = values || [];
const selectedSet = new Set(selectedIds);
const flowItemMap = new Map(
form.flowItems.map((item) => [item.organizationId, item] as const)
);
const keptItems = form.flowItems.filter((item) =>
selectedSet.has(item.organizationId || 0)
);
const appendItems = selectedIds
.filter((id) => !flowItemMap.has(id))
.map((organizationId) => ({
organizationId,
reviewUserId: undefined
}));
form.flowItems = [...keptItems, ...appendItems];
syncOrganizationIds();
};
const moveOrganization = (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= form.flowItems.length) {
return;
}
const items = [...form.flowItems];
[items[index], items[targetIndex]] = [items[targetIndex], items[index]];
form.flowItems = items;
syncOrganizationIds();
};
const removeOrganization = (index: number) => {
form.flowItems = form.flowItems.filter((_, i) => i !== index);
syncOrganizationIds();
};
const getOrganizationName = (organizationId?: number) => {
if (!organizationId) {
return '-';
}
return organizationNameMap.value[organizationId] || `机构${organizationId}`;
};
const loadReviewUsers = async (organizationId?: number) => {
if (!organizationId || reviewUserOptionsMap.value[organizationId]) {
return;
}
reviewUserLoadingMap.value = {
...reviewUserLoadingMap.value,
[organizationId]: true
};
try {
const result = await listUsers({ organizationId });
reviewUserOptionsMap.value = {
...reviewUserOptionsMap.value,
[organizationId]: result
};
reviewUserNameMap.value = result.reduce<Record<number, string>>(
(map, item) => {
if (item.userId) {
map[item.userId] =
item.realName || item.nickname || `用户${item.userId}`;
}
return map;
},
reviewUserNameMap.value
);
} catch (e: any) {
message.error(e.message || '审核人列表加载失败');
} finally {
reviewUserLoadingMap.value = {
...reviewUserLoadingMap.value,
[organizationId]: false
};
}
};
const getReviewUserOptions = (organizationId?: number) => {
if (!organizationId) {
return [];
}
return (reviewUserOptionsMap.value[organizationId] || []).map((item) => ({
value: item.userId,
label: item.realName || item.nickname || `用户${item.userId}`
}));
};
const filterReviewUserOption = (input: string, option: any) => {
return String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase());
};
const updateReviewUser = (index: number, reviewUserId?: number) => {
const items = [...form.flowItems];
const organizationId = items[index]?.organizationId;
const currentUser = organizationId
? reviewUserOptionsMap.value[organizationId]?.find(
(item) => item.userId === reviewUserId
)
: undefined;
if (reviewUserId && currentUser?.userId) {
reviewUserNameMap.value = {
...reviewUserNameMap.value,
[reviewUserId]:
currentUser.realName ||
currentUser.nickname ||
`用户${currentUser.userId}`
};
}
items[index] = {
...items[index],
reviewUserId
};
form.flowItems = items;
syncOrganizationIds();
};
const getSortedFlows = () => {
if (props.data?.flows?.length) {
return [...props.data.flows].sort(
(a, b) => (a.level || 0) - (b.level || 0)
);
}
if (props.data?.id) {
return [props.data];
}
return [];
};
const preloadReviewUsers = async () => {
for (const item of form.flowItems) {
await loadReviewUsers(item.organizationId);
}
};
/* 保存编辑 */
const save = async () => {
if (!formRef.value) {
return;
}
try {
await formRef.value.validate();
loading.value = true;
let successMessage = '';
if (isUpdate.value) {
const currentFlows = getSortedFlows();
for (const currentFlow of currentFlows) {
if (currentFlow.id) {
await removeReviewFlow(currentFlow.id);
}
}
for (let index = 0; index < form.flowItems.length; index++) {
const item = form.flowItems[index];
const formData: ReviewFlow = {
...form,
title: form.title,
organizationId: item.organizationId,
reviewUserId: item.reviewUserId,
level: index + 1
};
delete (formData as ReviewFlow & { organizationIds?: number[] })
.organizationIds;
delete (formData as ReviewFlow & { flowItems?: ReviewFlowItem[] })
.flowItems;
delete formData.id;
successMessage = await addReviewFlow(formData);
}
} else {
for (let index = 0; index < form.flowItems.length; index++) {
const item = form.flowItems[index];
const formData: ReviewFlow = {
...form,
organizationId: item.organizationId,
reviewUserId: item.reviewUserId,
level: index + 1
};
delete (formData as ReviewFlow & { organizationIds?: number[] })
.organizationIds;
delete (formData as ReviewFlow & { flowItems?: ReviewFlowItem[] })
.flowItems;
successMessage = await addReviewFlow(formData);
}
}
message.success(successMessage || '保存成功');
updateVisible(false);
emit('done');
} catch (e: any) {
if (e?.errorFields) {
return;
}
message.error(e.message || '保存失败');
} finally {
loading.value = false;
}
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
if (!organizationTreeData.value.length) {
await loadOrganizations();
}
if (props.data) {
assignObject(form, props.data);
const currentFlows = getSortedFlows();
form.flowItems = currentFlows
.filter((item) => typeof item.organizationId === 'number')
.map((item) => ({
organizationId: item.organizationId,
reviewUserId: item.reviewUserId
}));
syncOrganizationIds();
await preloadReviewUsers();
isUpdate.value = true;
} else {
resetFields();
form.organizationIds = [];
form.flowItems = [];
isUpdate.value = false;
}
} else {
resetFields();
form.organizationIds = [];
form.flowItems = [];
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.flow-step-list {
width: 100%;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 8px 12px;
background: #fafafa;
}
.flow-step-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.flow-step-item + .flow-step-item {
border-top: 1px solid #f0f0f0;
}
.flow-step-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
word-break: break-all;
}
.flow-step-reviewer {
width: 220px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,42 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon>
<PlusOutlined />
</template>
<span>添加</span>
</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue';
import type { GradeParam } from '@/api/user/grade/model';
import { watch } from 'vue';
const props = withDefaults(
defineProps<{
// 选中的角色
selection?: [];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: GradeParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
}>();
// 新增
const add = () => {
emit('add');
};
watch(
() => props.selection,
() => {}
);
</script>
+222
View File
@@ -0,0 +1,222 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<a-image :src="record.image" :width="50" />
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<ReviewFlowEdit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</div>
</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 ReviewFlowEdit from "./components/reviewFlowEdit.vue";
import { pageReviewFlow, removeReviewFlow, removeBatchReviewFlow } from "@/api/gxmu/reviewFlow";
import type { ReviewFlow, ReviewFlowParam } from "@/api/gxmu/reviewFlow/model";
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<ReviewFlow[]>([]);
// 当前编辑数据
const current = ref<ReviewFlow | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 加载状态
const loading = ref(true);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
}
where.withGroup = true;
return pageReviewFlow({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: "ID",
dataIndex: "id",
key: "id",
align: "center",
width: 90
},
{
title: "名称",
dataIndex: "title",
key: "title",
align: "center"
},
{
title: "创建时间",
dataIndex: "createTime",
key: "createTime",
align: "center",
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, "yyyy-MM-dd HH:mm:ss")
},
{
title: "操作",
key: "action",
width: 180,
fixed: "right",
align: "center",
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: ReviewFlowParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: ReviewFlow) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/* 删除单个 */
const remove = (row: ReviewFlow) => {
const hide = message.loading("请求中..", 0);
removeReviewFlow(row.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error("请至少选择一条数据");
return;
}
Modal.confirm({
title: "提示",
content: "确定要删除选中的记录吗?",
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading("请求中..", 0);
removeBatchReviewFlow(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;
};
/* 自定义行属性 */
const customRow = (record: ReviewFlow) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
query();
</script>
<script lang="ts">
export default {
name: "ReviewFlow"
};
</script>
<style lang="less" scoped></style>
@@ -0,0 +1,287 @@
<!-- 编辑弹窗 -->
<template>
<ele-modal
:width="800"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:confirm-loading="loading"
:title="isUpdate ? '编辑' : '添加'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 4, sm: 5, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="名称" name="title">
<a-input
allow-clear
placeholder="请输入名称"
v-model:value="form.title"
/>
</a-form-item>
<a-form-item label="审核流" name="flowId">
<a-select
allow-clear
show-search
:options="reviewFlowOptions"
:loading="reviewFlowLoading"
:filter-option="filterSelectOption"
placeholder="请选择审核流"
v-model:value="form.flowId"
/>
</a-form-item>
<a-form-item label="所属部门" name="organizationId">
<a-tree-select
allow-clear
show-search
tree-default-expand-all
:tree-data="organizationTreeData"
:loading="organizationLoading"
:dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
placeholder="请选择所属部门"
v-model:value="form.organizationId"
/>
</a-form-item>
<a-form-item label="模块" name="module">
<a-select
allow-clear
show-search
:options="moduleOptions"
:filter-option="filterSelectOption"
placeholder="请选择模块"
v-model:value="form.module"
/>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject } from 'ele-admin-pro';
import { toTreeData } from 'ele-admin-pro/es';
import {
addReviewFlowConfig,
updateReviewFlowConfig
} from '@/api/gxmu/reviewFlowConfig';
import { ReviewFlowConfig } from '@/api/gxmu/reviewFlowConfig/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
import { listReviewFlow } from '@/api/gxmu/reviewFlow';
import type { ReviewFlow } from '@/api/gxmu/reviewFlow/model';
import { listOrganizations } from '@/api/system/organization';
import type { Organization } from '@/api/system/organization/model';
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: ReviewFlowConfig | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 表格选中数据
const formRef = ref<FormInstance | null>(null);
const reviewFlowLoading = ref(false);
const reviewFlowOptions = ref<{ label: string; value: number }[]>([]);
const organizationLoading = ref(false);
const organizationTreeData = ref<Organization[]>([]);
const moduleOptions = [
{ label: '稿件', value: 'cms_manuscript' },
{ label: '十佳青年岗位能手', value: 'gxmu_sjqn' },
{ label: '十佳团支部书记', value: 'gxmu_sjtbzbsj' },
{ label: '五四红旗团委', value: 'gxmu_wshqtw' },
{ label: '五四红旗团支部', value: 'gxmu_wshqtzb' },
{ label: '优秀共青团干部', value: 'gxmu_yxgqtdgb' },
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
{ label: '青马工程', value: 'gxmu_qmgc_form' },
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' }
];
// 用户信息
const form = reactive<ReviewFlowConfig>({
id: undefined,
title: undefined,
organizationId: undefined,
flowId: undefined,
module: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
userId: undefined
});
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
title: [
{
required: true,
type: 'string',
whitespace: true,
message: '请输入名称',
trigger: 'blur'
}
],
flowId: [
{
required: true,
type: 'number',
message: '请选择审核流',
trigger: 'change'
}
],
organizationId: [
{
required: true,
type: 'number',
message: '请选择所属部门',
trigger: 'change'
}
],
module: [
{
required: true,
message: '请选择模块',
trigger: 'change'
}
]
});
const { resetFields } = useForm(form, rules);
const loadReviewFlowOptions = async () => {
reviewFlowLoading.value = true;
try {
const result = await listReviewFlow({ withGroup: true });
const optionMap = new Map<number, string>();
result.forEach((item: ReviewFlow) => {
if (item.id && item.title && !optionMap.has(item.id)) {
optionMap.set(item.id, item.title);
}
});
reviewFlowOptions.value = Array.from(optionMap.entries()).map(
([value, label]) => ({
label,
value
})
);
} catch (e: any) {
message.error(e.message || '审核流列表加载失败');
} finally {
reviewFlowLoading.value = false;
}
};
const loadOrganizations = async () => {
organizationLoading.value = true;
try {
const result = await listOrganizations();
organizationTreeData.value = toTreeData({
data: result.map((item) => ({
...item,
key: item.organizationId,
value: item.organizationId,
title: item.organizationName
})),
idField: 'organizationId',
parentIdField: 'parentId'
}) as Organization[];
} catch (e: any) {
message.error(e.message || '部门列表加载失败');
} finally {
organizationLoading.value = false;
}
};
const filterSelectOption = (input: string, option: any) => {
return String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase());
};
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const formData = {
...form
};
const saveOrUpdate = isUpdate.value
? updateReviewFlowConfig
: addReviewFlowConfig;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
if (!organizationTreeData.value.length) {
await loadOrganizations();
}
if (!reviewFlowOptions.value.length) {
await loadReviewFlowOptions();
}
if (props.data) {
assignObject(form, props.data);
isUpdate.value = true;
} else {
resetFields();
isUpdate.value = false;
}
} else {
resetFields();
}
},
{ immediate: true }
);
</script>
@@ -0,0 +1,42 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon>
<PlusOutlined />
</template>
<span>添加</span>
</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue';
import type { GradeParam } from '@/api/user/grade/model';
import { watch } from 'vue';
const props = withDefaults(
defineProps<{
// 选中的角色
selection?: [];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: GradeParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
}>();
// 新增
const add = () => {
emit('add');
};
watch(
() => props.selection,
() => {}
);
</script>
+233
View File
@@ -0,0 +1,233 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<a-image :src="record.image" :width="50" />
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<ReviewFlowConfigEdit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</div>
</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 ReviewFlowConfigEdit from './components/reviewFlowConfigEdit.vue';
import { pageReviewFlowConfig, removeReviewFlowConfig, removeBatchReviewFlowConfig } from '@/api/gxmu/reviewFlowConfig';
import type { ReviewFlowConfig, ReviewFlowConfigParam } from '@/api/gxmu/reviewFlowConfig/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<ReviewFlowConfig[]>([]);
// 当前编辑数据
const current = ref<ReviewFlowConfig | null>(null);
// 是否显示编辑弹窗
const showEdit = 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 pageReviewFlowConfig({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90,
},
{
title: '名称',
dataIndex: 'title',
key: 'title',
align: 'center',
},
{
title: '审核流',
dataIndex: 'flowName',
key: 'flowId',
align: 'center',
},
{
title: '模块',
dataIndex: 'moduleName',
key: 'module',
align: 'center',
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: ReviewFlowConfigParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: ReviewFlowConfig) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/* 删除单个 */
const remove = (row: ReviewFlowConfig) => {
const hide = message.loading('请求中..', 0);
removeReviewFlowConfig(row.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchReviewFlowConfig(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;
};
/* 自定义行属性 */
const customRow = (record: ReviewFlowConfig) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
query();
</script>
<script lang="ts">
export default {
name: 'ReviewFlowConfig'
};
</script>
<style lang="less" scoped></style>
@@ -0,0 +1,739 @@
<template>
<ele-modal
:width="960"
: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 && !descriptionItems.length"
:description="emptyDescription"
/>
<a-descriptions v-else bordered :column="2" size="middle">
<a-descriptions-item
v-for="item in descriptionItems"
:key="item.key"
:label="item.label"
:span="item.span"
>
<template v-if="isFileField(item.key)">
<div
v-for="(file, index) in normalizeFileEntries(item.value)"
:key="`${item.key}-file-${index}`"
class="file-entry"
>
<a :href="file.url" target="_blank" rel="noreferrer">
{{ file.name }}
</a>
</div>
</template>
<a-image
v-else-if="isImageField(item.key) && typeof item.value === 'string'"
:src="item.value"
:width="120"
/>
<template v-else-if="Array.isArray(item.value)">
<div
v-for="(entry, index) in item.value"
:key="`${item.key}-${index}`"
class="array-entry"
>
<div class="array-entry-index">第{{ index + 1 }}项</div>
<div
v-for="(line, lineIndex) in formatArrayEntry(entry)"
:key="`${item.key}-${index}-${lineIndex}`"
class="array-entry-line"
>
{{ line }}
</div>
</div>
</template>
<template v-else-if="isPlainObject(item.value)">
<div
v-for="(line, index) in formatObjectLines(item.value)"
:key="`${item.key}-obj-${index}`"
class="array-entry-line"
>
{{ line }}
</div>
</template>
<!-- eslint-disable vue/no-v-html -->
<div
v-else-if="isRichTextField(item.key)"
class="rich-text-content markdown-body"
v-html="formatRichText(item.value)"
></div>
<div v-else :class="{ 'multiline-text': isWideField(item.key) }">
{{ formatDisplayValue(item.key, item.value) }}
</div>
</a-descriptions-item>
</a-descriptions>
</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 { 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';
interface DescriptionItem {
key: string;
label: string;
value: unknown;
span: number;
}
interface ModuleConfig {
title: string;
fetcher: (id: number) => Promise<Record<string, unknown>>;
labels: Record<string, string>;
}
const COMMON_LABELS: Record<string, string> = {
year: '年份',
id: 'ID',
title: '标题',
content: '内容',
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: '所属团支部'
};
const MODULE_CONFIGS: Record<string, ModuleConfig> = {
cms_manuscript: {
title: '稿件',
fetcher: (id) => getManuscript(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
},
gxmu_sjqn: {
title: '十佳青年岗位能手',
fetcher: (id) => getSjqnForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
},
gxmu_sjtbzbsj: {
title: '十佳团支部书记',
fetcher: (id) => getSjtbzbsjForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
},
gxmu_wshqtw: {
title: '五四红旗团委',
fetcher: (id) => getWshqtwForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
},
gxmu_wshqtzb: {
title: '五四红旗团支部',
fetcher: (id) => getWshqtzbForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
},
gxmu_yxgqtdgb: {
title: '优秀共青团干部',
fetcher: (id) => getYxgqtdgbForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
},
gxmu_yxgqty: {
title: '优秀共青团员',
fetcher: (id) => getYxgqtyForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
},
gxmu_tzbcy_form: {
title: '挑战杯',
fetcher: (id) => getTzbcyForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
},
gxmu_qmgc_form: {
title: '青马工程',
fetcher: (id) => getQmgcForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
},
gxmu_wxxzx_project: {
title: '未来学术之星',
fetcher: (id) => getWxxzxForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS,
title: '职称'
}
},
gxmu_tygl_form: {
title: '团员管理',
fetcher: (id) => getTyglForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
},
gxmu_tygl: {
title: '团员管理',
fetcher: (id) => getTyglForm(id) as Promise<Record<string, unknown>>,
labels: {
...COMMON_LABELS
}
}
};
const HIDDEN_FIELDS = new Set([
'reviewList',
'deleted',
'tenantId',
'userId',
'branchOrganizationId'
]);
const IMAGE_FIELDS = new Set(['photo', 'cover', 'promiseSignature']);
const RICH_TEXT_FIELDS = new Set(['content']);
const DATE_TIME_FIELDS = new Set(['createTime', 'updateTime']);
const FILE_FIELDS = new Set([
'fundingBudgetFile',
'fundingAllocationFile',
'midtermMaterials',
'finalMaterials',
'archiveProofs'
]);
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 currentConfig = computed(() => {
return props.module ? MODULE_CONFIGS[props.module] : undefined;
});
const modalTitle = computed(() => {
return `${props.moduleName || currentConfig.value?.title || '数据'}详情`;
});
const emptyDescription = computed(() => {
if (loadFailed.value) {
return '数据加载失败';
}
if (!currentConfig.value) {
return '暂不支持查看该模块数据';
}
return '暂无可展示数据';
});
const getFieldLabel = (key: string) => {
const label = currentConfig.value?.labels[key];
if (label) {
return label;
}
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (text) => text.toUpperCase())
.trim();
};
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 isWideField = (key: string) => {
return WIDE_FIELDS.has(key);
};
const isImageField = (key: string) => {
return IMAGE_FIELDS.has(key);
};
const isFileField = (key: string) => {
return FILE_FIELDS.has(key);
};
const isRichTextField = (key: string) => {
return props.module === 'cms_manuscript' && RICH_TEXT_FIELDS.has(key);
};
const getFileName = (url?: string) => {
if (!url) {
return '未命名文件';
}
const cleanUrl = url.split('?')[0];
return decodeURIComponent(cleanUrl.split('/').pop() || cleanUrl);
};
const normalizeFileEntries = (value: unknown) => {
if (Array.isArray(value)) {
return value
.map((item) => {
if (typeof item === 'string' && item.trim()) {
return {
name: getFileName(item),
url: item
};
}
if (
isPlainObject(item) &&
typeof item.url === 'string' &&
item.url.trim()
) {
return {
name:
(typeof item.name === 'string' && item.name.trim()) ||
getFileName(item.url),
url: item.url
};
}
return null;
})
.filter((item): item is { name: string; url: string } => !!item);
}
if (typeof value === 'string' && value.trim()) {
return [
{
name: getFileName(value),
url: value
}
];
}
return [];
};
const normalizeDetailValue = (key: string, value: unknown) => {
if (!isFileField(key) || typeof value !== 'string') {
return value;
}
const text = value.trim();
if (!text) {
return value;
}
if (key === 'fundingBudgetFile' || key === 'fundingAllocationFile') {
return text;
}
try {
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? parsed : text;
} catch (e) {
return text;
}
};
const formatSimpleValue = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '-';
}
return String(value);
};
const formatStatusValue = (value: unknown) => {
if (props.module === 'cms_manuscript') {
if (Number(value) === 0) {
return '待审核';
}
if (Number(value) === 1) {
return '显示';
}
}
return formatSimpleValue(value);
};
const formatDateTimeValue = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '-';
}
return toDateString(String(value), 'yyyy-MM-dd HH:mm:ss');
};
const formatDisplayValue = (key: string, value: unknown) => {
if (DATE_TIME_FIELDS.has(key)) {
return formatDateTimeValue(value);
}
if (key === 'status') {
return formatStatusValue(value);
}
return formatSimpleValue(value);
};
const formatRichText = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '<p>-</p>';
}
return String(value);
};
const formatObjectLines = (value: Record<string, unknown>) => {
return Object.keys(value)
.filter((key) => !HIDDEN_FIELDS.has(key) && hasVisibleValue(value[key]))
.map((key) => `${getFieldLabel(key)}:${formatDisplayValue(key, value[key])}`);
};
const formatArrayEntry = (entry: unknown) => {
if (isPlainObject(entry)) {
const lines = formatObjectLines(entry);
return lines.length ? lines : ['-'];
}
return [formatSimpleValue(entry)];
};
const descriptionItems = computed<DescriptionItem[]>(() => {
const source = detail.value || {};
const orderedKeys = Object.keys(currentConfig.value?.labels || {}).filter(
(key) => !HIDDEN_FIELDS.has(key) && hasVisibleValue(source[key])
);
const extraKeys = Object.keys(source).filter(
(key) =>
!HIDDEN_FIELDS.has(key) &&
hasVisibleValue(source[key]) &&
!orderedKeys.includes(key)
);
return [...orderedKeys, ...extraKeys].map((key) => ({
key,
label: getFieldLabel(key),
value: normalizeDetailValue(key, source[key]),
span:
isWideField(key) ||
isFileField(key) ||
isImageField(key) ||
Array.isArray(normalizeDetailValue(key, source[key])) ||
isPlainObject(normalizeDetailValue(key, source[key]))
? 2
: 1
}));
});
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const loadDetail = async () => {
if (!props.visible) {
return;
}
detail.value = {};
loadFailed.value = false;
if (!props.pk || !currentConfig.value) {
if (props.module && !currentConfig.value) {
loadFailed.value = true;
}
return;
}
loading.value = true;
try {
detail.value = (await currentConfig.value.fetcher(props.pk)) || {};
} 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 = {};
loadFailed.value = false;
loading.value = false;
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
:deep(.markdown-body) {
background: #fff;
color: rgba(0, 0, 0, 0.88);
}
.multiline-text {
white-space: pre-wrap;
line-height: 1.8;
}
.rich-text-content {
min-height: 32px;
line-height: 1.8;
word-break: break-word;
}
.rich-text-content:deep(p:last-child) {
margin-bottom: 0;
}
.array-entry {
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.array-entry + .array-entry {
margin-top: 12px;
}
.array-entry-index {
margin-bottom: 8px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.array-entry-line {
line-height: 1.8;
white-space: pre-wrap;
word-break: break-all;
}
.file-entry + .file-entry {
margin-top: 8px;
}
</style>
@@ -0,0 +1,233 @@
<template>
<ele-modal
:width="900"
:visible="visible"
:maskClosable="false"
:maxable="true"
:title="`${moduleName || '项目'}审核流`"
:body-style="{ paddingBottom: '20px' }"
@update:visible="updateVisible"
okText="关闭"
@ok="updateVisible(false)"
>
<a-space style="margin-bottom: 16px">
<span>模块:</span>
<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-space>
<div v-if="flowList.length" class="flow-wrap">
<a-steps direction="vertical" :current="currentStepIndex">
<a-step v-for="item in flowList" :key="item.id">
<template #title>
<div class="flow-title">
<span>{{ item.user?.realName || item.user?.username || '未分配审核人' }}</span>
<a-tag :color="getStatusColor(item.status)">
{{ getStatusText(item.status) }}
</a-tag>
</div>
</template>
<template #description>
<div class="flow-desc">
<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>
<a-button danger size="small" :loading="submitting" @click="openActionModal('reject', item)">
拒绝
</a-button>
</a-space>
</div>
</template>
</a-step>
</a-steps>
</div>
<a-empty v-else description="暂无审核流数据" :image="simpleImage" />
<a-modal
v-model:visible="actionVisible"
:title="actionType === 'approve' ? '审核通过' : '审核拒绝'"
ok-text="确认"
cancel-text="取消"
:confirm-loading="submitting"
@ok="submitReview"
>
<a-form layout="vertical">
<a-form-item label="审核意见(非必填)">
<a-textarea
v-model:value="actionContent"
:rows="4"
allow-clear
placeholder="请输入审核意见"
/>
</a-form-item>
</a-form>
</a-modal>
</ele-modal>
</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';
const props = defineProps<{
visible: boolean;
module?: string;
moduleName?: string;
pk?: number;
}>();
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 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 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 = '';
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.flow-wrap {
max-height: 560px;
overflow-y: auto;
padding-right: 8px;
}
.flow-title {
display: flex;
align-items: center;
gap: 8px;
}
.flow-desc {
color: rgba(0, 0, 0, 0.65);
line-height: 1.8;
}
</style>
@@ -0,0 +1,191 @@
<!-- 编辑弹窗 -->
<template>
<ele-modal
:width="800"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑审核列表' : '添加审核列表'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 4, sm: 5, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="模块" name="module">
<a-input
allow-clear
placeholder="请输入模块"
v-model:value="form.module"
/>
</a-form-item>
<a-form-item label="主键" name="pk">
<a-input
allow-clear
placeholder="请输入主键"
v-model:value="form.pk"
/>
</a-form-item>
<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>
</a-radio-group>
</a-form-item>
<a-form-item label="审核意见" name="content">
<a-input
allow-clear
placeholder="请输入审核意见"
v-model:value="form.content"
/>
</a-form-item>
<a-form-item label="是否删除, 0否, 1是" name="deleted">
<a-input
allow-clear
placeholder="请输入是否删除, 0否, 1是"
v-model:value="form.deleted"
/>
</a-form-item>
<a-form-item label="修改时间" name="updateTime">
<a-input
allow-clear
placeholder="请输入修改时间"
v-model:value="form.updateTime"
/>
</a-form-item>
<a-form-item label="" name="userId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.userId"
/>
</a-form-item>
<a-form-item label="" name="sortNumber">
<a-input-number
:min="0"
:max="9999"
class="ele-fluid"
placeholder="请输入排序号"
v-model:value="form.sortNumber"
/>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject, uuid } from 'ele-admin-pro';
import { addReviewList, updateReviewList } from '@/api/gxmu/reviewList';
import { ReviewList } from '@/api/gxmu/reviewList/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
import { FileRecord } from '@/api/system/file/model';
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: ReviewList | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 表格选中数据
const formRef = ref<FormInstance | null>(null);
// 用户信息
const form = reactive<ReviewList>({
id: undefined,
module: undefined,
pk: undefined,
status: undefined,
content: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
userId: undefined,
sortNumber: undefined,
});
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
});
const { resetFields } = useForm(form, rules);
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const formData = {
...form
};
const saveOrUpdate = isUpdate.value ? updateReviewList : addReviewList;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
assignObject(form, props.data);
isUpdate.value = true;
} else {
isUpdate.value = false;
}
} else {
resetFields();
}
},
{ immediate: true }
);
</script>
@@ -0,0 +1,68 @@
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="where.module"
allow-clear
placeholder="请选择模块"
style="width: 220px"
:options="moduleOptions"
@change="search"
/>
<a-input-search
v-model:value="where.keywords"
allow-clear
placeholder="请输入名称/审核意见"
style="width: 260px"
@search="search"
@pressEnter="search"
/>
</a-space>
</template>
<script lang="ts" setup>
import { reactive, watch } from 'vue';
import type { ReviewListParam } from '@/api/gxmu/reviewList/model';
const props = withDefaults(
defineProps<{
selection?: [];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: ReviewListParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
}>();
const where = reactive<ReviewListParam>({
module: undefined,
keywords: undefined
});
const moduleOptions = [
{ label: '十佳青年岗位能手', value: 'gxmu_sjqn' },
{ label: '十佳团支部书记', value: 'gxmu_sjtbzbsj' },
{ label: '五四红旗团委', value: 'gxmu_wshqtw' },
{ label: '五四红旗团支部', value: 'gxmu_wshqtzb' },
{ label: '优秀共青团干部', value: 'gxmu_yxgqtdgb' },
{ label: '优秀共青团员', value: 'gxmu_yxgqty' },
{ label: '挑战杯', value: 'gxmu_tzbcy_form' },
{ label: '青马工程', value: 'gxmu_qmgc_form' },
{ label: '未来学术之星', value: 'gxmu_wxxzx_project' }
];
const search = () => {
emit('search', {
...where,
keywords: where.keywords?.trim() || undefined
});
};
watch(
() => props.selection,
() => {}
);
</script>
+216
View File
@@ -0,0 +1,216 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
</template>
<template #bodyCell="{ column, record }">
<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>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDataModal(record)">查看数据</a>
<a-divider type="vertical" />
<a @click="openFlowModal(record)">查看审核流</a>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<ReviewListEdit v-model:visible="showEdit" :data="current" @done="reload" />
<ModuleDataModal
v-model:visible="showDataVisible"
:module="dataCurrent?.module"
:module-name="dataCurrent?.moduleName"
:pk="dataCurrent?.pk"
/>
<ReviewFlowModal
v-model:visible="showFlowVisible"
:module="flowCurrent?.module"
:module-name="flowCurrent?.moduleName"
:pk="flowCurrent?.pk"
@done="reload"
/>
</div>
</div>
</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';
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,
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);
});
}
});
};
const query = () => {
loading.value = true;
};
query();
</script>
<script lang="ts">
export default {
name: 'ReviewList'
};
</script>
<style lang="less" scoped></style>
+64
View File
@@ -0,0 +1,64 @@
.gxmu-tag-tabs() {
:deep(.ant-tabs-nav) {
margin-bottom: 22px;
}
:deep(.ant-tabs-nav::before) {
border-bottom: 0;
}
:deep(.ant-tabs-tab) {
min-width: 168px;
height: 52px;
margin-right: 18px !important;
padding: 0 28px !important;
border: 1px solid #d31212 !important;
border-radius: 999px !important;
background: linear-gradient(90deg, #ffffff 0%, #fff7f7 100%) !important;
color: #c10e0e !important;
font-size: 16px;
font-weight: 600;
justify-content: center;
transition: all 0.2s ease;
}
:deep(.ant-tabs-tab:hover) {
color: #d31212 !important;
border-color: #d31212 !important;
}
:deep(.ant-tabs-tab.ant-tabs-tab-active) {
background: linear-gradient(90deg, #ef1b1b 0%, #c90707 100%) !important;
}
:deep(.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn) {
color: #fff !important;
}
:deep(.ant-tabs-tab .ant-tabs-tab-btn) {
color: inherit;
}
:deep(.ant-tabs-ink-bar) {
display: none;
}
@media (max-width: 768px) {
:deep(.ant-tabs-nav) {
margin-bottom: 16px;
}
:deep(.ant-tabs-nav-list) {
flex-wrap: wrap;
gap: 10px;
}
:deep(.ant-tabs-tab) {
min-width: auto;
height: 42px;
margin-right: 0 !important;
padding: 0 18px !important;
font-size: 14px;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
@import './form-theme.less';
.gxmu-field-theme();
.gxmu-toolbar-btn {
min-width: 60px;
height: 32px;
padding: 0 14px;
border-radius: 18px;
border-width: 2px;
border-style: solid;
font-size: 14px;
font-weight: 600;
color: #fff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12);
background: linear-gradient(82.9deg, #32bfff 14.75%, #1590ff 44.46%);
}
.gxmu-toolbar-btn:hover,
.gxmu-toolbar-btn:focus {
color: #fff;
}
.gxmu-toolbar-btn :deep(.ant-btn-loading-icon),
.gxmu-toolbar-btn :deep(.anticon) {
font-size: 14px;
color: #fff;
}
.gxmu-toolbar-btn > span {
display: inline-flex;
align-items: center;
gap: 10px;
}
.gxmu-add-btn {
background: linear-gradient(135deg, #e10000 0%, #c40000 100%);
border-color: #f3b3b3;
}
.gxmu-add-btn:hover,
.gxmu-add-btn:focus {
background: linear-gradient(135deg, #f01515 0%, #cd0a0a 100%);
border-color: #f7c3c3;
}
.gxmu-import-btn {
background: linear-gradient(135deg, #4ad9cd 0%, #18b68e 100%);
border-color: #92efe2;
}
.gxmu-import-btn:hover,
.gxmu-import-btn:focus {
background: linear-gradient(135deg, #60e3d8 0%, #21c29a 100%);
border-color: #b1f6ec;
}
@media (max-width: 768px) {
.gxmu-toolbar-btn {
min-width: 60px;
height: 32px;
padding: 0 12px;
font-size: 14px;
}
.gxmu-toolbar-btn :deep(.anticon) {
font-size: 14px;
}
}
+275
View File
@@ -0,0 +1,275 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
:scroll="{ x: 1450 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 260px"
placeholder="项目名称/学校/奖项"
@search="reload"
@pressEnter="reload"
/>
<a-input
v-model:value="schoolName"
allow-clear
style="width: 180px"
placeholder="高校名称"
@pressEnter="reload"
/>
<a-select
v-model:value="matchYear"
allow-clear
style="width: 140px"
placeholder="参赛年份"
:options="yearOptions"
/>
<a-select
v-model:value="matchLevel"
allow-clear
style="width: 140px"
placeholder="比赛级别"
:options="levelOptions"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'coverImage'">
<a-image
v-if="record.coverImage"
:src="record.coverImage"
:width="48"
:height="48"
style="object-fit: cover; border-radius: 4px"
/>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'sourceId'">
<a v-if="record.detailUrl" @click="copyDetailLink(record)">
{{ record.sourceId }}
</a>
<span v-else>{{ record.sourceId || '-' }}</span>
</template>
<template v-else-if="column.key === 'matchLevel'">
<a-tag v-if="record.matchLevel" color="blue">
{{ record.matchLevel }}
</a-tag>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="copyDetailLink(record)">复制链接</a>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-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 { pageTzbProjectList } from '@/api/gxmu/tzb-project-list';
import type {
TzbProjectListRecord,
TzbProjectListRecordParam
} from '@/api/gxmu/tzb-project-list/model';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const schoolName = ref('');
const matchYear = ref<number>();
const matchLevel = ref<string>();
const yearOptions = [2025, 2024, 2023, 2022, 2021, 2020, 2019].map((year) => ({
label: `${year}年`,
value: year
}));
const levelOptions = ['国赛', '省赛', '校赛'].map((level) => ({
label: level,
value: level
}));
const formatDateTime = (value?: string) => {
return value ? toDateString(value, 'yyyy-MM-dd HH:mm:ss') : '-';
};
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageTzbProjectList({
...(where || {}),
...orders,
page,
limit,
keywords: searchText.value.trim() || undefined,
schoolName: schoolName.value.trim() || undefined,
matchYear: matchYear.value,
matchLevel: matchLevel.value
} as TzbProjectListRecordParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const columns = ref<ColumnItem[]>([
{
title: '封面',
dataIndex: 'coverImage',
key: 'coverImage',
align: 'center',
width: 80
},
{
title: '来源ID',
dataIndex: 'sourceId',
key: 'sourceId',
align: 'center',
width: 100,
sorter: true
},
{
title: '项目名称',
dataIndex: 'projectName',
key: 'projectName',
align: 'left',
width: 360,
ellipsis: true
},
{
title: '高校',
dataIndex: 'schoolName',
key: 'schoolName',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '获奖情况',
dataIndex: 'awardName',
key: 'awardName',
align: 'center',
width: 120
},
{
title: '参赛年份',
dataIndex: 'matchYear',
key: 'matchYear',
align: 'center',
width: 110,
sorter: true
},
{
title: '参赛届次',
dataIndex: 'matchTerm',
key: 'matchTerm',
align: 'center',
width: 130
},
{
title: '比赛级别',
dataIndex: 'matchLevel',
key: 'matchLevel',
align: 'center',
width: 110
},
{
title: '来源页码',
dataIndex: 'pageNo',
key: 'pageNo',
align: 'center',
width: 110
},
{
title: '同步时间',
dataIndex: 'lastSyncTime',
key: 'lastSyncTime',
align: 'center',
width: 180,
sorter: true,
customRender: ({ text }) => formatDateTime(text)
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 120
}
]);
const reload = () => {
tableRef.value?.reload();
};
const resetSearch = () => {
searchText.value = '';
schoolName.value = '';
matchYear.value = undefined;
matchLevel.value = undefined;
reload();
};
const copyText = async (text: string) => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
};
const copyDetailLink = async (record: TzbProjectListRecord) => {
if (!record.detailUrl) {
message.warning('暂无详情链接');
return;
}
try {
await copyText(record.detailUrl);
message.success('详情链接已复制');
} catch (e) {
message.error('复制失败,请手动复制链接');
}
};
const customRow = (record: TzbProjectListRecord) => {
return {
onDblclick: () => {
copyDetailLink(record);
}
};
};
</script>
<script lang="ts">
export default {
name: 'TzbProjectList'
};
</script>
<style lang="less" scoped></style>
+283
View File
@@ -0,0 +1,283 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
:scroll="{ x: 1580 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 260px"
placeholder="姓名/项目/学校/奖项"
@search="reload"
@pressEnter="reload"
/>
<a-input
v-model:value="schoolName"
allow-clear
style="width: 180px"
placeholder="参赛学校"
@pressEnter="reload"
/>
<a-input
v-model:value="talentType"
allow-clear
style="width: 170px"
placeholder="人才类型"
@pressEnter="reload"
/>
<a-select
v-model:value="matchLevel"
allow-clear
style="width: 140px"
placeholder="比赛级别"
:options="levelOptions"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'coverImage'">
<a-image
v-if="record.coverImage"
:src="record.coverImage"
:width="48"
:height="48"
style="object-fit: cover; border-radius: 4px"
/>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'sourceId'">
<a v-if="record.detailUrl" @click="copyDetailLink(record)">
{{ record.sourceId }}
</a>
<span v-else>{{ record.sourceId || '-' }}</span>
</template>
<template v-else-if="column.key === 'matchLevel'">
<a-tag v-if="record.matchLevel" color="blue">
{{ record.matchLevel }}
</a-tag>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'talentType'">
<a-tag v-if="record.talentType" color="green">
{{ record.talentType }}
</a-tag>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="copyDetailLink(record)">复制链接</a>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-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 { pageTzbTalentList } from '@/api/gxmu/tzb-talent-list';
import type {
TzbTalentListRecord,
TzbTalentListRecordParam
} from '@/api/gxmu/tzb-talent-list/model';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const schoolName = ref('');
const talentType = ref('');
const matchLevel = ref<string>();
const levelOptions = ['国赛', '省赛', '校赛'].map((level) => ({
label: level,
value: level
}));
const formatDateTime = (value?: string) => {
return value ? toDateString(value, 'yyyy-MM-dd HH:mm:ss') : '-';
};
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageTzbTalentList({
...(where || {}),
...orders,
page,
limit,
keywords: searchText.value.trim() || undefined,
schoolName: schoolName.value.trim() || undefined,
talentType: talentType.value.trim() || undefined,
matchLevel: matchLevel.value
} as TzbTalentListRecordParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const columns = ref<ColumnItem[]>([
{
title: '头像',
dataIndex: 'coverImage',
key: 'coverImage',
align: 'center',
width: 80
},
{
title: '来源ID',
dataIndex: 'sourceId',
key: 'sourceId',
align: 'center',
width: 100,
sorter: true
},
{
title: '姓名',
dataIndex: 'personName',
key: 'personName',
align: 'center',
width: 120
},
{
title: '参赛学校',
dataIndex: 'schoolName',
key: 'schoolName',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '人才类型',
dataIndex: 'talentType',
key: 'talentType',
align: 'center',
width: 150
},
{
title: '参赛项目',
dataIndex: 'projectName',
key: 'projectName',
align: 'left',
width: 340,
ellipsis: true
},
{
title: '获奖情况',
dataIndex: 'awardName',
key: 'awardName',
align: 'center',
width: 120
},
{
title: '参赛届次',
dataIndex: 'matchTerm',
key: 'matchTerm',
align: 'center',
width: 130
},
{
title: '比赛级别',
dataIndex: 'matchLevel',
key: 'matchLevel',
align: 'center',
width: 110
},
{
title: '来源页码',
dataIndex: 'pageNo',
key: 'pageNo',
align: 'center',
width: 110
},
{
title: '同步时间',
dataIndex: 'lastSyncTime',
key: 'lastSyncTime',
align: 'center',
width: 180,
sorter: true,
customRender: ({ text }) => formatDateTime(text)
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 120
}
]);
const reload = () => {
tableRef.value?.reload();
};
const resetSearch = () => {
searchText.value = '';
schoolName.value = '';
talentType.value = '';
matchLevel.value = undefined;
reload();
};
const copyText = async (text: string) => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
};
const copyDetailLink = async (record: TzbTalentListRecord) => {
if (!record.detailUrl) {
message.warning('暂无详情链接');
return;
}
try {
await copyText(record.detailUrl);
message.success('详情链接已复制');
} catch (e) {
message.error('复制失败,请手动复制链接');
}
};
const customRow = (record: TzbTalentListRecord) => {
return {
onDblclick: () => {
copyDetailLink(record);
}
};
};
</script>
<script lang="ts">
export default {
name: 'TzbTalentList'
};
</script>
<style lang="less" scoped></style>
+311
View File
@@ -0,0 +1,311 @@
import { ref } from 'vue';
import { listDictData } from '@/api/system/dict-data';
import { listCollege } from '@/api/gxmu/college';
import { listClassInfo } from '@/api/gxmu/class';
import type { ClassInfo } from '@/api/gxmu/class/model';
export interface SelectOption {
label: string;
value: string;
}
export interface ClassCascaderOption {
label: string;
value: string;
children?: ClassCascaderOption[];
}
export interface ClassCascadeSelection {
classId?: number;
className: string;
collegeId?: number;
collegeName: string;
path: string[];
combinedLabel: string;
}
const NATION_DICT_ID = 173;
const POLITICS_DICT_ID = 172;
const genderOptions: SelectOption[] = [
{ label: '男', value: '男' },
{ label: '女', value: '女' }
];
let nationCache: SelectOption[] | null = null;
let politicsCache: SelectOption[] | null = null;
let collegeCache: SelectOption[] | null = null;
let classCache: SelectOption[] | null = null;
let classCascaderCache: ClassCascaderOption[] | null = null;
let classCascadeSelectionCache: ClassCascadeSelection[] | null = null;
const normalizeOptions = (values: string[]) =>
values
.map((item) => item.trim())
.filter((item, index, arr) => !!item && arr.indexOf(item) === index)
.map((item) => ({
label: item,
value: item
}));
const normalizeText = (value?: string) =>
String(value || '')
.trim()
.replace(/[\/|,,、]+/g, ' ')
.replace(/\s+/g, ' ');
const compactText = (value?: string) =>
normalizeText(value).replace(/\s+/g, '').toLowerCase();
const buildCombinedLabel = (collegeName?: string, className?: string) =>
[String(collegeName || '').trim(), String(className || '').trim()]
.filter(Boolean)
.join(' ');
const buildClassCascadeData = (list: ClassInfo[]) => {
const collegeMap = new Map<string, ClassCascaderOption>();
const classSelections: ClassCascadeSelection[] = [];
list.forEach((item, index) => {
const className = String(item.className || '').trim();
const collegeName = String(item.collegeName || '').trim();
if (!className || !collegeName) {
return;
}
const collegeValue = `college:${
item.collegeId ?? `${collegeName}:${index}`
}`;
const classValue = `class:${
item.id ?? `${collegeValue}:${className}:${index}`
}`;
let collegeOption = collegeMap.get(collegeValue);
if (!collegeOption) {
collegeOption = {
label: collegeName,
value: collegeValue,
children: []
};
collegeMap.set(collegeValue, collegeOption);
}
if (
!collegeOption.children?.some(
(child) => child.label === className && child.value === classValue
)
) {
collegeOption.children?.push({
label: className,
value: classValue
});
}
classSelections.push({
classId: item.id,
className,
collegeId: item.collegeId,
collegeName,
path: [collegeValue, classValue],
combinedLabel: buildCombinedLabel(collegeName, className)
});
});
return {
classOptions: normalizeOptions(
classSelections.map((item) => item.className)
),
cascaderOptions: Array.from(collegeMap.values()).filter(
(item) => item.children?.length
),
classSelections
};
};
const ensureClassDataLoaded = async () => {
if (classCache && classCascaderCache && classCascadeSelectionCache) {
return;
}
const list = await listClassInfo({ status: 1, limit: 1000 });
const { classOptions, cascaderOptions, classSelections } =
buildClassCascadeData(list);
classCache = classOptions;
classCascaderCache = cascaderOptions;
classCascadeSelectionCache = classSelections;
};
export const filterSelectOption = (input: string, option: SelectOption) => {
return String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase());
};
export const useGxmuFormSelectOptions = () => {
const loading = ref(false);
const loaded = ref(false);
const nationOptions = ref<SelectOption[]>(nationCache ?? []);
const politicsOptions = ref<SelectOption[]>(politicsCache ?? []);
const collegeOptions = ref<SelectOption[]>(collegeCache ?? []);
const classOptions = ref<SelectOption[]>(classCache ?? []);
const classCascaderOptions = ref<ClassCascaderOption[]>(
classCascaderCache ?? []
);
const loadNationOptions = async () => {
if (nationCache) {
nationOptions.value = nationCache;
return nationCache;
}
const result = await listDictData({ dictId: NATION_DICT_ID });
nationCache = result
.filter((item) => !!item.dictDataName)
.map((item) => ({
label: String(item.dictDataName),
value: String(item.dictDataName)
}));
nationOptions.value = nationCache;
return nationCache;
};
const loadCollegeOptions = async () => {
if (collegeCache) {
collegeOptions.value = collegeCache;
return collegeCache;
}
const list = await listCollege({ status: 1, limit: 500 });
collegeCache = normalizeOptions(
list.map((item) => item.collegeName || item.shortName || '')
);
collegeOptions.value = collegeCache;
return collegeCache;
};
const loadPoliticsOptions = async () => {
if (politicsCache) {
politicsOptions.value = politicsCache;
return politicsCache;
}
const result = await listDictData({ dictId: POLITICS_DICT_ID });
politicsCache = result
.filter((item) => !!item.dictDataName)
.map((item) => ({
label: String(item.dictDataName),
value: String(item.dictDataName)
}));
politicsOptions.value = politicsCache;
return politicsCache;
};
const loadClassOptions = async () => {
if (classCache) {
classOptions.value = classCache;
return classCache;
}
await ensureClassDataLoaded();
classOptions.value = classCache;
return classCache;
};
const loadClassCascaderOptions = async () => {
if (classCascaderCache) {
classCascaderOptions.value = classCascaderCache;
return classCascaderCache;
}
await ensureClassDataLoaded();
classCascaderOptions.value = classCascaderCache;
return classCascaderCache;
};
const findClassCascadeSelection = (
value?: string,
mode: 'class' | 'collegeClass' = 'class'
) => {
const source = classCascadeSelectionCache ?? [];
const normalizedValue = normalizeText(value);
const compactValue = compactText(value);
if (!normalizedValue) {
return void 0;
}
if (mode === 'collegeClass') {
return (
source.find(
(item) => normalizeText(item.combinedLabel) === normalizedValue
) ??
source.find(
(item) => compactText(item.combinedLabel) === compactValue
) ??
source.find((item) => {
return (
compactValue.includes(compactText(item.collegeName)) &&
compactValue.includes(compactText(item.className))
);
})
);
}
return (
source.find(
(item) => normalizeText(item.className) === normalizedValue
) ??
source.find((item) => compactText(item.className) === compactValue) ??
source.find(
(item) => normalizeText(item.combinedLabel) === normalizedValue
) ??
source.find((item) => compactText(item.combinedLabel) === compactValue)
);
};
const getClassCascadeSelectionByPath = (path?: string[]) => {
if (!path?.length) {
return void 0;
}
const key = path.join('>');
return (classCascadeSelectionCache ?? []).find(
(item) => item.path.join('>') === key
);
};
const loadAll = async () => {
if (loaded.value) {
return;
}
loading.value = true;
try {
await Promise.all([
loadNationOptions(),
loadPoliticsOptions(),
loadCollegeOptions(),
loadClassOptions(),
loadClassCascaderOptions()
]);
loaded.value = true;
} finally {
loading.value = false;
}
};
return {
loading,
loaded,
genderOptions,
nationOptions,
politicsOptions,
collegeOptions,
classOptions,
classCascaderOptions,
loadAll,
loadNationOptions,
loadPoliticsOptions,
loadCollegeOptions,
loadClassOptions,
loadClassCascaderOptions,
findClassCascadeSelection,
getClassCascadeSelectionByPath,
buildCombinedLabel
};
};
+19
View File
@@ -0,0 +1,19 @@
import type { ReviewList } from '@/api/gxmu/reviewList/model';
export function shouldHideEditByReviewList(reviewList?: ReviewList[]) {
const list = reviewList ?? [];
if (!list.length) {
return false;
}
const latest = [...list].sort((a, b) => {
const timeA = Date.parse(a.createTime || '');
const timeB = Date.parse(b.createTime || '');
const safeTimeA = Number.isFinite(timeA) ? timeA : 0;
const safeTimeB = Number.isFinite(timeB) ? timeB : 0;
if (safeTimeA !== safeTimeB) {
return safeTimeB - safeTimeA;
}
return Number(b.id || 0) - Number(a.id || 0);
})[0];
return latest?.status !== 2;
}
@@ -0,0 +1,436 @@
<template>
<a-drawer
placement="right"
width="78%"
:visible="visible"
:title="isUpdate ? `编辑${title}` : `新增${title}`"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<a-form ref="formRef" :model="form" layout="vertical">
<a-tabs v-model:activeKey="activeKey" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="cover" tab="封面信息">
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="单位" name="unitName">
<a-input v-model:value="form.unitName" placeholder="请输入单位" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="负责人" name="responsiblePerson">
<a-input
v-model:value="form.responsiblePerson"
placeholder="请输入负责人"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="使用开始月份">
<a-date-picker
v-model:value="form.useStartMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择开始月份"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="使用结束月份">
<a-date-picker
v-model:value="form.useEndMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择结束月份"
/>
</a-form-item>
</a-col>
</a-row>
</a-tab-pane>
<a-tab-pane key="overview" tab="团委简况">
<a-row :gutter="16">
<a-col v-for="item in overviewFields" :key="item.key" :xs="24" :md="8">
<a-form-item :label="item.label">
<a-input v-model:value="form[item.key]" :placeholder="`请输入${item.label}`" />
</a-form-item>
</a-col>
</a-row>
<editable-table
title="团委班子成员名单"
:rows="form.committeeMembers"
:columns="committeeMemberColumns"
:create-row="createCommitteeMember"
:scroll-x="1100"
/>
</a-tab-pane>
<a-tab-pane key="fees" tab="团费收支">
<editable-table
title="团费收支记录"
:rows="form.feeRecords"
:columns="feeRecordColumns"
:create-row="createFeeRecord"
:scroll-x="980"
/>
</a-tab-pane>
<a-tab-pane key="orgs" tab="下属组织">
<editable-table
title="团委下属团组织情况一览表"
:rows="form.subordinateOrgs"
:columns="subordinateOrgColumns"
:create-row="createSubordinateOrg"
:scroll-x="1100"
/>
</a-tab-pane>
<a-tab-pane key="honors" tab="荣誉与推优">
<editable-table
title="荣誉登记表"
:rows="form.honorRecords"
:columns="honorRecordColumns"
:create-row="createHonorRecord"
:scroll-x="1120"
/>
<editable-table
title="推荐入党积极分子个人选汇总表"
:rows="form.activistRecommendations"
:columns="activistColumns"
:create-row="createActivistRecommendation"
:scroll-x="1120"
/>
</a-tab-pane>
<a-tab-pane key="discipline" tab="团纪处理">
<editable-table
title="团员团纪处理情况记录"
:rows="form.disciplineRecords"
:columns="disciplineColumns"
:create-row="createDisciplineRecord"
:scroll-x="920"
/>
</a-tab-pane>
<a-tab-pane key="records" tab="会议活动">
<editable-table
title="团委有关会议记录"
:rows="form.meetingRecords"
:columns="meetingColumns"
:create-row="createMeetingRecord"
:scroll-x="1400"
/>
<editable-table
title="团委有关活动记录"
:rows="form.activityRecords"
:columns="activityColumns"
:create-row="createActivityRecord"
:scroll-x="1300"
/>
</a-tab-pane>
<a-tab-pane key="evaluation" tab="教育评议与总结">
<editable-table
title="年度团员教育评议结果汇总表"
:rows="form.educationEvaluations"
:columns="educationColumns"
:create-row="createEducationEvaluation"
:scroll-x="980"
/>
<a-form-item label="团委年度工作总结">
<a-textarea
v-model:value="form.annualSummary"
:rows="12"
placeholder="请输入团委年度工作总结"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="学社衔接率">
<a-input
v-model:value="form.schoolSocietyConnectionRate"
placeholder="若无请填无"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="编号发展团员智慧团建录入率">
<a-input
v-model:value="form.smartLeagueEntryRate"
placeholder="若无请填无"
/>
</a-form-item>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
</a-form>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import type { WorkLedger } from '@/api/gxmu/work-ledger/model';
import EditableTable from './editable-table.vue';
import { useGxmuFormSelectOptions } from '@/views/gxmu/useFormSelectOptions';
const props = defineProps<{
visible: boolean;
data?: WorkLedger | null;
title: string;
saveHandler: (data: WorkLedger) => Promise<string>;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const loading = ref(false);
const isUpdate = ref(false);
const activeKey = ref('cover');
const formRef = ref();
const { nationOptions, loadNationOptions } = useGxmuFormSelectOptions();
const rowKey = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const withKey = <T extends Record<string, any>>(row: T) => ({ ...row, __rowKey: rowKey() });
const createCommitteeMember = () => withKey({ name: '', gender: '', birthMonth: '', politics: '', leagueDuty: '', partTimeWork: '' });
const createFeeRecord = () => withKey({ date: '', purpose: '', income: '', expense: '', balance: '' });
const createSubordinateOrg = () => withKey({ branchName: '', leaderName: '', phone: '', memberCount: '', youthCount: '', feeCount: '', paidCount: '' });
const createHonorRecord = () => withKey({ honorName: '', awardTime: '', winner: '', rewardSituation: '', remark: '' });
const createActivistRecommendation = () => withKey({ name: '', gender: '', nation: '', birthMonth: '', classMajor: '', currentDuty: '' });
const createDisciplineRecord = () => withKey({ name: '', time: '', handlingType: '', reason: '' });
const createMeetingRecord = () => withKey({ meetingName: '', meetingTheme: '', time: '', location: '', host: '', attendees: '', absentees: '', content: '', recorder: '' });
const createActivityRecord = () => withKey({ activityName: '', activityTheme: '', activityTime: '', location: '', participants: '', content: '', recorder: '' });
const createEducationEvaluation = () => withKey({ branchName: '', name: '', result: '', remark: '' });
const overviewFields = [
{ label: '团委书记姓名', key: 'secretaryName' },
{ label: '所辖团(总)支部数', key: 'branchCount' },
{ label: '团员总数', key: 'memberTotal' },
{ label: '教职工团员数', key: 'facultyMemberCount' },
{ label: '学生团员数', key: 'studentMemberCount' },
{ label: '少数民族团员数', key: 'minorityMemberCount' },
{ label: '女团员数', key: 'femaleMemberCount' },
{ label: '14-28岁青年数', key: 'youth14To28Count' },
{ label: '29-35岁青年数', key: 'youth29To35Count' },
{ label: '团干总数', key: 'cadreTotal' },
{ label: '职工团干数', key: 'facultyCadreCount' },
{ label: '学生团干数', key: 'studentCadreCount' },
{ label: '最近一次换届时间', key: 'lastElectionTime' },
{ label: '团费余额', key: 'feeBalance' },
{ label: '上半年应缴团费', key: 'firstHalfFeeDue' },
{ label: '上半年实缴团费', key: 'firstHalfFeePaid' },
{ label: '下半年应缴团费', key: 'secondHalfFeeDue' },
{ label: '下半年实缴团费', key: 'secondHalfFeePaid' }
];
const committeeMemberColumns = [
{ title: '姓名', key: 'name', width: 140 },
{ title: '性别', key: 'gender', width: 100 },
{ title: '出生年月', key: 'birthMonth', width: 140 },
{ title: '政治面貌', key: 'politics', width: 140 },
{ title: '团内职务', key: 'leagueDuty', width: 140 },
{ title: '兼任工作', key: 'partTimeWork', width: 220 },
{ title: '操作', key: 'action', width: 90 }
];
const feeRecordColumns = [
{ title: '日期', key: 'date', width: 140 },
{ title: '用途', key: 'purpose', width: 320, textarea: true },
{ title: '收入', key: 'income', width: 140 },
{ title: '支出', key: 'expense', width: 140 },
{ title: '余额', key: 'balance', width: 140 },
{ title: '操作', key: 'action', width: 90 }
];
const subordinateOrgColumns = [
{ title: '团支部名称', key: 'branchName', width: 260 },
{ title: '负责人姓名', key: 'leaderName', width: 140 },
{ title: '联系电话', key: 'phone', width: 160 },
{ title: '团员数', key: 'memberCount', width: 120 },
{ title: '青年数', key: 'youthCount', width: 120 },
{ title: '团费数', key: 'feeCount', width: 120 },
{ title: '已缴数', key: 'paidCount', width: 120 },
{ title: '操作', key: 'action', width: 90 }
];
const honorRecordColumns = [
{ title: '荣誉名称(校级及以上)', key: 'honorName', width: 260 },
{ title: '获奖时间', key: 'awardTime', width: 140 },
{ title: '获奖者(集体或个人)', key: 'winner', width: 240 },
{ title: '奖励情况', key: 'rewardSituation', width: 160 },
{ title: '备注', key: 'remark', width: 180 },
{ title: '操作', key: 'action', width: 90 }
];
const activistColumns = computed(() => [
{ title: '姓名', key: 'name', width: 140 },
{ title: '性别', key: 'gender', width: 100 },
{ title: '民族', key: 'nation', width: 120, options: nationOptions.value },
{ title: '出生年月', key: 'birthMonth', width: 140 },
{ title: '专业班级', key: 'classMajor', width: 220 },
{ title: '现任职务', key: 'currentDuty', width: 180 },
{ title: '操作', key: 'action', width: 90 }
]);
const disciplineColumns = [
{ title: '姓名', key: 'name', width: 140 },
{ title: '时间', key: 'time', width: 160 },
{ title: '处理类型', key: 'handlingType', width: 180 },
{ title: '处理原因', key: 'reason', width: 420, textarea: true },
{ title: '操作', key: 'action', width: 90 }
];
const meetingColumns = [
{ title: '会议名称', key: 'meetingName', width: 180 },
{ title: '会议主题', key: 'meetingTheme', width: 220 },
{ title: '时间', key: 'time', width: 150 },
{ title: '地点', key: 'location', width: 150 },
{ title: '主持人', key: 'host', width: 120 },
{ title: '与会人员', key: 'attendees', width: 220, textarea: true },
{ title: '缺席人员', key: 'absentees', width: 160 },
{ title: '会议记录', key: 'content', width: 320, textarea: true },
{ title: '记录人', key: 'recorder', width: 120 },
{ title: '操作', key: 'action', width: 90 }
];
const activityColumns = [
{ title: '活动名称', key: 'activityName', width: 180 },
{ title: '活动主题', key: 'activityTheme', width: 220 },
{ title: '活动时间', key: 'activityTime', width: 150 },
{ title: '活动地点', key: 'location', width: 150 },
{ title: '参与人员', key: 'participants', width: 220, textarea: true },
{ title: '活动内容', key: 'content', width: 420, textarea: true },
{ title: '记录人', key: 'recorder', width: 120 },
{ title: '操作', key: 'action', width: 90 }
];
const educationColumns = [
{ title: '所在团支部', key: 'branchName', width: 240 },
{ title: '姓名', key: 'name', width: 140 },
{ title: '评议结果', key: 'result', width: 160 },
{ title: '备注', key: 'remark', width: 240 },
{ title: '操作', key: 'action', width: 90 }
];
const createDefaultForm = (): WorkLedger => ({
id: undefined,
unitName: '',
responsiblePerson: '',
useStartMonth: '',
useEndMonth: '',
secretaryName: '',
branchCount: '',
memberTotal: '',
facultyMemberCount: '',
studentMemberCount: '',
minorityMemberCount: '',
femaleMemberCount: '',
youth14To28Count: '',
youth29To35Count: '',
cadreTotal: '',
facultyCadreCount: '',
studentCadreCount: '',
lastElectionTime: '',
feeBalance: '',
firstHalfFeeDue: '',
firstHalfFeePaid: '',
secondHalfFeeDue: '',
secondHalfFeePaid: '',
schoolSocietyConnectionRate: '',
smartLeagueEntryRate: '',
annualSummary: '',
committeeMembers: [createCommitteeMember()],
feeRecords: [createFeeRecord()],
subordinateOrgs: [createSubordinateOrg()],
honorRecords: [createHonorRecord()],
activistRecommendations: [createActivistRecommendation()],
disciplineRecords: [createDisciplineRecord()],
meetingRecords: [createMeetingRecord()],
activityRecords: [createActivityRecord()],
educationEvaluations: [createEducationEvaluation()]
});
const form = reactive<WorkLedger>(createDefaultForm());
const normalizeRows = (rows: any[] | undefined, createRow: () => Record<string, any>) =>
rows?.length ? rows.map((item) => ({ ...createRow(), ...item, __rowKey: rowKey() })) : [createRow()];
const assignForm = (record?: WorkLedger | null) => {
const defaults = createDefaultForm();
Object.assign(form, defaults, record || {});
form.committeeMembers = normalizeRows(record?.committeeMembers, createCommitteeMember) as any;
form.feeRecords = normalizeRows(record?.feeRecords, createFeeRecord) as any;
form.subordinateOrgs = normalizeRows(record?.subordinateOrgs, createSubordinateOrg) as any;
form.honorRecords = normalizeRows(record?.honorRecords, createHonorRecord) as any;
form.activistRecommendations = normalizeRows(record?.activistRecommendations, createActivistRecommendation) as any;
form.disciplineRecords = normalizeRows(record?.disciplineRecords, createDisciplineRecord) as any;
form.meetingRecords = normalizeRows(record?.meetingRecords, createMeetingRecord) as any;
form.activityRecords = normalizeRows(record?.activityRecords, createActivityRecord) as any;
form.educationEvaluations = normalizeRows(record?.educationEvaluations, createEducationEvaluation) as any;
};
const cleanRows = (rows?: any[]) =>
(rows || [])
.map(({ __rowKey, ...item }) => item)
.filter((item) => Object.values(item).some((value) => String(value || '').trim()));
const save = () => {
loading.value = true;
const payload: WorkLedger = {
...form,
committeeMembers: cleanRows(form.committeeMembers) as any,
feeRecords: cleanRows(form.feeRecords) as any,
subordinateOrgs: cleanRows(form.subordinateOrgs) as any,
honorRecords: cleanRows(form.honorRecords) as any,
activistRecommendations: cleanRows(form.activistRecommendations) as any,
disciplineRecords: cleanRows(form.disciplineRecords) as any,
meetingRecords: cleanRows(form.meetingRecords) as any,
activityRecords: cleanRows(form.activityRecords) as any,
educationEvaluations: cleanRows(form.educationEvaluations) as any
};
props
.saveHandler(payload)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
await loadNationOptions().catch(() => {});
isUpdate.value = !!props.data?.id;
assignForm(props.data);
} else {
formRef.value?.resetFields?.();
assignForm();
}
activeKey.value = 'cover';
}
);
</script>
<style lang="less" scoped>
@import '../../tag-tabs.less';
.gxmu-tag-tabs();
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
</style>
@@ -0,0 +1,93 @@
<template>
<div class="editable-block">
<div class="editable-title">
<span>{{ title }}</span>
<a-button size="small" type="dashed" @click="addRow">新增一行</a-button>
</div>
<a-table
row-key="__rowKey"
:columns="columns"
:data-source="rows"
:pagination="false"
:scroll="{ x: scrollX }"
size="small"
bordered
>
<template #bodyCell="{ column, index }">
<template v-if="column.key === 'action'">
<a class="ele-text-danger" @click="removeRow(index)">删除</a>
</template>
<template v-else>
<a-select
v-if="column.options?.length"
v-model:value="rows[index][column.key]"
:options="column.options"
:placeholder="`请选择${column.title}`"
allow-clear
show-search
option-filter-prop="label"
/>
<a-textarea
v-else-if="column.textarea"
v-model:value="rows[index][column.key]"
:rows="2"
:placeholder="`请输入${column.title}`"
/>
<a-input
v-else
v-model:value="rows[index][column.key]"
:placeholder="`请输入${column.title}`"
/>
</template>
</template>
</a-table>
</div>
</template>
<script lang="ts" setup>
interface EditableColumn {
title: string;
key: string;
width?: number;
textarea?: boolean;
options?: { label: string; value: string }[];
}
const props = withDefaults(
defineProps<{
title: string;
rows: Record<string, any>[];
columns: EditableColumn[];
createRow: () => Record<string, any>;
scrollX?: number;
}>(),
{
scrollX: 980
}
);
const addRow = () => {
props.rows.push(props.createRow());
};
const removeRow = (index: number) => {
props.rows.splice(index, 1);
if (!props.rows.length) {
props.rows.push(props.createRow());
}
};
</script>
<style lang="less" scoped>
.editable-block {
margin-top: 18px;
}
.editable-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
font-weight: 600;
}
</style>
+190
View File
@@ -0,0 +1,190 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1180 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space wrap>
<a-button class="gxmu-toolbar-btn gxmu-add-btn" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
<span>添加</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入单位/负责人"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:title="title"
:save-handler="saveHandler"
@done="reload"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import type {
ColumnItem,
DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types';
import type { WorkLedger, WorkLedgerParam } from '@/api/gxmu/work-ledger/model';
import EditModal from './components/edit.vue';
const props = defineProps<{
title: string;
api: {
userPage: (params: WorkLedgerParam) => Promise<{ list?: WorkLedger[]; count?: number }>;
add: (data: WorkLedger) => Promise<string>;
update: (data: WorkLedger) => Promise<string>;
remove: (id?: number) => Promise<string>;
};
}>();
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const current = ref<WorkLedger | null>(null);
const columns = ref<ColumnItem[]>([
{
title: '单位',
dataIndex: 'unitName',
key: 'unitName',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '负责人',
dataIndex: 'responsiblePerson',
key: 'responsiblePerson',
align: 'center',
width: 120
},
{
title: '使用开始月份',
dataIndex: 'useStartMonth',
key: 'useStartMonth',
align: 'center',
width: 140
},
{
title: '使用结束月份',
dataIndex: 'useEndMonth',
key: 'useEndMonth',
align: 'center',
width: 140
},
{
title: '团员总数',
dataIndex: 'memberTotal',
key: 'memberTotal',
align: 'center',
width: 120
},
{
title: '团干总数',
dataIndex: 'cadreTotal',
key: 'cadreTotal',
align: 'center',
width: 120
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 180
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 170
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return props.api
.userPage({
page,
limit,
keywords: searchText.value.trim() || undefined
} as WorkLedgerParam)
.catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const openEdit = (record?: WorkLedger) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as WorkLedger)
: null;
showEdit.value = true;
};
const saveHandler = (data: WorkLedger) => {
return data.id ? props.api.update(data) : props.api.add(data);
};
const remove = (record: WorkLedger) => {
const hide = message.loading('请求中..', 0);
props.api
.remove(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
File diff suppressed because it is too large Load Diff