exportWordTemplates.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import { cloneDeep } from 'lodash'
  2. import { exportWordDocx, getBase64Sync } from './exportWord'
  3. import { baseURL } from './http'
  4. import { resJiLianFu } from './history'
  5. export enum EXPORT_WORD_ENUM {
  6. /** 借用藏品点交凭证 */
  7. BORROW = 1,
  8. /** 藏品馆内提退凭单 */
  9. HALL_PUT_BACK = 2,
  10. /** 分库藏品提退出入库记录单 */
  11. SUB_PUT_BACK = 3,
  12. /** 分库藏品入库记录单 */
  13. SUB_PUT_IN = 4,
  14. /** 入馆凭证 */
  15. VOUCHER = 5,
  16. /** 藏品图片及相关数字化信息使用申请单 */
  17. FORM_FOR_DIGITAL = 6,
  18. /** 藏品卡片 */
  19. COLLECTION_CARD = 7
  20. }
  21. type WallItem = {
  22. url: string
  23. width: number
  24. height: number
  25. img: string
  26. }
  27. const WORD_FILE_NAME_MAP = {
  28. [EXPORT_WORD_ENUM.BORROW]: {
  29. fileName: '义乌市博物馆借用藏品点交凭证',
  30. templateName: '1.docx'
  31. },
  32. [EXPORT_WORD_ENUM.HALL_PUT_BACK]: {
  33. fileName: '义乌市博物馆藏品馆内提退凭单',
  34. templateName: '2.docx',
  35. // 每个字段单行最大字符数
  36. perLine: {
  37. num: 9,
  38. name: 24,
  39. pcsUnit: 4,
  40. rtf: 6
  41. },
  42. row: {
  43. // 首页最大行数
  44. maxRowFirstPage: 1,
  45. // 尾页最大行数
  46. maxRowLastPage: 1,
  47. // 每页最大行数
  48. maxRowPage: 1,
  49. // 首页单行最大字符行数
  50. maxFirstCharLine: 20,
  51. // 尾页单行最大字符行数
  52. maxLastCharLine: 18,
  53. // 每页最大字符行数
  54. maxCharLine: 26,
  55. // 表格每行最小字符行数
  56. minRowCharLine: 2
  57. }
  58. },
  59. [EXPORT_WORD_ENUM.SUB_PUT_BACK]: {
  60. fileName: '义乌市博物馆分库藏品提退出入库记录单',
  61. templateName: '3.docx'
  62. },
  63. [EXPORT_WORD_ENUM.SUB_PUT_IN]: {
  64. fileName: '义乌市博物馆分库藏品入库记录单',
  65. templateName: '4.docx'
  66. },
  67. [EXPORT_WORD_ENUM.VOUCHER]: {
  68. fileName: '义乌市博物馆入馆凭证',
  69. templateName: '5.docx'
  70. },
  71. [EXPORT_WORD_ENUM.FORM_FOR_DIGITAL]: {
  72. fileName: '义乌市博物馆藏品图片及相关数字化信息使用申请单',
  73. templateName: '6.docx'
  74. },
  75. [EXPORT_WORD_ENUM.COLLECTION_CARD]: {
  76. fileName: '义乌市博物馆藏品卡片',
  77. templateName: '7.docx'
  78. }
  79. }
  80. export const numberToChinese = (num: number) => {
  81. if (isNaN(num)) {
  82. throw new Error('输入必须是一个有效的数字')
  83. }
  84. if (num < 0 || num > 9999) {
  85. throw new Error('输入数字超出范围 (0-9999)')
  86. }
  87. if (num === 0) {
  88. return '零'
  89. }
  90. const chineseNumbers = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']
  91. const chineseUnits = ['', '拾', '佰', '仟']
  92. let result = ''
  93. const numStr = num.toString()
  94. const length = numStr.length
  95. for (let i = 0; i < length; i++) {
  96. const digit = parseInt(numStr[i])
  97. const unit = length - i - 1
  98. if (digit !== 0) {
  99. result += chineseNumbers[digit] + chineseUnits[unit]
  100. } else {
  101. // 处理连续的零,只保留一个
  102. if (i < length - 1 && numStr[i + 1] !== '0') {
  103. result += chineseNumbers[digit]
  104. }
  105. }
  106. }
  107. // 处理10-19的情况,去掉开头的"壹"
  108. if (num >= 10 && num < 20) {
  109. result = result.replace('壹拾', '拾')
  110. }
  111. return result
  112. }
  113. const getImageDimensions = (url: string): Promise<{ width: number; height: number }> => {
  114. return new Promise(resolve => {
  115. const img = new Image()
  116. img.onload = () => {
  117. resolve({ width: img.width, height: img.height })
  118. }
  119. img.onerror = () => {
  120. resolve({ width: 100, height: 100 })
  121. }
  122. img.src = url
  123. })
  124. }
  125. /**
  126. * 将一维数组根据图片宽高比转成三维数组
  127. */
  128. const arrangeImages = async (images: { thumb: string }[]) => {
  129. const MAX_WALL_WIDTH = 520
  130. const MAX_WALL_HEIGHT = 750
  131. const MAX_ROWS_PER_WALL = 3
  132. const walls: Array<Array<Array<WallItem>>> = []
  133. let currentWall: Array<Array<WallItem>> = []
  134. let currentRow: Array<WallItem> = []
  135. let currentRowHeight = 0
  136. let currentX = 0
  137. const imagesWithDimensions = await Promise.all(
  138. images.map(async img => {
  139. const url = baseURL + img.thumb
  140. const dimensions = await getImageDimensions(url)
  141. return {
  142. url,
  143. originalWidth: dimensions.width,
  144. originalHeight: dimensions.height
  145. }
  146. })
  147. )
  148. for (const img of imagesWithDimensions) {
  149. const maxRowHeight = MAX_WALL_HEIGHT / MAX_ROWS_PER_WALL
  150. const aspectRatio = img.originalWidth / img.originalHeight
  151. let scaledWidth, scaledHeight
  152. scaledHeight = maxRowHeight
  153. scaledWidth = maxRowHeight * aspectRatio
  154. if (scaledWidth > MAX_WALL_WIDTH) {
  155. scaledWidth = MAX_WALL_WIDTH
  156. scaledHeight = MAX_WALL_WIDTH / aspectRatio
  157. }
  158. // TOFIX: 暂时无法解决图片并列渲染问题
  159. if (currentX + scaledWidth <= MAX_WALL_WIDTH && false) {
  160. currentRow.push({
  161. url: img.url,
  162. width: scaledWidth,
  163. height: scaledHeight,
  164. img: await getBase64Sync(img.url)
  165. })
  166. currentX += scaledWidth
  167. currentRowHeight = Math.max(currentRowHeight, scaledHeight)
  168. } else {
  169. if (currentRow.length > 0) {
  170. // eslint-disable-next-line no-loop-func
  171. currentRow.forEach(item => {
  172. item.height = currentRowHeight
  173. })
  174. currentWall.push(currentRow)
  175. }
  176. if (currentWall.length >= MAX_ROWS_PER_WALL) {
  177. walls.push(currentWall)
  178. currentWall = []
  179. }
  180. currentRow = [
  181. {
  182. url: img.url,
  183. width: scaledWidth,
  184. height: scaledHeight,
  185. img: await getBase64Sync(img.url)
  186. }
  187. ]
  188. currentX = scaledWidth
  189. currentRowHeight = scaledHeight
  190. }
  191. }
  192. if (currentRow.length > 0) {
  193. currentWall.push(currentRow)
  194. }
  195. if (currentWall.length > 0) {
  196. walls.push(currentWall)
  197. }
  198. return walls
  199. }
  200. const getEffectiveLength = (str: string) => {
  201. let length = 0
  202. for (const char of str) {
  203. // 全角字符(包括中文、全角符号等)的 Unicode 范围判断
  204. if (char.match(/[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/)) {
  205. length += 2 // 全角算2字符
  206. } else {
  207. length += 1 // 半角算1字符
  208. }
  209. }
  210. return length
  211. }
  212. const calculateRowCharLines = (row: Record<string, string>, perLine: Record<string, number>) => {
  213. let maxCharLines = 0
  214. for (const [field, text] of Object.entries(row)) {
  215. if (!perLine[field]) continue
  216. const fieldLength = getEffectiveLength(text)
  217. const fieldCharLines = Math.ceil(fieldLength / perLine[field])
  218. maxCharLines = Math.max(maxCharLines, fieldCharLines)
  219. }
  220. return maxCharLines || 1
  221. }
  222. /**
  223. * 计算表格数据的分页情况
  224. */
  225. const calcTablePages = (rows: any[], config: (typeof WORD_FILE_NAME_MAP)[2]) => {
  226. if (rows.length === 0) return 0
  227. let totalPages = 0
  228. let currentPageRows: any[] = []
  229. let currentPageCharLines = 0
  230. let isFirstPage = true
  231. let isLastPage = false
  232. for (let i = 0; i < rows.length; i++) {
  233. const row = rows[i]
  234. const rowCharLines = calculateRowCharLines(row, config.perLine)
  235. currentPageCharLines += rowCharLines
  236. currentPageRows.push(row)
  237. // 判断是否超出首页
  238. if (isFirstPage && currentPageCharLines > config.row.maxFirstCharLine) {
  239. isFirstPage = false
  240. totalPages++
  241. currentPageCharLines -= config.row.maxFirstCharLine
  242. // 如果剩下行数还超出尾页最大行数,也先预处理
  243. if (currentPageCharLines > config.row.maxLastCharLine) {
  244. totalPages++
  245. currentPageCharLines -= config.row.maxLastCharLine
  246. }
  247. }
  248. // 确定当前页的限制
  249. const maxRows = isFirstPage
  250. ? config.row.maxRowFirstPage
  251. : isLastPage
  252. ? config.row.maxRowLastPage
  253. : config.row.maxRowPage
  254. const maxCharLines = isFirstPage
  255. ? config.row.maxFirstCharLine
  256. : isLastPage
  257. ? config.row.maxLastCharLine
  258. : config.row.maxCharLine
  259. // 检查是否换页
  260. const isPageFull = currentPageRows.length >= maxRows || currentPageCharLines >= maxCharLines
  261. if (isPageFull || i === rows.length - 1) {
  262. totalPages +=
  263. Math.floor(currentPageCharLines / maxCharLines) +
  264. (currentPageCharLines % maxCharLines > 0 ? 1 : 0)
  265. currentPageRows = []
  266. currentPageCharLines = 0
  267. isFirstPage = false
  268. isLastPage = i === rows.length - 1
  269. }
  270. }
  271. return totalPages
  272. }
  273. /**
  274. * 根据业务类型导出数据
  275. */
  276. export const exportWordHandler = async (type: EXPORT_WORD_ENUM, data: Record<any, any>) => {
  277. let temp = cloneDeep(data)
  278. let page = 1
  279. const date = new Date()
  280. const item = WORD_FILE_NAME_MAP[type]
  281. temp.sizeUnit && (temp.sizeUnit = resJiLianFu(temp.sizeUnit))
  282. temp.qualityUnit && (temp.qualityUnit = resJiLianFu(temp.qualityUnit))
  283. temp.dictAge && (temp.dictAge = resJiLianFu(temp.dictAge))
  284. if (Array.isArray(temp.goods) && temp.goods.length) {
  285. for (let i = 0; i < temp.goods.length; i++) {
  286. const good = temp.goods[i]
  287. good.index = i + 1
  288. good.rtf && (good.rtf = JSON.parse(good.rtf).txtArr[0].txt)
  289. good.thumb && (good.thumb = await getBase64Sync(baseURL + good.thumb))
  290. good.dictAge && (good.dictAge = resJiLianFu(good.dictAge))
  291. good.pcsUnit && (good.pcsUnit = resJiLianFu(good.pcsUnit))
  292. good.dictTexture3 && (good.dictTexture = resJiLianFu(good.dictTexture3))
  293. good.dictTorn && (good.dictTorn = resJiLianFu(good.dictTorn))
  294. good.source && (good.source = resJiLianFu(good.source))
  295. }
  296. } else {
  297. temp.goods = []
  298. }
  299. // @ts-ignore
  300. if (item.perLine) {
  301. // @ts-ignore
  302. page = calcTablePages(temp.goods, item)
  303. }
  304. switch (type) {
  305. case EXPORT_WORD_ENUM.BORROW:
  306. temp = {
  307. ...temp,
  308. group: numberToChinese(temp.goods.length),
  309. num: numberToChinese(
  310. temp.goods.reduce((sum: number, item: any) => sum + (item.pcs || 0), 0)
  311. ),
  312. page: numberToChinese(page)
  313. }
  314. break
  315. case EXPORT_WORD_ENUM.HALL_PUT_BACK:
  316. temp = {
  317. ...temp,
  318. year: date.getFullYear(),
  319. group: numberToChinese(temp.goods.length),
  320. page: numberToChinese(page)
  321. }
  322. break
  323. case EXPORT_WORD_ENUM.VOUCHER:
  324. temp = {
  325. ...temp,
  326. year: date.getFullYear(),
  327. month: date.getMonth() + 1,
  328. day: date.getDay(),
  329. group: numberToChinese(temp.goods.length),
  330. page: numberToChinese(page)
  331. }
  332. break
  333. case EXPORT_WORD_ENUM.FORM_FOR_DIGITAL:
  334. temp = {
  335. ...temp,
  336. year: date.getFullYear(),
  337. month: date.getMonth() + 1,
  338. day: date.getDay()
  339. }
  340. break
  341. case EXPORT_WORD_ENUM.COLLECTION_CARD:
  342. temp.imagePages = await arrangeImages(temp.imagePages)
  343. break
  344. }
  345. exportWordDocx(`/templates/${item.templateName}`, temp, item.fileName)
  346. }