| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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' |
| | | }) |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // 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)}` |
| | | } |
| | | }) |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // ä¸è¿æ¥ä¸å¡å端ï¼å¨çæ£ç 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 }) |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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) |
| | | }) |
| | | } |
| | | } |
| | | } |