cc 1 settimana fa
parent
commit
6d91fd46bb

+ 1 - 1
ai-electron/frontend/src/call/views/sensitive/index.vue

@@ -753,7 +753,7 @@
     height: 100%;
     min-height: 0;
     margin: 0 -12px;
-    padding: 8px 10px;
+    padding: 8px 10px !important;
     background: #f4f6fa;
     box-sizing: border-box;
     overflow: hidden;

+ 18 - 613
ai-electron/frontend/src/case/views/dm/portrait.vue

@@ -764,218 +764,7 @@
 
     <StationMapModal v-model:open="stationMapModalVisible" :stations="stationMapPoints" :title="stationMapModalTitle" />
 
-    <Modal
-      v-model:open="groupModalVisible"
-      :footer="null"
-      :centered="true"
-      :width="960"
-      wrapClassName="object-group-modal"
-      @cancel="closeGroupModal"
-    >
-      <template #title>
-        <div class="portrait-modal-title-block">
-          <div class="portrait-modal-title">对象分组</div>
-          <div class="portrait-modal-subtitle">维护分组结构、查看成员状态并设置主要对象</div>
-        </div>
-      </template>
-
-      <div class="group-modal-content">
-        <div class="group-modal-actions">
-          <div class="group-modal-actions__info">左侧切换分组,右侧维护成员与主要对象。</div>
-          <div class="group-modal-actions__buttons">
-            <Button class="head-btn head-btn--primary" @click="openCreateGroupModal">新增分组</Button>
-            <Button class="head-btn" @click="handleEditGroup">编辑分组</Button>
-            <Button class="head-btn head-btn--danger" @click="handleDeleteGroup">删除分组</Button>
-            <Button class="head-btn head-btn--accent" @click="handleAddObject">添加对象</Button>
-          </div>
-        </div>
-
-        <div class="group-modal-main">
-          <div class="group-left">
-            <div class="group-panel-head">
-              <div class="group-panel-title">分组列表</div>
-              <div class="group-panel-badge">{{ objectGroups.length }}</div>
-            </div>
-            <List :data-source="objectGroups" size="small" class="group-list">
-              <template #renderItem="{ item }">
-                <List.Item>
-                  <Button
-                    block
-                    class="group-select-btn"
-                    :type="item.id === activeGroupId ? 'primary' : 'default'"
-                    @click="activeGroupId = item.id"
-                  >
-                    <span class="group-select-btn__name">{{ item.name }}</span>
-                  </Button>
-                </List.Item>
-              </template>
-            </List>
-          </div>
-
-          <div class="group-right">
-            <div class="group-panel-head group-panel-head--right">
-              <div class="group-panel-title-wrap">
-                <div class="group-panel-title">{{ activeGroup?.name || '分组成员' }}</div>
-                <div class="group-panel-subtitle">
-                  {{ activeGroup ? `当前分组共 ${activeGroupMembers.length} 人` : '请选择左侧分组查看成员' }}
-                </div>
-              </div>
-            </div>
-            <List :data-source="activeGroupMembers" class="member-list" item-layout="horizontal">
-              <template #renderItem="{ item }">
-                <List.Item class="member-item">
-                  <template #actions>
-                    <Button
-                      type="link"
-                      class="member-main-btn"
-                      :disabled="item.hasMain"
-                      @click="handleChangeMain(item.id)"
-                    >
-                      设为主对象
-                    </Button>
-                    <Button type="link" class="member-remove-btn" @click="handleRemoveMember(item.id)">移出分组</Button>
-                  </template>
-                  <List.Item.Meta>
-                    <template #avatar>
-                      <Avatar :size="34" class="member-avatar-icon">
-                        <svg viewBox="0 0 24 24" fill="currentColor">
-                          <path
-                            d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
-                          />
-                        </svg>
-                      </Avatar>
-                    </template>
-                    <template #title>
-                      <div class="member-name-row">
-                        <span class="member-name">{{ item.personName }}</span>
-                        <span v-if="item.hasMain" class="member-main-tag">主要</span>
-                      </div>
-                    </template>
-                    <template #description>
-                      <span class="member-desc">{{ item.hasMain ? '当前主对象' : '分组成员' }}</span>
-                    </template>
-                  </List.Item.Meta>
-                </List.Item>
-              </template>
-            </List>
-          </div>
-        </div>
-      </div>
-    </Modal>
-
-    <Modal
-      v-model:open="createGroupModalVisible"
-      :title="isEditGroupMode ? '编辑分组' : '新建分组'"
-      :confirmLoading="creatingGroup"
-      :width="640"
-      :centered="true"
-      :okText="isEditGroupMode ? '保存分组' : '创建分组'"
-      cancelText="取消"
-      wrapClassName="create-group-modal"
-      @ok="handleCreateGroup"
-      @cancel="closeCreateGroupModal"
-    >
-      <Form layout="vertical" class="create-group-form">
-        <div class="create-group-section">
-          <div class="create-group-section__head">
-            <div class="create-group-section__title">分组信息</div>
-            <div class="create-group-section__desc">建议使用便于识别的名称,主体对象可按需选择。</div>
-          </div>
-          <Form.Item label="分组名称" required :validateStatus="groupNameError ? 'error' : ''" :help="groupNameError">
-            <Input
-              v-model:value="createGroupForm.groupName"
-              placeholder="请输入分组名称"
-              @blur="() => validateGroupName(true)"
-            />
-          </Form.Item>
-          <Form.Item
-            label="分组主体对象"
-            :required="!isEditGroupMode"
-            :validateStatus="mainPersonError ? 'error' : ''"
-            :help="mainPersonError"
-          >
-            <Select
-              v-model:value="createGroupForm.mainPerson"
-              :options="personOptions"
-              :placeholder="isEditGroupMode ? '请选择主体对象' : '请选择主体对象'"
-              allowClear
-              :getPopupContainer="getModalPopupContainer"
-            />
-          </Form.Item>
-          <Form.Item label="备注">
-            <Input.TextArea v-model:value="createGroupForm.remark" :rows="4" placeholder="请输入分组说明" />
-          </Form.Item>
-        </div>
-      </Form>
-    </Modal>
-
-    <Modal
-      v-model:open="addObjectModalVisible"
-      title="添加对象"
-      :confirmLoading="addingObjectToGroup"
-      :width="720"
-      :centered="true"
-      okText="加入分组"
-      cancelText="取消"
-      wrapClassName="add-group-person-modal"
-      @ok="handleConfirmAddObject"
-      @cancel="closeAddObjectModal"
-    >
-      <div class="add-object-modal">
-        <div class="add-object-modal__search">
-          <div class="add-object-modal__search-head">
-            <div class="add-object-modal__title-block">
-              <div class="add-object-modal__title">选择对象</div>
-              <div class="add-object-modal__desc">
-                {{ activeGroup ? `当前分组:${activeGroup.name}` : '请选择需要加入分组的对象' }}
-              </div>
-            </div>
-            <div class="add-object-modal__stats">共 {{ filteredAddObjectCandidates.length }} 个对象</div>
-          </div>
-          <Input v-model:value="addObjectKeyword" allowClear placeholder="请输入姓名 / 证件号 / 手机号" />
-        </div>
-
-        <div class="add-object-modal__content">
-          <div v-if="filteredAddObjectCandidates.length" class="add-object-list">
-            <div
-              v-for="item in filteredAddObjectCandidates"
-              :key="item.key"
-              class="add-object-item"
-              :class="{
-                'is-selected': selectedAddPersonNames.includes(item.name),
-                'is-disabled': item.disabled,
-              }"
-              @click="handleToggleAddObject(item)"
-            >
-              <Checkbox
-                :checked="selectedAddPersonNames.includes(item.name)"
-                :disabled="item.disabled"
-                @click.stop
-                @change="(e) => handleCheckAddObject(item, e.target.checked)"
-              />
-              <PersonAvatar
-                class="add-object-item__avatar"
-                :src="item.photograph || undefined"
-                :alt="item.name"
-                :size="42"
-              />
-              <div class="add-object-item__body">
-                <div class="add-object-item__head">
-                  <div class="add-object-item__name">{{ item.name }}</div>
-                  <span v-if="item.disabled" class="add-object-item__tag">已在分组</span>
-                </div>
-                <div class="add-object-item__meta">
-                  <span>证件号:{{ maskCertNo(item.personCertNo) }}</span>
-                  <span>手机号:{{ item.primaryPhone }}</span>
-                  <span>银行卡:{{ item.cardCount }} 张</span>
-                </div>
-              </div>
-            </div>
-          </div>
-          <div v-else class="add-object-modal__empty">{{ addObjectEmptyText }}</div>
-        </div>
-      </div>
-    </Modal>
+    <PersonGroupManageModal ref="groupManageModalRef" v-model:open="groupModalVisible" />
   </div>
 </template>
 
