liding
23 小时以前 303d42574231d0cc57f18a736bf8047d158233ce
feat(report): 报告在线编辑
已添加5个文件
449 ■■■■■ 文件已修改
src/api/structural/sealUsageLedger.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/cryptoRandomUUID.js 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/structural/sealManage/usageLedger/index.vue 272 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/superdoc-http.smoke.mjs 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite/plugins/superdoc-http-worker.js 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/structural/sealUsageLedger.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
import request from '@/utils/request'
export function pageSealUsageLedger(query) {
  return request({
    url: '/sealUsageLedger/page',
    method: 'get',
    params: query
  })
}
export function updateSealUsageCopies(data) {
  return request({
    url: '/sealUsageLedger/updateCopies',
    method: 'post',
    data
  })
}
export function exportSealUsageLedger(data) {
  return request({
    url: '/sealUsageLedger/exportWord',
    method: 'post',
    data,
    responseType: 'blob'
  })
}
src/utils/cryptoRandomUUID.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
// HTTP å†…网地址没有原生 randomUUID;getRandomValues åœ¨è¯¥çŽ¯å¢ƒä¸‹ä»å¯ç”¨ã€‚
// æ­¤å‡½æ•°ä¹Ÿä¼šè¢«æž„建插件注入文档 Worker,必须保持无外部依赖。
export function installCryptoRandomUUID() {
  const crypto = globalThis.crypto
  if (!crypto || typeof crypto.randomUUID === 'function') return
  if (typeof crypto.getRandomValues !== 'function') {
    throw new Error('当前浏览器不支持安全随机数,请升级浏览器后重试')
  }
  Object.defineProperty(crypto, 'randomUUID', {
    configurable: true,
    writable: true,
    value() {
      const bytes = crypto.getRandomValues(new Uint8Array(16))
      bytes[6] = (bytes[6] & 0x0f) | 0x40
      bytes[8] = (bytes[8] & 0x3f) | 0x80
      const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
      return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
    }
  })
}
src/views/structural/sealManage/usageLedger/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,272 @@
<template>
  <div class="app-container seal-usage-ledger">
    <el-form ref="queryForm" :inline="true" :model="queryParams" size="small">
      <el-form-item label="报告编号" prop="reportCode">
        <el-input v-model="queryParams.reportCode" clearable placeholder="请输入" @keyup.enter="refreshTable" />
      </el-form-item>
      <el-form-item label="印章名称" prop="sealName">
        <el-input v-model="queryParams.sealName" clearable placeholder="请输入" @keyup.enter="refreshTable" />
      </el-form-item>
      <el-form-item label="检测项目名称" prop="projectName">
        <el-input v-model="queryParams.projectName" clearable placeholder="请输入" @keyup.enter="refreshTable" />
      </el-form-item>
      <el-form-item label="用印人" prop="useUserName">
        <el-input v-model="queryParams.useUserName" clearable placeholder="请输入" @keyup.enter="refreshTable" />
      </el-form-item>
      <el-form-item label="用印日期">
        <el-date-picker
          v-model="dateRange"
          type="daterange"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          value-format="YYYY-MM-DD"
          clearable
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" size="small" @click="refreshTable">查询</el-button>
        <el-button size="small" @click="resetQuery">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="toolbar">
      <el-button
        v-hasPermi="['structural:sealUsageLedger:export']"
        type="primary"
        size="small"
        :disabled="selection.length === 0 || exportLoading || submitLoading"
        :loading="selectedExportLoading"
        @click="exportSelected"
      >勾选导出</el-button>
      <el-button
        v-hasPermi="['structural:sealUsageLedger:export']"
        type="primary"
        size="small"
        :disabled="exportLoading || submitLoading"
        :loading="filteredExportLoading"
        @click="exportFiltered"
      >按条件导出</el-button>
    </div>
    <lims-table
      ref="ledgerTable"
      :table-data="tableData"
      :column="columns"
      :page="page"
      :table-loading="tableLoading"
      :is-selection="true"
      :handle-selection-change="handleSelectionChange"
      row-key="id"
      height="calc(100vh - 315px)"
      @pagination="pagination"
    >
      <template #sealNames="{ row }">
        <div class="multiline">{{ formatSealNames(row.sealNames) }}</div>
      </template>
      <template #projectName="{ row }">
        <div class="multiline">{{ row.projectName }}</div>
      </template>
      <template #copies="{ row }">
        <span>{{ row.copies }}</span>
        <el-button
          v-hasPermi="['structural:sealUsageLedger:edit']"
          link
          type="primary"
          size="small"
          class="edit-button"
          @click.stop="openCopiesDialog(row)"
        >修改</el-button>
      </template>
    </lims-table>
    <el-dialog v-model="copiesDialogVisible" title="修改用印份数" width="420px" :close-on-click-modal="false">
      <el-form ref="copiesForm" :model="copiesForm" :rules="copiesRules" label-width="90px">
        <el-form-item label="报告编号">
          <span>{{ copiesForm.reportCode }}</span>
        </el-form-item>
        <el-form-item label="份数" prop="copies">
          <el-input-number v-model="copiesForm.copies" :min="1" :max="2147483647" :precision="0" controls-position="right" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="copiesDialogVisible = false">取消</el-button>
        <el-button type="primary" :loading="submitLoading" @click="saveCopies">保存</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script>
