addOrEdit.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. <template>
  2. <div>
  3. <el-card shadow="never">
  4. <el-tabs v-model="tabValue" type="card" @tab-click="handleTabClick">
  5. <el-tab-pane v-if="query.type != 'view'" label="稿纸信息" name="1">
  6. <MainBodyTemplate ref="mainBodyTemplate" :type="routerQuery.type" menu="notice" :disabled="disabled" @sendFiles="getFiles"></MainBodyTemplate>
  7. </el-tab-pane>
  8. <el-tab-pane label="正文" name="2">
  9. <div class="editor-wrapper">
  10. <div class="editor-main">
  11. <div v-show="tabValue === '2'" style="min-height: 525px;">
  12. <tinymce-editor v-if="tinymceConfig && tabValue === '2'" :disabled="disabled" v-model="formData.content" :init="tinymceConfig" />
  13. </div>
  14. </div>
  15. <div class="seal-panel" v-if="isApprove">
  16. <div class="seal-section">
  17. <div class="panel-title">公用章</div>
  18. <div class="seal-grid">
  19. <div
  20. v-for="(img, index) in publicImages"
  21. :key="'pub-' + index"
  22. class="seal-item"
  23. draggable="true"
  24. @dragstart="handleDragStart($event, img, 'public')"
  25. >
  26. <img :src="img.imgUrl" :alt="img.name" />
  27. <span class="seal-name">{{ img.name }}</span>
  28. </div>
  29. <div v-if="!publicImages.length" class="no-seal">暂无公用章</div>
  30. </div>
  31. </div>
  32. <div class="seal-section">
  33. <div class="panel-title">个人章</div>
  34. <div class="seal-grid">
  35. <div
  36. v-for="(img, index) in privateImages"
  37. :key="'pri-' + index"
  38. class="seal-item"
  39. draggable="true"
  40. @dragstart="handleDragStart($event, img, 'private')"
  41. >
  42. <img :src="img.imgUrl" :alt="img.name" />
  43. <span class="seal-name">{{ img.name }}</span>
  44. </div>
  45. <div v-if="!privateImages.length" class="no-seal">暂无个人章</div>
  46. </div>
  47. </div>
  48. </div>
  49. </div>
  50. </el-tab-pane>
  51. <el-tab-pane label="附件查看" name="3">
  52. <div class="file-container">
  53. <el-row :gutter="20">
  54. <el-col :span="6">
  55. <div class="file-item" v-for="(item, index) in fileList" :key="item.id" @click="filesOpen(item)">
  56. <el-link type="primary">{{index + 1}}. {{item.name}}</el-link>
  57. <span v-if="!disabled" class="delete-icon" @click="deleteFile(index)">
  58. <i class="el-icon-delete"></i>
  59. </span>
  60. </div>
  61. </el-col>
  62. <el-col :span="18">
  63. <iframe class="file-iframe" style="width: 100%; height: 62vh;" v-if="fileList && fileList.length > 0" :src="currentFileUrl" frameborder="0" allowfullscreen="true"></iframe>
  64. </el-col>
  65. </el-row>
  66. </div>
  67. </el-tab-pane>
  68. <el-tab-pane v-if="formData?.processInstanceId && !isApprove && query.type != 'view'" label="流程详情" name="4">
  69. <bpmDetail
  70. v-if="formData.processInstanceId"
  71. :id="formData.processInstanceId"
  72. ></bpmDetail>
  73. </el-tab-pane>
  74. </el-tabs>
  75. <div v-if="!disabled" class="footer">
  76. <el-button type="primary" @click="save(0)" v-click-once>保存</el-button>
  77. <el-button type="primary" @click="save(1)" v-click-once>提交</el-button>
  78. <el-button @click="cancel">取消</el-button>
  79. </div>
  80. </el-card>
  81. <process-submit-dialog
  82. :processSubmitDialogFlag.sync="processSubmitDialogFlag"
  83. v-if="processSubmitDialogFlag"
  84. ref="processSubmitDialogRef"
  85. @reload="cancel"
  86. ></process-submit-dialog>
  87. </div>
  88. </template>
  89. <script>
  90. import MainBodyTemplate from '@/views/bpm/documents/documentTemplate/components/mainBodyTemplate.vue'
  91. import TinymceEditor from '@/components/TinymceEditor/index.vue';
  92. import { docTplTemplateById } from '@/api/documents/doc-manage';
  93. import { noticeDocumentCreateAPI, noticeDocumentByIdAPI, noticeDocumentUpdateAPI } from '@/api/documents/noticeIssuance/index.js';
  94. import bpmDetail from '@/views/bpm/processInstance/detailNew.vue';
  95. import { queryIds } from '@/components/addDoc/api/index'
  96. import { getCode } from '@/api/codeManagement';
  97. import { uploadFile } from '@/api/system/file/index.js';
  98. import processSubmitDialog from '@/BIZComponents/enventSubmitDialog/processSubmitDialog'
  99. import { setFileUrl } from '@/components/addDoc/util.js'
  100. import { getSealPage } from '@/api/bpm/components/sealManagement';
  101. import { getUserDetail } from '@/api/system/organization/index';
  102. export default {
  103. components: {
  104. MainBodyTemplate,
  105. TinymceEditor,
  106. bpmDetail,
  107. processSubmitDialog
  108. },
  109. props: {
  110. query: {
  111. default: () => {
  112. return {
  113. id: '',
  114. type: ''
  115. }
  116. }
  117. },
  118. isApprove: {
  119. default: false
  120. }
  121. },
  122. data() {
  123. return {
  124. drawer: false,
  125. businessId: '',
  126. processSubmitDialogFlag: false,
  127. currentFileUrl: '',
  128. tabValue: '1',
  129. formData: {
  130. code: '',
  131. name: '',
  132. status: true,
  133. content: '',
  134. fields: [],
  135. },
  136. fileList: [],
  137. publicImages: [],
  138. privateImages: [],
  139. sealsFetched: false,
  140. tinymceConfig: {
  141. height: 525,
  142. resize: false,
  143. autoresize_bottom_margin: 0,
  144. auto_focus: false,
  145. scroll_padding: 0,
  146. scroll_padding_bottom: 0,
  147. images_upload_handler: (blobInfo, success, error) => {
  148. const file = blobInfo.blob();
  149. console.log('file~~~', file);
  150. // 判断 file 是否为有效的文件对象
  151. if (!file || !(file instanceof File) || file.size === 0) {
  152. return;
  153. }
  154. // 使用 axios 上传,实际开发这段建议写在 api 中再调用 api
  155. uploadFile({
  156. module: 'main',
  157. multiPartFile: file
  158. }).then((res) => {
  159. if (res.data) {
  160. console.log('images_upload_handler~~~', res);
  161. success(res.data.url);
  162. } else {
  163. error(res.message);
  164. }
  165. }).catch(error => {
  166. this.$message.error('文件上传失败');
  167. });
  168. },
  169. setup: (editor) => {
  170. editor.on('init', () => {
  171. // 查看详情/只读模式下不注册任何拖拽事件
  172. // if (this.disabled) return;
  173. const body = editor.getBody();
  174. const win = editor.getWin();
  175. let dragState = null;
  176. let pendingImg = null;
  177. let pendingSx = 0, pendingSy = 0;
  178. const THRESHOLD = 5;
  179. /* ===== 1. 从外部签章面板拖入编辑器 ===== */
  180. body.addEventListener('dragover', (e) => {
  181. e.preventDefault();
  182. e.dataTransfer.dropEffect = 'copy';
  183. }, true);
  184. body.addEventListener('drop', (e) => {
  185. e.preventDefault();
  186. e.stopPropagation();
  187. const imageData = e.dataTransfer.getData('imageData');
  188. if (!imageData) return;
  189. try {
  190. const data = JSON.parse(imageData);
  191. const rect = body.getBoundingClientRect();
  192. const x = e.clientX - rect.left + body.scrollLeft;
  193. const y = e.clientY - rect.top + body.scrollTop;
  194. let w = 200, h = 200;
  195. if (data.sealType === 'private') { w = 210; h = 'auto'; }
  196. const imgHtml = `<img src="${data.url}" class="seal-drag-img"
  197. style="position:absolute;left:${Math.max(0, x)}px;top:${Math.max(0, y)}px;width:${w}px;height:${h}px;cursor:move;z-index:10;"
  198. data-seal-type="${data.sealType}" data-seal-name="${data.name}" />`;
  199. editor.undoManager.transact(() => {
  200. editor.execCommand('mceInsertContent', false, imgHtml);
  201. });
  202. this.$message({ showClose: true, message: '已插入签章', type: 'success' });
  203. } catch (err) {
  204. console.error('签章插入失败:', err);
  205. }
  206. }, true);
  207. /* ===== 2. 编辑器内拖动签章图片 ===== */
  208. body.addEventListener('mousedown', (e) => {
  209. const target = e.target;
  210. if (!target || !target.closest) return;
  211. const img = target.closest('img.seal-drag-img');
  212. if (!img) return;
  213. e.preventDefault();
  214. e.stopPropagation();
  215. pendingImg = img;
  216. pendingSx = e.clientX;
  217. pendingSy = e.clientY;
  218. }, true);
  219. win.addEventListener('mousemove', (e) => {
  220. if (pendingImg && !dragState) {
  221. const dx = e.clientX - pendingSx;
  222. const dy = e.clientY - pendingSy;
  223. if (Math.abs(dx) > THRESHOLD || Math.abs(dy) > THRESHOLD) {
  224. dragState = {
  225. img: pendingImg,
  226. sx: pendingSx,
  227. sy: pendingSy,
  228. sLeft: parseFloat(pendingImg.style.left) || 0,
  229. sTop: parseFloat(pendingImg.style.top) || 0
  230. };
  231. pendingImg = null;
  232. dragState.img.style.cursor = 'grabbing';
  233. body.style.userSelect = 'none';
  234. }
  235. }
  236. if (!dragState) return;
  237. e.preventDefault();
  238. dragState.img.style.left = (dragState.sLeft + e.clientX - dragState.sx) + 'px';
  239. dragState.img.style.top = (dragState.sTop + e.clientY - dragState.sy) + 'px';
  240. });
  241. win.addEventListener('mouseup', () => {
  242. if (dragState) {
  243. dragState.img.style.cursor = 'move';
  244. body.style.userSelect = '';
  245. dragState = null;
  246. }
  247. pendingImg = null;
  248. });
  249. body.addEventListener('mouseleave', () => {
  250. if (dragState) {
  251. dragState.img.style.cursor = 'move';
  252. body.style.userSelect = '';
  253. dragState = null;
  254. }
  255. pendingImg = null;
  256. });
  257. });
  258. }
  259. },
  260. }
  261. },
  262. created() {
  263. // this.routerQuery = this.isApprove ? this.query : this.$route.query;
  264. this.routerQuery = this.query;
  265. this.tabValue = this.query.type == 'view' ? '3' : '1';
  266. console.log('this.query~~~', this.query);
  267. this.$nextTick(() => {
  268. this.getDetail();
  269. })
  270. },
  271. computed: {
  272. disabled() {
  273. return this.routerQuery.type == 'detail' || this.routerQuery.type == 'view';
  274. },
  275. },
  276. methods: {
  277. // openDrawer(query) {
  278. // this.routerQuery = this.isApprove ? this.query : query;
  279. // this.drawer = true;
  280. // this.$nextTick(() => {
  281. // this.getDetail();
  282. // })
  283. // },
  284. filesOpen(item) {
  285. this.currentFileUrl = setFileUrl(item);
  286. console.log('currentFileUrl~~~', this.currentFileUrl);
  287. },
  288. async getFiles(ids) {
  289. if(ids) {
  290. let res = await queryIds({ ids: "'" + ids + "'" });
  291. this.fileList = res || [];
  292. this.filesOpen(res?.[0] || '');
  293. }
  294. },
  295. deleteFile(index) {
  296. this.fileList.splice(index, 1);
  297. this.$refs.mainBodyTemplate.setFiles(this.fileList);
  298. this.filesOpen(this.fileList.length > 0 ? this.fileList[0] : '');
  299. },
  300. async getDetail() {
  301. const requestUrl = this.routerQuery.type == 'add' ? docTplTemplateById : noticeDocumentByIdAPI;
  302. let res = await requestUrl(this.routerQuery.id);
  303. this.businessId = this.routerQuery.type == 'add' ? '' : res.id;
  304. console.log('type~~~', this.routerQuery.type);
  305. // 如果是新增,将发文编号设置到 fields 中
  306. if (this.routerQuery.type == 'add' && res.fields) {
  307. const documentNumberField = res.fields.find((item) => item.fieldKey == 'documentNumber');
  308. if (documentNumberField) {
  309. documentNumberField.defaultValue = await getCode('fm_document_number_code');
  310. console.log('documentNumberField~~~', documentNumberField, await getCode('fm_document_number_code'));
  311. }
  312. }
  313. this.formData = res;
  314. let ids =this.formData.fields.find((item) => item.fieldKey == 'attachments')?.defaultValue || '';
  315. this.getFiles(ids);
  316. console.log(this.formData, this.$refs.mainBodyTemplate);
  317. this.$nextTick(() => {
  318. this.$refs.mainBodyTemplate && this.$refs.mainBodyTemplate.setData(this.formData.fields);
  319. })
  320. },
  321. async getTemplateDetail() {
  322. let res = await docTplTemplateById(this.routerQuery.id);
  323. // res.status = res.status == 1 ? true : false;
  324. this.formData = res;
  325. let ids =this.formData.fields.find((item) => item.fieldKey == 'attachments')?.defaultValue || '';
  326. this.getFiles(ids);
  327. console.log(this.formData, this.$refs.mainBodyTemplate);
  328. this.$refs.mainBodyTemplate.setData(this.formData.fields);
  329. },
  330. handleTabClick(tab) {
  331. this.tabValue = tab.name;
  332. // 切换到「正文」Tab 时加载签章
  333. if (tab.name === '2' && !this.sealsFetched) {
  334. this.sealsFetched = true;
  335. this.fetchSeals();
  336. }
  337. },
  338. /* 获取签章列表 */
  339. async fetchSeals() {
  340. try {
  341. const userId = this.$store?.state?.user?.info?.userId;
  342. // 公用章
  343. getSealPage({
  344. size: 999,
  345. pageNum: 1,
  346. status: 1,
  347. approvalStatus: 2,
  348. sealHolderId: userId
  349. }).then((res) => {
  350. this.publicImages = res.list || [];
  351. }).catch(() => {});
  352. // 个人章(签名)
  353. if (userId) {
  354. getUserDetail(userId).then((res) => {
  355. if (res.signature?.[0]) {
  356. this.privateImages = [{
  357. imgUrl: res.signature[0].url,
  358. name: res.name
  359. }];
  360. }
  361. }).catch(() => {});
  362. }
  363. } catch (e) {
  364. console.error('获取签章失败:', e);
  365. }
  366. },
  367. /* 签章拖拽开始,写入 imageData */
  368. handleDragStart(e, img, type) {
  369. const data = {
  370. url: img.imgUrl,
  371. name: img.name,
  372. imgId: type === 'public' ? img.id : '',
  373. sealType: type
  374. };
  375. e.dataTransfer.setData('imageData', JSON.stringify(data));
  376. e.dataTransfer.effectAllowed = 'copy';
  377. },
  378. async save(type) {
  379. try {
  380. // 校验子组件 mainBodyTemplate 的必填项
  381. const mainBodyValid = await this.$refs.mainBodyTemplate.$refs.form.validate().catch(() => false);
  382. if (!mainBodyValid) {
  383. this.$message.warning('请完善稿纸信息的必填项');
  384. return;
  385. }
  386. // 获取自定义字段数据
  387. const customFields = this.$refs.mainBodyTemplate.generateCustomFields();
  388. // 将 customFields 放到 formData 字段
  389. this.formData.fields = customFields;
  390. this.formData.status = this.formData.status ? 1 : 0;
  391. if(this.routerQuery.type == 'add') {
  392. this.formData.templateId = this.formData.id;
  393. delete this.formData.id;
  394. }
  395. console.log('提交数据:', this.formData);
  396. // 提交到后端
  397. const loading = this.$loading({ lock: true });
  398. const requestUrl = this.routerQuery.type == 'add' ? noticeDocumentCreateAPI : noticeDocumentUpdateAPI;
  399. requestUrl(this.formData)
  400. .then((res) => {
  401. if(type == 1) {
  402. loading.close();
  403. this.submit(res);
  404. }else {
  405. loading.close();
  406. this.$message.success('保存成功');
  407. this.cancel();
  408. }
  409. })
  410. .catch((e) => {
  411. loading.close();
  412. console.error('保存失败:', e);
  413. });
  414. } catch (error) {
  415. console.error('保存异常:', error);
  416. }
  417. },
  418. async submit(res) {
  419. const data = await noticeDocumentByIdAPI(
  420. this.businessId || res
  421. );
  422. this.processSubmitDialogFlag = true;
  423. this.$nextTick(() => {
  424. let params = {
  425. businessId: data.id,
  426. businessKey: 'fm_notice_document_approval',
  427. formCreateUserId: data.createUserId,
  428. variables: {
  429. businessCode: data.docNo,
  430. businessName: data.title,
  431. businessType: '通知公文'
  432. }
  433. };
  434. this.$refs.processSubmitDialogRef.init(params);
  435. });
  436. },
  437. cancel() {
  438. this.tabValue = '1';
  439. this.$emit('done');
  440. },
  441. }
  442. }
  443. </script>
  444. <style scoped>
  445. .el-card {
  446. min-height: 600px;
  447. }
  448. .footer {
  449. text-align: center;
  450. margin-top: 20px;
  451. }
  452. .file-container {
  453. padding: 20px;
  454. }
  455. .file-item {
  456. font-size: 14px;
  457. margin-bottom: 10px;
  458. display: flex;
  459. align-items: center;
  460. }
  461. .delete-icon {
  462. color: red;
  463. cursor: pointer;
  464. margin-left: 20px;
  465. }
  466. /* 编辑器 + 签章面板布局 */
  467. .editor-wrapper {
  468. display: flex;
  469. gap: 16px;
  470. align-items: flex-start;
  471. }
  472. .editor-main {
  473. flex: 1;
  474. min-width: 0;
  475. }
  476. .seal-panel {
  477. flex: 0 0 200px;
  478. background: #f8fafc;
  479. border: 1px solid #e2e8f0;
  480. border-radius: 8px;
  481. padding: 12px;
  482. max-height: 525px;
  483. overflow-y: auto;
  484. }
  485. .seal-section {
  486. margin-bottom: 12px;
  487. }
  488. .seal-section:last-child {
  489. margin-bottom: 0;
  490. }
  491. .panel-title {
  492. font-weight: 600;
  493. font-size: 13px;
  494. color: #0f172a;
  495. margin-bottom: 8px;
  496. padding-bottom: 6px;
  497. border-bottom: 1px solid #e2e8f0;
  498. }
  499. .seal-grid {
  500. display: grid;
  501. grid-template-columns: repeat(2, 1fr);
  502. gap: 8px;
  503. }
  504. .seal-item {
  505. background: #fff;
  506. border: 2px solid #e2e8f0;
  507. border-radius: 8px;
  508. padding: 6px;
  509. cursor: grab;
  510. transition: all 0.2s;
  511. display: flex;
  512. flex-direction: column;
  513. align-items: center;
  514. gap: 4px;
  515. }
  516. .seal-item:hover {
  517. border-color: #3b82f6;
  518. background: #eff6ff;
  519. transform: translateY(-1px);
  520. box-shadow: 0 2px 8px rgba(59,130,246,0.15);
  521. }
  522. .seal-item:active {
  523. cursor: grabbing;
  524. }
  525. .seal-item img {
  526. width: 100%;
  527. height: 60px;
  528. object-fit: contain;
  529. pointer-events: none;
  530. border-radius: 4px;
  531. background: #f1f5f9;
  532. }
  533. .seal-name {
  534. font-size: 11px;
  535. color: #64748b;
  536. text-align: center;
  537. overflow: hidden;
  538. text-overflow: ellipsis;
  539. white-space: nowrap;
  540. width: 100%;
  541. }
  542. .no-seal {
  543. grid-column: 1 / -1;
  544. text-align: center;
  545. color: #94a3b8;
  546. font-size: 12px;
  547. padding: 12px 0;
  548. }
  549. </style>