@@ -993,19 +782,7 @@
     h,
   } from 'vue';
   import { useRoute, useRouter } from 'vue-router';
-  import {
-    Button,
-    DatePicker,
-    Input,
-    Select,
-    Modal,
-    Form,
-    Popover,
-    List,
-    Avatar,
-    Radio,
-    Checkbox,
-  } from 'ant-design-vue';
+  import { Button, DatePicker, Input, Select, Modal, Form, Popover, Radio } from 'ant-design-vue';
   import {
     SearchOutlined,
     PlusOutlined,
@@ -1035,9 +812,9 @@
     DEFAULT_COMPARE_OPERATOR,
     normalizeCompareOperator,
   } from '@/core/utils/compareOperator';
-  import { maskCertNo } from '@/core/utils/sensitive';
   import { EmptyState } from '@/core/components/EmptyState';
   import { PersonAvatar } from '@/core/components/PersonAvatar';
+  import PersonGroupManageModal from '@/person/components/PersonGroupManageModal.vue';
   import QueryTableModal from './components/QueryTableModal.vue';
   import IntimacyPanel from './components/IntimacyPanel.vue';
   import StationMapModal from './components/StationMapModal.vue';
@@ -1051,13 +828,6 @@
     createPersonBasicInfo,
     updatePersonBasicInfo,
     deletePersonBasicInfo,
