|
@@ -0,0 +1,390 @@
|
|
|
+import { cloneDeep } from 'lodash'
|
|
|
+import { exportWordDocx, getBase64Sync } from './exportWord'
|
|
|
+import { baseURL } from './http'
|
|
|
+import { resJiLianFu } from './history'
|
|
|
+
|
|
|
+export enum EXPORT_WORD_ENUM {
|
|
|
+ /** 借用藏品点交凭证 */
|
|
|
+ BORROW = 1,
|
|
|
+ /** 藏品馆内提退凭单 */
|
|
|
+ HALL_PUT_BACK = 2,
|
|
|
+ /** 分库藏品提退出入库记录单 */
|
|
|
+ SUB_PUT_BACK = 3,
|
|
|
+ /** 分库藏品入库记录单 */
|
|
|
+ SUB_PUT_IN = 4,
|
|
|
+ /** 入馆凭证 */
|
|
|
+ VOUCHER = 5,
|
|
|
+ /** 藏品图片及相关数字化信息使用申请单 */
|
|
|
+ FORM_FOR_DIGITAL = 6,
|
|
|
+ /** 藏品卡片 */
|
|
|
+ COLLECTION_CARD = 7
|
|
|
+}
|
|
|
+
|
|
|
+type WallItem = {
|
|
|
+ url: string
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ img: string
|
|
|
+}
|
|
|
+
|
|
|
+const WORD_FILE_NAME_MAP = {
|
|
|
+ [EXPORT_WORD_ENUM.BORROW]: {
|
|
|
+ fileName: '义乌市博物馆借用藏品点交凭证',
|
|
|
+ templateName: '1.docx'
|
|
|
+ },
|
|
|
+ [EXPORT_WORD_ENUM.HALL_PUT_BACK]: {
|
|
|
+ fileName: '义乌市博物馆藏品馆内提退凭单',
|
|
|
+ templateName: '2.docx',
|
|
|
+ // 每个字段单行最大字符数
|
|
|
+ perLine: {
|
|
|
+ num: 9,
|
|
|
+ name: 24,
|
|
|
+ pcsUnit: 4,
|
|
|
+ rtf: 6
|
|
|
+ },
|
|
|
+ row: {
|
|
|
+ // 首页最大行数
|
|
|
+ maxRowFirstPage: 1,
|
|
|
+ // 尾页最大行数
|
|
|
+ maxRowLastPage: 1,
|
|
|
+ // 每页最大行数
|
|
|
+ maxRowPage: 1,
|
|
|
+ // 首页单行最大字符行数
|
|
|
+ maxFirstCharLine: 20,
|
|
|
+ // 尾页单行最大字符行数
|
|
|
+ maxLastCharLine: 18,
|
|
|
+ // 每页最大字符行数
|
|
|
+ maxCharLine: 26,
|
|
|
+ // 表格每行最小字符行数
|
|
|
+ minRowCharLine: 2
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [EXPORT_WORD_ENUM.SUB_PUT_BACK]: {
|
|
|
+ fileName: '义乌市博物馆分库藏品提退出入库记录单',
|
|
|
+ templateName: '3.docx'
|
|
|
+ },
|
|
|
+ [EXPORT_WORD_ENUM.SUB_PUT_IN]: {
|
|
|
+ fileName: '义乌市博物馆分库藏品入库记录单',
|
|
|
+ templateName: '4.docx'
|
|
|
+ },
|
|
|
+ [EXPORT_WORD_ENUM.VOUCHER]: {
|
|
|
+ fileName: '义乌市博物馆入馆凭证',
|
|
|
+ templateName: '5.docx'
|
|
|
+ },
|
|
|
+ [EXPORT_WORD_ENUM.FORM_FOR_DIGITAL]: {
|
|
|
+ fileName: '义乌市博物馆藏品图片及相关数字化信息使用申请单',
|
|
|
+ templateName: '6.docx'
|
|
|
+ },
|
|
|
+ [EXPORT_WORD_ENUM.COLLECTION_CARD]: {
|
|
|
+ fileName: '义乌市博物馆藏品卡片',
|
|
|
+ templateName: '7.docx'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export const numberToChinese = (num: number) => {
|
|
|
+ if (isNaN(num)) {
|
|
|
+ throw new Error('输入必须是一个有效的数字')
|
|
|
+ }
|
|
|
+ if (num < 0 || num > 9999) {
|
|
|
+ throw new Error('输入数字超出范围 (0-9999)')
|
|
|
+ }
|
|
|
+ if (num === 0) {
|
|
|
+ return '零'
|
|
|
+ }
|
|
|
+
|
|
|
+ const chineseNumbers = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']
|
|
|
+ const chineseUnits = ['', '拾', '佰', '仟']
|
|
|
+
|
|
|
+ let result = ''
|
|
|
+ const numStr = num.toString()
|
|
|
+ const length = numStr.length
|
|
|
+
|
|
|
+ for (let i = 0; i < length; i++) {
|
|
|
+ const digit = parseInt(numStr[i])
|
|
|
+ const unit = length - i - 1
|
|
|
+
|
|
|
+ if (digit !== 0) {
|
|
|
+ result += chineseNumbers[digit] + chineseUnits[unit]
|
|
|
+ } else {
|
|
|
+ // 处理连续的零,只保留一个
|
|
|
+ if (i < length - 1 && numStr[i + 1] !== '0') {
|
|
|
+ result += chineseNumbers[digit]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理10-19的情况,去掉开头的"壹"
|
|
|
+ if (num >= 10 && num < 20) {
|
|
|
+ result = result.replace('壹拾', '拾')
|
|
|
+ }
|
|
|
+
|
|
|
+ return result
|
|
|
+}
|
|
|
+
|
|
|
+const getImageDimensions = (url: string): Promise<{ width: number; height: number }> => {
|
|
|
+ return new Promise(resolve => {
|
|
|
+ const img = new Image()
|
|
|
+ img.onload = () => {
|
|
|
+ resolve({ width: img.width, height: img.height })
|
|
|
+ }
|
|
|
+ img.onerror = () => {
|
|
|
+ resolve({ width: 100, height: 100 })
|
|
|
+ }
|
|
|
+ img.src = url
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 将一维数组根据图片宽高比转成三维数组
|
|
|
+ */
|
|
|
+const arrangeImages = async (images: { thumb: string }[]) => {
|
|
|
+ const MAX_WALL_WIDTH = 520
|
|
|
+ const MAX_WALL_HEIGHT = 750
|
|
|
+ const MAX_ROWS_PER_WALL = 3
|
|
|
+
|
|
|
+ const walls: Array<Array<Array<WallItem>>> = []
|
|
|
+
|
|
|
+ let currentWall: Array<Array<WallItem>> = []
|
|
|
+ let currentRow: Array<WallItem> = []
|
|
|
+
|
|
|
+ let currentRowHeight = 0
|
|
|
+ let currentX = 0
|
|
|
+
|
|
|
+ const imagesWithDimensions = await Promise.all(
|
|
|
+ images.map(async img => {
|
|
|
+ const url = baseURL + img.thumb
|
|
|
+ const dimensions = await getImageDimensions(url)
|
|
|
+ return {
|
|
|
+ url,
|
|
|
+ originalWidth: dimensions.width,
|
|
|
+ originalHeight: dimensions.height
|
|
|
+ }
|
|
|
+ })
|
|
|
+ )
|
|
|
+
|
|
|
+ for (const img of imagesWithDimensions) {
|
|
|
+ const maxRowHeight = MAX_WALL_HEIGHT / MAX_ROWS_PER_WALL
|
|
|
+ const aspectRatio = img.originalWidth / img.originalHeight
|
|
|
+
|
|
|
+ let scaledWidth, scaledHeight
|
|
|
+
|
|
|
+ scaledHeight = maxRowHeight
|
|
|
+ scaledWidth = maxRowHeight * aspectRatio
|
|
|
+
|
|
|
+ if (scaledWidth > MAX_WALL_WIDTH) {
|
|
|
+ scaledWidth = MAX_WALL_WIDTH
|
|
|
+ scaledHeight = MAX_WALL_WIDTH / aspectRatio
|
|
|
+ }
|
|
|
+
|
|
|
+ // TOFIX: 暂时无法解决图片并列渲染问题
|
|
|
+ if (currentX + scaledWidth <= MAX_WALL_WIDTH && false) {
|
|
|
+ currentRow.push({
|
|
|
+ url: img.url,
|
|
|
+ width: scaledWidth,
|
|
|
+ height: scaledHeight,
|
|
|
+ img: await getBase64Sync(img.url)
|
|
|
+ })
|
|
|
+ currentX += scaledWidth
|
|
|
+ currentRowHeight = Math.max(currentRowHeight, scaledHeight)
|
|
|
+ } else {
|
|
|
+ if (currentRow.length > 0) {
|
|
|
+ // eslint-disable-next-line no-loop-func
|
|
|
+ currentRow.forEach(item => {
|
|
|
+ item.height = currentRowHeight
|
|
|
+ })
|
|
|
+ currentWall.push(currentRow)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentWall.length >= MAX_ROWS_PER_WALL) {
|
|
|
+ walls.push(currentWall)
|
|
|
+ currentWall = []
|
|
|
+ }
|
|
|
+
|
|
|
+ currentRow = [
|
|
|
+ {
|
|
|
+ url: img.url,
|
|
|
+ width: scaledWidth,
|
|
|
+ height: scaledHeight,
|
|
|
+ img: await getBase64Sync(img.url)
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ currentX = scaledWidth
|
|
|
+ currentRowHeight = scaledHeight
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentRow.length > 0) {
|
|
|
+ currentWall.push(currentRow)
|
|
|
+ }
|
|
|
+ if (currentWall.length > 0) {
|
|
|
+ walls.push(currentWall)
|
|
|
+ }
|
|
|
+
|
|
|
+ return walls
|
|
|
+}
|
|
|
+
|
|
|
+const getEffectiveLength = (str: string) => {
|
|
|
+ let length = 0
|
|
|
+ for (const char of str) {
|
|
|
+ // 全角字符(包括中文、全角符号等)的 Unicode 范围判断
|
|
|
+ if (char.match(/[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/)) {
|
|
|
+ length += 2 // 全角算2字符
|
|
|
+ } else {
|
|
|
+ length += 1 // 半角算1字符
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return length
|
|
|
+}
|
|
|
+
|
|
|
+const calculateRowCharLines = (row: Record<string, string>, perLine: Record<string, number>) => {
|
|
|
+ let maxCharLines = 0
|
|
|
+ for (const [field, text] of Object.entries(row)) {
|
|
|
+ if (!perLine[field]) continue
|
|
|
+ const fieldLength = getEffectiveLength(text)
|
|
|
+ const fieldCharLines = Math.ceil(fieldLength / perLine[field])
|
|
|
+ maxCharLines = Math.max(maxCharLines, fieldCharLines)
|
|
|
+ }
|
|
|
+ return maxCharLines || 1
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 计算表格数据的分页情况
|
|
|
+ */
|
|
|
+const calcTablePages = (rows: any[], config: (typeof WORD_FILE_NAME_MAP)[2]) => {
|
|
|
+ if (rows.length === 0) return 0
|
|
|
+
|
|
|
+ let totalPages = 0
|
|
|
+ let currentPageRows: any[] = []
|
|
|
+ let currentPageCharLines = 0
|
|
|
+ let isFirstPage = true
|
|
|
+ let isLastPage = false
|
|
|
+
|
|
|
+ for (let i = 0; i < rows.length; i++) {
|
|
|
+ const row = rows[i]
|
|
|
+ const rowCharLines = calculateRowCharLines(row, config.perLine)
|
|
|
+
|
|
|
+ currentPageCharLines += rowCharLines
|
|
|
+ currentPageRows.push(row)
|
|
|
+
|
|
|
+ // 判断是否超出首页
|
|
|
+ if (isFirstPage && currentPageCharLines > config.row.maxFirstCharLine) {
|
|
|
+ isFirstPage = false
|
|
|
+ totalPages++
|
|
|
+ currentPageCharLines -= config.row.maxFirstCharLine
|
|
|
+
|
|
|
+ // 如果剩下行数还超出尾页最大行数,也先预处理
|
|
|
+ if (currentPageCharLines > config.row.maxLastCharLine) {
|
|
|
+ totalPages++
|
|
|
+ currentPageCharLines -= config.row.maxLastCharLine
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 确定当前页的限制
|
|
|
+ const maxRows = isFirstPage
|
|
|
+ ? config.row.maxRowFirstPage
|
|
|
+ : isLastPage
|
|
|
+ ? config.row.maxRowLastPage
|
|
|
+ : config.row.maxRowPage
|
|
|
+
|
|
|
+ const maxCharLines = isFirstPage
|
|
|
+ ? config.row.maxFirstCharLine
|
|
|
+ : isLastPage
|
|
|
+ ? config.row.maxLastCharLine
|
|
|
+ : config.row.maxCharLine
|
|
|
+
|
|
|
+ // 检查是否换页
|
|
|
+ const isPageFull = currentPageRows.length >= maxRows || currentPageCharLines >= maxCharLines
|
|
|
+
|
|
|
+ if (isPageFull || i === rows.length - 1) {
|
|
|
+ totalPages +=
|
|
|
+ Math.floor(currentPageCharLines / maxCharLines) +
|
|
|
+ (currentPageCharLines % maxCharLines > 0 ? 1 : 0)
|
|
|
+ currentPageRows = []
|
|
|
+ currentPageCharLines = 0
|
|
|
+ isFirstPage = false
|
|
|
+ isLastPage = i === rows.length - 1
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return totalPages
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 根据业务类型导出数据
|
|
|
+ */
|
|
|
+export const exportWordHandler = async (type: EXPORT_WORD_ENUM, data: Record<any, any>) => {
|
|
|
+ let temp = cloneDeep(data)
|
|
|
+ let page = 1
|
|
|
+ const date = new Date()
|
|
|
+ const item = WORD_FILE_NAME_MAP[type]
|
|
|
+
|
|
|
+ temp.sizeUnit && (temp.sizeUnit = resJiLianFu(temp.sizeUnit))
|
|
|
+ temp.qualityUnit && (temp.qualityUnit = resJiLianFu(temp.qualityUnit))
|
|
|
+ temp.dictAge && (temp.dictAge = resJiLianFu(temp.dictAge))
|
|
|
+
|
|
|
+ if (Array.isArray(temp.goods) && temp.goods.length) {
|
|
|
+ for (let i = 0; i < temp.goods.length; i++) {
|
|
|
+ const good = temp.goods[i]
|
|
|
+ good.index = i + 1
|
|
|
+ good.rtf && (good.rtf = JSON.parse(good.rtf).txtArr[0].txt)
|
|
|
+ good.thumb && (good.thumb = await getBase64Sync(baseURL + good.thumb))
|
|
|
+ good.dictAge && (good.dictAge = resJiLianFu(good.dictAge))
|
|
|
+ good.pcsUnit && (good.pcsUnit = resJiLianFu(good.pcsUnit))
|
|
|
+ good.dictTexture3 && (good.dictTexture = resJiLianFu(good.dictTexture3))
|
|
|
+ good.dictTorn && (good.dictTorn = resJiLianFu(good.dictTorn))
|
|
|
+ good.source && (good.source = resJiLianFu(good.source))
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ temp.goods = []
|
|
|
+ }
|
|
|
+
|
|
|
+ // @ts-ignore
|
|
|
+ if (item.perLine) {
|
|
|
+ // @ts-ignore
|
|
|
+ page = calcTablePages(temp.goods, item)
|
|
|
+ }
|
|
|
+
|
|
|
+ switch (type) {
|
|
|
+ case EXPORT_WORD_ENUM.BORROW:
|
|
|
+ temp = {
|
|
|
+ ...temp,
|
|
|
+ group: numberToChinese(temp.goods.length),
|
|
|
+ num: numberToChinese(
|
|
|
+ temp.goods.reduce((sum: number, item: any) => sum + (item.pcs || 0), 0)
|
|
|
+ ),
|
|
|
+ page: numberToChinese(page)
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case EXPORT_WORD_ENUM.HALL_PUT_BACK:
|
|
|
+ temp = {
|
|
|
+ ...temp,
|
|
|
+ year: date.getFullYear(),
|
|
|
+ group: numberToChinese(temp.goods.length),
|
|
|
+ page: numberToChinese(page)
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case EXPORT_WORD_ENUM.VOUCHER:
|
|
|
+ temp = {
|
|
|
+ ...temp,
|
|
|
+ year: date.getFullYear(),
|
|
|
+ month: date.getMonth() + 1,
|
|
|
+ day: date.getDay(),
|
|
|
+ group: numberToChinese(temp.goods.length),
|
|
|
+ page: numberToChinese(page)
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case EXPORT_WORD_ENUM.FORM_FOR_DIGITAL:
|
|
|
+ temp = {
|
|
|
+ ...temp,
|
|
|
+ year: date.getFullYear(),
|
|
|
+ month: date.getMonth() + 1,
|
|
|
+ day: date.getDay()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case EXPORT_WORD_ENUM.COLLECTION_CARD:
|
|
|
+ temp.imagePages = await arrangeImages(temp.imagePages)
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ exportWordDocx(`/templates/${item.templateName}`, temp, item.fileName)
|
|
|
+}
|