import limsTable from '@/components/Table/lims-table.vue'
import {
  exportSealUsageLedger,
  pageSealUsageLedger,
  updateSealUsageCopies
} from '@/api/structural/sealUsageLedger'
function emptyQuery() {
  return {
    reportCode: '',
    sealName: '',
    projectName: '',
    useUserName: ''
  }
}
export default {
  name: 'SealUsageLedger',
  components: { limsTable },
  data() {
    return {
      queryParams: emptyQuery(),
      dateRange: [],
      page: { total: 0, size: 20, current: 1 },
      tableData: [],
      selection: [],
      tableLoading: false,
      selectedExportLoading: false,
      filteredExportLoading: false,
      copiesDialogVisible: false,
      submitLoading: false,
      copiesForm: { id: null, reportCode: '', copies: 1, version: 0 },
      copiesRules: {
        copies: [
          { required: true, message: '请输入份数', trigger: 'change' },
          { type: 'integer', min: 1, message: '份数必须为正整数', trigger: 'change' }
        ]
      },
      columns: [
        { label: '日期', prop: 'useTime', width: '120px', formatData: value => value ? value.substring(0, 10) : '' },
        { label: '印章名称', prop: 'sealNames', dataType: 'slot', slot: 'sealNames', minWidth: '210px' },
        { label: '检测项目名称', prop: 'projectName', dataType: 'slot', slot: 'projectName', minWidth: '180px' },
        { label: '报告编号', prop: 'reportCode', minWidth: '180px' },
        { label: '份数', prop: 'copies', dataType: 'slot', slot: 'copies', width: '120px' },
        { label: '用印人签字', prop: 'useUserName', width: '140px' }
      ]
    }
  },
  computed: {
    exportLoading() {
      return this.selectedExportLoading || this.filteredExportLoading
    }
  },
  mounted() {
    this.getList()
  },
  methods: {
    buildQuery() {
      return {
        ...this.queryParams,
        startDate: this.dateRange && this.dateRange.length === 2 ? this.dateRange[0] : undefined,
        endDate: this.dateRange && this.dateRange.length === 2 ? this.dateRange[1] : undefined
      }
    },
    getList() {
      this.tableLoading = true
      this.selection = []
      pageSealUsageLedger({ ...this.buildQuery(), current: this.page.current, size: this.page.size }).then(res => {
        const data = res.data || {}
        this.tableData = data.records || []
        this.page.total = data.total || 0
      }).finally(() => {
        this.tableLoading = false
      })
    },
    refreshTable() {
      this.page.current = 1
      this.getList()
    },
    resetQuery() {
      this.queryParams = emptyQuery()
      this.dateRange = []
      this.page.current = 1
      this.getList()
    },
    pagination({ page, limit }) {
      this.page.current = page
      this.page.size = limit
      this.getList()
    },
    handleSelectionChange(rows) {
      this.selection = rows || []
    },
    formatSealNames(names) {
      return (names || '').replace(/[,,]/g, '\n')
    },
    openCopiesDialog(row) {
      this.copiesForm = {
        id: row.id,
        reportCode: row.reportCode,
        copies: row.copies,
        version: row.version
      }
      this.copiesDialogVisible = true
      this.$nextTick(() => this.$refs.copiesForm && this.$refs.copiesForm.clearValidate())
    },
    saveCopies() {
      this.$refs.copiesForm.validate(valid => {
        if (!valid) return
        this.submitLoading = true
        updateSealUsageCopies({
          id: this.copiesForm.id,
          copies: this.copiesForm.copies,
          version: this.copiesForm.version
        }).then(() => {
          this.$message.success('份数修改成功')
          this.copiesDialogVisible = false
          this.getList()
        }).finally(() => {
          this.submitLoading = false
        })
      })
    },
    exportSelected() {
      if (!this.selection.length) {
        this.$message.warning('请选择要导出的记录')
        return
      }
      this.selectedExportLoading = true
      this.downloadWord({ mode: 'selected', ids: this.selection.map(item => item.id) })
        .finally(() => { this.selectedExportLoading = false })
    },
    exportFiltered() {
      this.filteredExportLoading = true
      this.downloadWord({ mode: 'filtered', query: this.buildQuery() })
        .finally(() => { this.filteredExportLoading = false })
    },
    downloadWord(payload) {
      return exportSealUsageLedger(payload).then(async data => {
        const blob = data instanceof Blob ? data : new Blob([data])
        if (blob.type && blob.type.includes('json')) {
          const result = JSON.parse(await blob.text())
          throw new Error(result.msg || '导出失败')
        }
        const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
        this.$download.saveAs(blob, `印章使用登记表_${stamp}.docx`)
      }).catch(error => {
        this.$message.error(error.message || '用印台账导出失败')
        throw error
      })
    }
  }
}
</script>
<style scoped>
.toolbar {
  display: flex;
  justify-content: flex-end;
  margin-bottom: 10px;
}
.multiline {
  width: 100%;
  white-space: pre-line;
  word-break: break-all;
}
.edit-button {
  margin-left: 8px;
}
</style>
tests/superdoc-http.smoke.mjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
// ä¸è¿žæŽ¥ä¸šåŠ¡åŽç«¯ï¼šåœ¨çœŸæ­£çš„ HTTP éžå®‰å…¨ä¸Šä¸‹æ–‡ä¸­æ‰“开、导出并重新打开测试 DOCX。
// node tests/superdoc-http.smoke.mjs
import assert from 'node:assert/strict'
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { createServer } from 'node:http'
import { fileURLToPath } from 'node:url'
import { build } from 'vite'
import { chromium } from 'playwright'
import JSZip from 'jszip'
import superdocHttpWorker from '../vite/plugins/superdoc-http-worker.js'
const project = fileURLToPath(new URL('../', import.meta.url))
const temporary = await mkdtemp(path.join(tmpdir(), 'lims-superdoc-http-'))
const fixture = path.join(project, 'tests/.superdoc-http-smoke.html')
let browser
let server
try {
  await writeFile(fixture, `<!doctype html><html><body><div id="editor"></div><script type="module">
    import { loadSuperDoc } from '../src/utils/loadSuperDoc.js';
    import workerUrl from 'virtual:superdoc-http-worker-url';
    (async () => {
    window.before = { secure: isSecureContext, uuid: typeof crypto.randomUUID };
    const { SuperDoc } = await loadSuperDoc();
    window.openDocument = (blob) => new Promise((resolve, reject) => {
      window.editor?.destroy();
      window.editor = new SuperDoc({ selector: '#editor',
        document: new File([blob], 'http-test.docx'), workerUrls: { document: workerUrl },
        documentMode: 'editing', telemetry: { enabled: false },
        onReady: () => resolve(true), onException: event => reject(new Error(event.error?.message || String(event.error))),
        onContentError: event => reject(new Error(String(event.error))) });
    });
    try { await openDocument(await (await fetch('/fixture.docx')).blob()); window.result = 'ready'; }
    catch (error) { window.result = error.message; }
    })().catch(error => { window.result = error.message });
  </script></body></html>`)
  const outDir = path.join(temporary, 'dist')
  await build({ configFile: false, root: project, logLevel: 'warn', plugins: [superdocHttpWorker()],
    build: { outDir, emptyOutDir: true, rollupOptions: { input: fixture } } })
  const zip = new JSZip()
  zip.file('[Content_Types].xml', '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>')
  zip.file('_rels/.rels', '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>')
  zip.file('word/document.xml', '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>HTTP report smoke test</w:t></w:r></w:p><w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>')
  const docx = await zip.generateAsync({ type: 'nodebuffer' })
  server = createServer(async (req, res) => {
    try {
      const url = new URL(req.url, 'http://lims-http.test')
      if (url.pathname === '/fixture.docx') { res.end(docx); return }
      const relative = decodeURIComponent(url.pathname).replace(/^\/+/, '') || 'tests/.superdoc-http-smoke.html'
      const filename = path.resolve(outDir, relative)
      if (!filename.startsWith(outDir + path.sep)) { res.writeHead(403).end(); return }
      res.setHeader('Content-Type', filename.endsWith('.js') ? 'text/javascript' : filename.endsWith('.css') ? 'text/css' : 'text/html')
      res.end(await readFile(filename))
    } catch { res.writeHead(404).end() }
  })
  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
  browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe',
    headless: true, args: ['--no-proxy-server', '--host-resolver-rules=MAP lims-http.test 127.0.0.1'] })
  const page = await browser.newPage()
  const errors = []
  page.on('pageerror', error => errors.push(error.message))
  await page.goto(`http://lims-http.test:${server.address().port}/`)
  await page.waitForFunction(() => window.result, null, { timeout: 120000 })
  const before = await page.evaluate(() => window.before)
  assert.equal(before.secure, false)
  assert.equal(before.uuid, 'undefined')
  assert.equal(await page.evaluate(() => window.result), 'ready')
  const exported = await page.evaluate(async () => {
    const blob = await editor.export({ exportType: ['docx'], triggerDownload: false })
    const size = blob.size
    await openDocument(blob)
    return size
  })
  assert.ok(exported > 0)
  assert.deepEqual(errors, [])
  console.log(JSON.stringify({ result: 'PASS', before, opened: true, exportedBytes: exported, reopened: true, browserErrors: errors }, null, 2))
} finally {
  await browser?.close()
  if (server) await new Promise(resolve => server.close(resolve))
  await rm(fixture, { force: true })
  assert.ok(path.resolve(temporary).startsWith(path.resolve(tmpdir()) + path.sep + 'lims-superdoc-http-'))
  await rm(temporary, { recursive: true, force: true })
}
vite/plugins/superdoc-http-worker.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
import { createRequire } from 'node:module'
import { readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { transformWithEsbuild } from 'vite'
import { installCryptoRandomUUID } from '../../src/utils/cryptoRandomUUID.js'
const moduleId = 'virtual:superdoc-http-worker-url'
const resolvedId = '\0' + moduleId
// ä¸º SuperDoc å®˜æ–¹ Worker æ·»åŠ  HTTP ä¸‹çš„ UUID å…¼å®¹å…¥å£ã€‚
// emitFile æŒ‰æœ€ç»ˆå†…容生成 hash,发布后不会复用旧 Worker çš„æµè§ˆå™¨ç¼“存。
export default function superdocHttpWorker() {
  let base
  let building
  let source
  const route = '__superdoc_http_worker.js'
  return {
    name: 'superdoc-http-worker',
    configResolved(config) {
      base = config.base
      building = config.command === 'build'
      const require = createRequire(import.meta.url)
      const assetDir = path.join(path.dirname(require.resolve('@superdoc/docx-engine/package.json')), 'dist/assets')
      const entries = readdirSync(assetDir).filter(name => /^dom-worker-entry-.*\.js$/.test(name))
      if (entries.length !== 1) throw new Error('无法确定 SuperDoc æ–‡æ¡£ Worker,请检查依赖版本')
      source = `;(${installCryptoRandomUUID.toString()})();\n` + readFileSync(path.join(assetDir, entries[0]), 'utf8')
    },
    resolveId(id) {
      if (id === moduleId) return resolvedId
    },
    async load(id) {
      if (id !== resolvedId) return
      if (!building) return `export default ${JSON.stringify(base + route)}`
      const minified = await transformWithEsbuild(source, 'superdoc-http-worker.js', { minify: true, legalComments: 'inline' })
      const reference = this.emitFile({ type: 'asset', name: 'superdoc-http-worker.js', source: minified.code })
      return `export default import.meta.ROLLUP_FILE_URL_${reference}`
    },
    configureServer(server) {
      server.middlewares.use((req, res, next) => {
        if (req.url?.split('?')[0] !== base + route) return next()
        res.setHeader('Content-Type', 'text/javascript; charset=utf-8')
        res.setHeader('Cache-Control', 'no-cache')
        res.end(source)
      })
    }
  }
}