-    listPersonGroups,
-    createPersonGroup,
-    updatePersonGroup,
-    deletePersonGroup,
-    addPersonToGroup,
-    remPersonFromGroup,
-    changeMainPerson,
   } from '../../../person/api/personApi';
   import type {
     PersonBasicInfo,
@@ -1125,14 +895,8 @@
   const transRecordModalZIndex = 1210;
   const stationMapModalVisible = ref(false);
   const groupModalVisible = ref(false);
-  const createGroupModalVisible = ref(false);
-  const creatingGroup = ref(false);
-  const isEditGroupMode = ref(false);
-  const editingGroupData = ref<{ id: string; name: string; remark?: string } | null>(null);
+  const groupManageModalRef = ref<InstanceType<typeof PersonGroupManageModal> | null>(null);
   const isEditMode = ref(false);
-  const objectGroups = ref<{ id: string; name: string; remark?: string }[]>([]);
-  const activeGroupId = ref<string | null>(null);
-  const groupMembers = ref<{ id: string; groupId: string; personName: string; hasMain?: boolean }[]>([]);
   const saving = ref(false);
   const editingPerson = ref<PersonBasicInfo | null>(null);
   const loadingProfile = ref(false);
@@ -1225,18 +989,6 @@
   const phoneListErrors = ref<string[]>([]);
   const cardListErrors = ref<string[]>([]);
   const personNameError = ref('');
-  const groupNameError = ref('');
-  const mainPersonError = ref('');
-
-  const createGroupForm = reactive({
-    groupName: '',
-    mainPerson: undefined as string | undefined,
-    remark: '',
-  });
-  const addObjectModalVisible = ref(false);
-  const addingObjectToGroup = ref(false);
-  const addObjectKeyword = ref('');
-  const selectedAddPersonNames = ref<string[]>([]);
 
   const createCallSummaryQuery = () => ({
     keyword: '',
@@ -2185,53 +1937,27 @@
     personModalVisible.value = false;
   };
 
-  const loadGroupData = async () => {
-    try {
-      const groups = await listPersonGroups();
-      objectGroups.value = (groups || []).map((item) => ({
-        id: item.id || '',
-        name: item.groupName || `分组${item.id}`,
-        remark: item.remark,
-      }));
-      if (objectGroups.value.length) {
-        const currentExists = objectGroups.value.some((item) => item.id === activeGroupId.value);
-        activeGroupId.value = currentExists ? activeGroupId.value : objectGroups.value[0].id;
-      } else {
-        activeGroupId.value = null;
-      }
-      groupMembers.value = (groups || []).flatMap((group) =>
-        (group.personList || []).map((rel) => ({
-          id: rel.id || '',
-          groupId: rel.groupId || group.id || '',
-          personName: rel.personName || '-',
-          hasMain: rel.hasMain,
-        })),
-      );
-    } catch (error) {
-      console.error('获取分组列表失败', error);
-      createMessage.error('获取分组列表失败');
-    }
-  };
-
-  const openGroupModal = async () => {
+  const openGroupModal = () => {
     groupModalVisible.value = true;
-    await loadGroupData();
   };
 
   const openGroupModalFromRoute = async () => {
     if (route.query.openGroupModal !== '1') return;
-    await openGroupModal();
+    groupModalVisible.value = true;
     if (route.query.openCreateGroupModal === '1') {
       await nextTick();
-      openCreateGroupModal();
+      groupManageModalRef.value?.startCreateGroup();
     }
     const { openGroupModal: _openGroupModal, openCreateGroupModal: _openCreateGroupModal, ...query } = route.query;
     await router.replace({ path: route.path, query });
   };
 
-  const closeGroupModal = () => {
-    groupModalVisible.value = false;
-  };
+  watch(
+    () => [route.query.openGroupModal, route.query.openCreateGroupModal],
+    () => {
+      void openGroupModalFromRoute();
+    },
+  );
 
   const resolvePageTotal = (resp: any, records: unknown[] = []) => {
     const total = Number(resp?.total ?? resp?.count ?? resp?.page?.total ?? resp?.page?.count);
@@ -4825,327 +4551,6 @@
       .filter(Boolean)
       .slice(0, stationDisplayLimit);
   });
-
-  const activeGroupMembers = computed(() => {
-    return groupMembers.value.filter((item) => item.groupId === activeGroupId.value);
-  });
-
-  const activeGroup = computed(() => {
-    return objectGroups.value.find((item) => item.id === activeGroupId.value) || null;
-  });
-
-  const groupedPersonNameSet = computed(() => {
-    return new Set(
-      groupMembers.value
-        .map((item) => item.personName?.trim())
-        .filter((item): item is string => !!item && item !== '-'),
-    );
-  });
-
-  const personOptions = computed(() => {
-    const currentMainPerson = createGroupForm.mainPerson?.trim() || '';
-    return personList.value
-      .map((item) => {
-        const personName = item.personName?.trim() || '';
-        return { label: personName || '-', value: personName };
-      })
-      .filter((item) => !!item.value)
-      .filter((item) => {
-        if (isEditGroupMode.value && currentMainPerson && item.value === currentMainPerson) {
-          return true;
-        }
-        return !groupedPersonNameSet.value.has(item.value);
-      })
-      .filter((item) => !!item.value);
-  });
-
-  const addObjectCandidates = computed(() => {
-    return personList.value
-      .map((item, index) => {
-        const name = item.personName?.trim() || '';
-        const phoneValues = (item.phoneList || []).map((phone) => phone.personNo || '').filter(Boolean);
-        const cardCount = (item.cardList || []).filter((card) => !!card?.personNo).length;
-        return {
-          key: String(item.id ?? item.objId ?? name ?? index),
-          name: name || `未命名对象${index + 1}`,
-          personCertNo: item.personCertNo || '-',
-          primaryPhone: phoneValues[0] || '-',
-          cardCount,
-          photograph: item.photograph || '',
-          disabled: false,
-        };
-      })
-      .filter((item) => !!item.name && !groupedPersonNameSet.value.has(item.name))
-      .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
-  });
-
-  const filteredAddObjectCandidates = computed(() => {
-    const keyword = addObjectKeyword.value.trim().toLocaleLowerCase();
-    if (!keyword) return addObjectCandidates.value;
-    return addObjectCandidates.value.filter((item) =>
-      [item.name, item.personCertNo, item.primaryPhone].some((value) => value.toLocaleLowerCase().includes(keyword)),
-    );
-  });
-
-  const addObjectEmptyText = computed(() => {
-    if (!addObjectCandidates.value.length) {
-      return '暂无未分组对象,对象限定在单一分组';
-    }
-    return '暂无匹配对象';
-  });
-
-  const normalizeGroupName = (value?: string | null) => value?.trim().toLocaleLowerCase() || '';
-
-  const isDuplicateGroupName = (groupName: string) => {
-    const normalizedName = normalizeGroupName(groupName);
-    if (!normalizedName) return false;
-    const editingId = editingGroupData.value?.id !== undefined ? String(editingGroupData.value.id) : '';
-    return objectGroups.value.some((item) => {
-      const groupId = item.id !== undefined && item.id !== null ? String(item.id) : '';
-      if (editingId && groupId === editingId) return false;
-      return normalizeGroupName(item.name) === normalizedName;
-    });
-  };
-
-  const validateGroupName = (showRequired = false) => {
-    const groupName = createGroupForm.groupName.trim();
-    if (!groupName) {
-      groupNameError.value = showRequired ? '请输入分组名称' : '';
-      return !showRequired;
-    }
-    if (isDuplicateGroupName(groupName)) {
-      groupNameError.value = '分组名称已存在,请重新输入';
-      return false;
-    }
-    groupNameError.value = '';
-    return true;
-  };
-
-  const validateMainPerson = (showRequired = false) => {
-    if (isEditGroupMode.value) {
-      mainPersonError.value = '';
-      return true;
-    }
-    if (!createGroupForm.mainPerson) {
-      mainPersonError.value = showRequired ? '请选择分组主体对象' : '';
-      return !showRequired;
-    }
-    mainPersonError.value = '';
-    return true;
-  };
-
-  watch(
-    () => createGroupForm.groupName,
-    () => {
-      if (!createGroupModalVisible.value) return;
-      if (!createGroupForm.groupName.trim() && !groupNameError.value) return;
-      validateGroupName(Boolean(createGroupForm.groupName.trim()));
-    },
-  );
-
-  watch(
-    () => createGroupForm.mainPerson,
-    () => {
-      if (!createGroupModalVisible.value) return;
-      if (!createGroupForm.mainPerson && !mainPersonError.value) return;
-      validateMainPerson(Boolean(createGroupForm.mainPerson));
-    },
-  );
-
-  const openCreateGroupModal = () => {
-    isEditGroupMode.value = false;
-    editingGroupData.value = null;
-    createGroupForm.groupName = '';
-    createGroupForm.mainPerson = undefined;
-    createGroupForm.remark = '';
-    groupNameError.value = '';
-    mainPersonError.value = '';
-    createGroupModalVisible.value = true;
-  };
-
-  const closeCreateGroupModal = () => {
-    createGroupModalVisible.value = false;
-    isEditGroupMode.value = false;
-    editingGroupData.value = null;
-    groupNameError.value = '';
-    mainPersonError.value = '';
-  };
-
-  const closeAddObjectModal = () => {
-    addObjectModalVisible.value = false;
-    addObjectKeyword.value = '';
-    selectedAddPersonNames.value = [];
-  };
-
-  const handleCreateGroup = async () => {
-    const groupName = createGroupForm.groupName.trim();
-    if (!validateGroupName(true)) {
-      createMessage.warning(groupNameError.value || '请输入分组名称');
-      return;
-    }
-    if (!validateMainPerson(true)) {
-      createMessage.warning(mainPersonError.value || '请选择分组主体对象');
-      return;
-    }
-
-    creatingGroup.value = true;
-    try {
-      if (isEditGroupMode.value && editingGroupData.value) {
-        await updatePersonGroup({
-          id: editingGroupData.value.id,
-          groupName,
-          remark: createGroupForm.remark || undefined,
-        });
-        createMessage.success('更新分组成功');
-      } else {
-        await createPersonGroup({
-          groupName,
-          remark: createGroupForm.remark || undefined,
-          personList: createGroupForm.mainPerson
-            ? [
-                {
-                  personName: createGroupForm.mainPerson,
-                  hasMain: true,
-                },
-              ]
-            : undefined,
-        });
-        createMessage.success('新增分组成功');
-      }
-      createGroupModalVisible.value = false;
-      isEditGroupMode.value = false;
-      editingGroupData.value = null;
-      groupNameError.value = '';
-      mainPersonError.value = '';
-      await loadGroupData();
-    } catch (error) {
-      console.error(isEditGroupMode.value ? '更新分组失败' : '新增分组失败', error);
-      createMessage.error(isEditGroupMode.value ? '更新分组失败' : '新增分组失败');
-    } finally {
-      creatingGroup.value = false;
-    }
-  };
-
-  const handleEditGroup = () => {
-    if (!activeGroupId.value) {
-      createMessage.warning('请先选择分组');
-      return;
-    }
-    const current = objectGroups.value.find((item) => item.id === activeGroupId.value);
-    if (!current) return;
-    const mainMember = activeGroupMembers.value.find(
-      (item) => item.hasMain && item.personName && item.personName !== '-',
-    );
-    editingGroupData.value = { id: current.id, name: current.name, remark: current.remark };
-    isEditGroupMode.value = true;
-    createGroupForm.groupName = current.name;
-    createGroupForm.mainPerson = mainMember?.personName || undefined;
-    createGroupForm.remark = current.remark || '';
-    groupNameError.value = '';
-    mainPersonError.value = '';
-    createGroupModalVisible.value = true;
-  };
-
-  const handleDeleteGroup = () => {
-    if (!activeGroupId.value) {
-      createMessage.warning('请先选择分组');
-      return;
-    }
-    const current = objectGroups.value.find((item) => item.id === activeGroupId.value);
-    createConfirm({
-      iconType: 'warning',
-      centered: true,
-      title: '确认删除分组',
-      content: `确定要删除分组“${current?.name || ''}”吗?删除后无法恢复。`,
-      okButtonProps: { type: 'primary' },
-      autoFocusButton: 'ok',
-      async onOk() {
-        try {
-          await deletePersonGroup(activeGroupId.value!);
-          createMessage.success('删除分组成功');
-          await loadGroupData();
-        } catch (error) {
-          console.error('删除分组失败', error);
-          createMessage.error('删除分组失败');
-        }
-      },
-    });
-  };
-
-  const handleAddObject = () => {
-    if (!activeGroupId.value) {
-      createMessage.warning('请先选择分组');
-      return;
-    }
-    addObjectKeyword.value = '';
-    selectedAddPersonNames.value = [];
-    addObjectModalVisible.value = true;
-  };
-
-  const handleCheckAddObject = (item: { name: string; disabled: boolean }, checked: boolean) => {
-    if (item.disabled) return;
-    if (checked) {
-      selectedAddPersonNames.value = Array.from(new Set([...selectedAddPersonNames.value, item.name]));
-      return;
-    }
-    selectedAddPersonNames.value = selectedAddPersonNames.value.filter((name) => name !== item.name);
-  };
-
-  const handleToggleAddObject = (item: { name: string; disabled: boolean }) => {
-    if (item.disabled) return;
-    const checked = !selectedAddPersonNames.value.includes(item.name);
-    handleCheckAddObject(item, checked);
-  };
-
-  const handleConfirmAddObject = async () => {
-    if (!activeGroupId.value) {
-      createMessage.warning('请先选择分组');
-      return;
-    }
-    if (!selectedAddPersonNames.value.length) {
-      createMessage.warning('请选择要添加的对象');
-      return;
-    }
-
-    addingObjectToGroup.value = true;
-    try {
-      await addPersonToGroup({
-        groupId: activeGroupId.value,
-        personList: selectedAddPersonNames.value,
-      });
-      createMessage.success('添加对象成功');
-      closeAddObjectModal();
-      await loadGroupData();
-    } catch (error) {
-      console.error('添加对象失败', error);
-      createMessage.error('添加对象失败');
-    } finally {
-      addingObjectToGroup.value = false;
-    }
-  };
-
-  const handleRemoveMember = async (memberId: string) => {
-    try {
-      await remPersonFromGroup(memberId);
-      createMessage.success('移出分组成功');
-      await loadGroupData();
-    } catch (error) {
-      console.error('移出分组失败', error);
-      createMessage.error('移出分组失败');
-    }
-  };
-
-  const handleChangeMain = async (relId: string) => {
-    if (!activeGroupId.value) return;
-    try {
-      await changeMainPerson(relId, activeGroupId.value);
-      createMessage.success('变更主要人员成功');
-      await loadGroupData();
-    } catch (error) {
-      console.error('变更主要人员失败', error);
-      createMessage.error('变更主要人员失败');
-    }
-  };
 </script>
 
 <style lang="less">
@@ -9565,7 +8970,7 @@
      弹窗被 teleport 到 body,用本页挂载时加的 body 标记限定作用域,避免影响其他页面弹窗。
      需要 !important:全局弹窗页脚样式(core/design/ant/index.less)把页脚按钮定成了 36px,
      选择器层级更高,普通覆盖不生效。 */
-  body.case-portrait-active .ant-modal-wrap .ant-btn {
+  body.case-portrait-active .ant-modal-wrap:not(.pg-manage-modal) .ant-btn {
     height: 32px !important;
   }
 
@@ -9577,20 +8982,20 @@
   /* 数据画像页弹窗主按钮:统一使用项目主色 #2a50ec。
      组件内部(ModalFooter / QueryTableModal 等)对主按钮有更高优先级的历史配色覆盖,
      这里用 !important 统一成本页要求的项目主色。 */
-  body.case-portrait-active .ant-modal-wrap .ant-btn-primary {
+  body.case-portrait-active .ant-modal-wrap:not(.pg-manage-modal) .ant-btn-primary {
     border-color: #2a50ec !important;
     background: #2a50ec !important;
     color: #fff;
   }
 
-  body.case-portrait-active .ant-modal-wrap .ant-btn-primary:not(:disabled):hover,
-  body.case-portrait-active .ant-modal-wrap .ant-btn-primary:not(:disabled):focus {
+  body.case-portrait-active .ant-modal-wrap:not(.pg-manage-modal) .ant-btn-primary:not(:disabled):hover,
+  body.case-portrait-active .ant-modal-wrap:not(.pg-manage-modal) .ant-btn-primary:not(:disabled):focus {
     border-color: #4b6bf0 !important;
     background: #4b6bf0 !important;
     color: #fff;
   }
 
-  body.case-portrait-active .ant-modal-wrap .ant-btn-primary:not(:disabled):active {
+  body.case-portrait-active .ant-modal-wrap:not(.pg-manage-modal) .ant-btn-primary:not(:disabled):active {
     border-color: #1e3ed0 !important;
     background: #1e3ed0 !important;
     color: #fff;

+ 1528 - 0
ai-electron/frontend/src/person/components/PersonGroupManageModal.vue

@@ -0,0 +1,1528 @@
+<template>
+  <BasicModal
+    :open="open"
+    :width="960"
+    :centered="true"
+    :show-ok-btn="false"
+    :show-cancel-btn="false"
+    :can-fullscreen="false"
+    :use-wrapper="false"
+    :body-style="{ padding: '0' }"
+    wrap-class-name="pg-manage-modal"
+    @cancel="handleClose"
+  >
+    <template #title>
+      <div class="pg-modal-title-block">
+        <div class="pg-modal-title">对象分组</div>
+        <div class="pg-modal-subtitle">维护分组结构、查看成员状态并设置主要对象</div>
+      </div>
+    </template>
+
+    <Spin :spinning="loading" tip="加载分组数据...">
+      <div class="pg-body">
+        <aside class="pg-groups">
+          <div class="pg-groups__head">
+            <span class="pg-groups__title">
+              分组
+              <em>{{ objectGroups.length }}</em>
+            </span>
+            <Button class="pg-add-group-btn" :disabled="mode !== 'view'" @click="startCreateGroup">
+              <PlusOutlined />
+              新增分组
+            </Button>
+          </div>
+
+          <Input v-model:value="groupKeyword" class="pg-group-search" placeholder="搜索分组" allow-clear>
+            <template #prefix>
+              <SearchOutlined />
+            </template>
+          </Input>
+
+          <div class="pg-group-list">
+            <button
+              v-for="group in filteredGroups"
+              :key="group.id"
+              type="button"
+              class="pg-group-item"
+              :class="{ active: group.id === activeGroupId }"
+              :disabled="mode !== 'view'"
+              @click="selectGroup(group.id)"
+            >
+              <span class="pg-group-item__row">
+                <span class="pg-group-item__name">{{ group.name }}</span>
+                <span class="pg-group-item__count">{{ groupMemberCount(group.id) }}</span>
+              </span>
+              <span class="pg-group-item__meta">
+                {{ groupMainName(group.id) ? `主要 ${groupMainName(group.id)}` : '未设置主要对象' }}
+              </span>
+            </button>
+
+            <div v-if="!filteredGroups.length" class="pg-empty pg-empty--groups">
+              <div class="pg-empty__icon">
+                <TeamOutlined />
+              </div>
+              <div class="pg-empty__title">
+                {{ objectGroups.length ? '没有匹配的分组' : '还没有分组' }}
+              </div>
+              <div v-if="!objectGroups.length" class="pg-empty__desc">创建一个分组后即可开始分析</div>
+            </div>
+          </div>
+
+          <div class="pg-groups__foot">共 {{ objectGroups.length }} 个分组</div>
+        </aside>
+
+        <section class="pg-panel">
+          <template v-if="mode === 'view'">
+            <template v-if="activeGroup">
+              <div class="pg-panel__head">
+                <div class="pg-panel__heading">
+                  <div class="pg-panel__title" :title="activeGroup.name">{{ activeGroup.name }}</div>
+                  <div class="pg-panel__sub">
+                    {{ activeGroupMembers.length }} 位成员
+                    <template v-if="mainPersonName"> · 主要对象 {{ mainPersonName }}</template>
+                  </div>
+                </div>
+                <div class="pg-panel__actions">
+                  <Button @click="startAddMembers">
+                    <UserAddOutlined />
+                    添加对象
+                  </Button>
+                  <Button @click="startEditGroup">
+                    <EditOutlined />
+                    编辑分组
+                  </Button>
+                  <Popconfirm
+                    title="删除分组"
+                    description="删除后分组及成员关系不可恢复,确认删除?"
+                    ok-text="删除"
+                    cancel-text="取消"
+                    @confirm="handleDeleteGroup"
+                  >
+                    <Button danger>
+                      <DeleteOutlined />
+                      删除分组
+                    </Button>
+                  </Popconfirm>
+                </div>
+              </div>
+
+              <div class="pg-member-toolbar">
+                <Input v-model:value="memberKeyword" class="pg-member-search" placeholder="搜索成员" allow-clear>
+                  <template #prefix>
+                    <SearchOutlined />
+                  </template>
+                </Input>
+                <span class="pg-member-total">共 {{ activeGroupMembers.length }} 人</span>
+              </div>
+
+              <div class="pg-member-list">
+                <div v-for="member in filteredMembers" :key="member.id" class="pg-member-item">
+                  <Avatar :size="36" class="pg-member-avatar">
+                    <svg viewBox="0 0 24 24" fill="currentColor">
+                      <path
+                        d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
+                      />
+                    </svg>
+                  </Avatar>
+                  <div class="pg-member-info">
+                    <div class="pg-member-name-row">
+                      <span class="pg-member-name" :title="member.personName">{{ member.personName }}</span>
+                      <span v-if="member.hasMain" class="pg-member-tag">主要</span>
+                    </div>
+                    <div class="pg-member-desc">
+                      {{ member.hasMain ? '当前主对象' : '分组成员' }}
+                    </div>
+                  </div>
+                  <div class="pg-member-actions">
+                    <button v-if="!member.hasMain" type="button" class="pg-link" @click="handleChangeMain(member.id)">
+                      设为主对象
+                    </button>
+                    <Popconfirm
+                      title="移出成员"
+                      :description="`确认将「${member.personName}」移出当前分组?`"
+                      ok-text="移出"
+                      cancel-text="取消"
+                      @confirm="handleRemoveMember(member.id)"
+                    >
+                      <button type="button" class="pg-link pg-link--danger">移出</button>
+                    </Popconfirm>
+                  </div>
+                </div>
+
+                <div v-if="!filteredMembers.length" class="pg-empty pg-empty--page">
+                  <div class="pg-empty__icon">
+                    <UserAddOutlined />
+                  </div>
+                  <div class="pg-empty__title">
+                    {{ activeGroupMembers.length ? '没有匹配的成员' : '当前分组暂无成员' }}
+                  </div>
+                  <div v-if="!activeGroupMembers.length" class="pg-empty__desc"> 添加对象后,分组才会参与分析 </div>
+                  <Button v-if="!activeGroupMembers.length" type="primary" @click="startAddMembers"> 添加对象 </Button>
+                </div>
+              </div>
+            </template>
+
+            <div v-else class="pg-empty pg-empty--page">
+              <div class="pg-empty__icon">
+                <TeamOutlined />
+              </div>
+              <div class="pg-empty__title">
+                {{ objectGroups.length ? '请选择左侧分组' : '还没有分组' }}
+              </div>
+              <div class="pg-empty__desc">
+                {{ objectGroups.length ? '选择分组后可查看和维护成员' : '创建第一个分组,用于分析被调查对象' }}
+              </div>
+              <Button v-if="!objectGroups.length" type="primary" @click="startCreateGroup"> 新增分组 </Button>
+            </div>
+          </template>
+
+          <template v-else-if="mode === 'create' || mode === 'edit'">
+            <div class="pg-form">
+              <div class="pg-form__head">
+                <div class="pg-form__title">{{ mode === 'create' ? '新建分组' : '编辑分组' }}</div>
+                <div class="pg-form__desc">
+                  {{
+                    mode === 'create'
+                      ? '填写分组名称并选择分组主体对象,创建后可继续添加对象'
+                      : '修改分组名称与备注,主要对象请在成员列表中调整'
+                  }}
+                </div>
+              </div>
+
+              <label class="pg-field">
+                <span class="pg-field__label">分组名称 <em>*</em></span>
+                <Input
+                  v-model:value="form.groupName"
+                  :status="groupNameError ? 'error' : undefined"
+                  :maxlength="50"
+                  placeholder="请输入分组名称"
+                  @blur="() => validateGroupName(true)"
+                />
+                <span v-if="groupNameError" class="pg-field__error">{{ groupNameError }}</span>
+              </label>
+
+              <label v-if="mode === 'create'" class="pg-field">
+                <span class="pg-field__label">分组主体对象 <em>*</em></span>
+                <Select
+                  v-model:value="form.mainPerson"
+                  :options="personOptions"
+                  placeholder="请选择主体对象"
+                  allow-clear
+                  show-search
+                  option-filter-prop="label"
+                  :get-popup-container="getModalPopupContainer"
+                />
+                <span v-if="mainPersonError" class="pg-field__error">{{ mainPersonError }}</span>
+              </label>
+
+              <label class="pg-field">
+                <span class="pg-field__label">备注</span>
+                <Input.TextArea
+                  v-model:value="form.remark"
+                  :rows="3"
+                  :maxlength="200"
+                  placeholder="选填,便于识别分组用途"
+                />
+              </label>
+
+              <div class="pg-form__actions">
+                <Button @click="cancelForm">取消</Button>
+                <Button type="primary" :loading="savingGroup" @click="handleSaveGroup">
+                  {{ mode === 'create' ? '创建分组' : '保存修改' }}
+                </Button>
+              </div>
+            </div>
+          </template>
+
+          <template v-else-if="mode === 'addMembers'">
+            <div class="pg-panel__head">
+              <div class="pg-panel__heading">
+                <div class="pg-panel__title">添加对象</div>
+                <div class="pg-panel__sub">
+                  加入「{{ activeGroup?.name }}」,已选 {{ selectedAddNames.length }} 人
+                </div>
+              </div>
+              <div class="pg-panel__actions">
+                <Button @click="cancelAddMembers">取消</Button>
+                <Button
+                  type="primary"
+                  :loading="addingMembers"
+                  :disabled="!selectedAddNames.length"
+                  @click="confirmAddMembers"
+                >
+                  加入分组
+                </Button>
+              </div>
+            </div>
+
+            <div class="pg-member-toolbar">
+              <Input
+                v-model:value="addKeyword"
+                class="pg-member-search"
+                placeholder="搜索姓名 / 证件号 / 手机号"
+                allow-clear
+              >
+                <template #prefix>
+                  <SearchOutlined />
+                </template>
+              </Input>
+              <span class="pg-member-total">共 {{ filteredAddCandidates.length }} 个可选对象</span>
+            </div>
+
+            <div class="pg-candidate-list">
+              <label
+                v-for="item in filteredAddCandidates"
+                :key="item.key"
+                class="pg-candidate"
+                :class="{ 'is-selected': selectedAddNames.includes(item.name) }"
+              >
+                <Checkbox
+                  :checked="selectedAddNames.includes(item.name)"
+                  @click.stop
+                  @change="(e) => setCandidate(item, e.target.checked)"
+                />
+                <PersonAvatar
+                  class="pg-candidate__avatar"
+                  :src="item.photograph || undefined"
+                  :alt="item.name"
+                  :size="36"
+                />
+                <span class="pg-candidate__body">
+                  <span class="pg-candidate__name">{{ item.name }}</span>
+                  <span class="pg-candidate__meta">
+                    证件号 {{ maskCertNo(item.personCertNo) }} · 手机 {{ item.primaryPhone }} · 银行卡
+                    {{ item.cardCount }} 张
+                  </span>
+                </span>
+              </label>
+
+              <div v-if="!filteredAddCandidates.length" class="pg-empty pg-empty--page">
+                <div class="pg-empty__icon">
+                  <TeamOutlined />
+                </div>
+                <div class="pg-empty__title">{{ addEmptyText }}</div>
+              </div>
+            </div>
+          </template>
+        </section>
+      </div>
+    </Spin>
+  </BasicModal>
+</template>
+
+<script lang="ts" setup name="PersonGroupManageModal">
+  import { computed, reactive, ref, watch } from 'vue';
+  import { Avatar, Button, Checkbox, Input, Popconfirm, Select, Spin } from 'ant-design-vue';
+  import {
+    DeleteOutlined,
+    EditOutlined,
+    PlusOutlined,
+    SearchOutlined,
+    TeamOutlined,
+    UserAddOutlined,
+  } from '@ant-design/icons-vue';
+  import { PersonAvatar } from '@/core/components/PersonAvatar';
+  import { BasicModal } from '@/core/components/Modal';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import { getModalPopupContainer as resolveModalPopupContainer } from '@/core/utils';
+  import { maskCertNo } from '@/core/utils/sensitive';
+  import {
+    addPersonToGroup,
+    changeMainPerson,
+    createPersonGroup,
+    deletePersonGroup,
+    getPersons,
+    listPersonGroups,
+    remPersonFromGroup,
+    updatePersonGroup,
+  } from '@/person/api/personApi';
+  import type { PersonBasicInfo } from '@/person/api/types/dto';
+
+  type Mode = 'view' | 'create' | 'edit' | 'addMembers';
+
+  interface GroupItem {
+    id: string;
+    name: string;
+    remark?: string;
+  }
+
+  interface MemberItem {
+    id: string;
+    groupId: string;
+    personName: string;
+    hasMain: boolean;
+  }
+
+  interface CandidateItem {
+    key: string;
+    name: string;
+    personCertNo: string;
+    primaryPhone: string;
+    cardCount: number;
+    photograph?: string;
+  }
+
+  const props = withDefaults(defineProps<{ open: boolean }>(), { open: false });
+  const emit = defineEmits<{
+    (e: 'update:open', value: boolean): void;
+    (e: 'change'): void;
+    (e: 'created', groupName: string): void;
+  }>();
+
+  const { createMessage } = useMessage();
+
+  const loading = ref(false);
+  const mode = ref<Mode>('view');
+  const objectGroups = ref<GroupItem[]>([]);
+  const groupMembers = ref<MemberItem[]>([]);
+  const persons = ref<PersonBasicInfo[]>([]);
+  const activeGroupId = ref<string | null>(null);
+
+  const groupKeyword = ref('');
+  const memberKeyword = ref('');
+  const addKeyword = ref('');
+  const selectedAddNames = ref<string[]>([]);
+
+  const savingGroup = ref(false);
+  const addingMembers = ref(false);
+  const groupNameError = ref('');
+  const mainPersonError = ref('');
+  const form = reactive({
+    groupName: '',
+    mainPerson: undefined as string | undefined,
+    remark: '',
+  });
+
+  const normalizeGroupName = (value?: string | null) => value?.trim().toLocaleLowerCase() || '';
+  const hasMainValue = (value?: boolean | 'Y' | 'N') => value === true || value === 'Y';
+
+  const activeGroup = computed(() => objectGroups.value.find((item) => item.id === activeGroupId.value) || null);
+
+  const activeGroupMembers = computed(() => groupMembers.value.filter((item) => item.groupId === activeGroupId.value));
+
+  const mainPersonName = computed(() => activeGroupMembers.value.find((item) => item.hasMain)?.personName || '');
+
+  const memberCountMap = computed(() => {
+    const counts = new Map<string, number>();
+    groupMembers.value.forEach((item) => {
+      counts.set(item.groupId, (counts.get(item.groupId) || 0) + 1);
+    });
+    return counts;
+  });
+
+  const groupMemberCount = (groupId: string) => memberCountMap.value.get(groupId) || 0;
+
+  const groupMainName = (groupId: string) =>
+    groupMembers.value.find((item) => item.groupId === groupId && item.hasMain)?.personName || '';
+
+  const filteredGroups = computed(() => {
+    const keyword = groupKeyword.value.trim().toLocaleLowerCase();
+    if (!keyword) return objectGroups.value;
+    return objectGroups.value.filter((item) =>
+      [item.name, item.remark || ''].some((value) => value.toLocaleLowerCase().includes(keyword)),
+    );
+  });
+
+  const filteredMembers = computed(() => {
+    const keyword = memberKeyword.value.trim().toLocaleLowerCase();
+    if (!keyword) return activeGroupMembers.value;
+    return activeGroupMembers.value.filter((item) => item.personName.toLocaleLowerCase().includes(keyword));
+  });
+
+  const groupedPersonNameSet = computed(() => {
+    return new Set(
+      groupMembers.value
+        .map((item) => item.personName?.trim())
+        .filter((item): item is string => !!item && item !== '-'),
+    );
+  });
+
+  const personOptions = computed(() =>
+    persons.value
+      .map((item) => {
+        const personName = item.personName?.trim() || '';
+        return { label: personName || '-', value: personName };
+      })
+      .filter((item) => !!item.value && !groupedPersonNameSet.value.has(item.value)),
+  );
+
+  const addCandidates = computed<CandidateItem[]>(() =>
+    persons.value
+      .map((item, index) => {
+        const name = item.personName?.trim() || '';
+        const phoneValues = (item.phoneList || []).map((phone) => phone.personNo || '').filter(Boolean);
+        const cardCount = (item.cardList || []).filter((card) => !!card?.personNo).length;
+        return {
+          key: String(item.id ?? item.objId ?? name ?? index),
+          name: name || `未命名对象${index + 1}`,
+          personCertNo: item.personCertNo || '-',
+          primaryPhone: phoneValues[0] || '-',
+          cardCount,
+          photograph: item.photograph || '',
+        };
+      })
+      .filter((item) => !!item.name && !groupedPersonNameSet.value.has(item.name))
+      .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')),
+  );
+
+  const filteredAddCandidates = computed(() => {
+    const keyword = addKeyword.value.trim().toLocaleLowerCase();
+    if (!keyword) return addCandidates.value;
+    return addCandidates.value.filter((item) =>
+      [item.name, item.personCertNo, item.primaryPhone].some((value) => value.toLocaleLowerCase().includes(keyword)),
+    );
+  });
+
+  const addEmptyText = computed(() => {
+    if (!addCandidates.value.length) return '暂无未分组对象,对象限定在单一分组';
+    return '暂无匹配对象';
+  });
+
+  const getModalPopupContainer = (triggerNode?: HTMLElement) => resolveModalPopupContainer(triggerNode);
+
+  const loadData = async () => {
+    loading.value = true;
+    try {
+      const [groups, personList] = await Promise.all([listPersonGroups(), getPersons()]);
+      objectGroups.value = (groups || []).map((item) => ({
+        id: item.id || '',
+        name: item.groupName || `分组${item.id}`,
+        remark: item.remark,
+      }));
+      groupMembers.value = (groups || []).flatMap((group) =>
+        (group.personList || []).map((rel) => ({
+          id: rel.id || '',
+          groupId: rel.groupId || group.id || '',
+          personName: rel.personName || '-',
+          hasMain: hasMainValue(rel.hasMain),
+        })),
+      );
+      persons.value = Array.isArray(personList) ? personList : [];
+
+      if (objectGroups.value.length) {
+        const currentExists = objectGroups.value.some((item) => item.id === activeGroupId.value);
+        activeGroupId.value = currentExists ? activeGroupId.value : objectGroups.value[0].id;
+      } else {
+        activeGroupId.value = null;
+      }
+    } catch (error) {
+      console.error('获取分组列表失败', error);
+      createMessage.error('获取分组列表失败');
+    } finally {
+      loading.value = false;
+    }
+  };
+
+  const resetForm = () => {
+    form.groupName = '';
+    form.mainPerson = undefined;
+    form.remark = '';
+    groupNameError.value = '';
+    mainPersonError.value = '';
+  };
+
+  const selectGroup = (groupId: string) => {
+    if (mode.value !== 'view') return;
+    activeGroupId.value = groupId;
+    memberKeyword.value = '';
+  };
+
+  const startCreateGroup = () => {
+    mode.value = 'create';
+    resetForm();
+    memberKeyword.value = '';
+  };
+
+  const startEditGroup = () => {
+    if (!activeGroup.value) return;
+    mode.value = 'edit';
+    form.groupName = activeGroup.value.name;
+    form.mainPerson = mainPersonName.value || undefined;
+    form.remark = activeGroup.value.remark || '';
+    groupNameError.value = '';
+    mainPersonError.value = '';
+  };
+
+  const cancelForm = () => {
+    mode.value = 'view';
+    resetForm();
+  };
+
+  const isDuplicateGroupName = (groupName: string) => {
+    const normalizedName = normalizeGroupName(groupName);
+    if (!normalizedName) return false;
+    const editingId = mode.value === 'edit' ? activeGroupId.value : null;
+    return objectGroups.value.some((item) => {
+      if (editingId && item.id === editingId) return false;
+      return normalizeGroupName(item.name) === normalizedName;
+    });
+  };
+
+  const validateGroupName = (showRequired = false) => {
+    const groupName = form.groupName.trim();
+    if (!groupName) {
+      groupNameError.value = showRequired ? '请输入分组名称' : '';
+      return !showRequired;
+    }
+    if (isDuplicateGroupName(groupName)) {
+      groupNameError.value = '分组名称已存在,请重新输入';
+      return false;
+    }
+    groupNameError.value = '';
+    return true;
+  };
+
+  const validateMainPerson = (showRequired = false) => {
+    if (mode.value !== 'create') {
+      mainPersonError.value = '';
+      return true;
+    }
+    if (!form.mainPerson) {
+      mainPersonError.value = showRequired ? '请选择主体对象' : '';
+      return !showRequired;
+    }
+    mainPersonError.value = '';
+    return true;
+  };
+
+  const handleSaveGroup = async () => {
+    if (savingGroup.value) return;
+    const isCreate = mode.value === 'create';
+    const groupName = form.groupName.trim();
+
+    if (!validateGroupName(true)) return;
+    if (isCreate && !validateMainPerson(true)) return;
+
+    savingGroup.value = true;
+    try {
+      if (isCreate) {
+        await createPersonGroup({
+          groupName,
+          remark: form.remark.trim() || undefined,
+          personList: form.mainPerson ? [{ personName: form.mainPerson, hasMain: true }] : undefined,
+        });
+      } else if (activeGroupId.value) {
+        await updatePersonGroup({
+          id: activeGroupId.value,
+          groupName,
+          remark: form.remark.trim() || undefined,
+        });
+      }
+
+      createMessage.success(isCreate ? '新增分组成功' : '更新分组成功');
+      mode.value = 'view';
+      resetForm();
+      await loadData();
+
+      if (isCreate) {
+        const created = objectGroups.value.find(
+          (item) => normalizeGroupName(item.name) === normalizeGroupName(groupName),
+        );
+        if (created) activeGroupId.value = created.id;
+        emit('created', groupName);
+      }
+      emit('change');
+    } catch (error) {
+      console.error(isCreate ? '新增分组失败' : '更新分组失败', error);
+      createMessage.error(isCreate ? '新增分组失败' : '更新分组失败');
+    } finally {
+      savingGroup.value = false;
+    }
+  };
+
+  const handleDeleteGroup = async () => {
+    if (!activeGroupId.value) return;
+    try {
+      await deletePersonGroup(activeGroupId.value);
+      createMessage.success('删除分组成功');
+      mode.value = 'view';
+      activeGroupId.value = null;
+      await loadData();
+      emit('change');
+    } catch (error) {
+      console.error('删除分组失败', error);
+      createMessage.error('删除分组失败');
+    }
+  };
+
+  const startAddMembers = () => {
+    if (!activeGroup.value) return;
+    mode.value = 'addMembers';
+    addKeyword.value = '';
+    selectedAddNames.value = [];
+  };
+
+  const cancelAddMembers = () => {
+    mode.value = 'view';
+    addKeyword.value = '';
+    selectedAddNames.value = [];
+  };
+
+  const setCandidate = (item: CandidateItem, checked: boolean) => {
+    if (checked) {
+      selectedAddNames.value = Array.from(new Set([...selectedAddNames.value, item.name]));
+      return;
+    }
+    selectedAddNames.value = selectedAddNames.value.filter((name) => name !== item.name);
+  };
+
+  const confirmAddMembers = async () => {
+    if (!activeGroupId.value || !selectedAddNames.value.length || addingMembers.value) return;
+    addingMembers.value = true;
+    try {
+      await addPersonToGroup({
+        groupId: activeGroupId.value,
+        personList: selectedAddNames.value,
+      });
+      createMessage.success('添加对象成功');
+      mode.value = 'view';
+      addKeyword.value = '';
+      selectedAddNames.value = [];
+      await loadData();
+      emit('change');
+    } catch (error) {
+      console.error('添加对象失败', error);
+      createMessage.error('添加对象失败');
+    } finally {
+      addingMembers.value = false;
+    }
+  };
+
+  const handleRemoveMember = async (memberId: string) => {
+    try {
+      await remPersonFromGroup(memberId);
+      createMessage.success('移出分组成功');
+      await loadData();
+      emit('change');
+    } catch (error) {
+      console.error('移出分组失败', error);
+      createMessage.error('移出分组失败');
+    }
+  };
+
+  const handleChangeMain = async (relId: string) => {
+    if (!activeGroupId.value) return;
+    try {
+      await changeMainPerson(relId, activeGroupId.value);
+      createMessage.success('变更主要人员成功');
+      await loadData();
+      emit('change');
+    } catch (error) {
+      console.error('变更主要人员失败', error);
+      createMessage.error('变更主要人员失败');
+    }
+  };
+
+  const handleClose = () => {
+    mode.value = 'view';
+    resetForm();
+    groupKeyword.value = '';
+    memberKeyword.value = '';
+    addKeyword.value = '';
+    selectedAddNames.value = [];
+    emit('update:open', false);
+  };
+
+  watch(
+    () => form.groupName,
+    () => {
+      if (groupNameError.value) validateGroupName(Boolean(form.groupName.trim()));
+    },
+  );
+
+  watch(
+    () => form.mainPerson,
+    () => {
+      if (mainPersonError.value) validateMainPerson(Boolean(form.mainPerson));
+    },
+  );
+
+  watch(
+    () => props.open,
+    async (open) => {
+      if (!open) return;
+      mode.value = 'view';
+      groupKeyword.value = '';
+      memberKeyword.value = '';
+      addKeyword.value = '';
+      selectedAddNames.value = [];
+      resetForm();
+      await loadData();
+    },
+    { immediate: true },
+  );
+
+  defineExpose({
+    startCreateGroup,
+  });
+</script>
+
+<style lang="less">
+  /* ---------------- 对象分组管理弹框(960) ---------------- */
+  .pg-manage-modal {
+    .ant-modal {
+      width: 960px !important;
+      max-width: calc(100vw - 40px);
+    }
+
+    .ant-modal-content {
+      display: flex;
+      flex-direction: column;
+      height: 600px;
+      max-height: calc(100vh - 40px);
+      padding: 0;
+      overflow: hidden;
+      border-radius: 16px;
+      background: #fff;
+      box-shadow: 0 20px 50px rgb(15 23 42 / 18%);
+    }
+
+    .ant-modal-header {
+      flex: 0 0 auto;
+      margin: 0;
+      padding: 12px 16px;
+      border-bottom: 1px solid #eff2f7;
+      background: #fff;
+    }
+
+    .ant-modal-title {
+      color: inherit;
+    }
+
+    .ant-modal-close {
+      top: 14px;
+      right: 16px;
+      border-radius: 8px;
+      color: #98a2b3;
+
+      &:hover {
+        background: #f4f6fa;
+        color: #5b6577;
+      }
+    }
+
+    .ant-modal-close-x {
+      width: 34px;
+      height: 34px;
+    }
+
+    .ant-modal-body {
+      flex: 1 1 auto;
+      min-height: 0;
+      padding: 0 !important;
+      overflow: hidden;
+      background: #fff;
+    }
+
+    .ant-modal-body,
+    .ant-modal-body .scroll-container,
+    .ant-modal-body .scrollbar__wrap,
+    .ant-modal-body .scrollbar__view,
+    .ant-modal-body .scrollbar__view > div {
+      height: 100%;
+      min-height: 0;
+      max-height: none !important;
+    }
+
+    .ant-spin-nested-loading,
+    .ant-spin-container {
+      display: flex;
+      flex-direction: column;
+      height: 100%;
+      min-height: 0;
+    }
+  }
+
+  .pg-modal-title-block {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+  }
+
+  .pg-modal-title {
+    color: #111827;
+    font-size: 16px;
+    font-weight: 600;
+    line-height: 24px;
+  }
+
+  .pg-modal-subtitle {
+    color: #98a2b3;
+    font-size: 11px;
+    font-weight: 400;
+    line-height: 16px;
+  }
+
+  .pg-body {
+    display: grid;
+    grid-template-columns: minmax(0, 268px) minmax(0, 1fr);
+    align-items: stretch;
+    gap: 14px;
+    height: 100%;
+    padding: 12px;
+    box-sizing: border-box;
+  }
+
+  /* ---------------- 左侧分组 ---------------- */
+  .pg-groups {
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    max-height: calc(100vh - 280px);
+    padding: 10px;
+    border: 1px solid #eff2f7;
+    border-radius: 10px;
+    background: #f8fafc;
+    box-sizing: border-box;
+    overflow: hidden;
+  }
+
+  .pg-groups__head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    margin-bottom: 6px;
+  }
+
+  .pg-groups__title {
+    color: #111827;
+    font-size: 13px;
+    font-weight: 600;
+
+    em {
+      margin-left: 4px;
+      color: #98a2b3;
+      font-size: 11px;
+      font-style: normal;
+      font-weight: 500;
+    }
+  }
+
+  .pg-add-group-btn.ant-btn {
+    height: 26px;
+    padding: 0 8px;
+    border: 1px solid #bfcdfb;
+    border-radius: 8px;
+    background: #fff;
+    color: #2a50ec;
+    font-size: 12px;
+    box-shadow: none;
+
+    &:hover,
+    &:focus {
+      border-color: #2a50ec;
+      background: #f4f7ff;
+      color: #2a50ec;
+    }
+
+    &:disabled {
+      border-color: #e7ebf3;
+      color: #b6bfcc;
+    }
+  }
+
+  .pg-group-search.ant-input-affix-wrapper {
+    height: 30px;
+    margin-bottom: 8px;
+    border-color: #e7ebf3;
+    border-radius: 8px;
+    box-shadow: none;
+    font-size: 12px;
+  }
+
+  .pg-group-list {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    gap: 4px;
+    min-height: 0;
+    padding-right: 2px;
+    overflow-y: auto;
+  }
+
+  .pg-group-item {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    width: 100%;
+    padding: 8px 10px;
+    border: 1px solid #e7ebf3;
+    border-radius: 10px;
+    background: #fff;
+    text-align: left;
+    cursor: pointer;
+    appearance: none;
+    transition:
+      border-color 0.16s ease,
+      background 0.16s ease;
+
+    &:hover:not(:disabled) {
+      border-color: #bfcdfb;
+      background: #fbfcff;
+    }
+
+    &.active {
+      border-color: #bfcdfb;
+      background: #eef2ff;
+    }
+
+    &:disabled {
+      cursor: not-allowed;
+      opacity: 0.6;
+    }
+  }
+
+  .pg-group-item__row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+  }
+
+  .pg-group-item__name {
+    flex: 1;
+    min-width: 0;
+    overflow: hidden;
+    color: #1f2937;
+    font-size: 12px;
+    font-weight: 600;
+    line-height: 17px;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  .pg-group-item.active .pg-group-item__name {
+    color: #2448c9;
+  }
+
+  .pg-group-item__count {
+    flex: 0 0 auto;
+    min-width: 18px;
+    height: 16px;
+    padding: 0 5px;
+    border-radius: 999px;
+    background: #f1f5f9;
+    color: #5b6577;
+    font-size: 10px;
+    line-height: 16px;
+    text-align: center;
+  }
+
+  .pg-group-item.active .pg-group-item__count {
+    background: #dfe7ff;
+    color: #2448c9;
+  }
+
+  .pg-group-item__meta {
+    overflow: hidden;
+    color: #98a2b3;
+    font-size: 10px;
+    line-height: 15px;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  .pg-groups__foot {
+    margin-top: 6px;
+    padding-top: 8px;
+    border-top: 1px solid #eff2f7;
+    color: #98a2b3;
+    font-size: 10px;
+    line-height: 15px;
+  }
+
+  /* ---------------- 右侧面板 ---------------- */
+
+  .pg-panel {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    min-height: 0;
+  }
+
+  .pg-panel__head {
+    display: flex;
+    flex: 0 0 auto;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 12px;
+    padding-bottom: 10px;
+    border-bottom: 1px solid #eff2f7;
+  }
+
+  .pg-panel__heading {
+    min-width: 0;
+  }
+
+  .pg-panel__title {
+    overflow: hidden;
+    color: #111827;
+    font-size: 15px;
+    font-weight: 600;
+    line-height: 22px;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  .pg-panel__sub {
+    margin-top: 1px;
+    color: #98a2b3;
+    font-size: 11px;
+    line-height: 16px;
+  }
+
+  .pg-panel__actions {
+    display: flex;
+    flex: 0 0 auto;
+    align-items: center;
+    gap: 6px;
+
+    .ant-btn {
+      height: 28px;
+      padding: 0 8px;
+      border-color: #e7ebf3;
+      border-radius: 8px;
+      color: #5b6577;
+      font-size: 12px;
+      box-shadow: none;
+
+      &:hover,
+      &:focus {
+        border-color: #bfcdfb;
+        background: #f8faff;
+        color: #2a50ec;
+      }
+    }
+
+    .ant-btn-primary {
+      border-color: #2a50ec;
+      background: #2a50ec;
+      color: #fff;
+
+      &:hover,
+      &:focus {
+        border-color: #4b6bf0;
+        background: #4b6bf0;
+        color: #fff;
+      }
+
+      &:disabled,
+      &.ant-btn-disabled {
+        border-color: #e7ebf3;
+        background: #f4f6fa;
+        color: #b6bfcc;
+      }
+    }
+
+    .ant-btn-dangerous {
+      color: #dc2626;
+
+      &:hover,
+      &:focus {
+        border-color: #fecaca;
+        background: #fef2f2;
+        color: #dc2626;
+      }
+    }
+  }
+
+  .pg-member-toolbar {
+    display: flex;
+    flex: 0 0 auto;
+    align-items: center;
+    gap: 8px;
+    padding: 8px 0 0;
+  }
+
+  .pg-member-search.ant-input-affix-wrapper {
+    width: 200px;
+    height: 30px;
+    border-color: #e7ebf3;
+    border-radius: 8px;
+    box-shadow: none;
+    font-size: 12px;
+  }
+
+  .pg-member-total {
+    margin-left: auto;
+    color: #98a2b3;
+    font-size: 11px;
+    white-space: nowrap;
+  }
+
+  .pg-member-list {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    gap: 6px;
+    min-height: 0;
+    padding: 6px 0 0;
+    overflow-y: auto;
+  }
+
+  .pg-member-item {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 6px 10px;
+    border: 1px solid #eff2f7;
+    border-radius: 10px;
+    background: #fff;
+    transition:
+      border-color 0.16s ease,
+      background 0.16s ease;
+
+    &:hover {
+      border-color: #dfe7f5;
+      background: #fbfcff;
+    }
+  }
+
+  .pg-member-avatar.ant-avatar {
+    flex: 0 0 auto;
+    background: #e8eef8;
+    color: #5b74b1;
+  }
+
+  .pg-member-avatar svg {
+    width: 16px;
+    height: 16px;
+  }
+
+  .pg-member-info {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .pg-member-name-row {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+  }
+
+  .pg-member-name {
+    overflow: hidden;
+    color: #1f2937;
+    font-size: 13px;
+    font-weight: 600;
+    line-height: 20px;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  .pg-member-tag {
+    flex: 0 0 auto;
+    padding: 1px 6px;
+    border-radius: 999px;
+    background: #eef2ff;
+    color: #2448c9;
+    font-size: 10px;
+    font-weight: 600;
+    line-height: 16px;
+  }
+
+  .pg-member-desc {
+    color: #98a2b3;
+    font-size: 11px;
+    line-height: 16px;
+  }
+
+  .pg-member-actions {
+    display: flex;
+    flex: 0 0 auto;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .pg-link {
+    padding: 0;
+    border: 0;
+    background: transparent;
+    color: #5b6577;
+    font-size: 11px;
+    font-weight: 500;
+    cursor: pointer;
+
+    &:hover {
+      color: #2a50ec;
+    }
+  }
+
+  .pg-link--danger:hover {
+    color: #dc2626;
+  }
+
+  /* ---------------- 表单 / 添加成员 ---------------- */
+  .pg-form {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    max-width: 440px;
+    padding-top: 12px;
+  }
+
+  .pg-form__head {
+    margin-bottom: 2px;
+  }
+
+  .pg-form__title {
+    color: #111827;
+    font-size: 15px;
+    font-weight: 600;
+    line-height: 22px;
+  }
+
+  .pg-form__desc {
+    margin-top: 2px;
+    color: #98a2b3;
+    font-size: 11px;
+    line-height: 17px;
+  }
+
+  .pg-field {
+    display: flex;
+    flex-direction: column;
+    gap: 5px;
+  }
+
+  .pg-field__label {
+    color: #5b6577;
+    font-size: 12px;
+    line-height: 18px;
+
+    em {
+      color: #e5484d;
+      font-style: normal;
+    }
+  }
+
+  .pg-field .ant-input,
+  .pg-field .ant-input-affix-wrapper,
+  .pg-field .ant-select-selector {
+    height: 32px !important;
+    border-color: #e7ebf3;
+    border-radius: 8px;
+    box-shadow: none;
+    font-size: 12px;
+  }
+
+  .pg-field .ant-select-single .ant-select-selector .ant-select-selection-item,
+  .pg-field .ant-select-single .ant-select-selector .ant-select-selection-placeholder {
+    line-height: 30px;
+  }
+
+  .pg-field textarea.ant-input {
+    height: auto;
+    min-height: 68px;
+    padding: 6px 10px;
+  }
+
+  .pg-field__error {
+    color: #e5484d;
+    font-size: 11px;
+    line-height: 16px;
+  }
+
+  .pg-form__actions {
+    display: flex;
+    justify-content: flex-end;
+    gap: 8px;
+    padding-top: 4px;
+
+    .ant-btn {
+      height: 32px;
+      border-radius: 8px;
+      font-size: 12px;
+    }
+  }
+
+  .pg-candidate-list {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    gap: 6px;
+    min-height: 0;
+    padding: 6px 0 0;
+    overflow-y: auto;
+  }
+
+  .pg-candidate {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 8px 10px;
+    border: 1px solid #eff2f7;
+    border-radius: 10px;
+    background: #fff;
+    cursor: pointer;
+    transition:
+      border-color 0.16s ease,
+      background 0.16s ease;
+
+    &:hover {
+      border-color: #dfe7f5;
+      background: #fbfcff;
+    }
+
+    &.is-selected {
+      border-color: #bfcdfb;
+      background: #f4f7ff;
+    }
+  }
+
+  .pg-candidate__avatar {
+    flex: 0 0 auto;
+  }
+
+  .pg-candidate__body {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    min-width: 0;
+  }
+
+  .pg-candidate__name {
+    color: #1f2937;
+    font-size: 13px;
+    font-weight: 600;
+    line-height: 18px;
+  }
+
+  .pg-candidate__meta {
+    overflow: hidden;
+    color: #98a2b3;
+    font-size: 11px;
+    line-height: 16px;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  /* ---------------- 空状态 / 底部 ---------------- */
+  .pg-empty {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    padding: 20px 12px;
+    color: #98a2b3;
+    text-align: center;
+  }
+
+  .pg-empty--page {
+    flex: 1;
+  }
+
+  .pg-empty--groups {
+    padding: 16px 8px;
+  }
+
+  .pg-empty__icon {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 44px;
+    height: 44px;
+    border-radius: 12px;
+    background: #f4f6fa;
+    color: #b6c2d6;
+    font-size: 20px;
+  }
+
+  .pg-empty__title {
+    color: #5b6577;
+    font-size: 13px;
+    font-weight: 600;
+    line-height: 20px;
+  }
+
+  .pg-empty__desc {
+    font-size: 11px;
+    line-height: 17px;
+  }
+
+  .pg-empty .ant-btn {
+    height: 30px;
+    margin-top: 2px;
+    border-radius: 8px;
+    font-size: 12px;
+  }
+
+  @media (max-width: 960px) {
+    .pg-body {
+      grid-template-columns: minmax(0, 1fr);
+      height: auto;
+      max-height: calc(100vh - 240px);
+      overflow-y: auto;
+    }
+
+    .pg-groups {
+      max-height: 240px;
+    }
+
+    .pg-group-list {
+      max-height: 140px;
+    }
+
+    .pg-panel {
+      min-height: 320px;
+    }
+
+    .pg-panel__actions {
+      flex-wrap: wrap;
+    }
+  }
+
+  html[data-theme='dark'] {
+    .pg-manage-modal {
+      .ant-modal-content,
+      .ant-modal-header,
+      .ant-modal-body {
+        background: #111827;
+      }
+
+      .ant-modal-title,
+      .pg-modal-title,
+      .pg-panel__title,
+      .pg-group-item__name,
+      .pg-member-name,
+      .pg-form__title,
+      .pg-candidate__name {
+        color: #e5e7eb;
+      }
+
+      .pg-modal-subtitle,
+      .pg-panel__sub,
+      .pg-group-item__meta,
+      .pg-member-desc,
+      .pg-form__desc,
+      .pg-candidate__meta {
+        color: #94a3b8;
+      }
+
+      .pg-groups,
+      .pg-group-item,
+      .pg-member-item,
+      .pg-candidate {
+        border-color: #1f2937;
+        background: #0f172a;
+      }
+
+      .pg-panel__head,
+      .pg-groups__foot {
+        border-color: #1f2937;
+      }
+
+      .pg-group-item.active {
+        border-color: #2b3c78;
+        background: #1a2550;
+      }
+
+      .pg-member-avatar.ant-avatar {
+        background: #1e293b;
+        color: #93b4ff;
+      }
+    }
+  }
+</style>

File diff suppressed because it is too large
+ 542 - 483
ai-electron/frontend/src/trans/views/cardHolding/index.vue


+ 3 - 0
ai-server/src/main/java/com/zsjz/ai/module/person/service/PersonGroupService.java

@@ -1,7 +1,9 @@
 package com.zsjz.ai.module.person.service;
 
+import com.baomidou.dynamic.datasource.annotation.DS;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
+import com.zsjz.ai.common.constants.StrConsts;
 import com.zsjz.ai.common.model.person.dto.AddPersonGroupDTO;
 import com.zsjz.ai.common.model.person.entity.PersonGroup;
 import com.zsjz.ai.common.model.person.entity.PersonGroupRel;
@@ -33,6 +35,7 @@ public class PersonGroupService extends ServiceImpl<PersonGroupMapper, PersonGro
         personGroupRelMapper.insert(personGroup.getPersonList());
     }
 
+    @DS(StrConsts.DS_KEY_SLAVE)
     @Transactional
     public void deleteById(Long id) {
         removeById(id);

Some files were not shown because too many files changed in this diff