liu
4 天以前 b337be5073334334962e873c1a980968ad2cf068
feat(research): 课题申报成员新增成员类型并调整成员表列

- 成员表录入列调整为署名顺序/成员类型/成员姓名/所属部门/职称
- 联系电话与成员角色不再录入,保留字段兼容历史数据
- 成员统计关键字支持按成员类型检索

Co-Authored-By: Claude <noreply@anthropic.com>
已修改5个文件
182 ■■■■■ 文件已修改
src/api/research/application/index.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/detail/approve/index.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/member-statistics-modal.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/member-table.vue 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/research/application/index.ts
@@ -10,19 +10,21 @@
    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; // 参与课题名称(分号分隔)
src/views/research/application/data.ts
@@ -20,22 +20,24 @@
/** 表单类型 */
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,
src/views/research/application/detail/approve/index.vue
@@ -38,14 +38,13 @@
  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 },
];
/** 金额字段空值占位 */
src/views/research/application/modules/member-statistics-modal.vue
@@ -39,10 +39,9 @@
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 },
];
@@ -79,7 +78,9 @@
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}` : '';
@@ -122,7 +123,7 @@
    <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"
@@ -152,7 +153,7 @@
    />
    <div class="mt-2 text-xs text-gray-400">
      统计口径:同一「姓名 + 电话 + 单位」视为同一成员;参与课题数按不重复课题计数。
      统计口径:同一「姓名 + 所属部门」视为同一成员;参与课题数按不重复课题计数。
    </div>
  </Modal>
</template>
src/views/research/application/modules/member-table.vue
@@ -9,7 +9,7 @@
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<{
@@ -29,42 +29,35 @@
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: '操作',
@@ -113,7 +106,7 @@
async function handleAdd() {
  await gridApi.grid.insertAt(
    {
      memberRole: '参与人',
      memberType: '教师',
      sortNo: collectMembers().length + 1,
    } as unknown as ResearchApplicationApi.ApplicationMember,
    -1,
@@ -133,7 +126,7 @@
  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)) {
@@ -165,49 +158,6 @@
<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"
@@ -218,6 +168,40 @@
        @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="[