customTableNew.vue 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218
  1. <template>
  2. <div ref="tableContainer" style="margin-top: 10px; position: relative">
  3. <!-- 合并选择框 -->
  4. <div v-if="isSelecting" class="selection-box" :style="selectionStyle"></div>
  5. <!-- 操作按钮 -->
  6. <div v-if="edit">
  7. <el-button type="primary" @click="addColumn(-1)">新增列</el-button>
  8. <el-button type="primary" @click="addRow(-1)">新增行</el-button>
  9. <el-button type="primary" @click="copy">复制表格</el-button>
  10. <el-button type="primary" @click="printTable">预览表格</el-button>
  11. <el-checkbox v-model="isMerge" style="margin-left: 10px"
  12. >合并单元格</el-checkbox
  13. >
  14. </div>
  15. <!-- 表格主体 -->
  16. <div class="table" style="margin-top: 10px">
  17. <table
  18. class="custom-table"
  19. @mousedown="startSelecting"
  20. @mousemove="updateSelection"
  21. @mouseup="stopSelecting"
  22. >
  23. <tbody>
  24. <tr v-for="(row, rowIndex) in tableData" :key="'row-' + rowIndex">
  25. <template v-for="(cell, colIndex) in row">
  26. <td
  27. v-if="visibleMap[rowIndex + '-' + colIndex]"
  28. :key="cell.id"
  29. :rowspan="cell.rowspan"
  30. :colspan="cell.colspan"
  31. :style="getCellStyle(cell)"
  32. class="tableTd"
  33. :data-row="rowIndex"
  34. :data-col="colIndex"
  35. @mouseenter="onCellEnter(rowIndex, colIndex)"
  36. @contextmenu.prevent="onRightClick($event, rowIndex, colIndex)"
  37. >
  38. <!-- 表头操作图标(已注释) -->
  39. <!-- <i ... ></i> -->
  40. <div style="display: flex; align-items: center">
  41. <span>{{ cell.label }}</span>
  42. <template v-if="cell.mode === 'checkbox'">
  43. <div class="checkbox-group" style="flex: 1">
  44. <div
  45. v-for="cb in cell.checkboxes"
  46. :key="cb.id"
  47. class="checkbox-item"
  48. >
  49. <input type="checkbox" v-model="cb.checked" />
  50. <input
  51. v-if="edit"
  52. type="text"
  53. v-model="cb.label"
  54. class="checkbox-label-input"
  55. @input="
  56. onCheckboxLabelInput(rowIndex, colIndex, cb.id)
  57. "
  58. />
  59. <span v-else>{{ cb.label }}</span>
  60. <i
  61. v-if="edit"
  62. class="el-icon-delete"
  63. @click.stop="
  64. removeCheckbox(rowIndex, colIndex, cb.id)
  65. "
  66. ></i>
  67. </div>
  68. <div
  69. v-if="edit"
  70. class="add-checkbox"
  71. @click="addCheckbox(rowIndex, colIndex)"
  72. >
  73. <i class="el-icon-circle-plus-outline"></i> 新增选项
  74. </div>
  75. </div>
  76. </template>
  77. <!-- ========== 🆕 人员选择模式 ========== -->
  78. <template v-else-if="cell.mode === 'person'">
  79. <div
  80. style="
  81. width: 100%;
  82. position: relative;
  83. min-height: 30px;
  84. flex: 1;
  85. "
  86. >
  87. <!-- 人员选择组件 -->
  88. <personSelect
  89. v-model="cell.value"
  90. :disabled="readonly || cell.readonly === 2"
  91. :readonly="readonly || cell.readonly === 2"
  92. @change="onPersonChange(rowIndex, colIndex)"
  93. style="width: 95%"
  94. :init="false"
  95. :selectList="userPage"
  96. />
  97. <!-- 编辑图标(仅在编辑模式下显示) -->
  98. <i
  99. v-if="edit"
  100. class="el-icon-edit person-edit-icon"
  101. @click.stop="openPersonConfig(cell, rowIndex, colIndex)"
  102. ></i>
  103. </div>
  104. </template>
  105. <!-- ========== 🆕 部门选择模式 ========== -->
  106. <template v-else-if="cell.mode === 'dept'">
  107. <div
  108. style="
  109. width: 100%;
  110. position: relative;
  111. min-height: 30px;
  112. flex: 1;
  113. "
  114. >
  115. <deptSelect
  116. v-model="cell.value"
  117. :disabled="readonly || cell.readonly === 2"
  118. :isBindPlan="readonly || cell.readonly === 2"
  119. @change="onDeptChange(rowIndex, colIndex)"
  120. style="width: 95%"
  121. placeholder="请选择部门"
  122. :init="false"
  123. :selectList="deptList"
  124. />
  125. <i
  126. v-if="edit"
  127. class="el-icon-edit person-edit-icon"
  128. @click.stop="openPersonConfig(cell, rowIndex, colIndex)"
  129. ></i>
  130. </div>
  131. </template>
  132. <!-- ========== 文本模式 ========== -->
  133. <template v-else>
  134. <textarea
  135. v-model="cell.value"
  136. class="templateInput"
  137. :style="{
  138. textAlign: cell.textAlign || 'left',
  139. flex: 1
  140. }"
  141. :id="cell.id"
  142. :ref="cell.id + 'ref'"
  143. :readonly="cell.readonly === 2 || readonly"
  144. @mousedown="onCellMouseDown"
  145. @click="
  146. inputClick(
  147. cell,
  148. rowIndex === 0 ? 'columns' : null,
  149. $event
  150. )
  151. "
  152. @input="onInput($event)"
  153. autocomplete="off"
  154. ></textarea>
  155. </template>
  156. </div>
  157. <!-- ========== 复选框模式 ========== -->
  158. </td>
  159. </template>
  160. </tr>
  161. </tbody>
  162. </table>
  163. </div>
  164. </div>
  165. </template>
  166. <script>
  167. import { generateRandomString } from '@/utils/util';
  168. import personSelect from '@/components/CommomSelect/person-select.vue';
  169. import deptSelect from '@/components/CommomSelect/dept-select.vue'; // 🆕 导入部门选择组件
  170. import { getUserPage } from '@/api/system/organization';
  171. import { listOrganizations } from '@/api/system/organization'; // 🆕 导入部门列表接口
  172. export default {
  173. // 🆕 注册人员选择组件
  174. components: { personSelect, deptSelect },
  175. props: {
  176. id: { type: String, default: '' },
  177. edit: { type: Boolean, default: true },
  178. readonly: { type: Boolean, default: false }
  179. },
  180. data() {
  181. return {
  182. userPage: [],
  183. deptList: [],
  184. form: null,
  185. valueObj: {},
  186. tableData: [],
  187. units: {},
  188. equation: {},
  189. domId: '',
  190. currentRowIndex: 0,
  191. currentColumnIndex: 0,
  192. // 合并相关
  193. isMerge: false,
  194. isSelecting: false,
  195. startX: 0,
  196. startY: 0,
  197. endX: 0,
  198. endY: 0,
  199. startRow: -1,
  200. startCol: -1,
  201. endRow: -1,
  202. endCol: -1,
  203. // 防抖定时器
  204. calcTimer: null,
  205. // 右键菜单关闭监听
  206. menuCloseHandler: null,
  207. userMap: {},
  208. userMapLoaded: false
  209. };
  210. },
  211. computed: {
  212. visibleMap() {
  213. const map = {};
  214. const rows = this.tableData.length;
  215. if (!rows) return map;
  216. const cols = this.tableData[0]?.length || 0;
  217. for (let r = 0; r < rows; r++) {
  218. for (let c = 0; c < cols; c++) {
  219. const key = `${r}-${c}`;
  220. let hidden = false;
  221. for (let rr = r - 1; rr >= 0; rr--) {
  222. for (let cc = 0; cc < this.tableData[rr].length; cc++) {
  223. const cell = this.tableData[rr][cc];
  224. if (
  225. cell.rowspan > 1 &&
  226. rr + cell.rowspan > r &&
  227. cc <= c &&
  228. cc + cell.colspan > c
  229. ) {
  230. hidden = true;
  231. break;
  232. }
  233. }
  234. if (hidden) break;
  235. }
  236. if (!hidden) {
  237. for (let cc = c - 1; cc >= 0; cc--) {
  238. const cell = this.tableData[r][cc];
  239. if (cell.colspan > 1 && cc + cell.colspan > c) {
  240. hidden = true;
  241. break;
  242. }
  243. }
  244. }
  245. map[key] = !hidden;
  246. }
  247. }
  248. return map;
  249. },
  250. selectionStyle() {
  251. const container = this.$refs.tableContainer;
  252. if (!container) return {};
  253. const rect = container.getBoundingClientRect();
  254. return {
  255. position: 'absolute',
  256. zIndex: 100,
  257. left: `${Math.min(this.startX, this.endX) - rect.left}px`,
  258. top: `${Math.min(this.startY, this.endY) - rect.top}px`,
  259. width: `${Math.abs(this.endX - this.startX)}px`,
  260. height: `${Math.abs(this.endY - this.startY)}px`,
  261. border: '2px dashed black',
  262. pointerEvents: 'none'
  263. };
  264. }
  265. },
  266. beforeDestroy() {
  267. if (this.calcTimer) {
  268. clearTimeout(this.calcTimer);
  269. this.calcTimer = null;
  270. }
  271. if (this.menuCloseHandler) {
  272. document.removeEventListener('click', this.menuCloseHandler);
  273. this.menuCloseHandler = null;
  274. }
  275. },
  276. created() {
  277. getUserPage({ pageNum: 1, size: -1 }).then((res) => {
  278. this.userPage = res.list;
  279. });
  280. listOrganizations({ pageNum: 1, size: -1 }).then((res) => {
  281. this.deptList = res
  282. });
  283. },
  284. methods: {
  285. getTransitions(key) {
  286. return [
  287. { target: 'text', label: '转为文本模式', method: 'disableDeptMode' },
  288. {
  289. target: 'checkbox',
  290. label: '转为复选框模式',
  291. method: 'enableCheckboxMode'
  292. },
  293. {
  294. target: 'person',
  295. label: '转为人员选择模式',
  296. method: 'enablePersonMode'
  297. },
  298. {
  299. target: 'dept',
  300. label: '转为部门选择模式',
  301. method: 'enableDeptMode'
  302. }
  303. ].filter((item) => item.target !== key);
  304. },
  305. // ========== 工具 ==========
  306. getCellStyle(cell) {
  307. let width = parseFloat(cell.style?.width || cell.width || 100);
  308. if (isNaN(width)) width = 100;
  309. return {
  310. ...cell.style,
  311. width: width + 'px',
  312. 'text-align': cell.textAlign || 'left' // 默认居中
  313. };
  314. },
  315. getInput(width) {
  316. const w = parseFloat(width);
  317. const safeWidth = isNaN(w) ? 100 : w;
  318. return {
  319. id: generateRandomString(5),
  320. value: '',
  321. rowspan: 1,
  322. colspan: 1,
  323. label: '',
  324. width: safeWidth,
  325. style: { width: safeWidth },
  326. readonly: 1,
  327. mode: 'text', // 'text' | 'checkbox' | 'person'
  328. textAlign: 'left',
  329. checkboxes: []
  330. };
  331. },
  332. getIndex(id) {
  333. for (let r = 0; r < this.tableData.length; r++) {
  334. for (let c = 0; c < this.tableData[r].length; c++) {
  335. if (this.tableData[r][c].id === id)
  336. return { rowIndex: r, colIndex: c };
  337. }
  338. }
  339. return { rowIndex: -1, colIndex: -1 };
  340. },
  341. isLastVisibleCell(rowIndex, colIndex) {
  342. const row = this.tableData[rowIndex];
  343. for (let c = row.length - 1; c >= 0; c--) {
  344. if (this.visibleMap[`${rowIndex}-${c}`]) {
  345. return c === colIndex;
  346. }
  347. }
  348. return false;
  349. },
  350. /**
  351. * 检查指定 id 在当前表格中是否重复(排除某个单元格)
  352. * @param {string} id - 要检查的 id
  353. * @param {string} excludeId - 排除的单元格 id(通常是当前编辑的)
  354. * @returns {boolean} 是否重复
  355. */
  356. isIdDuplicate(id, excludeId) {
  357. // 遍历所有行和列
  358. for (let r = 0; r < this.tableData.length; r++) {
  359. for (let c = 0; c < this.tableData[r].length; c++) {
  360. const cell = this.tableData[r][c];
  361. if (cell.id === id && cell.id !== excludeId) {
  362. return true;
  363. }
  364. }
  365. }
  366. return false;
  367. },
  368. // ========== 自动调整高度 ==========
  369. autoResizeAll() {
  370. this.$nextTick(() => {
  371. this.$el.querySelectorAll('.templateInput').forEach((ta) => {
  372. ta.style.height = 'auto';
  373. ta.style.height = ta.scrollHeight + 'px';
  374. });
  375. });
  376. },
  377. // ========== 行列操作 ==========
  378. addColumn(colIndex) {
  379. if (colIndex === -1) {
  380. if (this.tableData.length === 0) {
  381. this.tableData = [[this.getInput()]];
  382. } else {
  383. this.tableData.forEach((row) => row.push(this.getInput()));
  384. }
  385. this.$nextTick(this.autoResizeAll);
  386. return;
  387. }
  388. let insertAt = colIndex + 1;
  389. this.tableData.forEach((row) => {
  390. const cell = row[colIndex];
  391. if (cell && cell.colspan > 1) {
  392. insertAt = Math.max(insertAt, colIndex + cell.colspan);
  393. }
  394. });
  395. this.tableData.forEach((row) => {
  396. let covered = false;
  397. for (let c = 0; c < insertAt && c < row.length; c++) {
  398. const cell = row[c];
  399. if (cell.colspan > 1 && c + cell.colspan > insertAt) {
  400. cell.colspan += 1;
  401. covered = true;
  402. break;
  403. }
  404. }
  405. if (!covered) {
  406. const raw =
  407. row[colIndex]?.style?.width ?? row[colIndex]?.width ?? 100;
  408. row.splice(insertAt, 0, this.getInput(raw));
  409. }
  410. });
  411. this.$nextTick(this.autoResizeAll);
  412. },
  413. removeColumn(colIndex) {
  414. for (let r = 0; r < this.tableData.length; r++) {
  415. const row = this.tableData[r];
  416. for (let rr = r - 1; rr >= 0; rr--) {
  417. for (let cc = 0; cc < this.tableData[rr].length; cc++) {
  418. const cell = this.tableData[rr][cc];
  419. if (
  420. cell.rowspan > 1 &&
  421. rr + cell.rowspan > r &&
  422. cell.colspan > 1 &&
  423. cc <= colIndex &&
  424. cc + cell.colspan > colIndex
  425. ) {
  426. cell.colspan -= 1;
  427. break;
  428. }
  429. }
  430. }
  431. for (let c = colIndex - 1; c >= 0; c--) {
  432. const leftCell = row[c];
  433. if (
  434. leftCell &&
  435. leftCell.colspan > 1 &&
  436. c + leftCell.colspan > colIndex
  437. ) {
  438. leftCell.colspan -= 1;
  439. break;
  440. }
  441. }
  442. const cell = row[colIndex];
  443. if (cell && cell.colspan > 1 && colIndex + 1 < row.length) {
  444. row[colIndex + 1].colspan = cell.colspan - 1;
  445. row[colIndex + 1].rowspan = cell.rowspan;
  446. row[colIndex + 1].style = { ...cell.style };
  447. }
  448. row.splice(colIndex, 1);
  449. }
  450. this.$nextTick(this.autoResizeAll);
  451. },
  452. addRow(rowIndex) {
  453. if (rowIndex === -1) {
  454. if (this.tableData.length === 0) {
  455. this.tableData = [[this.getInput()]];
  456. } else {
  457. this.tableData.push(this.tableData[0].map(() => this.getInput()));
  458. }
  459. this.$nextTick(this.autoResizeAll);
  460. return;
  461. }
  462. let insertAt = rowIndex + 1;
  463. for (let c = 0; c < this.tableData[rowIndex].length; c++) {
  464. const cell = this.tableData[rowIndex][c];
  465. if (cell.rowspan > 1) {
  466. insertAt = Math.max(insertAt, rowIndex + cell.rowspan);
  467. }
  468. }
  469. const newRow = [];
  470. for (let c = 0; c < this.tableData[0].length; c++) {
  471. let covered = false;
  472. for (let r = 0; r < insertAt && r < this.tableData.length; r++) {
  473. const cell = this.tableData[r][c];
  474. if (cell.rowspan > 1 && r + cell.rowspan > insertAt) {
  475. cell.rowspan += 1;
  476. covered = true;
  477. break;
  478. }
  479. }
  480. if (!covered) {
  481. const raw =
  482. this.tableData[0][c]?.style?.width ??
  483. this.tableData[0][c]?.width ??
  484. 100;
  485. newRow.push(this.getInput(raw));
  486. }
  487. }
  488. this.tableData.splice(insertAt, 0, newRow);
  489. this.$nextTick(this.autoResizeAll);
  490. },
  491. removeRow(rowIndex) {
  492. for (let c = 0; c < this.tableData[rowIndex].length; c++) {
  493. const cell = this.tableData[rowIndex][c];
  494. if (cell.rowspan > 1) {
  495. const next = this.tableData[rowIndex + 1]?.[c];
  496. if (next) {
  497. next.rowspan = cell.rowspan - 1;
  498. next.colspan = cell.colspan;
  499. next.value = cell.value;
  500. next.style = { ...cell.style };
  501. }
  502. }
  503. }
  504. this.tableData.splice(rowIndex, 1);
  505. this.$nextTick(this.autoResizeAll);
  506. },
  507. // ========== 合并 ==========
  508. startSelecting(event) {
  509. if (!this.isMerge) return;
  510. this.isSelecting = true;
  511. const rect = this.$refs.tableContainer.getBoundingClientRect();
  512. this.startX = event.clientX;
  513. this.startY = event.clientY;
  514. this.endX = this.startX;
  515. this.endY = this.startY;
  516. const td = event.target.closest('td');
  517. if (td) {
  518. this.startRow = Number(td.dataset.row);
  519. this.startCol = Number(td.dataset.col);
  520. this.endRow = this.startRow;
  521. this.endCol = this.startCol;
  522. }
  523. },
  524. updateSelection(event) {
  525. if (this.isSelecting) {
  526. this.endX = event.clientX;
  527. this.endY = event.clientY;
  528. }
  529. },
  530. stopSelecting() {
  531. if (!this.isSelecting) return;
  532. this.isSelecting = false;
  533. this.calculateSelectedItems();
  534. this.startRow = this.startCol = this.endRow = this.endCol = -1;
  535. },
  536. onCellEnter(rowIndex, colIndex) {
  537. if (this.isSelecting) {
  538. this.endRow = rowIndex;
  539. this.endCol = colIndex;
  540. }
  541. },
  542. calculateSelectedItems() {
  543. if (
  544. this.startRow < 0 ||
  545. this.startCol < 0 ||
  546. this.endRow < 0 ||
  547. this.endCol < 0
  548. )
  549. return;
  550. if (this.startRow === this.endRow && this.startCol === this.endCol)
  551. return;
  552. const minRow = Math.min(this.startRow, this.endRow);
  553. const maxRow = Math.max(this.startRow, this.endRow);
  554. const minCol = Math.min(this.startCol, this.endCol);
  555. const maxCol = Math.max(this.startCol, this.endCol);
  556. if (minRow === 0 && maxRow > 0) return;
  557. for (let r = minRow; r <= maxRow; r++) {
  558. for (let c = minCol; c <= maxCol; c++) {
  559. const cell = this.tableData[r]?.[c];
  560. if (!cell) return;
  561. if (
  562. (cell.rowspan > 1 || cell.colspan > 1) &&
  563. (r !== minRow || c !== minCol)
  564. ) {
  565. return;
  566. }
  567. }
  568. }
  569. const cell = this.tableData[minRow][minCol];
  570. this.$set(cell, 'rowspan', maxRow - minRow + 1);
  571. this.$set(cell, 'colspan', maxCol - minCol + 1);
  572. this.$nextTick(() => {
  573. requestAnimationFrame(() => {
  574. const ref = this.$refs[cell.id + 'ref'];
  575. const ta = Array.isArray(ref) ? ref[0] : ref;
  576. const td = ta?.closest?.('td');
  577. if (td) {
  578. const realWidth = td.offsetWidth;
  579. this.$set(cell, 'width', realWidth);
  580. this.$set(cell.style, 'width', realWidth);
  581. }
  582. });
  583. });
  584. this.startRow = this.startCol = this.endRow = this.endCol = -1;
  585. },
  586. // ========== 右键菜单(修复内存泄漏 + 增加“人员选择”菜单项) ==========
  587. onRightClick(event, rowIndex, colIndex) {
  588. event.preventDefault();
  589. this.currentRowIndex = rowIndex;
  590. this.currentColumnIndex = colIndex;
  591. // 移除旧菜单和监听
  592. const existingMenu = document.querySelector('.custom-context-menu');
  593. if (existingMenu) existingMenu.remove();
  594. if (this.menuCloseHandler) {
  595. document.removeEventListener('click', this.menuCloseHandler);
  596. this.menuCloseHandler = null;
  597. }
  598. // 创建菜单容器
  599. const menu = document.createElement('div');
  600. menu.className = 'custom-context-menu';
  601. menu.style.cssText = `
  602. position: fixed;
  603. left: ${event.clientX}px;
  604. top: ${event.clientY}px;
  605. background: white;
  606. border: 1px solid #ccc;
  607. box-shadow: 2px 2px 8px rgba(0,0,0,0.2);
  608. z-index: 10000;
  609. padding: 5px 0;
  610. min-width: 120px;
  611. `;
  612. // 菜单项添加函数
  613. const addItem = (text, onClick, isSeparator = false) => {
  614. if (isSeparator) {
  615. const hr = document.createElement('hr');
  616. hr.style.margin = '5px 0';
  617. menu.appendChild(hr);
  618. return;
  619. }
  620. const item = document.createElement('div');
  621. item.textContent = text;
  622. item.style.cssText =
  623. 'padding: 5px 15px; cursor: pointer; white-space: nowrap;';
  624. item.onmouseenter = () => (item.style.backgroundColor = '#f0f0f0');
  625. item.onmouseleave = () => (item.style.backgroundColor = '');
  626. item.onclick = () => {
  627. if (onClick) onClick();
  628. menu.remove();
  629. if (this.menuCloseHandler) {
  630. document.removeEventListener('click', this.menuCloseHandler);
  631. this.menuCloseHandler = null;
  632. }
  633. };
  634. menu.appendChild(item);
  635. };
  636. // 关闭菜单的全局监听
  637. this.menuCloseHandler = (e) => {
  638. if (!menu.contains(e.target)) {
  639. menu.remove();
  640. document.removeEventListener('click', this.menuCloseHandler);
  641. this.menuCloseHandler = null;
  642. }
  643. };
  644. // ---- 行操作菜单 ----
  645. if (rowIndex === 0) {
  646. addItem('插入列', () => this.addColumn(colIndex));
  647. addItem('删除列', () => this.removeColumn(colIndex));
  648. addItem('', null, true);
  649. } else {
  650. addItem('插入行', () => this.addRow(rowIndex));
  651. addItem('删除行', () => this.removeRow(rowIndex));
  652. addItem('', null, true);
  653. }
  654. // ---- 模式切换
  655. const cell = this.tableData[rowIndex][colIndex];
  656. if (cell) {
  657. const currentMode = cell.mode || 'text';
  658. this.getTransitions(currentMode).forEach(({ label, method }) => {
  659. addItem(label, () => this[method](rowIndex, colIndex));
  660. });
  661. // 复选框模式额外选项
  662. if (currentMode === 'checkbox') {
  663. addItem('添加复选框', () => this.addCheckbox(rowIndex, colIndex));
  664. }
  665. }
  666. // 渲染菜单
  667. document.body.appendChild(menu);
  668. setTimeout(() => {
  669. document.addEventListener('click', this.menuCloseHandler);
  670. }, 0);
  671. },
  672. // ========== 复选框 ==========
  673. enableCheckboxMode(rowIndex, colIndex) {
  674. const cell = this.tableData[rowIndex][colIndex];
  675. if (!cell) return;
  676. const currentText = cell.value || '';
  677. this.$set(cell, 'mode', 'checkbox');
  678. this.$set(cell, 'checkboxes', [
  679. { id: generateRandomString(6), label: currentText, checked: false }
  680. ]);
  681. cell.value = '';
  682. },
  683. disableCheckboxMode(rowIndex, colIndex) {
  684. const cell = this.tableData[rowIndex][colIndex];
  685. if (!cell) return;
  686. const text = cell.checkboxes
  687. .map((cb) => cb.label)
  688. .filter(Boolean)
  689. .join('; ');
  690. this.$set(cell, 'mode', 'text');
  691. this.$set(cell, 'value', text);
  692. this.$set(cell, 'checkboxes', []);
  693. },
  694. addCheckbox(rowIndex, colIndex) {
  695. const cell = this.tableData[rowIndex][colIndex];
  696. if (!cell || cell.mode !== 'checkbox') return;
  697. if (!cell.checkboxes) cell.checkboxes = [];
  698. cell.checkboxes.push({
  699. id: generateRandomString(6),
  700. label: '',
  701. checked: false
  702. });
  703. this.$forceUpdate();
  704. },
  705. removeCheckbox(rowIndex, colIndex, cbId) {
  706. const cell = this.tableData[rowIndex][colIndex];
  707. if (!cell || cell.mode !== 'checkbox') return;
  708. const idx = cell.checkboxes.findIndex((cb) => cb.id === cbId);
  709. if (idx !== -1) {
  710. cell.checkboxes.splice(idx, 1);
  711. if (cell.checkboxes.length === 0) {
  712. this.disableCheckboxMode(rowIndex, colIndex);
  713. } else {
  714. this.$forceUpdate();
  715. }
  716. }
  717. },
  718. onCheckboxLabelInput() {},
  719. enableDeptMode(rowIndex, colIndex) {
  720. const cell = this.tableData[rowIndex][colIndex];
  721. if (!cell) return;
  722. this.$set(cell, 'mode', 'dept');
  723. this.$forceUpdate();
  724. },
  725. disableDeptMode(rowIndex, colIndex) {
  726. const cell = this.tableData[rowIndex][colIndex];
  727. if (!cell) return;
  728. this.$set(cell, 'mode', 'text');
  729. this.$forceUpdate();
  730. },
  731. onDeptChange() {},
  732. // ========== 🆕 人员选择 ==========
  733. enablePersonMode(rowIndex, colIndex) {
  734. const cell = this.tableData[rowIndex][colIndex];
  735. if (!cell) return;
  736. // 保留当前文本作为选中值(如果 person-select 能解析,否则可置空)
  737. this.$set(cell, 'mode', 'person');
  738. // 如果希望保留当前值,可保留 cell.value
  739. this.$forceUpdate();
  740. },
  741. disablePersonMode(rowIndex, colIndex) {
  742. const cell = this.tableData[rowIndex][colIndex];
  743. if (!cell) return;
  744. // 将当前人员选择的值转换为文本(显示人员名称或ID)
  745. this.$set(cell, 'mode', 'text');
  746. // 注意:cell.value 存储的是人员ID或名称,如果希望显示标签,需要映射,这里直接显示原值
  747. this.$forceUpdate();
  748. },
  749. onPersonChange(rowIndex, colIndex) {
  750. // 触发公式计算
  751. // this.calculation();
  752. },
  753. // ========== 输入 ==========
  754. onInput(event) {
  755. const ta = event.target;
  756. ta.style.height = 'auto';
  757. ta.style.height = ta.scrollHeight + 'px';
  758. this.debouncedCalculation();
  759. },
  760. debouncedCalculation() {
  761. if (this.calcTimer) clearTimeout(this.calcTimer);
  762. this.calcTimer = setTimeout(() => {
  763. this.calculation();
  764. this.calcTimer = null;
  765. }, 300);
  766. },
  767. calculation() {
  768. this.$emit('calculation');
  769. },
  770. onCellMouseDown(event) {
  771. if (this.isMerge) event.preventDefault();
  772. },
  773. openPersonConfig(cell, rowIndex, colIndex) {
  774. if (!this.edit) return;
  775. this.domId = cell.id;
  776. // 获取单元格真实宽度
  777. let realWidth = cell.width || 100;
  778. const td = this.$el.querySelector(
  779. `td[data-row="${rowIndex}"][data-col="${colIndex}"]`
  780. );
  781. if (td) {
  782. realWidth = td.offsetWidth || realWidth;
  783. }
  784. this.$emit('editShow', {
  785. templateDivRef: 'customTextRef' + this.id,
  786. domObj: {
  787. ...cell,
  788. width: realWidth,
  789. isNoWidth: false,
  790. equation: this.equation[cell.id],
  791. units: this.units[cell.id] || {}
  792. }
  793. });
  794. },
  795. inputClick(item, type, event) {
  796. if (!this.edit) return;
  797. this.domId = item.id;
  798. const ref = this.$refs[item.id + 'ref'];
  799. const ta = Array.isArray(ref) ? ref[0] : ref;
  800. const td = ta?.closest?.('td');
  801. const realWidth = td
  802. ? td.offsetWidth
  803. : parseFloat(item.style?.width || item.width || 100);
  804. this.$emit('editShow', {
  805. templateDivRef: 'customTextRef' + this.id,
  806. domObj: {
  807. ...item,
  808. width: realWidth,
  809. isNoWidth: type === 'columns',
  810. equation: this.equation[item.id],
  811. units: this.units[item.id] || {}
  812. }
  813. });
  814. },
  815. // ========== 数据通信 ==========
  816. equationValue({ domId, value }) {
  817. const { rowIndex, colIndex } = this.getIndex(domId);
  818. if (rowIndex >= 0 && colIndex >= 0) {
  819. this.$set(this.tableData[rowIndex][colIndex], 'value', value);
  820. }
  821. },
  822. getValue() {
  823. return {
  824. form: null,
  825. equation: this.equation,
  826. units: this.units,
  827. valueObj: {
  828. tableData: this.tableData
  829. }
  830. };
  831. },
  832. init({ form, valueObj, equation, units }) {
  833. this.form = form;
  834. this.tableData = valueObj.tableData || valueObj.columns || [];
  835. this.equation = equation || {};
  836. this.units = units || {};
  837. this.autoResizeAll();
  838. },
  839. editInputChange(domObj) {
  840. const data = JSON.parse(JSON.stringify(domObj));
  841. if (data.equation) this.equation[data.id] = data.equation;
  842. if (data.units) this.units[data.id] = data.units;
  843. const w = Number(data.width);
  844. if (!isNaN(w)) {
  845. data.style = { ...(data.style || {}), width: w };
  846. data.width = w;
  847. } else if (data.style && data.style.width != null) {
  848. data.width = Number(data.style.width);
  849. }
  850. const { rowIndex, colIndex } = this.getIndex(this.domId);
  851. if (rowIndex >= 0 && colIndex >= 0) {
  852. this.$set(this.tableData[rowIndex], colIndex, data);
  853. }
  854. },
  855. copy() {
  856. const tableData = JSON.parse(JSON.stringify(this.tableData));
  857. tableData.forEach((row) =>
  858. row.forEach((cell) => (cell.id = generateRandomString(5)))
  859. );
  860. this.$emit('copy', {
  861. value: null,
  862. equation: { ...this.equation },
  863. units: { ...this.units },
  864. valueObj: { tableData }
  865. });
  866. },
  867. async fetchDeptMap() {
  868. if (this.deptMapLoaded) return;
  869. try {
  870. const res = await listOrganizations({ pageNum: 1, size: -1 });
  871. // 递归展平树形数据
  872. const flatten = (nodes) => {
  873. let result = [];
  874. nodes.forEach((node) => {
  875. result.push({ id: node.id, name: node.name });
  876. if (node.children && node.children.length) {
  877. result = result.concat(flatten(node.children));
  878. }
  879. });
  880. return result;
  881. };
  882. const flatList = flatten(res || []);
  883. const map = {};
  884. flatList.forEach((dept) => {
  885. map[dept.id] = dept.name || '未知部门';
  886. });
  887. this.deptMap = map;
  888. this.deptMapLoaded = true;
  889. } catch (error) {
  890. console.warn('获取部门列表失败,打印时将显示ID', error);
  891. }
  892. },
  893. // ========== 🆕 获取用户映射(供打印使用) ==========
  894. async fetchUserMap() {
  895. if (this.userMapLoaded) return;
  896. try {
  897. const res = await getUserPage({ pageNum: 1, size: -1 });
  898. if (res && res.list) {
  899. const map = {};
  900. res.list.forEach((user) => {
  901. map[user.id] =
  902. user.name || user.realName || user.username || '未知';
  903. });
  904. this.userMap = map;
  905. this.userMapLoaded = true;
  906. }
  907. } catch (error) {
  908. console.warn('获取人员列表失败,打印时将显示ID', error);
  909. }
  910. },
  911. // ========== 🆕 打印(异步 + 人员名称映射) ==========
  912. async printTable() {
  913. await this.fetchUserMap();
  914. await this.fetchDeptMap();
  915. const tableEl = this.$el.querySelector('.custom-table');
  916. if (!tableEl) return;
  917. const clone = tableEl.cloneNode(true);
  918. clone
  919. .querySelectorAll(
  920. '.delete, .add, .deleteRow, .addRow, .selection-box'
  921. )
  922. .forEach((el) => el.remove());
  923. // 遍历所有单元格,根据模式重新构建显示内容(保留 label)
  924. for (let r = 0; r < this.tableData.length; r++) {
  925. for (let c = 0; c < this.tableData[r].length; c++) {
  926. const cell = this.tableData[r][c];
  927. const td = clone.querySelector(
  928. `td[data-row="${r}"][data-col="${c}"]`
  929. );
  930. if (!td) continue;
  931. const label = cell.label || '';
  932. let displayHtml = '';
  933. td.style.padding = '3px';
  934. if (cell.mode === 'checkbox' && Array.isArray(cell.checkboxes)) {
  935. const items = cell.checkboxes
  936. .map((cb) => {
  937. const symbol = cb.checked ? '☑' : '☐';
  938. return `<span style="margin:0 4px;">${symbol} ${
  939. cb.label || ''
  940. }</span>`;
  941. })
  942. .join('');
  943. displayHtml = `<div style="display:flex; align-items:center; justify-content:center; gap:5px; flex-wrap:wrap;">${
  944. label ? `<span >${label}</span>` : ''
  945. }${items}</div>`;
  946. } else if (cell.mode === 'person') {
  947. const userName = this.userMap[cell.value] || cell.value || '';
  948. displayHtml = `<div style="display:flex; align-items:center; justify-content:center; gap:5px;">${
  949. label ? `<span >${label}</span>` : ''
  950. }<span style="flex:1">${userName}</span></div>`;
  951. } else if (cell.mode === 'dept') {
  952. const deptName = this.deptMap[cell.value] || cell.value || '';
  953. displayHtml = `<div style="display:flex; align-items:center; justify-content:center; gap:5px;">${
  954. label ? `<span >${label}</span>` : ''
  955. }<span style="flex:1">${deptName}</span></div>`;
  956. } else {
  957. console.log(cell.textAlign);
  958. // 文本模式
  959. displayHtml = `<div style="display:flex; align-items:center; justify-content:${
  960. cell.textAlign == 'left'
  961. ? 'flex-start'
  962. : cell.textAlign == 'right'
  963. ? 'flex-end'
  964. : 'center'
  965. };">${label ? `<span >${label}</span>` : ''}<span>${
  966. cell.value || ''
  967. }</span></div>`;
  968. }
  969. td.innerHTML = displayHtml;
  970. }
  971. }
  972. // 打印 iframe(保持不变)
  973. const iframe = document.createElement('iframe');
  974. iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;';
  975. document.body.appendChild(iframe);
  976. const doc = iframe.contentWindow.document;
  977. doc.write(`
  978. <!DOCTYPE html>
  979. <html>
  980. <head>
  981. <meta charset="utf-8">
  982. <style>
  983. body { margin: 0; padding: 0; font-family: Arial, sans-serif; }
  984. table { width: 100%; border-collapse: collapse; table-layout: auto; }
  985. td { border: 1px solid #000; vertical-align: middle; padding: 1px; font-size: 12px; padding:0px; }
  986. </style>
  987. </head>
  988. <body>${clone.outerHTML}</body>
  989. </html>
  990. `);
  991. doc.close();
  992. iframe.contentWindow.focus();
  993. iframe.contentWindow.print();
  994. setTimeout(() => document.body.removeChild(iframe), 1000);
  995. }
  996. }
  997. };
  998. </script>
  999. <style lang="scss" scoped>
  1000. .custom-table {
  1001. border-collapse: collapse;
  1002. width: auto;
  1003. td {
  1004. border: 1px solid #ddd;
  1005. padding: 0;
  1006. min-height: 20px;
  1007. position: relative;
  1008. }
  1009. }
  1010. :deep(.templateInput) {
  1011. width: 100%;
  1012. min-height: 20px;
  1013. height: auto;
  1014. border: none;
  1015. text-align: center;
  1016. background-color: #fff;
  1017. resize: none;
  1018. padding: 4px;
  1019. box-sizing: border-box;
  1020. overflow: hidden;
  1021. display: block;
  1022. &:focus {
  1023. border-color: #66afe9;
  1024. outline: none;
  1025. }
  1026. }
  1027. .deleteRow {
  1028. display: block !important;
  1029. position: absolute;
  1030. bottom: 0px;
  1031. right: -15px;
  1032. color: #f56c6c;
  1033. z-index: 10;
  1034. }
  1035. .delete,
  1036. .add,
  1037. .addRow {
  1038. display: none !important;
  1039. }
  1040. .tableTd:hover {
  1041. .delete {
  1042. display: block !important;
  1043. position: absolute;
  1044. right: 0;
  1045. top: 0;
  1046. color: #f56c6c;
  1047. z-index: 10;
  1048. }
  1049. .add {
  1050. display: block !important;
  1051. position: absolute;
  1052. right: 20px;
  1053. top: 0;
  1054. color: #409eff;
  1055. z-index: 10;
  1056. }
  1057. .addRow {
  1058. display: block !important;
  1059. position: absolute;
  1060. bottom: 0px;
  1061. right: 10px;
  1062. color: #409eff;
  1063. z-index: 10;
  1064. }
  1065. }
  1066. .checkbox-group {
  1067. display: flex;
  1068. flex-wrap: wrap;
  1069. gap: 8px;
  1070. padding: 4px;
  1071. .checkbox-item {
  1072. display: inline-flex;
  1073. align-items: center;
  1074. gap: 4px;
  1075. background: #fff;
  1076. input[type='checkbox'] {
  1077. margin: 0;
  1078. flex-shrink: 0;
  1079. }
  1080. .checkbox-label-input {
  1081. border: 1px solid #dcdfe6;
  1082. border-radius: 2px;
  1083. padding: 2px 4px;
  1084. font-size: 12px;
  1085. min-width: 4em;
  1086. width: auto;
  1087. background: #fff;
  1088. &:focus {
  1089. outline: none;
  1090. border-color: #409eff;
  1091. }
  1092. }
  1093. .el-icon-delete {
  1094. color: #f56c6c;
  1095. cursor: pointer;
  1096. font-size: 14px;
  1097. flex-shrink: 0;
  1098. &:hover {
  1099. opacity: 0.8;
  1100. }
  1101. }
  1102. }
  1103. .add-checkbox {
  1104. display: inline-flex;
  1105. align-items: center;
  1106. gap: 4px;
  1107. color: #409eff;
  1108. cursor: pointer;
  1109. font-size: 12px;
  1110. white-space: nowrap;
  1111. &:hover {
  1112. text-decoration: underline;
  1113. }
  1114. }
  1115. }
  1116. @media print {
  1117. .add-checkbox,
  1118. .checkbox-item .el-icon-delete {
  1119. display: none !important;
  1120. }
  1121. .checkbox-label-input {
  1122. border: none !important;
  1123. background: transparent !important;
  1124. padding: 0 !important;
  1125. min-width: auto !important;
  1126. width: auto !important;
  1127. }
  1128. .checkbox-item {
  1129. break-inside: avoid;
  1130. }
  1131. }
  1132. .person-edit-icon {
  1133. position: absolute;
  1134. right: 4px;
  1135. top: 50%;
  1136. transform: translateY(-50%);
  1137. color: #409eff;
  1138. font-size: 14px;
  1139. cursor: pointer;
  1140. opacity: 0.6;
  1141. transition: opacity 0.2s;
  1142. &:hover {
  1143. opacity: 1;
  1144. }
  1145. }
  1146. </style>