SpreadViewer.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035
  1. <template>
  2. <div v-show="showSpread"
  3. :class="[!isFullscreen ? 'lab-designer-container' : 'lab-designer-container-fullscreen']"
  4. v-loading="loading" >
  5. <div class="header-row">
  6. <div class="title">
  7. <slot name="title"></slot>
  8. </div>
  9. <div class="unit-footer-btns">
  10. <el-dropdown trigger="click">
  11. <el-button type="primary" plain style="margin-right: 12px;">
  12. 更多操作<el-icon class="el-icon--right"><arrow-down /></el-icon>
  13. </el-button>
  14. <template #dropdown>
  15. <el-dropdown-menu>
  16. <el-dropdown-item @click="handleGenerate(true)">
  17. <div class="dropdown-item-content">
  18. <el-button type="warning" plain style="margin-right: 12px;">生成续页</el-button>
  19. </div>
  20. </el-dropdown-item>
  21. <el-dropdown-item>
  22. <div class="dropdown-item-content">
  23. <BatchUploadFile @uploadImg="addPic" :colList="colListData" />
  24. </div>
  25. </el-dropdown-item>
  26. <el-dropdown-item @click="openWingdingsDialog">
  27. <div class="dropdown-item-content">
  28. <el-button type="info" plain style="margin-right: 12px;">插入特殊符号</el-button>
  29. </div>
  30. </el-dropdown-item>
  31. <el-dropdown-item @click="handleCopyRow">
  32. <div class="dropdown-item-content">
  33. <el-button type="primary" plain style="margin-right: 12px;">复制表单</el-button>
  34. </div>
  35. </el-dropdown-item>
  36. </el-dropdown-menu>
  37. </template>
  38. </el-dropdown>
  39. <el-button type="success" @click="handleSave">保存</el-button>
  40. <el-button type="primary" @click="openPdf">预览 PDF</el-button>
  41. <el-button type="danger" @click="close">关闭</el-button>
  42. </div>
  43. </div>
  44. <div v-loading="loading" ref="previewContainer" class="spread-container"></div>
  45. </div>
  46. <div v-show="showPdf" class="pdf-viewer-container" v-loading="pdfLoading" ref="pdfViewer">
  47. <div class="pdf-viewer-content">
  48. <VuePdfEmbed
  49. :key="pdfTimestamp"
  50. :height="pdfViewerHeight"
  51. :width="pdfContentWidth"
  52. :source="recordSource"
  53. text-layer
  54. :annotation-layer="false"
  55. @rendered="handlePdfRendered"
  56. />
  57. </div>
  58. </div>
  59. <!-- Wingdings 符号选择弹窗 -->
  60. <el-dialog
  61. v-model="wingdingsDialogVisible"
  62. title="选择特殊符号"
  63. width="600px"
  64. :close-on-click-modal="false"
  65. >
  66. <div class="wingdings-container">
  67. <div class="wingdings-grid">
  68. <div
  69. v-for="(item, index) in getWingdingsList"
  70. :key="index"
  71. class="wingdings-item"
  72. @click="selectWingdings(item)"
  73. >
  74. <span class="wingdings-symbol">{{ item.value }}</span>
  75. <span class="wingdings-label">{{ item.label }}</span>
  76. </div>
  77. </div>
  78. </div>
  79. <template #footer>
  80. <el-button @click="wingdingsDialogVisible = false">取消</el-button>
  81. </template>
  82. </el-dialog>
  83. </template>
  84. <script lang="ts" setup>
  85. import '@grapecity-software/spread-sheets-designer-resources-cn'
  86. import * as GC from '@grapecity-software/spread-sheets'
  87. // 导入PDF导出模块
  88. import '@grapecity-software/spread-sheets-pdf'
  89. import VuePdfEmbed from 'vue-pdf-embed'
  90. import 'vue-pdf-embed/dist/styles/annotationLayer.css'
  91. import 'vue-pdf-embed/dist/styles/textLayer.css'
  92. import * as ExcelIO from "@grapecity-software/spread-excelio"
  93. import SpreadDesigner from '@/components/SpreadDesigner/index.vue'
  94. import { Printer, Download, Refresh, DocumentAdd, Picture, ArrowDown } from '@element-plus/icons-vue'
  95. import { useTagsViewStore } from '@/store/modules/tagsView'
  96. import {
  97. getPDF
  98. } from '@/api/laboratory/standard/template';
  99. import {
  100. getPDFByInspectionLocal,
  101. getStandardTemplateInfo,
  102. } from '@/api/pressure2/standard/template'
  103. import {ref, onMounted,onUnmounted} from "vue";
  104. import {InitParams} from './SpreadInterface';
  105. import { buildFileUrl } from '@/utils'
  106. import axios from 'axios'
  107. import { DynamicTbApi } from '@/api/pressure2/dynamictb'
  108. import {DynamicTbColApi} from "@/api/pressure2/dynamictbcol";
  109. import { ExcelApi } from '@/api/pressure2/excel'
  110. import { DynamicTbValApi } from '@/api/pressure2/dynamictbval'
  111. import BatchUploadFile from "./BatchUploadFile.vue";
  112. import {editReport, handleCopy, collectSheetDataSource, collectShapesIntoDataSource, serializeDataSourceForStorage} from "@/utils/reportUtil";
  113. import Designer from '@grapecity-software/spread-sheets-designer-vue'
  114. import { ElLoading } from "element-plus"
  115. import {getPDF2, getPDFByInspection} from "@/api/pressure2/standard/template";
  116. import { useDictStore } from '@/store/modules/dict'
  117. defineOptions({ name: 'SpreadViewer' });
  118. const props = defineProps({
  119. initData: {
  120. type: Object as PropType<InitParams>,
  121. default: ()=>({}),
  122. required: true
  123. },
  124. isFullscreen:{
  125. type: Boolean,
  126. default: false,
  127. required: false
  128. }
  129. });
  130. const insId=ref<string>(null);
  131. const colListData=ref([]);
  132. const addPic = (picData) => {
  133. if (previewSpread) {
  134. if (props.initData.refName == '红外热成像检测') {
  135. picData.name.sheet = previewSpread.getActiveSheet().name()
  136. previewSpread.getActiveSheet().shapes.addPictureShape(JSON.stringify(picData.name), picData.base, picData.name.x, picData.name.y, 240, 320);
  137. return
  138. }
  139. picData.name.sheet = previewSpread.getActiveSheet().name()
  140. previewSpread.getActiveSheet().shapes.addPictureShape(JSON.stringify(picData.name), picData.base, picData.name.x, picData.name.y, picData.name.width, picData.name.height);
  141. }
  142. }
  143. /*
  144. watch(()=>[props.initData.templateId,props.initData.refId],([newTide,newRid],[oldTid,oldRid])=>{
  145. if(newTide && newRid){
  146. initPreview();
  147. }
  148. }); */
  149. //const route = useRoute()
  150. // spread相关
  151. const tagsViewStore = useTagsViewStore()
  152. const dictStore = useDictStore()
  153. const loading = ref(true)
  154. const showSpread=ref(true)
  155. const previewContainer = ref(null)
  156. let sheetData = {};
  157. const designerKey = import.meta.env.VITE_SPREADJS_DESIGNER_KEY
  158. const nodeEnv = import.meta.env.VITE_NODE_ENV
  159. const licenseKey = import.meta.env.VITE_SPREADJS_LICENSE_KEY
  160. GC.Spread.Sheets.Designer.LicenseKey = designerKey
  161. //同时对SpreadJS与ExcelIO进行授权
  162. GC.Spread.Sheets.LicenseKey = licenseKey
  163. // pdf相关
  164. let previewSpread = null
  165. const pdfLoading = ref(false);
  166. const showPdf=ref(false);
  167. const recordSource = ref('')
  168. const pdfViewer = ref()
  169. const pdfContentWidth = ref(800)
  170. const pdfViewerHeight = ref(600)
  171. const pdfTimestamp = ref('')
  172. // Wingdings 弹窗相关
  173. const wingdingsDialogVisible = ref(false)
  174. const currentCell = ref(null)
  175. const getWingdingsList = computed(
  176. () => dictStore.getDictMap['Wingdings']
  177. )
  178. const handlePdfRendered = () => {
  179. pdfLoading.value = false
  180. }
  181. // 获取模版详情
  182. const fetchTemplateData = async () => {
  183. console.log(this)
  184. if(!props.initData.templateId){
  185. return;
  186. }
  187. const templateRes = await getStandardTemplateInfo({ id: props.initData.templateId })
  188. if (templateRes && templateRes.fileUrl) {
  189. const fileUrl = buildFileUrl(templateRes.fileUrl)
  190. const response = await axios.get(fileUrl, { responseType: 'blob' });
  191. return new Blob([response.data], { type: response.headers['content-type'] });
  192. }
  193. return;
  194. }
  195. const initPreview = async () => {
  196. const { refId, opType, templateId } = props.initData;
  197. if (!refId) {
  198. return false;
  199. }
  200. loading.value = true;
  201. if (opType === 0) {
  202. showSpread.value = true;
  203. pdfLoading.value = false;
  204. }
  205. previewSpread?.destroy();
  206. previewSpread = new GC.Spread.Sheets.Workbook(previewContainer.value);
  207. const handleOpenError = (error: any) => {
  208. console.error('文件打开失败:', error);
  209. loading.value = false;
  210. };
  211. const handleCopyIfNeeded = () => {
  212. if (opType === 0) {
  213. handleCopy(previewSpread);
  214. }
  215. };
  216. const initCommonData = async () => {
  217. const res = await DynamicTbValApi.getDynamicTbInsAndValByRefId(props.initData);
  218. insId.value = res.dynamicTbInsRespVO.id;
  219. dynamicTbColRespVOList = res.dynamicTbColRespVOList;
  220. colListData.value = res.dynamicTbColRespVOList.filter(i => i.colValType === 4);
  221. return res;
  222. };
  223. if (nodeEnv === 'dev' || nodeEnv === 'hst' || nodeEnv === 'uat') {
  224. try {
  225. // 1. 获取实例和字段数据(数据绑定留给前端处理,后端只做图片/高亮/续页处理)
  226. const res = await initCommonData();
  227. // 2. 调用后端接口获取处理好的 SJS
  228. const apiMethod = nodeEnv === 'dev' ? ExcelApi.process : ExcelApi.excel;
  229. const sjsBlob: any = await apiMethod({ templateId, refId });
  230. // 3. 打开 SJS 后在回调中绑定数据源
  231. previewSpread.open(sjsBlob, () => {
  232. console.log('后端处理完成,预览加载完成');
  233. const sheets = previewSpread.sheets;
  234. // 构建数据源并绑定(后端不 setDataSource,交由前端处理)
  235. // 后端已将 .jpg/.png 路径处理为背景图片,此处设 null 避免覆盖
  236. if (res.dynamicTbValRespVOList && res.dynamicTbValRespVOList.length > 0) {
  237. sheetData = {};
  238. res.dynamicTbValRespVOList.forEach(i => {
  239. const val = i.valValue;
  240. if (typeof val === 'string') {
  241. const trimmed = val.trim();
  242. // 后端已将图片路径值处理为背景图片,跳过避免文字覆盖
  243. if (trimmed.endsWith('.jpg') || trimmed.endsWith('.png')) {
  244. // sheetData[i.colCode] = null;
  245. } else if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
  246. if(i.colCode === 'Illustration'){
  247. sheetData[i.colCode] = null
  248. }else{
  249. try { sheetData[i.colCode] = JSON.parse(val); } catch { sheetData[i.colCode] = val; }
  250. }
  251. } else if (val == 'false' || val == 'true') {
  252. sheetData[i.colCode] = val == 'true' ? 'true' : null;
  253. } else {
  254. sheetData[i.colCode] = val;
  255. }
  256. } else {
  257. sheetData[i.colCode] = val;
  258. }
  259. });
  260. }
  261. let dataSource1 = new GC.Spread.Sheets.Bindings.CellBindingSource(sheetData);
  262. if (props.initData.dataSource) {
  263. dataSource1 = new GC.Spread.Sheets.Bindings.CellBindingSource(props.initData.dataSource);
  264. }
  265. sheets.forEach(sheet => sheet.setDataSource(dataSource1));
  266. // 表格行样式同步(数据绑定后前端处理)
  267. handleTableRowsAfterDataBind(sheets);
  268. // 续页处理
  269. hiddenPage().then(() => {
  270. if (opType === 1) {
  271. showSpread.value = false;
  272. pdfLoading.value = true;
  273. openPdf();
  274. } else {
  275. showPdf.value = false;
  276. loading.value = false;
  277. }
  278. });
  279. }, handleOpenError);
  280. handleCopyIfNeeded();
  281. } catch (error) {
  282. console.error('后端处理 Excel 失败:', error);
  283. loading.value = false;
  284. }
  285. return;
  286. }
  287. try {
  288. const blob = await fetchTemplateData();
  289. if (!blob) return;
  290. previewSpread.open(blob, async () => {
  291. console.log('预览加载完成');
  292. const sheets = previewSpread.sheets;
  293. const res = await DynamicTbValApi.getDynamicTbInsAndValByRefId(props.initData);
  294. insId.value = res.dynamicTbInsRespVO.id;
  295. if (res.dynamicTbValRespVOList && res.dynamicTbValRespVOList.length > 0) {
  296. sheetData = {};
  297. res.dynamicTbValRespVOList.forEach(i => {
  298. const val = i.valValue;
  299. if (typeof val === 'string') {
  300. const trimmed = val.trim();
  301. if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
  302. try {
  303. sheetData[i.colCode] = JSON.parse(val);
  304. } catch {
  305. sheetData[i.colCode] = val;
  306. }
  307. } else if (val == 'false' || val == 'true') {
  308. sheetData[i.colCode] = val == 'true' ? 'true' : null;
  309. } else {
  310. sheetData[i.colCode] = val;
  311. }
  312. } else {
  313. sheetData[i.colCode] = val;
  314. }
  315. });
  316. }
  317. let dataSource1 = new GC.Spread.Sheets.Bindings.CellBindingSource(sheetData);
  318. if (props.initData.dataSource) {
  319. dataSource1 = new GC.Spread.Sheets.Bindings.CellBindingSource(props.initData.dataSource);
  320. }
  321. sheets.forEach(sheet => sheet.setDataSource(dataSource1));
  322. dynamicTbColRespVOList = res.dynamicTbColRespVOList;
  323. const editCols = res.dynamicTbColRespVOList.filter(i => i.isEdit).map(i => i.colCode);
  324. const imgCols = res.dynamicTbColRespVOList.filter(i => i.colValType === 4).map(i => i.colCode);
  325. colListData.value = res.dynamicTbColRespVOList.filter(i => i.colValType === 4);
  326. for (const sheet of sheets) {
  327. await editReport(sheet, editCols, imgCols, opType);
  328. }
  329. handleTableRowsAfterDataBind(sheets);
  330. await hiddenPage();
  331. if (opType === 1) {
  332. showSpread.value = false;
  333. pdfLoading.value = true;
  334. await openPdf();
  335. } else {
  336. showPdf.value = false;
  337. loading.value = false;
  338. }
  339. }, handleOpenError);
  340. handleCopyIfNeeded();
  341. } catch (error) {
  342. console.error('预览加载失败:', error);
  343. }
  344. }
  345. let dynamicTbColRespVOList;
  346. const openPdf = async () => {
  347. const formData = new FormData()
  348. const rules = []
  349. previewSpread.sheets.forEach((sheet) => {
  350. sheet.conditionalFormats.getRules().forEach(rule => {
  351. if (rule.condition() && rule.condition().formula() == 'TRUE') {
  352. console.log(rule)
  353. rules.push({
  354. sheet,
  355. rule
  356. })
  357. }
  358. })
  359. rules.forEach(rule => {
  360. sheet.conditionalFormats.removeRule(rule.rule)
  361. })
  362. });
  363. await previewSpread.save(async function (blob) {
  364. formData.append('file', blob)
  365. let response = null
  366. //uat环境葡萄城pdf接口
  367. if (nodeEnv == 'uat') {
  368. // 手动拼接pdf的url,适配检验方案
  369. if (props.initData.manualUrl){
  370. formData.append('manualUrl', props.initData.manualUrl)
  371. response = await getPDFByInspection(formData)
  372. }else{
  373. response = await getPDF2(formData)
  374. }
  375. } else {
  376. //本地测试环境葡萄城pdf接口
  377. if (props.initData.manualUrl){
  378. // 手动拼接pdf的url,适配检验方案
  379. formData.append('manualUrl', props.initData.manualUrl)
  380. response = await getPDFByInspectionLocal(formData)
  381. }else{
  382. response = await getPDF2(formData)
  383. }
  384. }
  385. if (response) {
  386. const flow = new Blob([response], { type: 'application/pdf' })
  387. recordSource.value = window.URL.createObjectURL(flow);
  388. if(props.initData.opType==0){
  389. loading.value = false;
  390. window.open(recordSource.value, '_blank');
  391. }
  392. if(props.initData.opType==1){
  393. //showSpread.value=false;
  394. pdfTimestamp.value = Date.now();
  395. showPdf.value=true;
  396. pdfLoading.value=true;
  397. setTimeout(() => {
  398. calculatePdfSize()
  399. },100)
  400. }
  401. }
  402. }, (e) => {
  403. }, {
  404. includeBindingSource: true
  405. })
  406. previewSpread.sheets.forEach((sheet) => {
  407. rules.forEach(rule => {
  408. if (rule.sheet === sheet){
  409. sheet.conditionalFormats.addRule(rule.rule)
  410. }
  411. })
  412. });
  413. }
  414. const handleSpreadPrint = () => {
  415. if (!previewSpread) return
  416. previewSpread.print()
  417. }
  418. /**
  419. * 打开 Wingdings 符号选择弹窗
  420. */
  421. const openWingdingsDialog = () => {
  422. if (!previewSpread) {
  423. ElMessage.warning('请先加载文档')
  424. return
  425. }
  426. // 获取当前选中的单元格
  427. const activeSheet = previewSpread.getActiveSheet()
  428. const selections = activeSheet.getSelections()
  429. if (!selections || selections.length === 0) {
  430. ElMessage.warning('请先选择一个单元格')
  431. return
  432. }
  433. // 保存当前单元格信息
  434. const selection = selections[0]
  435. currentCell.value = {
  436. sheet: activeSheet,
  437. row: selection.row,
  438. col: selection.col
  439. }
  440. wingdingsDialogVisible.value = true
  441. }
  442. /**
  443. * 选择 Wingdings 符号并插入到单元格
  444. */
  445. const selectWingdings = (item) => {
  446. if (!currentCell.value) {
  447. ElMessage.error('未找到目标单元格')
  448. return
  449. }
  450. const { sheet, row, col } = currentCell.value
  451. const cell = sheet.getCell(row, col)
  452. // if (!cell.allowEditInCell()){
  453. // wingdingsDialogVisible.value = false
  454. // return;
  455. // }
  456. // 获取当前单元格的值
  457. const currentValue = cell.value() || ''
  458. // 追加符号到单元格
  459. const newValue = currentValue + item.value
  460. cell.value(newValue)
  461. ElMessage.success(`已插入符号: ${item.label}`)
  462. wingdingsDialogVisible.value = false
  463. }
  464. const handleSave = () => {
  465. loading.value = true;
  466. let dataSource = {};
  467. previewSpread.sheets.forEach((sheet) => {
  468. if (sheet.name() && sheet.name() !== '试用版警告') {
  469. // 收集 sheet 的数据源(兼容 table 绑定字段)
  470. const sheetData = collectSheetDataSource(sheet);
  471. console.log(sheetData)
  472. for (const key in sheetData) {
  473. if (dataSource[key] && sheetData[key] == sheetData[key]) {
  474. } else {
  475. dataSource[key] = sheetData[key];
  476. }
  477. }
  478. // 收集形状(浮动图片)数据
  479. collectShapesIntoDataSource(sheet, dataSource);
  480. }
  481. });
  482. if (Object.keys(dataSource).length > 0) {
  483. // 序列化为后端存储格式(对象/数组 → JSON 字符串)
  484. const serializedData = serializeDataSourceForStorage(dataSource);
  485. console.log(serializedData);
  486. DynamicTbValApi.saveAllColValue(insId.value, serializedData).then(res => {
  487. if (res) {
  488. ElMessage.success('保存成功')
  489. emit('saveSuccess', {insId:insId.value,dataSource:serializedData,refId:props.initData.refId});
  490. } else{
  491. ElMessage.error('保存失败')
  492. emit('saveFail');
  493. }
  494. }).catch(e => {
  495. ElMessage.error('保存失败')
  496. emit('saveFail');
  497. }).finally(() => {
  498. loading.value = false;
  499. });
  500. } else {
  501. loading.value = false;
  502. }
  503. }
  504. const handleCopyRow = () => {
  505. if (!previewSpread) return ElMessage.warning('请先加载文档')
  506. const activeSheet = previewSpread.getActiveSheet()
  507. const selections = activeSheet.getSelections()
  508. if (!selections || selections.length === 0) return ElMessage.warning('请先选择一个单元格')
  509. const { row, col } = selections[0]
  510. // 找到包含当前单元格的 table
  511. const tables = activeSheet.tables?.all() || []
  512. let targetTable = null
  513. for (const table of tables) {
  514. const range = table.range()
  515. if (row >= range.row && row < range.row + range.rowCount && col >= range.col && col < range.col + range.colCount) {
  516. targetTable = table
  517. break
  518. }
  519. }
  520. if (!targetTable) return ElMessage.warning('当前单元格不在表格内,请选中表格内的单元格')
  521. const range = targetTable.range()
  522. if (row < range.row + 1) return ElMessage.warning('请选中表格内的数据行')
  523. // 绑定源中追加空行,让 table 自动扩展一行
  524. const dataSource = activeSheet.getDataSource()
  525. const sourceData = dataSource?.getSource()
  526. const bindingPath = targetTable.bindingPath()
  527. if (!bindingPath || !sourceData?.[bindingPath] || !Array.isArray(sourceData[bindingPath])) {
  528. return ElMessage.warning('表格未绑定数据源')
  529. }
  530. // 在刷新数据源前,先收集第一行的合并规则(刷新时会清空)
  531. const { col: tableCol, colCount: tableColCount } = range
  532. const firstRowSpans = collectFirstRowSpans(activeSheet, range.row + 1, tableCol, tableColCount)
  533. // 记录新行位置(当前 rowCount 就是新增行的索引偏移)
  534. const newRowIndex = range.row + range.rowCount
  535. // 在绑定数组中追加空对象
  536. sourceData[bindingPath].push({})
  537. activeSheet.setDataSource(null)
  538. activeSheet.setDataSource(dataSource)
  539. // 复制样式/行高/合并到新行
  540. previewSpread.suspendPaint()
  541. try {
  542. const copyOps = GC.Spread.Sheets.CopyToOptions.style | GC.Spread.Sheets.CopyToOptions.formula
  543. activeSheet.copyTo(row, tableCol, newRowIndex, tableCol, 1, tableColCount, copyOps)
  544. activeSheet.setRowHeight(newRowIndex, activeSheet.getRowHeight(row))
  545. if (firstRowSpans.length > 0) {
  546. applyMergeRules(activeSheet, newRowIndex, firstRowSpans)
  547. }
  548. } finally {
  549. previewSpread.resumePaint()
  550. }
  551. ElMessage.success('已复制行')
  552. }
  553. const close = () => {
  554. emit('close')
  555. }
  556. let generateData
  557. let sheetNum = ref(2)
  558. /**
  559. * 生成续页
  560. * @param {boolean} isSetTimeout 是否设置定时器
  561. */
  562. const handleGenerate = async (isSetTimeout) => {
  563. if (!generateData || !generateData.sheetName){
  564. ElMessage.error('请配置模板续页名称')
  565. return
  566. }
  567. /**
  568. * 生成续页
  569. * @param isAdd 是否继续生成
  570. */
  571. const generate = async (isAdd) => {
  572. const sheet = previewSpread.getSheetFromName(generateData.sheetName)
  573. if (sheet.visible()) {
  574. previewSpread.commandManager().execute({
  575. cmd: "copySheet",
  576. sheetName: generateData.sheetName,
  577. targetIndex: 999,
  578. newName: generateData.sheetName + " (" + sheetNum.value + ")",
  579. includeBindingSource: true
  580. });
  581. } else {
  582. sheet.visible(true)
  583. return
  584. }
  585. // 范围内的字段叠加
  586. let col = parseInt(generateData.copyRange.topLeft.split(',')[0])
  587. let row = parseInt(generateData.copyRange.topLeft.split(',')[1])
  588. let colCount = parseInt(generateData.copyRange.topRight.split(',')[0]) - col
  589. let rowCount = parseInt(generateData.copyRange.bottomLeft.split(',')[1]) - row
  590. const sheet2 = previewSpread.getSheetFromName(generateData.sheetName + " (" + sheetNum.value + ")")
  591. let colPathList = []
  592. let dynamicTbColRespVOListCode = dynamicTbColRespVOList.map(i => i.colCode)
  593. for (let i = 0; i <= colCount; i++) {
  594. for (let j = 0; j <= rowCount; j++) {
  595. const bindingPath = sheet.getBindingPath(row + j, col + i)
  596. if (bindingPath) {
  597. let newPath = bindingPath.split('_')[0] + "_" + (parseInt(bindingPath.split('_')[1]) + (rowCount + 1) * (sheetNum.value - 1))
  598. sheet2.setBindingPath(row + j, col + i, newPath)
  599. if (!dynamicTbColRespVOListCode.includes(newPath)){
  600. colPathList.push(newPath)
  601. }
  602. }
  603. }
  604. }
  605. sheetNum.value = sheetNum.value + 1
  606. if (colPathList.length != 0){
  607. await DynamicTbColApi.createBatchByCodes(colPathList,props.initData.templateId)
  608. }
  609. if (isAdd){
  610. let col = parseInt(generateData.copyRange.topLeft.split(',')[0])
  611. let row = parseInt(generateData.copyRange.topLeft.split(',')[1])
  612. let colCount = parseInt(generateData.copyRange.topRight.split(',')[0]) - col
  613. let rowCount = parseInt(generateData.copyRange.bottomLeft.split(',')[1]) - row
  614. if (!sheet2) {
  615. return;
  616. }
  617. if (generateData.hidden) {
  618. // 判断第一行,如果全部为空,则隐藏
  619. let isEmpty = true;
  620. for (let i = 0; i < colCount; i++) {
  621. const range = sheet2.getCell(row, col + i)
  622. if (range.value()) {
  623. isEmpty = false;
  624. break;
  625. }
  626. }
  627. if (isEmpty) {
  628. sheet2.visible(false)
  629. }
  630. }
  631. // 判断最后一行,如果不为空,自动续页
  632. let isNotEmpty = false;
  633. for (let i = 0; i < colCount; i++) {
  634. const range = sheet2.getCell(row + rowCount, col + i)
  635. if (range.value()) {
  636. isNotEmpty = true;
  637. break;
  638. }
  639. }
  640. if (isNotEmpty) {
  641. await generate(true)
  642. }
  643. }
  644. }
  645. if (isSetTimeout) {
  646. const loading = ElLoading.service({text: '正在生成中'})
  647. setTimeout(async () => {
  648. try {
  649. await generate(false)
  650. } finally {
  651. loading.close()
  652. }
  653. }, 20)
  654. } else {
  655. const loading = ElLoading.service({text: '生成续页中,数据量较大'})
  656. await generate(true)
  657. loading.close()
  658. }
  659. }
  660. /**
  661. * 隐藏续页
  662. */
  663. const hiddenPage = async () => {
  664. sheetNum.value = 2
  665. const data = await DynamicTbApi.getDynamicTb(props.initData.templateId)
  666. if (data && data.copyConfig) {
  667. generateData = data.copyConfig
  668. if (!generateData.isContinuePage){
  669. return
  670. }
  671. } else {
  672. return
  673. }
  674. // 计算坐标
  675. let col = parseInt(generateData.copyRange.topLeft.split(',')[0])
  676. let row = parseInt(generateData.copyRange.topLeft.split(',')[1])
  677. let colCount = parseInt(generateData.copyRange.topRight.split(',')[0]) - col
  678. let rowCount = parseInt(generateData.copyRange.bottomLeft.split(',')[1]) - row
  679. const sheet = previewSpread.getSheetFromName(generateData.sheetName)
  680. if (!sheet) {
  681. return;
  682. }
  683. if (generateData.hidden) {
  684. // 判断第一行,如果全部为空,则隐藏
  685. let isEmpty = true;
  686. for (let i = 0; i < colCount; i++) {
  687. const range = sheet.getCell(row, col + i)
  688. if (range.value()) {
  689. isEmpty = false;
  690. break;
  691. }
  692. }
  693. if (isEmpty) {
  694. sheet.visible(false)
  695. }
  696. }
  697. // 判断最后一行,如果不为空,自动续页
  698. let isNotEmpty = false;
  699. for (let i = 0; i < colCount; i++) {
  700. const range = sheet.getCell(row + rowCount, col + i)
  701. if (range.value()) {
  702. isNotEmpty = true;
  703. break;
  704. }
  705. }
  706. if (isNotEmpty) {
  707. handleGenerate(false)
  708. }
  709. }
  710. /**
  711. * 获取数据
  712. */
  713. const getData = () => {
  714. let dataSource = {};
  715. previewSpread.sheets.forEach((sheet) => {
  716. const sheetData = collectSheetDataSource(sheet);
  717. for (const key in sheetData) {
  718. if (dataSource[key] && sheetData[key] == sheetData[key]) {
  719. } else {
  720. dataSource[key] = sheetData[key];
  721. }
  722. }
  723. collectShapesIntoDataSource(sheet, dataSource);
  724. });
  725. return dataSource;
  726. }
  727. /**
  728. * 数据绑定后同步表格样式——将第一行(模板行)的合并规则、样式、行高复制到所有自动扩展的数据行
  729. * 参考 SpreadDesigner 的 handleSheetTableCopyToOptimized
  730. */
  731. const copyOptions = GC.Spread.Sheets.CopyToOptions.style | GC.Spread.Sheets.CopyToOptions.formula
  732. const handleTableRowsAfterDataBind = (sheets) => {
  733. previewSpread.suspendPaint()
  734. try {
  735. for (const sheet of sheets) {
  736. const tables = sheet.tables?.all() || []
  737. for (const table of tables) {
  738. const range = table.range()
  739. const { row, rowCount, col, colCount } = range
  740. if (rowCount <= 1) continue
  741. // 批量设置自动换行
  742. sheet.getRange(row, col, rowCount, colCount).wordWrap(true)
  743. // 表格内所有单元格设为可编辑
  744. sheet.getRange(row, col, rowCount, colCount).allowEditInCell(true)
  745. // 收集第一行的合并规则
  746. const firstRowSpans = collectFirstRowSpans(sheet, row, col, colCount)
  747. // 复制样式+行高 并 应用合并
  748. const firstRowHeight = sheet.getRowHeight(row)
  749. for (let r = 1; r < rowCount; r++) {
  750. const currentRow = row + r
  751. // 复制第一行样式和公式到当前数据行
  752. sheet.copyTo(row, col, currentRow, col, 1, colCount, copyOptions)
  753. // 复制行高
  754. sheet.setRowHeight(currentRow, firstRowHeight)
  755. // 应用合并规则
  756. if (firstRowSpans.length > 0) {
  757. applyMergeRules(sheet, currentRow, firstRowSpans)
  758. }
  759. }
  760. }
  761. }
  762. } finally {
  763. previewSpread.resumePaint()
  764. }
  765. }
  766. /** 收集第一行(模板行)的合并单元格规则 */
  767. const collectFirstRowSpans = (sheet, row, col, colCount) => {
  768. const firstRowSpans = []
  769. for (let c = col; c < col + colCount; c++) {
  770. const span = sheet.getSpan(row, c)
  771. if (span && span.row === row) {
  772. firstRowSpans.push({ startCol: span.col, colCount: span.colCount })
  773. c += span.colCount - 1
  774. }
  775. }
  776. // 如果没有预置合并单元格,根据第一行内容自动识别(相邻同值列合并)
  777. if (firstRowSpans.length === 0) {
  778. const firstRowValues = []
  779. for (let c = col; c < col + colCount; c++) {
  780. firstRowValues.push(sheet.getValue(row, c))
  781. }
  782. let startMergeCol = 0
  783. for (let c = 1; c <= colCount; c++) {
  784. if (c === colCount || firstRowValues[c] !== firstRowValues[c - 1]) {
  785. if (c - startMergeCol > 1) {
  786. const startCol = col + startMergeCol
  787. const mergeColCount = c - startMergeCol
  788. firstRowSpans.push({ startCol, colCount: mergeColCount })
  789. sheet.addSpan(row, startCol, 1, mergeColCount)
  790. }
  791. startMergeCol = c
  792. }
  793. }
  794. }
  795. return firstRowSpans
  796. }
  797. /** 将合并规则应用到指定行(直接按模板列的 span 结构合并,不做内容相等校验) */
  798. const applyMergeRules = (sheet, currentRow, firstRowSpans) => {
  799. firstRowSpans.forEach((mergeInfo) => {
  800. try {
  801. sheet.addSpan(currentRow, mergeInfo.startCol, 1, mergeInfo.colCount)
  802. } catch (error) {
  803. // 忽略合并失败
  804. }
  805. })
  806. }
  807. defineExpose({reloadView:initPreview, handleSave, getWorkbook: () => previewSpread,getData});
  808. const emit = defineEmits(['saveSuccess','docReady','docCreate','saveFail','close'])
  809. // 动态计算PDF查看器尺寸
  810. const calculatePdfSize = () => {
  811. pdfContentWidth.value = pdfViewer.value.clientWidth - 45
  812. pdfViewerHeight.value = pdfViewer.value.clientHeight
  813. console.log('props.pdfUrl', pdfContentWidth.value, pdfViewerHeight.value)
  814. }
  815. onMounted(() => {
  816. window.addEventListener('resize', calculatePdfSize)
  817. console.log("SpreadViewer,onMounted:"+JSON.stringify(props.initData));
  818. //initPreview();
  819. })
  820. onUnmounted(()=>{
  821. window.removeEventListener('resize', calculatePdfSize)
  822. previewSpread?.destroy();
  823. })
  824. </script>
  825. <style lang="scss" scoped>
  826. :deep(.default-toolbar) {
  827. padding-top: 0;
  828. }
  829. .lab-designer-container {
  830. position: relative;
  831. display: flex;
  832. flex-direction: column;
  833. align-items: stretch;
  834. /*height: calc(100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 140px);*/
  835. height: calc(100vh - var(--top-tool-height) - 5px );
  836. .spread-designer-container {
  837. width: calc(100% - 440px);
  838. height: 100%;
  839. padding: 0;
  840. }
  841. }
  842. .lab-designer-container-fullscreen {
  843. position: relative;
  844. display: flex;
  845. flex-direction: column;
  846. align-items: stretch;
  847. /*height: calc(100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 140px);*/
  848. height: 100%;
  849. .spread-designer-container {
  850. width: calc(100% - 440px);
  851. height: 100%;
  852. padding: 0;
  853. }
  854. }
  855. .header-row {
  856. display: flex;
  857. justify-content: space-between;
  858. align-items: center;
  859. padding: 0px 20px 16px 20px;
  860. background: #fff;
  861. border-bottom: 1px solid #e8e8e8;
  862. }
  863. .header-row .title {
  864. font-size: 16px;
  865. font-weight: 600;
  866. color: #303133;
  867. }
  868. .unit-footer-btns {
  869. align-items: center;
  870. }
  871. .dropdown-item-content {
  872. display: flex;
  873. align-items: center;
  874. gap: 8px;
  875. justify-content: center;
  876. width: 100%;
  877. }
  878. .spread-container {
  879. flex: 1;
  880. background: #f5f7fa;
  881. overflow: auto;
  882. padding: 0;
  883. }
  884. .pdf-viewer-container {
  885. width: 100%;
  886. height: calc(100% - 40px);
  887. //background-color: #8E8E9D;
  888. padding: 5px;
  889. box-sizing: border-box;
  890. overflow-y: auto;
  891. }
  892. .pdf-viewer-content {
  893. display: flex;
  894. justify-content: center;
  895. align-items: center;
  896. }
  897. // Wingdings 符号选择样式
  898. .wingdings-container {
  899. max-height: 500px;
  900. overflow-y: auto;
  901. }
  902. .wingdings-grid {
  903. display: grid;
  904. grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
  905. gap: 12px;
  906. padding: 10px;
  907. }
  908. .wingdings-item {
  909. display: flex;
  910. flex-direction: column;
  911. align-items: center;
  912. justify-content: center;
  913. padding: 12px 8px;
  914. border: 1px solid #dcdfe6;
  915. border-radius: 4px;
  916. cursor: pointer;
  917. transition: all 0.3s;
  918. &:hover {
  919. border-color: #409eff;
  920. background-color: #ecf5ff;
  921. transform: translateY(-2px);
  922. box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
  923. }
  924. }
  925. .wingdings-symbol {
  926. font-size: 24px;
  927. margin-bottom: 4px;
  928. color: #303133;
  929. }
  930. .wingdings-label {
  931. font-size: 12px;
  932. color: #909399;
  933. text-align: center;
  934. word-break: break-all;
  935. }
  936. </style>