From 303d42574231d0cc57f18a736bf8047d158233ce Mon Sep 17 00:00:00 2001
From: liding <756868258@qq.com>
Date: 星期五, 23 十月 2026 18:42:07 +0800
Subject: [PATCH] feat(report): 报告在线编辑

---
 src/api/structural/sealUsageLedger.js                 |   26 +++
 src/views/structural/sealManage/usageLedger/index.vue |  272 ++++++++++++++++++++++++++++++++++
 vite/plugins/superdoc-http-worker.js                  |   47 +++++
 src/utils/cryptoRandomUUID.js                         |   20 ++
 tests/superdoc-http.smoke.mjs                         |   84 ++++++++++
 5 files changed, 449 insertions(+), 0 deletions(-)

diff --git a/src/api/structural/sealUsageLedger.js b/src/api/structural/sealUsageLedger.js
new file mode 100644
index 0000000..7a7bb71
--- /dev/null
+++ b/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'
+  })
+}
diff --git a/src/utils/cryptoRandomUUID.js b/src/utils/cryptoRandomUUID.js
new file mode 100644
index 0000000..d92a8a6
--- /dev/null
+++ b/src/utils/cryptoRandomUUID.js
@@ -0,0 +1,20 @@
+// HTTP 鍐呯綉鍦板潃娌℃湁鍘熺敓 randomUUID锛沢etRandomValues 鍦ㄨ鐜涓嬩粛鍙敤銆�
+// 姝ゅ嚱鏁颁篃浼氳鏋勫缓鎻掍欢娉ㄥ叆鏂囨。 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)}`
+    }
+  })
+}
diff --git a/src/views/structural/sealManage/usageLedger/index.vue b/src/views/structural/sealManage/usageLedger/index.vue
new file mode 100644
index 0000000..665d47c
--- /dev/null
+++ b/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>
diff --git a/tests/superdoc-http.smoke.mjs b/tests/superdoc-http.smoke.mjs
new file mode 100644
index 0000000..cb811e4
--- /dev/null
+++ b/tests/superdoc-http.smoke.mjs
@@ -0,0 +1,84 @@
+// 涓嶈繛鎺ヤ笟鍔″悗绔細鍦ㄧ湡姝g殑 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 })
+}
diff --git a/vite/plugins/superdoc-http-worker.js b/vite/plugins/superdoc-http-worker.js
new file mode 100644
index 0000000..69742a1
--- /dev/null
+++ b/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)
+      })
+    }
+  }
+}

--
Gitblit v1.9.3