feat(research): 课题申报成员新增成员类型并调整成员表列
- 成员表录入列调整为署名顺序/成员类型/成员姓名/所属部门/职称
- 联系电话与成员角色不再录入,保留字段兼容历史数据
- 成员统计关键字支持按成员类型检索
Co-Authored-By: Claude <noreply@anthropic.com>
| | |
| | | id?: number; |
| | | applicationId?: number; |
| | | name: string; // 成员姓名 |
| | | contactPhone?: string; // 联系电话 |
| | | workUnit?: string; // 所属单位 |
| | | jobTitle?: string; // 职称/职务 |
| | | memberRole?: string; // 成员角色:主持人/核心成员/参与人 |
| | | sortNo?: number; // 排序号 |
| | | memberType?: string; // 成员类型:教师/学生/校外人员 |
| | | contactPhone?: string; // 联系电话(历史字段,不再录入) |
| | | workUnit?: string; // 所属部门 |
| | | jobTitle?: string; // 职称 |
| | | memberRole?: string; // 成员角色(历史字段,不再录入) |
| | | sortNo?: number; // 署名顺序 |
| | | } |
| | | |
| | | /** 成员维度统计 */ |
| | | export interface MemberStatistics { |
| | | name: string; // 成员姓名 |
| | | memberTypes?: string; // 成员类型(多个以顿号分隔) |
| | | contactPhone?: string; // 联系电话 |
| | | workUnit?: string; // 所属单位 |
| | | jobTitle?: string; // 职称/职务 |
| | | workUnit?: string; // 所属部门 |
| | | jobTitle?: string; // 职称 |
| | | memberRoles?: string; // 成员角色(多个以顿号分隔) |
| | | projectCount?: number; // 参与课题数 |
| | | projectTitles?: string; // 参与课题名称(分号分隔) |
| | |
| | | /** 表单类型 */ |
| | | export type FormType = 'create' | 'detail' | 'edit'; |
| | | |
| | | /** 课题组成员角色选项 */ |
| | | export const MEMBER_ROLE_OPTIONS = [ |
| | | { label: '主持人', value: '主持人' }, |
| | | { label: '核心成员', value: '核心成员' }, |
| | | { label: '参与人', value: '参与人' }, |
| | | /** 课题组成员类型选项(对齐学校版成员表) */ |
| | | export const MEMBER_TYPE_OPTIONS = [ |
| | | { label: '教师', value: '教师' }, |
| | | { label: '学生', value: '学生' }, |
| | | { label: '校外人员', value: '校外人员' }, |
| | | ]; |
| | | |
| | | /** |
| | | * 成员行投影为可提交对象:只保留后端接受的字段, |
| | | * 不带 id / applicationId,避免与「先删后增」的软删行主键冲突。 |
| | | * 联系电话、成员角色已不在表单录入,但历史行原值继续透传,避免编辑时被清空。 |
| | | */ |
| | | export function toSubmitMembers( |
| | | members?: ResearchApplicationApi.ApplicationMember[], |
| | | ): ResearchApplicationApi.ApplicationMember[] { |
| | | return (members ?? []).map((member) => ({ |
| | | name: member.name, |
| | | memberType: member.memberType, |
| | | contactPhone: member.contactPhone, |
| | | workUnit: member.workUnit, |
| | | jobTitle: member.jobTitle, |
| | |
| | | return val === 0 || val ? String(val) : '-'; |
| | | } |
| | | |
| | | /** 课题组成员只读列 */ |
| | | /** 课题组成员只读列(对齐学校版成员表) */ |
| | | const memberColumns = [ |
| | | { title: '姓名', dataIndex: 'name', key: 'name', width: 110 }, |
| | | { title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', width: 130 }, |
| | | { title: '所属单位', dataIndex: 'workUnit', key: 'workUnit' }, |
| | | { title: '职称/职务', dataIndex: 'jobTitle', key: 'jobTitle', width: 120 }, |
| | | { title: '成员角色', dataIndex: 'memberRole', key: 'memberRole', width: 110 }, |
| | | { title: '排序', dataIndex: 'sortNo', key: 'sortNo', width: 70 }, |
| | | { title: '署名顺序', dataIndex: 'sortNo', key: 'sortNo', width: 100 }, |
| | | { title: '成员类型', dataIndex: 'memberType', key: 'memberType', width: 120 }, |
| | | { title: '成员姓名', dataIndex: 'name', key: 'name', width: 120 }, |
| | | { title: '所属部门', dataIndex: 'workUnit', key: 'workUnit' }, |
| | | { title: '职称', dataIndex: 'jobTitle', key: 'jobTitle', width: 120 }, |
| | | ]; |
| | | |
| | | /** 金额字段空值占位 */ |
| | |
| | | |
| | | const columns: TableColumnsType<ResearchApplicationApi.MemberStatistics> = [ |
| | | { title: '成员姓名', dataIndex: 'name', key: 'name', width: 110, fixed: 'left' }, |
| | | { title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', width: 130 }, |
| | | { title: '所属单位', dataIndex: 'workUnit', key: 'workUnit', width: 180 }, |
| | | { title: '职称/职务', dataIndex: 'jobTitle', key: 'jobTitle', width: 120 }, |
| | | { title: '成员角色', dataIndex: 'memberRoles', key: 'memberRoles', width: 140 }, |
| | | { title: '成员类型', dataIndex: 'memberTypes', key: 'memberTypes', width: 120 }, |
| | | { title: '所属部门', dataIndex: 'workUnit', key: 'workUnit', width: 200 }, |
| | | { title: '职称', dataIndex: 'jobTitle', key: 'jobTitle', width: 110 }, |
| | | { title: '参与课题数', dataIndex: 'projectCount', key: 'projectCount', width: 110 }, |
| | | { title: '参与课题', dataIndex: 'projectTitles', key: 'projectTitles', ellipsis: true }, |
| | | ]; |
| | |
| | | function buildPublishText() { |
| | | const lines = ['【课题成员名单】']; |
| | | rows.value.forEach((row, index) => { |
| | | const meta = [row.workUnit, row.jobTitle].filter(Boolean).join('/'); |
| | | const meta = [row.memberTypes, row.workUnit, row.jobTitle] |
| | | .filter(Boolean) |
| | | .join('/'); |
| | | const metaText = meta ? `(${meta})` : ''; |
| | | const roles = row.memberRoles ? `[${row.memberRoles}]` : ''; |
| | | const projects = row.projectTitles ? `:${row.projectTitles}` : ''; |
| | |
| | | <div class="mb-3 flex flex-wrap items-center gap-2"> |
| | | <Input |
| | | v-model:value="keyword" |
| | | placeholder="姓名 / 电话 / 单位" |
| | | placeholder="姓名 / 所属部门" |
| | | allow-clear |
| | | style="width: 220px" |
| | | @press-enter="loadData" |
| | |
| | | /> |
| | | |
| | | <div class="mt-2 text-xs text-gray-400"> |
| | | 统计口径:同一「姓名 + 电话 + 单位」视为同一成员;参与课题数按不重复课题计数。 |
| | | 统计口径:同一「姓名 + 所属部门」视为同一成员;参与课题数按不重复课题计数。 |
| | | </div> |
| | | </Modal> |
| | | </template> |
| | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import { MEMBER_ROLE_OPTIONS, toSubmitMembers } from '../data'; |
| | | import { MEMBER_TYPE_OPTIONS, toSubmitMembers } from '../data'; |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | gridOptions: { |
| | | columns: [ |
| | | { type: 'seq', title: '序号', width: 55, fixed: 'left' }, |
| | | { |
| | | field: 'sortNo', |
| | | title: '署名顺序', |
| | | width: 110, |
| | | slots: { default: 'sortNo' }, |
| | | }, |
| | | { |
| | | field: 'memberType', |
| | | title: '成员类型', |
| | | width: 130, |
| | | slots: { default: 'memberType' }, |
| | | }, |
| | | { |
| | | field: 'name', |
| | | title: '姓名', |
| | | minWidth: 110, |
| | | title: '成员姓名', |
| | | minWidth: 120, |
| | | slots: { default: 'name' }, |
| | | }, |
| | | { |
| | | field: 'contactPhone', |
| | | title: '联系电话', |
| | | minWidth: 140, |
| | | slots: { default: 'contactPhone' }, |
| | | }, |
| | | { |
| | | field: 'workUnit', |
| | | title: '所属单位', |
| | | minWidth: 180, |
| | | title: '所属部门', |
| | | minWidth: 220, |
| | | slots: { default: 'workUnit' }, |
| | | }, |
| | | { |
| | | field: 'jobTitle', |
| | | title: '职称/职务', |
| | | minWidth: 120, |
| | | slots: { default: 'jobTitle' }, |
| | | }, |
| | | { |
| | | field: 'memberRole', |
| | | title: '成员角色', |
| | | title: '职称', |
| | | minWidth: 130, |
| | | slots: { default: 'memberRole' }, |
| | | }, |
| | | { |
| | | field: 'sortNo', |
| | | title: '排序', |
| | | width: 90, |
| | | slots: { default: 'sortNo' }, |
| | | slots: { default: 'jobTitle' }, |
| | | }, |
| | | { |
| | | title: '操作', |
| | |
| | | async function handleAdd() { |
| | | await gridApi.grid.insertAt( |
| | | { |
| | | memberRole: '参与人', |
| | | memberType: '教师', |
| | | sortNo: collectMembers().length + 1, |
| | | } as unknown as ResearchApplicationApi.ApplicationMember, |
| | | -1, |
| | |
| | | for (let i = 0; i < rows.length; i++) { |
| | | const row = rows[i]; |
| | | if (!row?.name || String(row.name).trim() === '') { |
| | | throw new Error(`第 ${i + 1} 行成员:请填写姓名`); |
| | | throw new Error(`第 ${i + 1} 行成员:请填写成员姓名`); |
| | | } |
| | | const phone = row.contactPhone ? String(row.contactPhone).trim() : ''; |
| | | if (phone !== '' && !/^1[3-9]\d{9}$/.test(phone)) { |
| | |
| | | |
| | | <template> |
| | | <Grid class="w-full"> |
| | | <template #name="{ row }"> |
| | | <Input |
| | | v-model:value="row.name" |
| | | :disabled="disabled" |
| | | placeholder="请输入姓名" |
| | | @change="() => recalc()" |
| | | /> |
| | | </template> |
| | | <template #contactPhone="{ row }"> |
| | | <Input |
| | | v-model:value="row.contactPhone" |
| | | :disabled="disabled" |
| | | :maxlength="11" |
| | | placeholder="11 位手机号(选填)" |
| | | @change="() => recalc()" |
| | | /> |
| | | </template> |
| | | <template #workUnit="{ row }"> |
| | | <Input |
| | | v-model:value="row.workUnit" |
| | | :disabled="disabled" |
| | | placeholder="请输入所属单位" |
| | | @change="() => recalc()" |
| | | /> |
| | | </template> |
| | | <template #jobTitle="{ row }"> |
| | | <Input |
| | | v-model:value="row.jobTitle" |
| | | :disabled="disabled" |
| | | placeholder="请输入职称/职务" |
| | | @change="() => recalc()" |
| | | /> |
| | | </template> |
| | | <template #memberRole="{ row }"> |
| | | <Select |
| | | v-model:value="row.memberRole" |
| | | :disabled="disabled" |
| | | :options="MEMBER_ROLE_OPTIONS" |
| | | placeholder="请选择角色" |
| | | allow-clear |
| | | @change="() => recalc()" |
| | | /> |
| | | </template> |
| | | <template #sortNo="{ row }"> |
| | | <InputNumber |
| | | v-model:value="row.sortNo" |
| | |
| | | @change="() => recalc()" |
| | | /> |
| | | </template> |
| | | <template #memberType="{ row }"> |
| | | <Select |
| | | v-model:value="row.memberType" |
| | | :disabled="disabled" |
| | | :options="MEMBER_TYPE_OPTIONS" |
| | | placeholder="请选择成员类型" |
| | | allow-clear |
| | | @change="() => recalc()" |
| | | /> |
| | | </template> |
| | | <template #name="{ row }"> |
| | | <Input |
| | | v-model:value="row.name" |
| | | :disabled="disabled" |
| | | placeholder="请输入成员姓名" |
| | | @change="() => recalc()" |
| | | /> |
| | | </template> |
| | | <template #workUnit="{ row }"> |
| | | <Input |
| | | v-model:value="row.workUnit" |
| | | :disabled="disabled" |
| | | placeholder="请输入所属部门" |
| | | @change="() => recalc()" |
| | | /> |
| | | </template> |
| | | <template #jobTitle="{ row }"> |
| | | <Input |
| | | v-model:value="row.jobTitle" |
| | | :disabled="disabled" |
| | | placeholder="请输入职称" |
| | | @change="() => recalc()" |
| | | /> |
| | | </template> |
| | | <template #actions="{ row }"> |
| | | <TableAction |
| | | :actions="[ |