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