selectObject.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771
  1. <template>
  2. <view class="page_select_object">
  3. <view class="select_header">
  4. <uni-icons type="back" size="24" color="#333333" @click="goBack"></uni-icons>
  5. <text class="header_title">选择通知对象</text>
  6. <text class="header_placeholder"></text>
  7. </view>
  8. <view class="tabs_container">
  9. <view v-for="tab in tabs" :key="tab.key" class="tab_item" :class="{ active: activeTab === tab.key, disabled: editMode }"
  10. @click="!editMode && handleTabChange(tab.key)">
  11. <text class="tab_text">{{ tab.label }}</text>
  12. </view>
  13. </view>
  14. <view class="search_container">
  15. <view class="search_box">
  16. <image src="/static/image/icon/search.png" class="search_icon" mode="aspectFit"></image>
  17. <input type="text" class="search_input" v-model="searchKeyword" @input="searchTree(searchKeyword)" placeholder="请输入关键字搜索" />
  18. </view>
  19. </view>
  20. <scroll-view scroll-y class="content_list">
  21. <TreeNode :tree-data="treeData" :expanded-ids="expandedIds" @toggle-expand="toggleExpand"
  22. @toggle-select="toggleSelect" />
  23. </scroll-view>
  24. <view class="select_footer">
  25. <view class="confirm_btn" @click="handleConfirm">
  26. <text class="btn_text">确定(已选{{ selectedCount }}人)</text>
  27. </view>
  28. </view>
  29. </view>
  30. </template>
  31. <script setup>
  32. import { ref, computed, onMounted } from 'vue';
  33. import uniIcons from '@/uni_modules/uni-icons/components/uni-icons/uni-icons.vue';
  34. import TreeNode from '@/components/tree-node.vue';
  35. import overview from '@/reqApi/overview.js';
  36. const activeTab = ref('department');
  37. const searchKeyword = ref('');
  38. const schoolYearId = ref('');
  39. const editMode = ref(false);
  40. const noticeType = ref(0);
  41. const selectedUserIds = ref([]);
  42. const tabs = [
  43. { key: 'department', label: '按部门' },
  44. { key: 'subject', label: '按学科' },
  45. { key: 'grade', label: '按年级' },
  46. { key: 'permission', label: '按权限' },
  47. { key: 'student', label: '按学生' }
  48. ];
  49. const treeData = ref([]);
  50. const fullTreeData = ref([]);
  51. const expandedIds = ref([]);
  52. // 按 teacherId 去重用户列表
  53. const deduplicateUsers = (users) => {
  54. if (!users || !Array.isArray(users)) return [];
  55. const seen = new Set();
  56. return users.filter(user => {
  57. if (user.teacherId && seen.has(user.teacherId)) {
  58. return false;
  59. }
  60. if (user.teacherId) {
  61. seen.add(user.teacherId);
  62. }
  63. return true;
  64. });
  65. };
  66. // 转换按部门接口数据为树形结构
  67. const transformDepartmentData = (data) => {
  68. if (!data || !Array.isArray(data)) return [];
  69. return data.map(dept => {
  70. const groups = (dept.groupVOS || []).map(group => ({
  71. id: group.groupId,
  72. name: group.groupName,
  73. checked: false,
  74. halfChecked: false,
  75. children: deduplicateUsers(group.groupUserVOS || []).map(user => ({
  76. id: user.userId,
  77. name: `${user.teacherName} (${user.userAccount})`,
  78. userId: user.userId,
  79. checked: false,
  80. halfChecked: false
  81. }))
  82. }));
  83. return {
  84. id: dept.id,
  85. name: dept.name,
  86. checked: false,
  87. halfChecked: false,
  88. children: groups
  89. };
  90. });
  91. };
  92. // 根据 userId 同步所有相同用户的选中状态
  93. const syncTeacherSelection = (userId, checked) => {
  94. const syncNode = (nodes) => {
  95. nodes.forEach(node => {
  96. if (node.userId && node.userId === userId) {
  97. node.checked = checked;
  98. node.halfChecked = false;
  99. }
  100. if (node.children && node.children.length > 0) {
  101. syncNode(node.children);
  102. }
  103. });
  104. };
  105. syncNode(treeData.value);
  106. };
  107. // 转换按学科的数据为树形结构
  108. const transformSubjectData = (data) => {
  109. if (!data || !Array.isArray(data)) return [];
  110. return data.map(subject => {
  111. const grades = (subject.gradePersonVos || []).map(grade => ({
  112. id: grade.schoolYearGradeId || grade.gradeCode,
  113. name: grade.gradeName,
  114. checked: false,
  115. halfChecked: false,
  116. children: deduplicateUsers(grade.personVoList || []).map(user => ({
  117. id: user.userId,
  118. name: `${user.teacherName} (${user.userAccount})`,
  119. userId: user.userId,
  120. checked: false,
  121. halfChecked: false
  122. }))
  123. }));
  124. return {
  125. id: subject.subjectCode,
  126. name: subject.subjectName,
  127. checked: false,
  128. halfChecked: false,
  129. children: grades
  130. };
  131. });
  132. };
  133. // 转换按年级的数据为树形结构
  134. const transformGradeData = (data) => {
  135. if (!data || !Array.isArray(data)) return [];
  136. return data.map(grade => {
  137. const classTypes = (grade.classTypePersonVoList || []).map(classType => {
  138. const classes = (classType.classInfoPersonVoList || []).map(cls => ({
  139. id: cls.classCode,
  140. name: cls.className,
  141. checked: false,
  142. halfChecked: false,
  143. children: deduplicateUsers(cls.personVoList || []).map(user => ({
  144. id: user.userId,
  145. name: `${user.teacherName} (${user.userAccount})`,
  146. userId: user.userId,
  147. checked: false,
  148. halfChecked: false
  149. }))
  150. }));
  151. return {
  152. id: classType.classType,
  153. name: classType.classTypeName,
  154. checked: false,
  155. halfChecked: false,
  156. children: classes
  157. };
  158. });
  159. return {
  160. id: grade.schoolYearGradeId || grade.gradeCode,
  161. name: grade.gradeName,
  162. checked: false,
  163. halfChecked: false,
  164. children: classTypes
  165. };
  166. });
  167. };
  168. // 转换按权限的数据为树形结构
  169. const transformPersonalData = (data) => {
  170. if (!data || !Array.isArray(data)) return [];
  171. return data.map(item => {
  172. return {
  173. id: item.roleId,
  174. name: item.roleName,
  175. checked: false,
  176. halfChecked: false,
  177. children: deduplicateUsers(item.personVoList || []).map(user => ({
  178. id: user.userId,
  179. name: `${user.teacherName} (${user.userAccount})`,
  180. userId: user.userId,
  181. checked: false,
  182. halfChecked: false
  183. }))
  184. };
  185. });
  186. };
  187. // 转换按学生的数据为树形结构
  188. const transformStudentData = (data) => {
  189. if (!data || !Array.isArray(data)) return [];
  190. return data.map(grade => {
  191. const classes = (grade.clsVOS || []).map(cls => ({
  192. id: cls.classId,
  193. name: cls.className,
  194. checked: false,
  195. halfChecked: false,
  196. children: (cls.studentTblVOS || []).map(student => ({
  197. id: student.userId,
  198. name: `${student.studentName} (${student.studentCode})`,
  199. userId: student.userId,
  200. checked: false,
  201. halfChecked: false
  202. }))
  203. }));
  204. return {
  205. id: grade.schoolYearGradeId || grade.gradeCode,
  206. name: grade.gradeName,
  207. checked: false,
  208. halfChecked: false,
  209. children: classes
  210. };
  211. });
  212. };
  213. // 获取学年
  214. const getSchoolYear = async () => {
  215. try {
  216. const res = await overview.findSchoolYear();
  217. if (res.data && res.data.length > 0) {
  218. schoolYearId.value = res.data[0].id;
  219. }
  220. } catch (error) {
  221. console.error('获取学年失败:', error);
  222. }
  223. };
  224. // 获取按部门数据
  225. const loadDepartmentData = async () => {
  226. if (!schoolYearId.value) return;
  227. try {
  228. const res = await overview.find_depart_contain_user({ schoolYearId: schoolYearId.value });
  229. fullTreeData.value = transformDepartmentData(res.data);
  230. treeData.value = fullTreeData.value;
  231. expandedIds.value = [];
  232. } catch (error) {
  233. console.error('获取部门数据失败:', error);
  234. }
  235. };
  236. // 获取按学科/年级/权限数据
  237. const loadPersonalData = async (type) => {
  238. if (!schoolYearId.value) return;
  239. try {
  240. const res = await overview.find_collect_personal_info({ schoolYearId: schoolYearId.value });
  241. let data = [];
  242. let transformFn = transformPersonalData;
  243. if (type === 'subject') {
  244. data = res.data.subjectPersonVoList || [];
  245. transformFn = transformSubjectData;
  246. } else if (type === 'grade') {
  247. data = res.data.collectGradePersonVos || [];
  248. transformFn = transformGradeData;
  249. } else if (type === 'permission') {
  250. data = res.data.collectPermissionPersonVos || [];
  251. }
  252. fullTreeData.value = transformFn(data);
  253. treeData.value = fullTreeData.value;
  254. expandedIds.value = [];
  255. } catch (error) {
  256. console.error(`获取${type}数据失败:`, error);
  257. }
  258. };
  259. // 获取按学生数据
  260. const loadStudentData = async () => {
  261. if (!schoolYearId.value) return;
  262. try {
  263. const res = await overview.query_notice_student({ id: 0, schoolYearId: schoolYearId.value });
  264. fullTreeData.value = transformStudentData(res.data || []);
  265. treeData.value = fullTreeData.value;
  266. expandedIds.value = [];
  267. } catch (error) {
  268. console.error('获取学生数据失败:', error);
  269. }
  270. };
  271. // 切换标签
  272. const handleTabChange = async (key) => {
  273. activeTab.value = key;
  274. if (key === 'department') {
  275. await loadDepartmentData();
  276. } else if (key === 'subject' || key === 'grade' || key === 'permission') {
  277. await loadPersonalData(key);
  278. } else if (key === 'student') {
  279. await loadStudentData();
  280. }
  281. };
  282. const getDescendantIds = (nodes, parentId) => {
  283. const ids = [];
  284. const findDescendants = (items, targetId) => {
  285. items.forEach(item => {
  286. if (item.id === targetId && item.children && item.children.length > 0) {
  287. const collectIds = (children) => {
  288. children.forEach(child => {
  289. ids.push(child.id);
  290. if (child.children && child.children.length > 0) {
  291. collectIds(child.children);
  292. }
  293. });
  294. };
  295. collectIds(item.children);
  296. }
  297. if (item.children && item.children.length > 0) {
  298. findDescendants(item.children, targetId);
  299. }
  300. });
  301. };
  302. findDescendants(nodes, parentId);
  303. return ids;
  304. };
  305. const searchTree = (keyword) => {
  306. if (!keyword.trim()) {
  307. treeData.value = fullTreeData.value;
  308. return;
  309. }
  310. const filterNodes = (nodes) => {
  311. const filtered = [];
  312. nodes.forEach(node => {
  313. const hasMatch = node.name && node.name.includes(keyword);
  314. let childrenFiltered = [];
  315. if (node.children && node.children.length > 0) {
  316. childrenFiltered = filterNodes(node.children);
  317. }
  318. if (hasMatch || childrenFiltered.length > 0) {
  319. filtered.push({
  320. ...node,
  321. children: childrenFiltered
  322. });
  323. }
  324. });
  325. return filtered;
  326. };
  327. treeData.value = filterNodes(fullTreeData.value);
  328. };
  329. const toggleExpand = ({ itemId, siblingIds, isExpanded }) => {
  330. if (isExpanded) {
  331. expandedIds.value = expandedIds.value.filter(id => id !== itemId);
  332. } else {
  333. const descendantIds = getDescendantIds(treeData.value, itemId);
  334. expandedIds.value = expandedIds.value.filter(id => {
  335. if (siblingIds.includes(id)) {
  336. return false;
  337. }
  338. if (descendantIds.includes(id)) {
  339. return false;
  340. }
  341. return true;
  342. });
  343. expandedIds.value = [...expandedIds.value, itemId];
  344. }
  345. };
  346. // 向下级联:更新所有子节点的选中状态
  347. const toggleChildren = (children, checked) => {
  348. children.forEach(child => {
  349. child.checked = checked;
  350. child.halfChecked = false;
  351. if (child.children && child.children.length > 0) {
  352. toggleChildren(child.children, checked);
  353. }
  354. });
  355. };
  356. // 向上级联:更新父节点的选中状态
  357. const updateParentStatus = () => {
  358. const updateNode = (nodes) => {
  359. nodes.forEach(node => {
  360. if (node.children && node.children.length > 0) {
  361. updateNode(node.children);
  362. // 检查子节点的选中状态
  363. const childrenChecked = node.children.filter(c => c.checked);
  364. const childrenHalfChecked = node.children.filter(c => c.halfChecked);
  365. if (childrenChecked.length === node.children.length) {
  366. // 所有子节点都选中,父节点也选中
  367. node.checked = true;
  368. node.halfChecked = false;
  369. } else if (childrenChecked.length > 0 || childrenHalfChecked.length > 0) {
  370. // 部分子节点选中,父节点半选
  371. node.checked = false;
  372. node.halfChecked = true;
  373. } else {
  374. // 没有子节点选中
  375. node.checked = false;
  376. node.halfChecked = false;
  377. }
  378. }
  379. });
  380. };
  381. updateNode(treeData.value);
  382. };
  383. const toggleSelect = (item) => {
  384. item.checked = !item.checked;
  385. item.halfChecked = false;
  386. // 如果是叶子节点(用户),同步所有相同 userId 的节点
  387. if (!item.children || item.children.length === 0) {
  388. if (item.userId) {
  389. syncTeacherSelection(item.userId, item.checked);
  390. }
  391. } else {
  392. // 向下级联
  393. toggleChildren(item.children, item.checked);
  394. // 获取所有叶子节点的 userId 并同步到其他分组
  395. const getUserIds = (nodes) => {
  396. const ids = [];
  397. nodes.forEach(node => {
  398. if (node.userId) {
  399. ids.push(node.userId);
  400. } else if (node.children && node.children.length > 0) {
  401. ids.push(...getUserIds(node.children));
  402. }
  403. });
  404. return ids;
  405. };
  406. const userIds = getUserIds(item.children);
  407. userIds.forEach(id => {
  408. syncTeacherSelection(id, item.checked);
  409. });
  410. }
  411. // 向上级联
  412. updateParentStatus();
  413. };
  414. const selectedCount = computed(() => {
  415. const selectedUserIds = new Set();
  416. const collectChecked = (items) => {
  417. items.forEach(item => {
  418. if (item.children) {
  419. collectChecked(item.children);
  420. } else if (item.checked && item.userId) {
  421. selectedUserIds.add(item.userId);
  422. }
  423. });
  424. };
  425. collectChecked(treeData.value);
  426. return selectedUserIds.size;
  427. });
  428. const goBack = () => {
  429. uni.navigateBack();
  430. };
  431. const collectSelectedUsers = () => {
  432. const selectedUsers = [];
  433. const collect = (nodes) => {
  434. nodes.forEach(node => {
  435. if (node.children && node.children.length > 0) {
  436. collect(node.children);
  437. } else if (node.checked && node.userId) {
  438. selectedUsers.push({
  439. id: node.id,
  440. name: node.name,
  441. userId: node.userId
  442. });
  443. }
  444. });
  445. };
  446. collect(treeData.value);
  447. return selectedUsers;
  448. };
  449. const handleConfirm = () => {
  450. const data = {
  451. tab: tabs.find(t => t.key === activeTab.value).label,
  452. count: selectedCount.value,
  453. users: collectSelectedUsers()
  454. };
  455. uni.setStorageSync('selectObjectResult', JSON.stringify(data));
  456. uni.navigateBack();
  457. };
  458. const setSelectedUsers = (users) => {
  459. if (!users || !Array.isArray(users)) return;
  460. const selectedTeacherIds = new Set(users.map(u => u.teacherId));
  461. const setNode = (nodes) => {
  462. nodes.forEach(node => {
  463. if (node.children && node.children.length > 0) {
  464. setNode(node.children);
  465. } else if (node.teacherId && selectedTeacherIds.has(node.teacherId)) {
  466. node.checked = true;
  467. }
  468. });
  469. };
  470. setNode(treeData.value);
  471. updateParentStatus();
  472. };
  473. const setSelectedUsersById = (userIds) => {
  474. if (!userIds || !Array.isArray(userIds)) return;
  475. const selectedIds = new Set(userIds);
  476. const setNode = (nodes) => {
  477. nodes.forEach(node => {
  478. if (node.children && node.children.length > 0) {
  479. setNode(node.children);
  480. } else if (node.id && selectedIds.has(node.id)) {
  481. node.checked = true;
  482. if (node.teacherId) {
  483. syncTeacherSelection(node.teacherId, true);
  484. }
  485. }
  486. });
  487. };
  488. setNode(treeData.value);
  489. updateParentStatus();
  490. };
  491. onMounted(async () => {
  492. const pages = getCurrentPages();
  493. if (pages.length === 1) {
  494. uni.redirectTo({ url: '/pages/notice/publish/index' });
  495. return;
  496. }
  497. const lastSelection = uni.getStorageSync('selectObjectLastSelection');
  498. if (lastSelection) {
  499. try {
  500. const data = JSON.parse(lastSelection);
  501. if (data.isEdit) {
  502. editMode.value = true;
  503. }
  504. if (data.noticeType) {
  505. noticeType.value = data.noticeType;
  506. const typeMap = {
  507. 1: 'department',
  508. 2: 'subject',
  509. 3: 'grade',
  510. 4: 'permission',
  511. 5: 'student'
  512. };
  513. const tabKey = typeMap[data.noticeType];
  514. if (tabKey) {
  515. activeTab.value = tabKey;
  516. }
  517. } else if (data.tab) {
  518. const tab = tabs.find(t => t.label === data.tab);
  519. if (tab) {
  520. activeTab.value = tab.key;
  521. }
  522. }
  523. if (data.users) {
  524. selectedUserIds.value = data.users.map(u => u.id);
  525. }
  526. } catch (e) {
  527. console.error('解析上次选择失败:', e);
  528. }
  529. }
  530. await getSchoolYear();
  531. if (activeTab.value === 'department') {
  532. await loadDepartmentData();
  533. } else if (activeTab.value === 'subject' || activeTab.value === 'grade' || activeTab.value === 'permission') {
  534. await loadPersonalData(activeTab.value);
  535. } else if (activeTab.value === 'student') {
  536. await loadStudentData();
  537. }
  538. if (selectedUserIds.value.length > 0) {
  539. setSelectedUsersById(selectedUserIds.value);
  540. } else if (lastSelection) {
  541. try {
  542. const data = JSON.parse(lastSelection);
  543. if (data.users) {
  544. setSelectedUsers(data.users);
  545. }
  546. } catch (e) {
  547. console.error('设置选中状态失败:', e);
  548. }
  549. }
  550. });
  551. </script>
  552. <style lang="scss" scoped>
  553. .page_select_object {
  554. display: flex;
  555. flex-direction: column;
  556. min-height: 100vh;
  557. }
  558. .select_header {
  559. position: fixed;
  560. top: 0;
  561. left: 0;
  562. right: 0;
  563. z-index: 100;
  564. display: flex;
  565. align-items: center;
  566. justify-content: space-between;
  567. height: 96rpx;
  568. padding: 0 24rpx;
  569. background-color: #FFFFFF;
  570. border-bottom: 2rpx solid #F3F3F3;
  571. .header_title {
  572. font-weight: 500;
  573. font-size: 32rpx;
  574. color: #333333;
  575. }
  576. .header_placeholder {
  577. width: 48rpx;
  578. }
  579. }
  580. .tabs_container {
  581. position: fixed;
  582. top: 96rpx;
  583. left: 0;
  584. right: 0;
  585. z-index: 99;
  586. display: flex;
  587. padding: 24rpx;
  588. background-color: #FFFFFF;
  589. justify-content: space-between;
  590. .tab_item {
  591. flex-shrink: 0;
  592. width: 122rpx;
  593. height: 72rpx;
  594. background: #FFFFFF;
  595. border-radius: 8rpx;
  596. border: 2rpx solid #DCDFE6;
  597. display: flex;
  598. align-items: center;
  599. justify-content: center;
  600. &.active {
  601. background: #2E64FA;
  602. border-color: #2E64FA;
  603. .tab_text {
  604. color: #FFFFFF;
  605. }
  606. }
  607. .tab_text {
  608. font-weight: 400;
  609. font-size: 28rpx;
  610. color: #333333;
  611. }
  612. &.disabled {
  613. pointer-events: none;
  614. opacity: 0.5;
  615. }
  616. }
  617. }
  618. .search_container {
  619. position: fixed;
  620. top: 192rpx;
  621. left: 0;
  622. right: 0;
  623. z-index: 98;
  624. padding: 32rpx 0rpx;
  625. margin: 0 24rpx;
  626. background-color: #FFFFFF;
  627. border-bottom: 2rpx solid #F3F3F3;
  628. .search_box {
  629. flex: 1;
  630. display: flex;
  631. align-items: center;
  632. padding: 0rpx 24rpx;
  633. height: 72rpx;
  634. line-height: 72rpx;
  635. border-radius: 8rpx;
  636. border: 2rpx solid #DCDFE6;
  637. .search_icon {
  638. width: 32rpx;
  639. height: 32rpx;
  640. margin-right: 8rpx;
  641. }
  642. .search_input {
  643. flex: 1;
  644. height: 72rpx;
  645. font-size: 28rpx;
  646. color: #333333;
  647. padding-right:24rpx;
  648. box-sizing: border-box;
  649. }
  650. }
  651. }
  652. .content_list {
  653. flex: 1;
  654. padding: 330rpx 0 150rpx 0;
  655. }
  656. .select_footer {
  657. position: fixed;
  658. bottom: 0;
  659. left: 0;
  660. right: 0;
  661. padding: 24rpx;
  662. padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
  663. background-color: #FFFFFF;
  664. border-top: 2rpx solid #F3F3F3;
  665. .confirm_btn {
  666. width: 100%;
  667. height: 90rpx;
  668. background: #2E64FA;
  669. border-radius: 8rpx;
  670. text-align: center;
  671. line-height: 90rpx;
  672. .btn_text {
  673. font-size: 32rpx;
  674. font-weight: 500;
  675. color: #FFFFFF;
  676. }
  677. }
  678. }
  679. </style>