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
+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>