feat(signing): 支持点击文中签名位就地采集签名
This commit is contained in:
+13
-2
@@ -122,13 +122,14 @@ ClinicalLayout
|
||||
|
||||
- `components/signing/NewSigningTaskDialog.vue`:新增签署任务公共弹窗,供签署工作台和文档库管理复用;
|
||||
- `composables/useSigningTaskForm.ts`:封装患者定位、就诊选择、模板选择、签署方式、校验和任务提交状态;
|
||||
- `utils/signing-document.ts`:把文书定稿 HTML 变换为签署态 HTML 的渲染管线(占位符填充、签名域、勾选与行内选项识别),签署工作台的任务详情和文档库「签署预览」共用同一份实现。
|
||||
- `utils/signing-document.ts`:把文书定稿 HTML 变换为签署态 HTML 的渲染管线(占位符填充、签名域、勾选与行内选项识别),签署工作台的任务详情和文档库「签署预览」共用同一份实现;
|
||||
- `utils/signing-editor-marks.ts`:编辑区里把占位符与勾选框渲染成带类型角标的胶囊,只改渲染不改数据(见下「文书编写约定」)。
|
||||
|
||||
## 文书编写约定
|
||||
|
||||
科室在文档库的 Word 编辑器中维护文书,签署时需要交互化的内容按下面的写法即可,系统在签署和「签署预览」时自动转换:
|
||||
|
||||
- 占位符:`{患者姓名}`、`{性别}`、`{年龄}`、`{就诊号}`、`{签署日期}`、`{患者签名}` 等,由签署任务的患者与就诊快照填充;
|
||||
- 占位符:`{患者姓名}`、`{性别}`、`{年龄}`、`{就诊号}`、`{签署日期}`、`{患者签名}`、`{医师签名}` 等,由签署任务的患者与就诊快照填充;
|
||||
- 勾选表:做成表格,第一行表头写「勾选」,另设一列写「项目」作为勾选条目名,签署时「勾选」列每个单元格变成复选框;
|
||||
- 行内选项:正文里直接写方框符号,如 `□否 □是`、`□同意 □不同意`。同一段落或同一单元格内出现两个及以上方框时按二选一(互斥单选)处理,只有一个方框时保持独立复选框语义。
|
||||
|
||||
@@ -136,6 +137,16 @@ ClinicalLayout
|
||||
|
||||
识别规则集中在 `utils/signing-document.ts`,调整后签署端与「签署预览」会同步生效。
|
||||
|
||||
### 编辑区与签署态的呈现差异
|
||||
|
||||
**编辑区**(`utils/signing-editor-marks.ts`)把 `{…}` 与 `□` 渲染成带类型角标的胶囊,让科室一眼分出「正文」和「待签署的内容」。这是**只读投影**:靠 wangEditor 的 `decorate` 把文本切成叶子再挂样式,`editor.getHtml()` 输出一字未变,保存与签署链路的口径不受影响。
|
||||
|
||||
**签署态**(`utils/signing-document.ts`)把占位符就地换成可交互控件。其中**签名位**(`kind=signature`)在任务处于可签署状态时渲染成「✍ 点击签名」的可点击入口:点一下就打开签字板,签完的图就地回显到该签名位,只回填这一个字段,**不会结束签署任务**——任务级的「签署完成」仍由主签名位(`{患者签名}`)通过「手写板签署 / 线上签署」触发。
|
||||
|
||||
签名位之间互不覆盖:主签名位存在任务的 `signatureDataUrl`(提交时由接口固定落到 `signature` 字段),其余签名位(医师签名等)各自存在 `fieldAnswers` 里。
|
||||
|
||||
签名图只作为渲染结果里的 `<img src>` 存在,**不写进模板正文**:它是 base64 大字符串,进正文会让模板哈希随签名变化、缓存键跟着失效。
|
||||
|
||||
## API 与类型约定
|
||||
|
||||
API 模块与页面按业务域对应。工作台页面使用 `api/workbench` 下的页面文件,管理页面使用 `api/management` 下的直接文件:
|
||||
|
||||
@@ -90,6 +90,41 @@ VERIFY_URL=http://127.0.0.1:5174/ node scripts/verify-editor-marks.cjs
|
||||
截图:`04-editor-marks.png`(整窗)、`04b-editor-marks-zoom.png`(编辑区 4 倍放大,用来核对胶囊细节)、
|
||||
`05-editor-marks-caret.png`(插入字符后)。
|
||||
|
||||
## verify-signature-click.cjs — 点击文中签名位采集签名
|
||||
|
||||
**为什么需要**:签名位的点击采集横跨三层(`utils/signing-document` 渲染 → `SigningDocumentInteractive`
|
||||
事件委托 → 签署页弹窗与回灌),每一层都可能在编译期看不出问题:
|
||||
|
||||
- 渲染层挂了 `data-sign-action` 但没人监听 → 看着像按钮,点了没反应;
|
||||
- 事件委托的 `closest()` 没做容器校验 → 容器外的事件也会误触发;
|
||||
- 回灌走了「整篇重渲染」而不是就地改写 → 正在输入的填写项连同光标一起丢。
|
||||
|
||||
所以必须真的用鼠标点、真的在签字板上画一笔、再看签名图有没有回到那一个签名位上。
|
||||
|
||||
**用法**(登录步骤同上,端口要和实际 dev server 一致):
|
||||
|
||||
```bash
|
||||
node scripts/login.cjs # 抓验证码,看图认数字
|
||||
node scripts/login.cjs 8944 # 登录
|
||||
VERIFY_URL=http://127.0.0.1:5173/ node scripts/verify-signature-click.cjs
|
||||
```
|
||||
|
||||
脚本会:打开签署工作台 → 选一个「待签署」任务 → 输出签名位探针 → 真实点击「医师签名」→
|
||||
在签字板上画一笔并确认 → 复检回显与页脚提示 → 用键盘 `Enter` 触发「患者签名」的采集入口。
|
||||
|
||||
**关键断言**:
|
||||
|
||||
- 空签名位必须同时有 `data-sign-action=signature`、`role=button`、`tabindex=0`、
|
||||
`cursor: pointer` 与 `✍ 点击签名` 文案——少一样就是「看着能点其实不能点」;
|
||||
- 采集后该签名位出现 `img.sign-doc-signature` 且 `src` 是 `data:image/png`,
|
||||
同时采集入口被摘掉(`data-sign-action` 与 `sign-field--actionable` 都消失);
|
||||
- **患者签名位的采集入口不能被误伤**——医师签名采完,患者签名还得能点;
|
||||
- 页脚不再把已采集的签名位列进「还有签名位待采集」。
|
||||
|
||||
截图:`06-signature-actions.png`(初始:两个签名位都是可点入口)、
|
||||
`07-signature-dialog.png`(弹窗标题指向被点的签名位)、`08-signature-applied.png`(回显 + 页脚更新)、
|
||||
`09-signature-keyboard.png`(键盘触发)。
|
||||
|
||||
## 踩过的坑
|
||||
|
||||
改脚本前先读这段,都是实际踩出来的。
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 临时复现脚本:插入勾选框后删除,看编辑器 DOM / 数据里有没有残留。
|
||||
* 用法:
|
||||
* node scripts/login.cjs <code> && node scripts/repro-checkbox-delete.cjs
|
||||
*/
|
||||
const { spawn } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const WebSocket = require('ws')
|
||||
|
||||
const CHROME = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe'
|
||||
const PORT = Number(process.env.VERIFY_CDP_PORT || 9461)
|
||||
const TARGET_URL = process.env.VERIFY_URL || 'http://127.0.0.1:5173/'
|
||||
const OUT = process.env.VERIFY_OUT || path.resolve(__dirname, '../../screenshots')
|
||||
const SESSION_FILE = path.join(__dirname, '.session.json')
|
||||
const PROFILE = path.join(process.env.TEMP || '/tmp', 'cdp-prof-repro-' + process.pid)
|
||||
|
||||
fs.mkdirSync(OUT, { recursive: true })
|
||||
fs.rmSync(PROFILE, { recursive: true, force: true })
|
||||
|
||||
const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'))
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const BOOT_SCRIPT = `
|
||||
try {
|
||||
localStorage.setItem('token', ${JSON.stringify(session.token)})
|
||||
localStorage.setItem('tokenExpiresAt', ${JSON.stringify(session.expiresAt)})
|
||||
localStorage.setItem('userInfo', ${JSON.stringify(JSON.stringify(session.user))})
|
||||
} catch (e) {}
|
||||
`
|
||||
|
||||
const chrome = spawn(CHROME, [
|
||||
'--headless=new',
|
||||
'--disable-gpu',
|
||||
'--no-sandbox',
|
||||
'--hide-scrollbars',
|
||||
`--remote-debugging-port=${PORT}`,
|
||||
`--user-data-dir=${PROFILE}`,
|
||||
'--window-size=1680,1300',
|
||||
'about:blank',
|
||||
])
|
||||
|
||||
let msgId = 0
|
||||
function send(ws, method, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++msgId
|
||||
const onMessage = (raw) => {
|
||||
const msg = JSON.parse(raw)
|
||||
if (msg.id !== id) return
|
||||
ws.off('message', onMessage)
|
||||
msg.error ? reject(new Error(method + ': ' + msg.error.message)) : resolve(msg.result)
|
||||
}
|
||||
ws.on('message', onMessage)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
setTimeout(() => reject(new Error(method + ' 超时')), 25000)
|
||||
})
|
||||
}
|
||||
|
||||
async function evaluate(ws, expression) {
|
||||
const res = await send(ws, 'Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
})
|
||||
if (res.exceptionDetails) {
|
||||
const d = res.exceptionDetails
|
||||
throw new Error('[evaluate] ' + (d.exception?.description || d.text || '').slice(0, 400))
|
||||
}
|
||||
return res.result.value
|
||||
}
|
||||
|
||||
async function shot(ws, name, clip) {
|
||||
const { data } = await send(
|
||||
ws,
|
||||
'Page.captureScreenshot',
|
||||
clip ? { format: 'png', clip } : { format: 'png' },
|
||||
)
|
||||
fs.writeFileSync(path.join(OUT, name), Buffer.from(data, 'base64'))
|
||||
console.log(' 截图 -> ' + path.join(OUT, name))
|
||||
}
|
||||
|
||||
const clickByText = (text, tag = 'button') => `
|
||||
(() => {
|
||||
const el = [...document.querySelectorAll('${tag}')]
|
||||
.find(e => e.textContent.trim().includes(${JSON.stringify(text)}));
|
||||
if (!el) return 'NOT_FOUND: ${text}';
|
||||
el.click();
|
||||
return 'CLICKED: ' + el.textContent.trim().slice(0, 30);
|
||||
})()`
|
||||
|
||||
async function key(ws, k, code, vk) {
|
||||
await send(ws, 'Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: k,
|
||||
code,
|
||||
windowsVirtualKeyCode: vk,
|
||||
nativeVirtualKeyCode: vk,
|
||||
})
|
||||
await send(ws, 'Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: k,
|
||||
code,
|
||||
windowsVirtualKeyCode: vk,
|
||||
nativeVirtualKeyCode: vk,
|
||||
})
|
||||
}
|
||||
|
||||
async function gotoInPage(ws, routePath, settleMs = 5000) {
|
||||
const result = await evaluate(
|
||||
ws,
|
||||
`(async () => {
|
||||
const app = document.getElementById('app')
|
||||
const router = app && app.__vue_app__ && app.__vue_app__.config.globalProperties.$router
|
||||
if (!router) return 'NO_ROUTER'
|
||||
await router.push(${JSON.stringify(routePath)})
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
return 'PUSHED:' + router.currentRoute.value.path
|
||||
})()`,
|
||||
)
|
||||
console.log(' ' + result)
|
||||
await sleep(settleMs)
|
||||
}
|
||||
|
||||
async function getTarget() {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json()
|
||||
const page = list.find((t) => t.type === 'page')
|
||||
if (page) return page
|
||||
} catch {}
|
||||
await sleep(300)
|
||||
}
|
||||
throw new Error('未找到页面')
|
||||
}
|
||||
|
||||
/** 把编辑区每个段落的叶子结构原样 dump 出来:类名 + 文本(含不可见字符的码位) */
|
||||
const LEAF_PROBE = `(() => {
|
||||
const root = document.querySelector('.pane-content [data-slate-editor]')
|
||||
if (!root) return { error: 'NO_EDITOR' }
|
||||
const codes = (s) => [...s].map((c) => (c.codePointAt(0) > 126 ? 'U+' + c.codePointAt(0).toString(16).toUpperCase() : c)).join('')
|
||||
const blocks = [...root.querySelectorAll('[data-slate-node="element"]')]
|
||||
return {
|
||||
editableHtml: root.innerHTML,
|
||||
blocks: blocks.map((el) => ({
|
||||
tag: el.tagName,
|
||||
elementHtml: el.innerHTML,
|
||||
leaves: [...el.querySelectorAll('[data-slate-leaf]')].map((leaf) => {
|
||||
const span = leaf.firstElementChild
|
||||
return {
|
||||
cls: span ? span.className : null,
|
||||
text: span ? span.textContent : null,
|
||||
codes: span ? codes(span.textContent || '') : null,
|
||||
zeroWidth: span ? span.getAttribute('data-slate-zero-width') : null,
|
||||
outer: span ? span.outerHTML : null,
|
||||
}
|
||||
}),
|
||||
textNodes: [...el.querySelectorAll('[data-slate-node="text"]')].length,
|
||||
})),
|
||||
}
|
||||
})()`
|
||||
|
||||
const pageErrors = []
|
||||
|
||||
;(async () => {
|
||||
let ws
|
||||
try {
|
||||
const target = await getTarget()
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl, { perMessageDeflate: false })
|
||||
ws.setMaxListeners(0)
|
||||
await new Promise((res, rej) => {
|
||||
ws.on('open', res)
|
||||
ws.on('error', rej)
|
||||
})
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw)
|
||||
if (msg.method === 'Runtime.exceptionThrown') {
|
||||
const d = msg.params.exceptionDetails
|
||||
pageErrors.push('[exception] ' + (d.exception?.description || d.text || '').slice(0, 300))
|
||||
}
|
||||
if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
|
||||
const text = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(' ')
|
||||
pageErrors.push('[console.error] ' + text.slice(0, 300))
|
||||
}
|
||||
})
|
||||
|
||||
await send(ws, 'Page.enable')
|
||||
await send(ws, 'Runtime.enable')
|
||||
await send(ws, 'Page.addScriptToEvaluateOnNewDocument', { source: BOOT_SCRIPT })
|
||||
await send(ws, 'Page.navigate', { url: TARGET_URL })
|
||||
await sleep(7000)
|
||||
|
||||
const bootPath = await evaluate(ws, 'location.pathname')
|
||||
console.log('\n[启动] pathname = ' + bootPath)
|
||||
if (String(bootPath).includes('login')) throw new Error('会话已失效')
|
||||
|
||||
console.log('\n=== 打开文档库 → 新增文档 ===')
|
||||
await gotoInPage(ws, '/management/documents', 6000)
|
||||
console.log(' ' + (await evaluate(ws, clickByText('新增文档'))))
|
||||
await sleep(2500)
|
||||
|
||||
const hasEditor = await evaluate(
|
||||
ws,
|
||||
`!!document.querySelector('.pane-content [data-slate-editor]')`,
|
||||
)
|
||||
if (!hasEditor) throw new Error('编辑器没打开')
|
||||
|
||||
console.log('\n=== 在空段落里插入勾选框 □ ===')
|
||||
console.log(' ' + (await evaluate(ws, clickByText('勾选框 □', '.placeholder-chip'))))
|
||||
await sleep(1200)
|
||||
console.log('\n[插入后]')
|
||||
console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2))
|
||||
await shot(ws, 'tmp-repro-01-inserted.png')
|
||||
|
||||
console.log('\n=== 按一次退格 ===')
|
||||
await key(ws, 'Backspace', 'Backspace', 8)
|
||||
await sleep(1000)
|
||||
console.log('\n[退格后]')
|
||||
console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2))
|
||||
await shot(ws, 'tmp-repro-02-backspace.png')
|
||||
|
||||
console.log('\n=== 再插一次 □ 并插入 二选一,然后用 Ctrl+A + Delete 全删 ===')
|
||||
console.log(' ' + (await evaluate(ws, clickByText('勾选框 □', '.placeholder-chip'))))
|
||||
await sleep(600)
|
||||
console.log(' ' + (await evaluate(ws, clickByText('二选一 □是 □否', '.placeholder-chip'))))
|
||||
await sleep(1000)
|
||||
console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2))
|
||||
|
||||
await key(ws, 'a', 'KeyA', 65)
|
||||
await sleep(300)
|
||||
await key(ws, 'Delete', 'Delete', 46)
|
||||
await sleep(1000)
|
||||
console.log('\n[全选删除后]')
|
||||
console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2))
|
||||
await shot(ws, 'tmp-repro-03-selectall-delete.png')
|
||||
|
||||
console.log('\n=== 页面运行时错误 ===')
|
||||
console.log(pageErrors.length ? [...new Set(pageErrors)].join('\n') : '无(干净)')
|
||||
} catch (err) {
|
||||
console.error('复现失败:', err.message)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
if (ws) ws.close()
|
||||
chrome.kill()
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 签署页「点击文中签名位采集签名」的渲染与交互验证。
|
||||
*
|
||||
* 验证目标(utils/signing-document + SigningDocumentInteractive + 签署页):
|
||||
* ① 签署态的文书里,空签名位渲染成可点击的采集入口
|
||||
* (data-sign-action=signature、role=button、tabindex、✍ 点击签名 文案、手型光标);
|
||||
* ② 患者签名与医师签名是两个独立签名位,都挂着采集入口;
|
||||
* ③ 真实点击医师签名 → 打开签字板弹窗,标题是「采集「医师签名」」;
|
||||
* ④ 在签字板上画一笔 → 确认 → 签名图回显到该签名位(img.sign-doc-signature),
|
||||
* 且该位的采集入口被摘掉、页脚「还有签名位待采集」不再点名它;
|
||||
* ⑤ 患者签名位不受影响,仍然可点;
|
||||
* ⑥ 键盘 Enter 也能触发采集入口(签名位挂了 role=button,不能只有鼠标能用)。
|
||||
*
|
||||
* 用法:
|
||||
* 1. 起 dev server
|
||||
* 2. VERIFY_BASE_URL=http://127.0.0.1:<port> node scripts/login.cjs # 抓验证码
|
||||
* VERIFY_BASE_URL=http://127.0.0.1:<port> node scripts/login.cjs 1234 # 登录
|
||||
* 3. VERIFY_URL=http://127.0.0.1:<port>/ node scripts/verify-signature-click.cjs
|
||||
*
|
||||
* 注意:后端会话只活约 10 分钟,login 与 verify 要串在同一条命令里跑。
|
||||
* 依赖 ws,与 verify-pages.cjs 相同(NODE_PATH 指向隔离 workspace)。
|
||||
*/
|
||||
const { spawn } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
let WebSocket
|
||||
try {
|
||||
WebSocket = require('ws')
|
||||
} catch {
|
||||
console.error('缺少依赖 ws。请先安装:')
|
||||
console.error(' cd C:/Users/<you>/.workbuddy-ai/binaries/node/workspace')
|
||||
console.error(' <node>/npm.cmd install ws')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const CHROME = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe'
|
||||
const PORT = Number(process.env.VERIFY_CDP_PORT || 9453)
|
||||
const TARGET_URL = process.env.VERIFY_URL || 'http://127.0.0.1:5174/'
|
||||
const OUT = process.env.VERIFY_OUT || path.resolve(__dirname, '../../screenshots')
|
||||
const SESSION_FILE = path.join(__dirname, '.session.json')
|
||||
const PROFILE = path.join(
|
||||
process.env.TEMP || process.env.TMPDIR || '/tmp',
|
||||
'cdp-prof-signclick-' + process.pid,
|
||||
)
|
||||
|
||||
fs.mkdirSync(OUT, { recursive: true })
|
||||
fs.rmSync(PROFILE, { recursive: true, force: true })
|
||||
|
||||
if (!fs.existsSync(SESSION_FILE)) {
|
||||
console.error('缺少会话文件 ' + SESSION_FILE + ',请先执行 node scripts/login.cjs')
|
||||
process.exit(1)
|
||||
}
|
||||
const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'))
|
||||
console.log(`[会话] ${session.user.displayName} / token=${String(session.token).slice(0, 8)}…`)
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const BOOT_SCRIPT = `
|
||||
try {
|
||||
localStorage.setItem('token', ${JSON.stringify(session.token)})
|
||||
localStorage.setItem('tokenExpiresAt', ${JSON.stringify(session.expiresAt)})
|
||||
localStorage.setItem('userInfo', ${JSON.stringify(JSON.stringify(session.user))})
|
||||
} catch (e) {}
|
||||
`
|
||||
|
||||
const chrome = spawn(CHROME, [
|
||||
'--headless=new',
|
||||
'--disable-gpu',
|
||||
'--no-sandbox',
|
||||
'--hide-scrollbars',
|
||||
`--remote-debugging-port=${PORT}`,
|
||||
`--user-data-dir=${PROFILE}`,
|
||||
'--window-size=1680,1300',
|
||||
'about:blank',
|
||||
])
|
||||
|
||||
let msgId = 0
|
||||
function send(ws, method, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++msgId
|
||||
const onMessage = (raw) => {
|
||||
const msg = JSON.parse(raw)
|
||||
if (msg.id !== id) return
|
||||
ws.off('message', onMessage)
|
||||
msg.error ? reject(new Error(method + ': ' + msg.error.message)) : resolve(msg.result)
|
||||
}
|
||||
ws.on('message', onMessage)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
setTimeout(() => reject(new Error(method + ' 超时')), 25000)
|
||||
})
|
||||
}
|
||||
|
||||
async function evaluate(ws, expression) {
|
||||
const res = await send(ws, 'Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
})
|
||||
if (res.exceptionDetails) {
|
||||
const d = res.exceptionDetails
|
||||
throw new Error('[evaluate] ' + (d.exception?.description || d.text || '').slice(0, 400))
|
||||
}
|
||||
return res.result.value
|
||||
}
|
||||
|
||||
async function shot(ws, name, clip) {
|
||||
const { data } = await send(
|
||||
ws,
|
||||
'Page.captureScreenshot',
|
||||
clip ? { format: 'png', clip } : { format: 'png' },
|
||||
)
|
||||
const file = path.join(OUT, name)
|
||||
fs.writeFileSync(file, Buffer.from(data, 'base64'))
|
||||
console.log(' 截图 -> ' + file)
|
||||
}
|
||||
|
||||
async function mouse(ws, type, x, y, extra = {}) {
|
||||
await send(ws, 'Input.dispatchMouseEvent', {
|
||||
type,
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
button: 'left',
|
||||
buttons: type === 'mouseReleased' ? 0 : 1,
|
||||
clickCount: 1,
|
||||
pointerType: 'mouse',
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
async function clickAt(ws, x, y) {
|
||||
await mouse(ws, 'mousePressed', x, y)
|
||||
await sleep(40)
|
||||
await mouse(ws, 'mouseReleased', x, y)
|
||||
}
|
||||
|
||||
/** 元素中心点(视口坐标),元素不存在返回 null */
|
||||
async function centerOf(ws, selector) {
|
||||
return evaluate(
|
||||
ws,
|
||||
`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)})
|
||||
if (!el) return null
|
||||
const r = el.getBoundingClientRect()
|
||||
if (!r.width || !r.height) return null
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2, w: r.width, h: r.height }
|
||||
})()`,
|
||||
)
|
||||
}
|
||||
|
||||
const clickByText = (text, tag = 'button') => `
|
||||
(() => {
|
||||
const el = [...document.querySelectorAll('${tag}')]
|
||||
.find(e => e.textContent.trim().includes(${JSON.stringify(text)}));
|
||||
if (!el) return 'NOT_FOUND: ${text}';
|
||||
el.click();
|
||||
return 'CLICKED: ' + el.textContent.trim().slice(0, 30);
|
||||
})()`
|
||||
|
||||
async function gotoInPage(ws, routePath, settleMs = 5000) {
|
||||
const result = await evaluate(
|
||||
ws,
|
||||
`(async () => {
|
||||
const app = document.getElementById('app')
|
||||
const router = app && app.__vue_app__ && app.__vue_app__.config.globalProperties.$router
|
||||
if (!router) return 'NO_ROUTER'
|
||||
await router.push(${JSON.stringify(routePath)})
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
return 'PUSHED:' + router.currentRoute.value.path
|
||||
})()`,
|
||||
)
|
||||
console.log(' ' + result)
|
||||
await sleep(settleMs)
|
||||
}
|
||||
|
||||
async function getTarget() {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json()
|
||||
const page = list.find((t) => t.type === 'page')
|
||||
if (page) return page
|
||||
} catch {}
|
||||
await sleep(300)
|
||||
}
|
||||
throw new Error('未找到页面')
|
||||
}
|
||||
|
||||
/** 文书探针:签名位的可点击状态、回显的签名图、页脚提示、签字板弹窗 */
|
||||
const DOC_PROBE = `(() => {
|
||||
const body = document.querySelector('.sign-doc-body')
|
||||
if (!body) return { error: 'NO_DOC_BODY' }
|
||||
const hosts = [...body.querySelectorAll('[data-sign-field]')]
|
||||
const sigHosts = hosts.filter((h) => h.dataset.signKind === 'signature')
|
||||
const footer = document.querySelector('.sign-doc-footer')
|
||||
const card = document.querySelector('.signature-dialog')
|
||||
return {
|
||||
fieldIds: hosts.map((h) => h.dataset.signField),
|
||||
signatureHosts: sigHosts.map((h) => {
|
||||
const img = h.querySelector('img.sign-doc-signature')
|
||||
const cs = getComputedStyle(h)
|
||||
return {
|
||||
id: h.dataset.signField,
|
||||
label: h.dataset.signLabel,
|
||||
action: h.dataset.signAction || null,
|
||||
role: h.getAttribute('role'),
|
||||
tabindex: h.getAttribute('tabindex'),
|
||||
title: h.title || null,
|
||||
actionableClass: h.classList.contains('sign-field--actionable'),
|
||||
controlText: (h.querySelector('.sign-field__control') || {}).textContent?.trim() ?? null,
|
||||
cursor: cs.cursor,
|
||||
background: cs.backgroundColor,
|
||||
borderColor: cs.borderTopColor,
|
||||
imageSrcPrefix: img ? img.src.slice(0, 22) : null,
|
||||
imageSrcLength: img ? img.src.length : 0,
|
||||
}
|
||||
}),
|
||||
footer: footer ? footer.innerText.replace(/\\s+/g, ' ').trim() : null,
|
||||
dialog: card
|
||||
? {
|
||||
title: card.querySelector('.dialog-header strong')?.textContent.trim() ?? null,
|
||||
hint: card.querySelector('.dialog-hint')?.textContent.trim() ?? null,
|
||||
buttons: [...card.querySelectorAll('button')].map((b) => b.textContent.trim()),
|
||||
canvasInk: (() => {
|
||||
const c = card.querySelector('canvas')
|
||||
if (!c) return null
|
||||
const ctx = c.getContext('2d')
|
||||
const data = ctx.getImageData(0, 0, c.width, c.height).data
|
||||
let painted = 0
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i] > 0) painted++
|
||||
return painted
|
||||
})(),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})()`
|
||||
|
||||
const pageErrors = []
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(TARGET_URL)
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||
} catch (err) {
|
||||
console.error(`\n[致命] 无法访问 ${TARGET_URL} —— ${err.message}`)
|
||||
console.error('请先启动 dev server,并用 VERIFY_URL 指定正确地址。\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let ws
|
||||
try {
|
||||
const target = await getTarget()
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl, { perMessageDeflate: false })
|
||||
ws.setMaxListeners(0)
|
||||
await new Promise((res, rej) => {
|
||||
ws.on('open', res)
|
||||
ws.on('error', rej)
|
||||
})
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw)
|
||||
if (msg.method === 'Runtime.exceptionThrown') {
|
||||
const d = msg.params.exceptionDetails
|
||||
pageErrors.push('[exception] ' + (d.exception?.description || d.text || '').slice(0, 300))
|
||||
}
|
||||
if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
|
||||
const text = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(' ')
|
||||
pageErrors.push('[console.error] ' + text.slice(0, 300))
|
||||
}
|
||||
})
|
||||
|
||||
await send(ws, 'Page.enable')
|
||||
await send(ws, 'Runtime.enable')
|
||||
await send(ws, 'Page.addScriptToEvaluateOnNewDocument', { source: BOOT_SCRIPT })
|
||||
|
||||
await send(ws, 'Page.navigate', { url: TARGET_URL })
|
||||
await sleep(7000)
|
||||
|
||||
const bootPath = await evaluate(ws, 'location.pathname')
|
||||
console.log('\n[启动] pathname = ' + bootPath)
|
||||
if (String(bootPath).includes('login')) {
|
||||
throw new Error('会话已失效,落到了登录页。重新执行 node scripts/login.cjs')
|
||||
}
|
||||
|
||||
console.log('\n=== 打开签署工作台 ===')
|
||||
await gotoInPage(ws, '/workbench/signing', 6000)
|
||||
|
||||
console.log('\n=== 选一个待签署任务 ===')
|
||||
const picked = await evaluate(
|
||||
ws,
|
||||
`(() => {
|
||||
const items = [...document.querySelectorAll('.task-item')]
|
||||
if (!items.length) return 'NO_TASK_ITEM'
|
||||
const pending = items.find((el) => el.textContent.includes('待签署'))
|
||||
if (!pending) return 'NO_PENDING_TASK:' + items.length
|
||||
pending.click()
|
||||
return 'PICKED: ' + pending.textContent.replace(/\\s+/g, ' ').trim().slice(0, 60)
|
||||
})()`,
|
||||
)
|
||||
console.log(' ' + picked)
|
||||
if (String(picked).startsWith('NO_')) throw new Error('没有可用的待签署任务:' + picked)
|
||||
await sleep(4000)
|
||||
|
||||
console.log('\n=== ① 签名位初始渲染探针 ===')
|
||||
const before = await evaluate(ws, DOC_PROBE)
|
||||
console.log(JSON.stringify(before, null, 2))
|
||||
await shot(ws, '06-signature-actions.png')
|
||||
|
||||
if (before.error) throw new Error('文书没有渲染:' + before.error)
|
||||
|
||||
const actionable = before.signatureHosts.filter((h) => h.action === 'signature')
|
||||
console.log(`\n 可点击签名位 ${actionable.length} 个:${actionable.map((h) => h.label).join('、')}`)
|
||||
|
||||
const doctor = before.signatureHosts.find((h) => h.label === '医师签名')
|
||||
if (!doctor) throw new Error('文书里没有「医师签名」签名位,无法验证点击采集')
|
||||
if (doctor.action !== 'signature') throw new Error('「医师签名」没有渲染成可点击采集入口')
|
||||
if (doctor.cursor !== 'pointer') throw new Error('「医师签名」没有手型光标,观感上看不出能点')
|
||||
if (!doctor.actionableClass) throw new Error('「医师签名」缺少 sign-field--actionable 类')
|
||||
|
||||
console.log('\n=== ② 真实点击「医师签名」 ===')
|
||||
const doctorBox = await centerOf(ws, '[data-sign-field="doctor"]')
|
||||
if (!doctorBox) throw new Error('找不到「医师签名」宿主节点的可点区域')
|
||||
await clickAt(ws, doctorBox.x, doctorBox.y)
|
||||
await sleep(1200)
|
||||
|
||||
const dialog = await evaluate(ws, DOC_PROBE)
|
||||
console.log(' 弹窗:' + JSON.stringify(dialog.dialog))
|
||||
if (!dialog.dialog) throw new Error('点击「医师签名」没有打开签字板弹窗')
|
||||
if (!String(dialog.dialog.title).includes('医师签名')) {
|
||||
throw new Error('弹窗标题没有指向被点的签名位:' + dialog.dialog.title)
|
||||
}
|
||||
await shot(ws, '07-signature-dialog.png')
|
||||
|
||||
console.log('\n=== ③ 在签字板上画一笔并确认 ===')
|
||||
const canvasBox = await centerOf(ws, '.signature-dialog canvas')
|
||||
if (!canvasBox) throw new Error('签字板 canvas 不可见')
|
||||
const x0 = canvasBox.x - canvasBox.w * 0.3
|
||||
const y0 = canvasBox.y
|
||||
await mouse(ws, 'mousePressed', x0, y0)
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
await mouse(ws, 'mouseMoved', x0 + i * (canvasBox.w * 0.05), y0 + (i % 2 ? 18 : -18))
|
||||
await sleep(16)
|
||||
}
|
||||
await mouse(ws, 'mouseReleased', x0 + canvasBox.w * 0.6, y0)
|
||||
await sleep(600)
|
||||
|
||||
const drawn = await evaluate(ws, DOC_PROBE)
|
||||
console.log(' 画布着墨像素:' + drawn.dialog.canvasInk)
|
||||
if (!drawn.dialog.canvasInk) throw new Error('签字板上没有画出内容,无法验证回显')
|
||||
|
||||
console.log(' ' + (await evaluate(ws, clickByText('确认采集'))))
|
||||
await sleep(1500)
|
||||
|
||||
console.log('\n=== ④ 采集结果探针 ===')
|
||||
const after = await evaluate(ws, DOC_PROBE)
|
||||
console.log(JSON.stringify(after, null, 2))
|
||||
await shot(ws, '08-signature-applied.png')
|
||||
|
||||
const doctorAfter = after.signatureHosts.find((h) => h.id === 'doctor')
|
||||
if (!doctorAfter) throw new Error('采集后找不到「医师签名」签名位')
|
||||
if (!doctorAfter.imageSrcPrefix?.startsWith('data:image/png')) {
|
||||
throw new Error('「医师签名」没有回显签名图:' + doctorAfter.imageSrcPrefix)
|
||||
}
|
||||
if (doctorAfter.action) throw new Error('「医师签名」已采集,却还留着采集入口')
|
||||
if (doctorAfter.actionableClass) throw new Error('「医师签名」已采集,却还留着可点击样式')
|
||||
|
||||
const patientAfter = after.signatureHosts.find((h) => h.id === 'signature')
|
||||
if (!patientAfter) throw new Error('找不到「患者签名」签名位')
|
||||
if (patientAfter.action !== 'signature') {
|
||||
throw new Error('采集医师签名后,患者签名位的采集入口被误伤')
|
||||
}
|
||||
|
||||
if (after.footer && after.footer.includes('医师签名')) {
|
||||
throw new Error('页脚仍在提示「医师签名」待采集:' + after.footer)
|
||||
}
|
||||
|
||||
console.log('\n=== ⑤ 键盘 Enter 触发「患者签名」 ===')
|
||||
const focused = await evaluate(
|
||||
ws,
|
||||
`(() => {
|
||||
const host = document.querySelector('[data-sign-field="signature"]')
|
||||
if (!host) return 'NO_HOST'
|
||||
host.focus()
|
||||
return document.activeElement === host ? 'FOCUSED' : 'FOCUS_FAILED'
|
||||
})()`,
|
||||
)
|
||||
console.log(' ' + focused)
|
||||
await send(ws, 'Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
nativeVirtualKeyCode: 13,
|
||||
})
|
||||
await send(ws, 'Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
nativeVirtualKeyCode: 13,
|
||||
})
|
||||
await sleep(1200)
|
||||
|
||||
const keyboard = await evaluate(ws, DOC_PROBE)
|
||||
console.log(' 弹窗:' + JSON.stringify(keyboard.dialog && keyboard.dialog.title))
|
||||
if (!keyboard.dialog || !String(keyboard.dialog.title).includes('患者签名')) {
|
||||
throw new Error('键盘 Enter 没有触发「患者签名」的采集入口')
|
||||
}
|
||||
await shot(ws, '09-signature-keyboard.png')
|
||||
|
||||
console.log('\n=== 页面运行时错误 ===')
|
||||
console.log(pageErrors.length ? [...new Set(pageErrors)].slice(0, 20).join('\n') : '无(干净)')
|
||||
if (pageErrors.length) process.exitCode = 1
|
||||
} catch (err) {
|
||||
console.error('验证失败:', err.message)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
if (ws) ws.close()
|
||||
chrome.kill()
|
||||
}
|
||||
})()
|
||||
@@ -275,16 +275,20 @@ function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[]
|
||||
next = { ...next, signatureDataUrl: DEMO_SIGNATURE_DATA_URL }
|
||||
}
|
||||
|
||||
if (next.documentContentHtml) {
|
||||
return next
|
||||
}
|
||||
|
||||
const template = templates.get(next.documentId)
|
||||
|
||||
if (!template?.contentHtml) {
|
||||
return next
|
||||
}
|
||||
|
||||
// 已经进入签署流程的任务保留当时固化的正文——那是留痕的一部分,不能事后被换掉。
|
||||
// 尚未开始签署的任务则跟着演示种子走:演示数据是持久化在 localStorage 里的,
|
||||
// 改了 mock-document-library 的种子却还渲染上一轮的正文,表现为「新加的内容看不见」,
|
||||
// 排查成本很高(本地文档库演示入口已随 documents 开关一起关掉了)。
|
||||
if (next.documentContentHtml && !isTaskContentRefreshable(next.status)) {
|
||||
return next
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
templateVersionId: next.templateVersionId || template.versionId,
|
||||
@@ -293,6 +297,11 @@ function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[]
|
||||
})
|
||||
}
|
||||
|
||||
/** 正文可跟随演示种子刷新的状态:还没真正开始签署,正文只是待签的快照 */
|
||||
function isTaskContentRefreshable(status: SigningTaskStatus): boolean {
|
||||
return status === 'pending' || status === 'expired'
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示任务记录:优先用本地保存的,刷新后仍保留(任务固化的文书 HTML、签名图都在里面)。
|
||||
* 所有写操作都要调用 persistMockRecords 落盘,否则刷新会回到切换前的状态。
|
||||
|
||||
@@ -86,7 +86,10 @@ interface DemoDocumentSpec {
|
||||
/**
|
||||
* 生成演示文书正文。
|
||||
* 标题、说明段、勾选表、签名位、签署日期都用真实文书的写法,
|
||||
* 其中 {患者签名}{签署日期} 是签署端识别签名位与日期的依据。
|
||||
* 其中 {患者签名}{医师签名}{签署日期} 是签署端识别签名位与日期的依据。
|
||||
*
|
||||
* 患者签名与医师签名是两栏:前者是主签名位(手写板/线上签署采集),
|
||||
* 后者是文书上的独立签名位,由医师在签署页点击就地采集,两者互不覆盖。
|
||||
*/
|
||||
function buildDemoDocumentHtml(spec: DemoDocumentSpec): string {
|
||||
const paragraphs = [
|
||||
@@ -109,6 +112,7 @@ function buildDemoDocumentHtml(spec: DemoDocumentSpec): string {
|
||||
checkboxTable,
|
||||
...extraFields,
|
||||
'<p>患者(或家属)签名:{患者签名}</p>',
|
||||
'<p>医师签名:{医师签名}</p>',
|
||||
'<p>签署日期:{签署日期}</p>',
|
||||
].join('')
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/wo
|
||||
import {
|
||||
createFieldTokenPattern,
|
||||
FIELD_TOKEN_OPEN,
|
||||
PRIMARY_SIGNATURE_FIELD_ID,
|
||||
resolveAnswersFromSystem,
|
||||
resolveFieldByLabel,
|
||||
SIGNING_DOCUMENT_HEADER,
|
||||
@@ -44,6 +45,20 @@ const FIELD_KIND_ATTR = 'data-sign-kind'
|
||||
const FIELD_LABEL_ATTR = 'data-sign-label'
|
||||
const FIELD_REQUIRED_ATTR = 'data-sign-required'
|
||||
const FIELD_RISK_ATTR = 'data-sign-risk'
|
||||
/** 需要宿主接管交互的填写项(目前只有「点击采集签名」),值是动作名 */
|
||||
const FIELD_ACTION_ATTR = 'data-sign-action'
|
||||
/** 签名位的采集动作名 */
|
||||
const SIGNATURE_ACTION = 'signature'
|
||||
/** 已采集签名图的类名,readAnswersFromRoot 与写入端都按它找 */
|
||||
const SIGNATURE_IMAGE_CLASS = 'sign-doc-signature'
|
||||
/** 可点击采集的签名位类名,样式与摘除入口时都要用同一个 */
|
||||
const ACTIONABLE_CLASS = 'sign-field--actionable'
|
||||
|
||||
/**
|
||||
* 可点击采集签名的填写项宿主节点选择器。
|
||||
* 导出给签署端做事件委托用 —— 渲染与识别必须指向同一个属性,不能两边各写一份字符串。
|
||||
*/
|
||||
export const SIGNATURE_ACTION_SELECTOR = `[${FIELD_ACTION_ATTR}="${SIGNATURE_ACTION}"]`
|
||||
|
||||
/**
|
||||
* 科室手打的复选框符号见 SIGNING_OPTION_MARKERS(utils/signing-fields):
|
||||
@@ -66,6 +81,25 @@ export interface SigningFieldProgress {
|
||||
missingRequired: string[]
|
||||
/** 取值命中风险的项标签 */
|
||||
abnormal: string[]
|
||||
/**
|
||||
* 仍未采集的签名位标签(不含主签名位「患者签名」)。
|
||||
*
|
||||
* 签名位在文书里可以逐个点击采集(医师签名、家属签名……),
|
||||
* 主签名位则是手写板 / 线上签署要采集的那一个,把它列进来等于让签名入口挡住自己。
|
||||
* 签署端用这份清单在采集前提醒「还有签名位没签」,避免文书带着空签名位被固化。
|
||||
*/
|
||||
pendingSignatures: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 签署端点击文中签名位后发出的采集请求。
|
||||
*
|
||||
* label 一并带出去,是为了让签字板弹窗能说清「现在采的是谁的名字」——
|
||||
* 只传 fieldId 的话弹窗只能显示一个内部键,操作人员看不出这笔签名要落在哪。
|
||||
*/
|
||||
export interface SigningSignatureRequest {
|
||||
fieldId: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function buildFieldWidget(
|
||||
@@ -73,6 +107,7 @@ function buildFieldWidget(
|
||||
field: SigningFieldDef,
|
||||
answers: SigningAnswers,
|
||||
interactive: boolean,
|
||||
signatureActionable: boolean,
|
||||
): HTMLElement {
|
||||
const value = answers[field.id]
|
||||
const abnormal =
|
||||
@@ -89,6 +124,18 @@ function buildFieldWidget(
|
||||
host.setAttribute(FIELD_RISK_ATTR, field.risk)
|
||||
}
|
||||
|
||||
// 已签名的签名位不再挂动作:留着会让"还能再点一次"的观感落空
|
||||
const signaturePending = field.kind === 'signature' && !value
|
||||
|
||||
if (signatureActionable && signaturePending) {
|
||||
host.classList.add(ACTIONABLE_CLASS)
|
||||
host.setAttribute(FIELD_ACTION_ATTR, SIGNATURE_ACTION)
|
||||
// 可点就得可键盘触发,否则只在鼠标下可用
|
||||
host.setAttribute('role', 'button')
|
||||
host.setAttribute('tabindex', '0')
|
||||
host.setAttribute('title', `点击采集「${field.label}」`)
|
||||
}
|
||||
|
||||
const label = doc.createElement('span')
|
||||
label.className = 'sign-field__label'
|
||||
label.textContent = field.label
|
||||
@@ -104,7 +151,7 @@ function buildFieldWidget(
|
||||
|
||||
const control = doc.createElement('span')
|
||||
control.className = 'sign-field__control'
|
||||
control.appendChild(buildFieldControl(doc, field, value, interactive))
|
||||
control.appendChild(buildFieldControl(doc, field, value, interactive, signaturePending))
|
||||
host.appendChild(control)
|
||||
|
||||
if (abnormal) {
|
||||
@@ -122,25 +169,30 @@ function buildFieldControl(
|
||||
field: SigningFieldDef,
|
||||
value: SigningAnswers[string],
|
||||
interactive: boolean,
|
||||
signaturePending: boolean,
|
||||
): Node {
|
||||
const textValue = typeof value === 'string' ? value : value ? '是' : ''
|
||||
|
||||
if (field.kind === 'signature') {
|
||||
if (textValue) {
|
||||
const image = doc.createElement('img')
|
||||
image.className = 'sign-doc-signature'
|
||||
image.className = SIGNATURE_IMAGE_CLASS
|
||||
image.src = textValue
|
||||
image.alt = field.label
|
||||
return image
|
||||
}
|
||||
|
||||
// 签名位只写「待签名」:宿主胶囊上已经写着「医师签名」了,
|
||||
// 这里再写一遍「待医师签名」是重复信息
|
||||
const empty = doc.createElement('span')
|
||||
empty.className = 'sign-field__signature-empty'
|
||||
empty.textContent = `待${field.label}`
|
||||
empty.textContent = signaturePending ? '✍ 点击签名' : '待签名'
|
||||
return empty
|
||||
}
|
||||
|
||||
if (field.kind === 'choice') {
|
||||
// 有选项才渲染单选组;没有选项(结构化占位符只声明了 kind=choice 却没给 options)
|
||||
// 就退化成文本框,否则科室拿到的是一个点不动的空控件
|
||||
if (field.kind === 'choice' && field.options.length) {
|
||||
const group = doc.createElement('span')
|
||||
group.className = 'sign-field__choices'
|
||||
|
||||
@@ -209,7 +261,12 @@ function buildFieldControl(
|
||||
* 模板未收录的占位符按自定义文本项处理,仍可在文书上填写,
|
||||
* 避免出现"写了占位符但签署时既填不上也看不见"的情况。
|
||||
*/
|
||||
function renderTokenFields(root: Element, answers: SigningAnswers, interactive: boolean) {
|
||||
function renderTokenFields(
|
||||
root: Element,
|
||||
answers: SigningAnswers,
|
||||
interactive: boolean,
|
||||
signatureActionable: boolean,
|
||||
) {
|
||||
const doc = root.ownerDocument
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
const targets: Text[] = []
|
||||
@@ -236,7 +293,13 @@ function renderTokenFields(root: Element, answers: SigningAnswers, interactive:
|
||||
}
|
||||
|
||||
fragment.appendChild(
|
||||
buildFieldWidget(doc, resolveFieldByLabel(match[1] ?? ''), answers, interactive),
|
||||
buildFieldWidget(
|
||||
doc,
|
||||
resolveFieldByLabel(match[1] ?? ''),
|
||||
answers,
|
||||
interactive,
|
||||
signatureActionable,
|
||||
),
|
||||
)
|
||||
|
||||
cursor = pattern.lastIndex
|
||||
@@ -255,7 +318,12 @@ function renderTokenFields(root: Element, answers: SigningAnswers, interactive:
|
||||
* 该写法是目标格式(字段语义显式、不依赖中文标签),
|
||||
* 供后端模板或后续换编辑器后生产的内容使用。
|
||||
*/
|
||||
function renderStructuredFields(root: Element, answers: SigningAnswers, interactive: boolean) {
|
||||
function renderStructuredFields(
|
||||
root: Element,
|
||||
answers: SigningAnswers,
|
||||
interactive: boolean,
|
||||
signatureActionable: boolean,
|
||||
) {
|
||||
const doc = root.ownerDocument
|
||||
|
||||
root.querySelectorAll('span[data-field]').forEach((element) => {
|
||||
@@ -275,7 +343,7 @@ function renderStructuredFields(root: Element, answers: SigningAnswers, interact
|
||||
risk: element.getAttribute('data-risk') ?? undefined,
|
||||
}
|
||||
|
||||
element.replaceWith(buildFieldWidget(doc, field, answers, interactive))
|
||||
element.replaceWith(buildFieldWidget(doc, field, answers, interactive, signatureActionable))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -488,6 +556,18 @@ function buildDocumentHeader(doc: Document, answers: SigningAnswers): HTMLElemen
|
||||
return header
|
||||
}
|
||||
|
||||
/** 渲染开关。默认全部关闭,只读展示是"安全侧"缺省。 */
|
||||
export interface SigningDocumentRenderOptions {
|
||||
/**
|
||||
* 签名位是否渲染成「✍ 点击签名」的可点按钮。
|
||||
*
|
||||
* 与 interactive 分开是必须的:文书库的静态预览也是 interactive 的
|
||||
* (科室能在预览里试勾选、确认交互效果),但那里没有事件委托宿主,
|
||||
* 签名位点了不会有任何反应 —— 渲染成可点按钮就是骗人。
|
||||
*/
|
||||
signatureActionable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签署态文书 HTML。
|
||||
* contentHtml 为空时返回空串;解析失败时原样返回,不会抛错。
|
||||
@@ -497,6 +577,7 @@ export function buildSigningDocumentHtml(
|
||||
contentHtml: string,
|
||||
data: SigningDocumentData,
|
||||
interactive = false,
|
||||
options: SigningDocumentRenderOptions = {},
|
||||
): string {
|
||||
if (!contentHtml) {
|
||||
return ''
|
||||
@@ -522,8 +603,10 @@ export function buildSigningDocumentHtml(
|
||||
answers.signature = data.signatureDataUrl
|
||||
}
|
||||
|
||||
renderStructuredFields(root, answers, interactive)
|
||||
renderTokenFields(root, answers, interactive)
|
||||
const signatureActionable = interactive && Boolean(options.signatureActionable)
|
||||
|
||||
renderStructuredFields(root, answers, interactive, signatureActionable)
|
||||
renderTokenFields(root, answers, interactive, signatureActionable)
|
||||
|
||||
const selected = new Set(data.selectedItems ?? [])
|
||||
convertCheckboxes(root, interactive, selected)
|
||||
@@ -574,6 +657,49 @@ export function readAnswersFromRoot(root: HTMLElement): SigningAnswers {
|
||||
return answers
|
||||
}
|
||||
|
||||
/**
|
||||
* 把签名图写进指定签名位,并摘掉它的采集入口。返回是否真的发生了改动。
|
||||
*
|
||||
* 只改这一个宿主节点,不重建整篇文书 —— 整篇重建会换掉 v-html 的 DOM,
|
||||
* 操作人员正在输入的填写项会连同光标一起丢。写完后由调用方回读 DOM
|
||||
* (readAnswersFromRoot),文书 DOM 始终是取值的唯一来源。
|
||||
*
|
||||
* 签名图只作为 <img src> 写进渲染结果,不进模板正文:
|
||||
* 它是 base64 大字符串,进正文会让模板哈希随签名变化,缓存键跟着失效。
|
||||
*/
|
||||
export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl: string): boolean {
|
||||
if (!fieldId || !dataUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
const host = root.querySelector<HTMLElement>(`[${FIELD_ATTR}="${fieldId}"]`)
|
||||
const control = host?.querySelector('.sign-field__control')
|
||||
|
||||
if (!host || !control) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 同一张图重复写入没有意义,返回 false 也让调用方少跑一轮回读
|
||||
if (control.querySelector(`img.${SIGNATURE_IMAGE_CLASS}`)?.getAttribute('src') === dataUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
const image = root.ownerDocument.createElement('img')
|
||||
image.className = SIGNATURE_IMAGE_CLASS
|
||||
image.src = dataUrl
|
||||
image.alt = host.getAttribute(FIELD_LABEL_ATTR) ?? fieldId
|
||||
control.replaceChildren(image)
|
||||
|
||||
// 采集入口用完即摘:留着会让「还能再点一次」的观感落空
|
||||
host.classList.remove(ACTIONABLE_CLASS)
|
||||
host.removeAttribute(FIELD_ACTION_ATTR)
|
||||
host.removeAttribute('role')
|
||||
host.removeAttribute('tabindex')
|
||||
host.removeAttribute('title')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计填写项进度:已填数量、未填的必填项、命中的异常项。
|
||||
*
|
||||
@@ -589,6 +715,7 @@ export function describeFieldProgress(
|
||||
filled: 0,
|
||||
missingRequired: [],
|
||||
abnormal: [],
|
||||
pendingSignatures: [],
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
|
||||
@@ -617,6 +744,17 @@ export function describeFieldProgress(
|
||||
progress.missingRequired.push(label)
|
||||
}
|
||||
|
||||
// 待采集的签名位 = 挂上了采集动作、却还没采集的那一个(见 buildFieldWidget)。
|
||||
// 直接认渲染时打的动作标记,而不是自己再推一遍「kind=signature 且无值」:
|
||||
// 动作标记本身已经排除了已签名的位,两边判据一致才不会出现
|
||||
// 「界面显示不可点、清单却催你点」的矛盾。
|
||||
if (
|
||||
host.getAttribute(FIELD_ACTION_ATTR) === SIGNATURE_ACTION &&
|
||||
id !== PRIMARY_SIGNATURE_FIELD_ID
|
||||
) {
|
||||
progress.pendingSignatures.push(label)
|
||||
}
|
||||
|
||||
if (risk && value === risk) {
|
||||
progress.abnormal.push(label)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,20 @@ export function createFieldTokenPattern() {
|
||||
return new RegExp(FIELD_TOKEN_SOURCE, 'g')
|
||||
}
|
||||
|
||||
/**
|
||||
* 主签名位字段键(患者签名)。
|
||||
*
|
||||
* 一个签署任务只有一份「签名图」产物,接口与任务记录都把它固定落在 signature 上
|
||||
* (见 api/workbench/signing 的 completeSigningTask:`{ ...fieldAnswers, signature }`)。
|
||||
* 其余签名位(如医师签名)各自存在 fieldAnswers 里,互不覆盖。
|
||||
*
|
||||
* 这条约定有两个用处:
|
||||
* ① 手写板 / 线上签署采集的就是主签名位,因此它不能算「待补的签名位」,
|
||||
* 否则签名入口会把自己挡住;
|
||||
* ② 判断"还有别的签名位没签"时要排除它。
|
||||
*/
|
||||
export const PRIMARY_SIGNATURE_FIELD_ID = 'signature'
|
||||
|
||||
/**
|
||||
* 填写项目录:key 为字段键,label 是文书里显示的文字,kind 决定签署时的控件。
|
||||
* id 与 label 的对应关系由这里统一维护,渲染端与编辑器共用同一份,避免两处各自演化。
|
||||
|
||||
@@ -52,6 +52,7 @@ const signingProgress = ref<SigningFieldProgress>({
|
||||
filled: 0,
|
||||
missingRequired: [],
|
||||
abnormal: [],
|
||||
pendingSignatures: [],
|
||||
})
|
||||
const signingBodyRef = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -69,7 +70,13 @@ watch(
|
||||
if (visible) {
|
||||
activeTab.value = 'content'
|
||||
signingSelection.value = []
|
||||
signingProgress.value = { total: 0, filled: 0, missingRequired: [], abnormal: [] }
|
||||
signingProgress.value = {
|
||||
total: 0,
|
||||
filled: 0,
|
||||
missingRequired: [],
|
||||
abnormal: [],
|
||||
pendingSignatures: [],
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { nextTick, ref, useId, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||
|
||||
import SignatureCanvas from './SignatureCanvas.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
task: SigningTaskRecord | null
|
||||
}>()
|
||||
// 同一个签字板承担两件事:任务级的主签名采集,以及文书里单个签名位(医师签名、
|
||||
// 家属签名……)的采集。两者只有文案与「确认后做什么」不同,采集方式完全一样,
|
||||
// 因此用参数区分而不是复制一份弹窗。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
task: SigningTaskRecord | null
|
||||
/** 弹窗标题:任务级是「手写板签署」,按签名位采集时传字段名(如「医师签名」) */
|
||||
title?: string
|
||||
/** 说明这一笔签名要落在哪里,避免医师签在了患者签名位 */
|
||||
hint?: string
|
||||
confirmLabel?: string
|
||||
}>(),
|
||||
{
|
||||
title: '手写板签署',
|
||||
hint: '请患者在下框内签名(当前为 Canvas 演示,真实手写板接入走设备适配器):',
|
||||
confirmLabel: '确认签署',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [signatureDataUrl: string]
|
||||
}>()
|
||||
|
||||
// 两个实例可能同时挂在 DOM 上(主签名位与某个签名位),标题 id 必须各自唯一,
|
||||
// 否则 aria-labelledby 会指到别人的标题上
|
||||
const titleId = useId()
|
||||
|
||||
const signatureDataUrl = ref<string | null>(null)
|
||||
const signatureCanvas = ref<{ clear: () => void } | null>(null)
|
||||
|
||||
@@ -37,7 +56,7 @@ function clearSignature() {
|
||||
|
||||
function confirmSignature() {
|
||||
if (!signatureDataUrl.value) {
|
||||
ElMessage.warning('请先完成患者签名')
|
||||
ElMessage.warning('请先完成签名')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,19 +71,17 @@ function confirmSignature() {
|
||||
class="dialog-card signature-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pad-title"
|
||||
:aria-labelledby="titleId"
|
||||
>
|
||||
<header class="dialog-header">
|
||||
<strong id="pad-title">手写板签署</strong>
|
||||
<strong :id="titleId">{{ title }}</strong>
|
||||
<span>{{ task.patientName }} · {{ task.documentName }}</span>
|
||||
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-hint">
|
||||
请患者在下框内签名(当前为 Canvas 演示,真实手写板接入走设备适配器):
|
||||
</p>
|
||||
<p class="dialog-hint">{{ hint }}</p>
|
||||
<SignatureCanvas
|
||||
ref="signatureCanvas"
|
||||
:height="300"
|
||||
@@ -77,7 +94,9 @@ function confirmSignature() {
|
||||
<button type="button" class="action-button action-button--ghost" @click="clearSignature">
|
||||
清空重写
|
||||
</button>
|
||||
<button type="button" class="action-button" @click="confirmSignature">确认签署</button>
|
||||
<button type="button" class="action-button" @click="confirmSignature">
|
||||
{{ confirmLabel }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+145
-2
@@ -3,11 +3,14 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { SigningAnswers, SigningTaskRecord } from '@/api/workbench/types'
|
||||
import {
|
||||
applySignatureToRoot,
|
||||
buildSigningDocumentHtml,
|
||||
describeFieldProgress,
|
||||
readAnswersFromRoot,
|
||||
SIGNATURE_ACTION_SELECTOR,
|
||||
type SigningDocumentData,
|
||||
type SigningFieldProgress,
|
||||
type SigningSignatureRequest,
|
||||
} from '@/utils/signing-document'
|
||||
|
||||
// 签署文书交互化渲染:填写项渲染(HIS 预填 + 现场确认)、签名回显、勾选收集见
|
||||
@@ -17,13 +20,22 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
task: SigningTaskRecord
|
||||
interactive?: boolean
|
||||
/**
|
||||
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
|
||||
*
|
||||
* 不走 task:task 是服务端快照,这里装的是"本次会话刚采到、还没提交"的取值。
|
||||
* 单独一份也让「整篇重渲染」不会把刚采的签名冲掉。
|
||||
*/
|
||||
signatureInjections?: SigningAnswers
|
||||
}>(),
|
||||
{ interactive: false },
|
||||
{ interactive: false, signatureInjections: () => ({}) },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'selection-change': [items: string[]]
|
||||
'field-change': [answers: SigningAnswers, progress: SigningFieldProgress]
|
||||
/** 请求采集某个签名位,由签署页打开签字板并把结果回灌到 signatureInjections */
|
||||
'signature-request': [request: SigningSignatureRequest]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
@@ -34,6 +46,7 @@ const progress = ref<SigningFieldProgress>({
|
||||
filled: 0,
|
||||
missingRequired: [],
|
||||
abnormal: [],
|
||||
pendingSignatures: [],
|
||||
})
|
||||
|
||||
// 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象:
|
||||
@@ -55,6 +68,9 @@ const processedHtml = computed(() =>
|
||||
props.task.documentContentHtml ?? '',
|
||||
documentData.value,
|
||||
props.interactive,
|
||||
// 本组件就是签名位的点击宿主(见 handleDocumentClick),
|
||||
// 所以可采集与否只取决于文书当前是否可交互
|
||||
{ signatureActionable: props.interactive },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -158,10 +174,100 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(processedHtml, applyStoredValues, { flush: 'post' })
|
||||
/**
|
||||
* 从事件目标往上找「可点击采集的签名位」。
|
||||
* 用 closest 而不是直接比对 target:签名位胶囊里还有类型标签、虚线框等子节点,
|
||||
* 点在这些子节点上时 target 并不是宿主节点本身。
|
||||
*/
|
||||
function resolveSignatureHost(target: EventTarget | null): HTMLElement | null {
|
||||
if (!(target instanceof Element)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const host = target.closest<HTMLElement>(SIGNATURE_ACTION_SELECTOR)
|
||||
|
||||
// closest 会一路向上穿过容器,事件也可能来自容器外,必须确认落在本文书里
|
||||
return host && containerRef.value?.contains(host) ? host : null
|
||||
}
|
||||
|
||||
function requestSignature(host: HTMLElement) {
|
||||
const fieldId = host.getAttribute('data-sign-field') ?? ''
|
||||
|
||||
if (!fieldId) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('signature-request', {
|
||||
fieldId,
|
||||
label: host.getAttribute('data-sign-label') ?? fieldId,
|
||||
})
|
||||
}
|
||||
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!props.interactive) {
|
||||
return
|
||||
}
|
||||
|
||||
const host = resolveSignatureHost(event.target)
|
||||
|
||||
if (host) {
|
||||
requestSignature(host)
|
||||
}
|
||||
}
|
||||
|
||||
/** 签名位挂了 role=button 与 tabindex,键盘必须同样可用,否则只有鼠标点得动 */
|
||||
function handleDocumentKeydown(event: KeyboardEvent) {
|
||||
if (!props.interactive || (event.key !== 'Enter' && event.key !== ' ')) {
|
||||
return
|
||||
}
|
||||
|
||||
const host = resolveSignatureHost(event.target)
|
||||
|
||||
if (host) {
|
||||
// 空格默认会滚动容器,必须挡掉
|
||||
event.preventDefault()
|
||||
requestSignature(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把外部采集到的签名图就地写进签名位,写完立刻回读 DOM 上报取值。
|
||||
*
|
||||
* 不重算 processedHtml:整篇重建会换掉 v-html 的 DOM,
|
||||
* 操作人员正在输入的填写项会连同光标一起丢。
|
||||
*/
|
||||
function patchSignatures() {
|
||||
const container = containerRef.value
|
||||
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const patched = Object.entries(props.signatureInjections).some(
|
||||
([fieldId, value]) =>
|
||||
typeof value === 'string' && applySignatureToRoot(container, fieldId, value),
|
||||
)
|
||||
|
||||
if (patched) {
|
||||
syncFromDom()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
processedHtml,
|
||||
() => {
|
||||
applyStoredValues()
|
||||
// 重渲染会丢掉就地写入的签名图,得重新贴一遍
|
||||
patchSignatures()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
watch(() => props.signatureInjections, patchSignatures, { deep: true, flush: 'post' })
|
||||
|
||||
onMounted(() => {
|
||||
applyStoredValues()
|
||||
patchSignatures()
|
||||
syncFromDom()
|
||||
})
|
||||
|
||||
@@ -190,6 +296,8 @@ defineExpose({ getDocumentElement })
|
||||
class="sign-doc-body"
|
||||
@change="syncFromDom"
|
||||
@input="syncFromDom"
|
||||
@click="handleDocumentClick"
|
||||
@keydown="handleDocumentKeydown"
|
||||
v-html="processedHtml"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
@@ -204,6 +312,10 @@ defineExpose({ getDocumentElement })
|
||||
<span v-if="progress.missingRequired.length" class="is-missing">
|
||||
必填未完成:{{ progress.missingRequired.join('、') }},请补充后再采集签名。
|
||||
</span>
|
||||
<span v-else-if="progress.pendingSignatures.length" class="is-pending">
|
||||
还有签名位待采集:<strong>{{ progress.pendingSignatures.join('、') }}</strong
|
||||
>,可直接点击文中签名位完成。
|
||||
</span>
|
||||
<span v-else
|
||||
>完成填写后通过「手写板签署 / 线上签署」采集签名,填写项与勾选结果随签署证据一并提交。</span
|
||||
>
|
||||
@@ -297,6 +409,14 @@ defineExpose({ getDocumentElement })
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.sign-doc-footer .is-pending {
|
||||
color: #a96900;
|
||||
}
|
||||
|
||||
.sign-doc-footer .is-pending strong {
|
||||
color: #a96900;
|
||||
}
|
||||
|
||||
/* 注入的文书 HTML(v-html 内容不携带 scoped 属性,统一用 :deep 覆盖基础文书样式) */
|
||||
.sign-doc-body :deep(p) {
|
||||
margin: 0 0 6px;
|
||||
@@ -469,6 +589,29 @@ defineExpose({ getDocumentElement })
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 可点击采集的签名位:整块胶囊都是点击热区(子节点会冒泡到宿主),
|
||||
hover / 聚焦给出明确反馈,否则操作人员不知道这里能点 */
|
||||
.sign-doc-body :deep(.sign-field--actionable) {
|
||||
cursor: pointer;
|
||||
background: #fffaf0;
|
||||
border-color: #f0a21a;
|
||||
}
|
||||
|
||||
.sign-doc-body :deep(.sign-field--actionable:hover) {
|
||||
background: #fff3d8;
|
||||
border-color: #d98c05;
|
||||
box-shadow: 0 0 0 2px rgb(240 162 26 / 20%);
|
||||
}
|
||||
|
||||
.sign-doc-body :deep(.sign-field--actionable:focus-visible) {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.sign-doc-body :deep(.sign-field--actionable .sign-field__signature-empty) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 签名位比其他填写项更宽,避免签名图被压扁 */
|
||||
.sign-doc-body :deep(.sign-field--signature) {
|
||||
padding: 3px 8px;
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
SigningTaskEvent,
|
||||
SigningTaskRecord,
|
||||
} from '@/api/workbench/types'
|
||||
import type { SigningFieldProgress } from '@/utils/signing-document'
|
||||
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
|
||||
|
||||
import SigningDocumentInteractive from './SigningDocumentInteractive.vue'
|
||||
import SigningDocumentPreview from './SigningDocumentPreview.vue'
|
||||
@@ -33,6 +33,8 @@ defineProps<{
|
||||
canViewAudit: boolean
|
||||
canViewArtifacts: boolean
|
||||
canDownloadArtifact: boolean
|
||||
/** 本次会话在文书里点击采集到的签名图,由签署页持有并回灌 */
|
||||
signatureInjections: SigningAnswers
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -40,6 +42,7 @@ const emit = defineEmits<{
|
||||
downloadArtifact: [artifact: SigningArtifact]
|
||||
selectionChange: [items: string[]]
|
||||
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
|
||||
signatureRequest: [request: SigningSignatureRequest]
|
||||
}>()
|
||||
|
||||
const documentRef = ref<InstanceType<typeof SigningDocumentInteractive> | null>(null)
|
||||
@@ -173,8 +176,10 @@ defineExpose({ getDocumentElement })
|
||||
ref="documentRef"
|
||||
:task="task"
|
||||
:interactive="allowLocalSigning && task.status !== 'signed' && task.status !== 'void'"
|
||||
:signature-injections="signatureInjections"
|
||||
@selection-change="emit('selectionChange', $event)"
|
||||
@field-change="(answers, progress) => emit('fieldChange', answers, progress)"
|
||||
@signature-request="emit('signatureRequest', $event)"
|
||||
/>
|
||||
<SigningDocumentPreview v-else :task="task" />
|
||||
<TaskAuditTimeline
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||
@@ -38,7 +38,7 @@ import type {
|
||||
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
import type { SigningFieldProgress } from '@/utils/signing-document'
|
||||
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
|
||||
import { downloadSigningPdf } from '@/utils/signing-pdf'
|
||||
|
||||
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
|
||||
@@ -95,6 +95,15 @@ const signingSelection = ref<string[]>([])
|
||||
const signingAnswers = ref<SigningAnswers>({})
|
||||
/** 文书填写项完成情况:签署前用它做必填校验、提示异常项 */
|
||||
const signingProgress = ref<SigningFieldProgress | null>(null)
|
||||
/**
|
||||
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
|
||||
* 与 signingAnswers 分开:signingAnswers 每次输入都会变,把它当渲染输入会让
|
||||
* 整篇文书重渲染、光标丢失;这份只在真的采到签名时变化。
|
||||
*/
|
||||
const fieldSignatureInjections = ref<SigningAnswers>({})
|
||||
/** 当前正在采集的签名位,null 表示弹窗不是由「点击文中签名位」打开的 */
|
||||
const pendingSignatureField = ref<SigningSignatureRequest | null>(null)
|
||||
const fieldSignatureDialogVisible = ref(false)
|
||||
const taskEvents = ref<SigningTaskEvent[]>([])
|
||||
const artifacts = ref<SigningArtifact[]>([])
|
||||
const templates = ref<SigningTemplate[]>([])
|
||||
@@ -253,6 +262,9 @@ async function loadTaskDetail(id: string | null) {
|
||||
signingSelection.value = []
|
||||
signingAnswers.value = {}
|
||||
signingProgress.value = null
|
||||
fieldSignatureInjections.value = {}
|
||||
pendingSignatureField.value = null
|
||||
fieldSignatureDialogVisible.value = false
|
||||
|
||||
if (!id) {
|
||||
selectedTask.value = null
|
||||
@@ -408,10 +420,14 @@ function handleFieldChange(answers: SigningAnswers, progress: SigningFieldProgre
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集签名前的填写项校验。
|
||||
* 必填项没填完就采集签名,等于签了一份内容不完整的文书,因此在入口处挡住。
|
||||
* 采集主签名前的检查。
|
||||
*
|
||||
* ① 必填项没填完 → 直接挡住:签一份内容不完整的文书没有意义;
|
||||
* ② 文书里还有没采集的签名位 → 让操作人员确认后再继续。
|
||||
* 这一条不能硬挡:医师确实可能签在纸质件上,替业务做决定是越界;
|
||||
* 但也不能不提醒,否则文书会带着空签名位被固化。
|
||||
*/
|
||||
function assertFieldsReady() {
|
||||
async function assertSigningReady() {
|
||||
const missing = signingProgress.value?.missingRequired ?? []
|
||||
|
||||
if (missing.length) {
|
||||
@@ -419,7 +435,59 @@ function assertFieldsReady() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
const pending = signingProgress.value?.pendingSignatures ?? []
|
||||
|
||||
if (!pending.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`文书中还有签名位未采集:${pending.join('、')}。可直接点击文中签名位完成,也可以继续采集主签名。`,
|
||||
'还有签名位待采集',
|
||||
{ confirmButtonText: '继续签署', cancelButtonText: '返回采集', type: 'warning' },
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击文中签名位:先记下采的是哪一个,再由弹窗把签名图回灌到该位置 */
|
||||
function handleSignatureRequest(request: SigningSignatureRequest) {
|
||||
if (!selectedTask.value) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingSignatureField.value = request
|
||||
fieldSignatureDialogVisible.value = true
|
||||
}
|
||||
|
||||
function closeFieldSignatureDialog() {
|
||||
fieldSignatureDialogVisible.value = false
|
||||
pendingSignatureField.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个签名位采集完成。
|
||||
*
|
||||
* 这里不调 completeSigningTask:医师签名只是文书上的一个填写项,
|
||||
* 任务级的「签署完成」仍要由主签名位(患者签名)触发,
|
||||
* 否则点一下医师签名就把整个任务标记成已签署了。
|
||||
*/
|
||||
function confirmFieldSignature(signatureDataUrl: string) {
|
||||
const request = pendingSignatureField.value
|
||||
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
|
||||
fieldSignatureInjections.value = {
|
||||
...fieldSignatureInjections.value,
|
||||
[request.fieldId]: signatureDataUrl,
|
||||
}
|
||||
closeFieldSignatureDialog()
|
||||
ElMessage.success(`「${request.label}」签名已采集,将随本次签署一并固化`)
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
@@ -471,7 +539,7 @@ async function handleTaskAction(action: SigningTaskAction) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!assertFieldsReady()) {
|
||||
if (!(await assertSigningReady())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -501,7 +569,7 @@ async function handleTaskAction(action: SigningTaskAction) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!assertFieldsReady()) {
|
||||
if (!(await assertSigningReady())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -803,10 +871,12 @@ onMounted(() => {
|
||||
:can-view-audit="canViewEvents"
|
||||
:can-view-artifacts="canViewArtifacts"
|
||||
:can-download-artifact="canDownloadArtifacts"
|
||||
:signature-injections="fieldSignatureInjections"
|
||||
@action="handleTaskAction"
|
||||
@download-artifact="downloadArtifact"
|
||||
@selection-change="handleSelectionChange"
|
||||
@field-change="handleFieldChange"
|
||||
@signature-request="handleSignatureRequest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -821,6 +891,16 @@ onMounted(() => {
|
||||
@close="signatureDialogVisible = false"
|
||||
@confirm="confirmPadSignature"
|
||||
/>
|
||||
<!-- 文书里单个签名位(医师签名等)的采集入口:签完只回填该位置,不结束任务 -->
|
||||
<SignaturePadDialog
|
||||
:visible="fieldSignatureDialogVisible"
|
||||
:task="selectedTask"
|
||||
:title="`采集「${pendingSignatureField?.label ?? '签名'}」`"
|
||||
:hint="`请让签署人在下框内签名,签名图将回显到文书中的「${pendingSignatureField?.label ?? '签名'}」位置;本次采集不会结束签署任务:`"
|
||||
confirm-label="确认采集"
|
||||
@close="closeFieldSignatureDialog"
|
||||
@confirm="confirmFieldSignature"
|
||||
/>
|
||||
<OnlineSigningDialog
|
||||
:visible="onlineDialogVisible"
|
||||
:task="selectedTask"
|
||||
|
||||
Reference in New Issue
Block a user