feat(文档库): 打通模板正文真实模式并新增渲染验证

模板列表接口不返回正文,展示、编辑与发起签署前需再取版本详情获取 contentHtml,
为此新增 getTemplateContent、createTemplateWithContent、updateTemplateContent 与
deleteTemplate(归档),并在签署端按 contentSha256 缓存正文快照。

同时新增 scripts/login.cjs 与 scripts/verify-pages.cjs,通过真实登录与 CDP
渲染截图、断言关键内容并收集运行时错误,并补充 .gitignore、eslint 忽略与文档说明。
This commit is contained in:
yelan
2026-09-17 14:10:45 +08:00
parent 0d271d90be
commit 81922d774f
20 changed files with 1640 additions and 187 deletions
+6
View File
@@ -25,3 +25,9 @@ pnpm-debug.log*
# TypeScript cache # TypeScript cache
*.tsbuildinfo *.tsbuildinfo
# 渲染验证的截图产物(scripts/verify-pages.cjs 生成)
screenshots/
# 本地 AI 工作区数据(含记忆、技能等),不随仓库分发
.workbuddy-ai/
+5 -1
View File
@@ -4,7 +4,11 @@ VITE_API_BASE_URL=/api
# Mock 开关:按业务模块(对齐侧边栏菜单)独立控制,取值只认 true / false。 # Mock 开关:按业务模块(对齐侧边栏菜单)独立控制,取值只认 true / false。
# 留空或注释掉即使用该模块的缺省值,缺省值以 src/utils/mock-flags.ts 为准, # 留空或注释掉即使用该模块的缺省值,缺省值以 src/utils/mock-flags.ts 为准,
# 目前是「签署工作台 + 文档库走演示数据,其余模块走真实接口」。 # 目前是「签署工作台 + 文档库走演示数据,其余模块走真实接口」。
# 只有想让某个模块偏离缺省时才需要在这里(或本地 .env.*.local)显式声明。 #
# 建议在本地 .env.*.local 里显式声明这两个开关,而不是依赖缺省值:
# 缺省值只保证「不改代码时的默认行为」,缺省值一旦调整,
# 本地开发者会在无感知的情况下被切到真实后端(或反之),联调时表现为
# 「接口报错」或「数据不对」,排查成本很高。
# VITE_MOCK_WORKBENCH_HOME=false # VITE_MOCK_WORKBENCH_HOME=false
# VITE_MOCK_SIGNING=true # VITE_MOCK_SIGNING=true
# VITE_MOCK_DOCUMENTS=true # VITE_MOCK_DOCUMENTS=true
+4
View File
@@ -12,6 +12,10 @@ dist
dist-ssr dist-ssr
*.local *.local
# 渲染验证脚本的运行产物:会话文件含真实 token,绝不能提交;验证码图片是临时文件
scripts/.session.json
scripts/.captcha.json
scripts/.captcha.png
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
+13 -3
View File
@@ -84,9 +84,17 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
## 当前状态 ## 当前状态
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台第一版 Mock 和文档库管理页面。登录、首页统计、签署工作台的患者/就诊/模板/任务/审计、签署文件下载、短信重发、模板创建/版本工作流、用户组织查询与用户创建编辑,以及按模板维护文档权限已经接入 MEDISIGN 后端;系统设置仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。 已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台和文档库管理页面。登录、首页统计、签署工作台的患者/就诊/模板/任务/审计、签署文件下载、短信重发、模板创建/版本工作流、用户组织查询与用户创建编辑,以及按模板维护文档权限已经接入 MEDISIGN 后端;文档库管理已接入模板与版本接口,系统设置仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
自测与演示:开启 `VITE_MOCK_DOCUMENTS` / `VITE_MOCK_SIGNING`(两者缺省即为开启)时,文档库和签署任务的演示数据会镜像到 localStorage(见 `utils/mock-storage.ts`),刷新页面后仍可继续,不必每次重建文书、重发签署;文档库页头的「重置演示数据」可清空这些本地数据回到初始状态。持久化只针对演示数据,不涉及登录态与真实患者信息。 文档库管理的真实模式已打通「模板 → 版本 → 正文」三段:
- 列表读 `GET /api/v1/templates`(自动翻完所有页),再把扁平的模板列表组织成页面需要的「科室 → 分类 → 文书」树;
- 正文不在列表接口里(`TemplateResponseDto` 只给 `templateContentSha256`,不给正文),预览、编辑、发起签署前都要再取一次版本详情 `GET /api/v1/templates/{id}/versions/{versionId}` 拿 `contentHtml`。取不到正文时页面如实报错,不展示空壳模板;
- 新建走「建模板 + 建首版」两步,编辑走「建新版本 + 更新模板」;模板没有 DELETE 接口,页面上的「删除」实际是归档动作(`POST .../versions/{versionId}/archive`),保留历史留痕链。
签署工作台的真实模式同样依赖正文:`GET /api/v1/templates/available-versions` 只返回 `contentSha256`,签署端用这个哈希当缓存键去换回正文快照(同一哈希命中同一份正文,版本更新后哈希变化即自动失效),避免版本更新后仍按旧正文签署。取不到正文时任务详情如实提示并给出占位版式,不退化成「看起来像真文书」的假纸样。
自测与演示:开启 `VITE_MOCK_DOCUMENTS` / `VITE_MOCK_SIGNING`(两者缺省即为开启)时,文档库和签署任务的演示数据会镜像到 localStorage(见 `utils/mock-storage.ts`),刷新页面后仍可继续,不必每次重建文书、重发签署;文档库页头的「重置演示数据」可清空这些本地数据回到初始状态(该按钮只在演示模式下出现)。持久化只针对演示数据,不涉及登录态与真实患者信息。本地建议在 `.env.development.local` 里显式写出这两个开关,不要依赖缺省值。
## 当前路由结构 ## 当前路由结构
@@ -170,7 +178,9 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
- `GET /api/v1/sign-artifacts/{artifactId}/download`:原始 PDF、签署后 PDF、签名原图下载; - `GET /api/v1/sign-artifacts/{artifactId}/download`:原始 PDF、签署后 PDF、签名原图下载;
- `POST /api/v1/sign-deliveries/{taskId}/sms/resend`:短信重发,自动携带幂等键; - `POST /api/v1/sign-deliveries/{taskId}/sms/resend`:短信重发,自动携带幂等键;
- `GET/POST /api/v1/templates`、`GET /api/v1/templates/{id}`:模板列表、详情; - `GET/POST /api/v1/templates`、`GET /api/v1/templates/{id}`:模板列表、详情;
- `GET/POST /api/v1/templates/{id}/versions`、版本工作流接口:版本查询、创建、送审、驳回、通过、发布、停用、归档; - `PUT /api/v1/templates/{id}`:更新模板元数据;
- `GET/POST /api/v1/templates/{id}/versions`、`GET /api/v1/templates/{id}/versions/{versionId}`:版本列表、创建与版本详情(**正文 `contentHtml` 只在这个详情接口里返回**);
- `POST /api/v1/templates/{id}/versions/{versionId}/{action}`(submit-review / reject / approve / publish / disable / archive):版本工作流,模板「删除」即走 archive;
- `GET /api/v1/users`、`GET /api/v1/roles`、`GET /api/v1/campuses`、`GET /api/v1/departments`:用户页真实查询及组织字典; - `GET /api/v1/users`、`GET /api/v1/roles`、`GET /api/v1/campuses`、`GET /api/v1/departments`:用户页真实查询及组织字典;
- `GET /api/v1/users/{id}/roles`、`PUT /api/v1/users/{id}/roles`、`PUT /api/v1/users/{id}/management`:用户角色读取和用户资料/角色聚合保存; - `GET /api/v1/users/{id}/roles`、`PUT /api/v1/users/{id}/roles`、`PUT /api/v1/users/{id}/management`:用户角色读取和用户资料/角色聚合保存;
- `POST /api/v1/users/{id}/password/reset`、`POST /api/v1/users/import/preview`、`POST /api/v1/users/import/commit`、`GET /api/v1/users/import/jobs/{jobId}`:管理员重置密码和用户导入流程; - `POST /api/v1/users/{id}/password/reset`、`POST /api/v1/users/import/preview`、`POST /api/v1/users/import/commit`、`GET /api/v1/users/import/jobs/{jobId}`:管理员重置密码和用户导入流程;
+9 -1
View File
@@ -6,7 +6,15 @@ import tseslint from 'typescript-eslint'
export default tseslint.config( export default tseslint.config(
{ {
ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**', '**/*.d.ts'], ignores: [
'**/dist/**',
'**/node_modules/**',
'**/coverage/**',
'**/*.d.ts',
// 本地验证工具脚本(CommonJS,跑在 Node 里,不参与构建)。
// 它们刻意用 require / 空 catch,套前端项目的 TS 规则只会产生噪音。
'scripts/**',
],
}, },
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
+107
View File
@@ -0,0 +1,107 @@
# scripts
只用于本地开发与验证,不参与打包。
## verify-pages.cjs — 浏览器真实渲染验证
**为什么需要**:`lint` / `vue-tsc` / `build` 通过 ≠ 页面能跑。数据加载失败导致列表空白、
解析逻辑只处理部分数据、组件渲染时抛错,这些问题在类型检查里完全看不出来,
只有真实打开浏览器渲染才发现。
**用法**:
```bash
# 1. 起 dev server(另开一个终端)
npm run dev
# 2. 抓验证码图片,然后自己看图认出数字
node scripts/login.cjs
# 图片在 scripts/.captcha.png,例如显示 8944
# 3. 带验证码登录,会话写入 scripts/.session.json
node scripts/login.cjs 8944
# 4. 跑渲染验证:打开页面、截图、断言关键内容、收集运行时错误
node scripts/verify-pages.cjs
```
截图输出到仓库根目录的 `screenshots/`。
**环境变量**:
| 变量 | 默认值 | 说明 |
| ------------------------------------- | ------------------------------------------ | ---------------------- |
| `VERIFY_URL` | `http://127.0.0.1:5173/` | 目标地址 |
| `VERIFY_ROUTES` | `/management/documents,/workbench/signing` | 要验证的路由,逗号分隔 |
| `VERIFY_OUT` | `<repo>/screenshots` | 截图输出目录 |
| `CHROME_PATH` | Chrome 默认安装路径 | Chrome 可执行文件 |
| `VERIFY_USERNAME` / `VERIFY_PASSWORD` | `admin` / `admin` | 登录账号 |
| `VERIFY_BASE_URL` | `http://127.0.0.1:5173` | 登录接口所在地址 |
**依赖**:需要 `ws`(CDP 通信)。装到隔离 workspace,不要装进项目:
```bash
cd C:/Users/<you>/.workbuddy-ai/binaries/node/workspace
<node>/npm.cmd install ws
```
运行脚本时指定 `NODE_PATH` 指向该目录:
```bash
NODE_PATH=C:/Users/<you>/.workbuddy-ai/binaries/node/workspace/node_modules \
node scripts/verify-pages.cjs
```
**验证真实模式**(关掉演示数据)另起一个 server:
```bash
VITE_MOCK_DOCUMENTS=false VITE_MOCK_SIGNING=false npx vite --port 5199
VERIFY_URL=http://127.0.0.1:5199/ node scripts/verify-pages.cjs
```
## 踩过的坑
改脚本前先读这段,都是实际踩出来的。
### 1. 登录不要 stub,走真实登录
MEDISIGN 后端对无效 token 返回 **401**,而 `utils/request.ts` 的响应拦截器收到 401 会
`clearAuthStorage()` + 跳登录页。伪造会话很容易被踢出去。
如果确实要 stub XHR(比如只想验证某个组件),**必须实现完整响应**:
`getAllResponseHeaders` / `getResponseHeader` / `onloadend` 一个都不能少。
只设 `responseText` 会让 axios 在解析响应头时抛错、**Promise 永不 settle**,
路由守卫的 `await` 一直挂着 → `#app` 只剩 `<!---->`、**整页空白**。
这个现象极易被误判成「页面渲染挂了」,实际是验证脚本自己的 bug。
### 2. 验证码是强制的
不传 `captchaId` / `captchaCode` 会报「验证码错误或已过期」。
验证码有时效,抓图和登录要连着做。
### 3. CDP 连续导航在 SPA 里不可靠
`Page.navigate` 连续调用时:
- `pushState` 跳转**不触发** `Page.loadEventFired`
- 轮询 `document.readyState` 会立刻通过,但读到的是**旧文档**
结果就是断言拿到上一个页面的内容,看起来像「导航了但没渲染」。
正确做法(脚本已采用):只做**一次**整页 `Page.navigate`,后续切页取
```js
document.getElementById('app').__vue_app__.config.globalProperties.$router
```
调 `router.push()`。注意 `history.pushState` + 手动派发 `popstate` 对 Vue Router **无效**。
### 4. 文件用 .cjs 后缀
`package.json` 里有 `"type": "module"`,`.js` 会被当成 ES 模块,
而这里用 `require` 更简单,所以显式声明成 CommonJS。
## 安全提醒
`scripts/.session.json` 里是**真实 token**,已在 `.gitignore` 中忽略,**不要提交**。
`.captcha.json` / `.captcha.png` 是临时文件,同样已忽略。
+105
View File
@@ -0,0 +1,105 @@
/**
* 登录并保存会话,供页面渲染验证脚本复用。
*
* 为什么不 stub 接口:MEDISIGN 后端对无效 token 返回 401,
* utils/request.ts 的响应拦截器收到 401 会 clearAuthStorage() + 跳登录页,
* 伪造会话很容易被踢出去。走真实登录最省事。
*
* 验证码是强制的(不传会报「验证码错误或已过期」)。用法:
*
* node scripts/login.cjs # 第一步:抓验证码图片
* (用看图工具认出图片里的数字)
* node scripts/login.cjs 5274 # 第二步:带验证码登录,写 .session.json
*
* 结果写到 scripts/.session.json(已在 .gitignore 里,不会提交 token)。
*
* 文件用 .cjs 后缀:package.json 里的 "type": "module" 会把 .js 当 ES 模块,
* 而这里用 require 更简单,所以显式声明成 CommonJS。
*
* 依赖:Node 18+(内置 fetch)。无需额外安装依赖。
*/
const fs = require('fs')
const path = require('path')
const BASE_URL = process.env.VERIFY_BASE_URL || 'http://127.0.0.1:5173'
const USERNAME = process.env.VERIFY_USERNAME || 'admin'
const PASSWORD = process.env.VERIFY_PASSWORD || 'admin'
const OUT_DIR = __dirname
const CAPTCHA_JSON = path.join(OUT_DIR, '.captcha.json')
const CAPTCHA_PNG = path.join(OUT_DIR, '.captcha.png')
const SESSION_FILE = path.join(OUT_DIR, '.session.json')
async function fetchCaptcha() {
const res = await fetch(`${BASE_URL}/api/v1/auth/captcha`)
const json = await res.json()
if (json.code !== '0' && json.code !== 0) {
throw new Error('获取验证码失败: ' + json.message)
}
const { captchaId, imageBase64 } = json.data
fs.writeFileSync(CAPTCHA_JSON, JSON.stringify(json))
fs.writeFileSync(
CAPTCHA_PNG,
Buffer.from(String(imageBase64).replace(/^data:image\/\w+;base64,/, ''), 'base64'),
)
console.log('验证码已保存,请打开图片认出里面的数字:')
console.log(' ' + CAPTCHA_PNG)
console.log('captchaId = ' + captchaId)
console.log('')
console.log('然后执行: node scripts/login.cjs <识别出的数字>')
}
async function login(captchaCode) {
if (!fs.existsSync(CAPTCHA_JSON)) {
throw new Error('没有找到验证码,请先执行 node scripts/login.cjs 抓取验证码')
}
const { data } = JSON.parse(fs.readFileSync(CAPTCHA_JSON, 'utf8'))
const res = await fetch(`${BASE_URL}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: USERNAME,
password: PASSWORD,
captchaId: data.captchaId,
captchaCode,
}),
})
const json = await res.json()
if (json.code !== '0' && json.code !== 0) {
throw new Error(
'登录失败: ' + json.message + '(验证码可能已过期,重新执行 node scripts/login.cjs)',
)
}
fs.writeFileSync(SESSION_FILE, JSON.stringify(json.data, null, 2))
console.log('登录成功')
console.log(
' 用户 = ' + json.data.user.displayName + ' (' + json.data.user.username + ')',
)
console.log(' campusId = ' + json.data.user.campusId)
console.log(' dataScope = ' + json.data.user.dataScope)
console.log(' permissions = ' + json.data.user.permissions.length + ' 项')
console.log(' 会话已写入 ' + SESSION_FILE)
}
async function main() {
const captchaCode = process.argv[2]
if (captchaCode) {
await login(captchaCode.trim())
} else {
await fetchCaptcha()
}
}
main().catch((err) => {
console.error(err.message)
process.exit(1)
})
+263
View File
@@ -0,0 +1,263 @@
/**
* 浏览器真实渲染验证:打开页面截图 + 断言关键内容 + 收集运行时错误。
*
* 为什么需要它:编译通过(lint / vue-tsc / build)不等于页面能跑。
* 数据加载失败导致列表空白、解析逻辑只处理部分数据、组件渲染时抛错,
* 这些问题在类型检查里完全看不出来,只有真实渲染才发现。
*
* 用法:
* 1. 起 dev server(默认 http://127.0.0.1:5173)
* 2. node scripts/login.cjs # 抓验证码图片并认数字
* node scripts/login.cjs 5274 # 登录,写 .session.json
* 3. node scripts/verify-pages.cjs
*
* 环境变量:
* VERIFY_URL 目标地址,默认 http://127.0.0.1:5173/
* VERIFY_ROUTES 要验证的路由,逗号分隔,默认 /management/documents,/workbench/signing
* VERIFY_OUT 截图输出目录,默认 <repo>/screenshots
* CHROME_PATH Chrome 可执行文件路径
*
* 依赖:ws(CDP 需要)。装到隔离 workspace:
* cd C:/Users/<you>/.workbuddy-ai/binaries/node/workspace
* <node>/npm.cmd install ws
* 运行时指定 NODE_PATH 指向该 workspace 的 node_modules。
*
* ─────────────────────────────────────────────────────────────
* 踩过的坑(改这个脚本前先读):
*
* 1. 登录不要 stub。后端对无效 token 返回 401 → utils/request.ts 会
* clearAuthStorage() + 跳登录页。伪造会话很容易被踢。走真实登录。
* 若一定要 stub XHR,必须实现 getAllResponseHeaders / getResponseHeader /
* onloadend——只给 responseText 会让 axios 解析失败、Promise 永不 settle,
* 路由守卫的 await 一直挂着 → #app 只剩 <!---->、整页空白,
* 极易误判成"页面渲染挂了"。
*
* 2. CDP 连续 Page.navigate 在 SPA 里不可靠:pushState 不触发
* Page.loadEventFired,轮询 readyState 又读到旧文档,断言会拿到上一页内容。
* 这里只做一次整页 navigate,后续切页用 router.push()。
* history.pushState + 手动派发 popstate 对 Vue Router 无效。
*
* 3. Chrome 可执行路径写死容易失效,用 CHROME_PATH 覆盖。
*/
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')
console.error('然后设置 NODE_PATH 指向该目录的 node_modules 再运行本脚本。')
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 || 9448)
const TARGET_URL = process.env.VERIFY_URL || 'http://127.0.0.1:5173/'
const ROUTES = (process.env.VERIFY_ROUTES || '/management/documents,/workbench/signing')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
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-verify-' + process.pid,
)
fs.mkdirSync(OUT, { recursive: true })
fs.rmSync(PROFILE, { recursive: true, force: true })
if (!fs.existsSync(SESSION_FILE)) {
console.error('缺少会话文件 ' + SESSION_FILE)
console.error('请先执行: 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))
// 先于应用代码执行,保证 store 初始化时就能读到会话
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) {}
`
async function assertReachable(url) {
try {
const res = await fetch(url)
if (!res.ok) throw new Error('HTTP ' + res.status)
} catch (err) {
console.error(`\n[致命] 无法访问 ${url} —— ${err.message}`)
console.error('请先启动 dev server,然后用 VERIFY_URL 指定正确地址。\n')
process.exit(1)
}
}
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) {
const { data } = await send(ws, 'Page.captureScreenshot', { format: 'png' })
const file = path.join(OUT, name)
fs.writeFileSync(file, Buffer.from(data, 'base64'))
console.log(' 截图 -> ' + file)
}
/** 页面内跳转:取 app 上的 router 实例 push,避免 CDP 连续 navigate 的坑 */
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)
}
/** 页面通用断言:布局是否挂载、有无空态/错误提示、正文长度 */
const PAGE_PROBE = `(() => {
const t = document.body.innerText
const app = document.getElementById('app')
return {
path: location.pathname,
appHtmlLen: app ? app.innerHTML.length : -1,
vueMounted: !!(app && app.__vue_app__),
hasLayout: !!document.querySelector('.clinical-layout'),
bodyTextLen: t.length,
hasEmptyHint: /暂无|没有|为空|加载失败|请从左侧列表选择/.test(t),
hasErrorHint: /失败|错误|异常/.test(t),
snippet: t.slice(0, 900),
}
})()`
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 pageErrors = []
const apiCalls = []
;(async () => {
await assertReachable(TARGET_URL)
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))
}
if (msg.method === 'Network.responseReceived') {
const r = msg.params.response
if (r.url.includes('/api/v1/')) {
apiCalls.push(r.status + ' ' + r.url.replace(/^https?:\/\/[^/]+/, ''))
}
}
})
await send(ws, 'Page.enable')
await send(ws, 'Runtime.enable')
await send(ws, 'Network.enable')
await send(ws, 'Page.addScriptToEvaluateOnNewDocument', { source: BOOT_SCRIPT })
// 只做一次整页导航,后续切页都在页面内完成
await send(ws, 'Page.navigate', { url: TARGET_URL })
await sleep(7000)
const boot = await evaluate(ws, PAGE_PROBE)
console.log('\n[启动态] ' + JSON.stringify(boot))
for (const [index, route] of ROUTES.entries()) {
console.log(`\n=== ${route} ===`)
await gotoInPage(ws, route, 6000)
console.log(JSON.stringify(await evaluate(ws, PAGE_PROBE), null, 2))
const safe = route.replace(/^\//, '').replace(/\//g, '-')
await shot(ws, String(index + 1).padStart(2, '0') + '-' + safe + '.png')
}
console.log('\n=== 后端接口调用 ===')
const uniq = [...new Set(apiCalls)]
console.log(uniq.length ? uniq.slice(0, 40).join('\n') : '(无)')
console.log('\n=== 页面运行时错误 ===')
console.log(pageErrors.length ? [...new Set(pageErrors)].slice(0, 20).join('\n') : '无(干净)')
} catch (err) {
console.error('验证失败:', err.message)
process.exitCode = 1
} finally {
if (ws) ws.close()
chrome.kill()
}
})()
+224 -3
View File
@@ -3,6 +3,7 @@ import axios from 'axios'
import { unwrapApiResponse } from '@/utils/api-response' import { unwrapApiResponse } from '@/utils/api-response'
import { mockFlags } from '@/utils/mock-flags' import { mockFlags } from '@/utils/mock-flags'
import { request } from '@/utils/request' import { request } from '@/utils/request'
import { collectFieldDefs } from '@/utils/signing-fields'
import type { ApiResponse, PageResult } from '@/types/common' import type { ApiResponse, PageResult } from '@/types/common'
import type { import type {
@@ -102,6 +103,146 @@ function mapDocument(dto: TemplateResponseDto): DocumentRecord {
} }
} }
/**
* 从模板版本 DTO 里取正文 HTML。
* 契约给了 contentHtml 与 content 两个别名(见 TemplateVersionResponseDto),
* 这里按 contentHtml → content 的顺序取,两个都没有才返回空。
*/
export function readVersionContentHtml(dto: TemplateVersionResponseDto | null): string {
if (!dto) {
return ''
}
return dto.contentHtml || dto.content || ''
}
/**
* 把正文 HTML 里的占位符编译成后端可留存的结构化字段定义。
*
* 后端版本 DTO 的 signatureFields 字段承载「这份模板有哪些填写项」,
* 正文本身仍以 contentHtml 提交。不提交这个字段的话,后端拿不到字段口径,
* 也就无法在签署侧做必填校验与证据固化。
*/
export function buildSignatureFieldsPayload(contentHtml: string): unknown[] {
return collectFieldDefs(contentHtml).map((field) => ({
id: field.id,
label: field.label,
kind: field.kind,
required: field.required,
options: field.options,
...(field.risk ? { risk: field.risk } : {}),
}))
}
/**
* 读取模板当前版本的正文。
* 模板列表接口只给 currentVersionId 与哈希,不给正文,
* 因此展示/编辑正文必须再取一次版本详情——这是文档库能否接入的关键一步。
*/
export async function getTemplateContent(templateId: string): Promise<string> {
if (useMockData) {
return ''
}
const template = await getDocumentDetail(templateId)
if (!template?.versionId) {
return ''
}
const version = await getTemplateVersionDetail(templateId, template.versionId)
return readVersionContentHtml(version)
}
/**
* 按版本正文哈希换回正文 HTML。
*
* 签署端拿不到正文:available-versions 只返回 contentSha256,sign-tasks 也只返回
* templateContentSha256。这里用哈希当 key 缓存"哈希 → 正文",命中就直接复用,
* 不必重复穿透;同时哈希变了自然会取到新正文,不会出现「版本更新了还按旧正文签署」。
*/
const contentCacheBySha = new Map<string, string>()
const contentRequestBySha = new Map<string, Promise<string>>()
export function readCachedTemplateContent(contentSha256: string | undefined): string {
return contentSha256 ? (contentCacheBySha.get(contentSha256) ?? '') : ''
}
/**
* 按模板版本 id 取正文并写进哈希缓存。
* 调用方(签署端)手里只有 versionId 与哈希,没有 templateId,
* 因此这里先用版本详情换出 templateId,再按哈希登记。
*/
export async function fetchTemplateContentByVersion(
templateVersionId: string,
contentSha256?: string,
): Promise<string> {
if (useMockData || !templateVersionId) {
return ''
}
const cacheKey = contentSha256 || templateVersionId
const cached = contentCacheBySha.get(cacheKey)
if (cached !== undefined) {
return cached
}
const pending = contentRequestBySha.get(cacheKey)
if (pending) {
return pending
}
const task = (async () => {
// 版本详情接口挂在模板下,先反查模板 id。列表接口按 currentVersionId 过滤过于昂贵,
// 这里直接走版本详情的「按版本 id 直查」路径:后端对未知 templateId 会 404,
// 因此改用模板列表逐页找匹配版本(模板数量在权限范围内通常只有几十份)。
const content = await findContentByVersionId(templateVersionId)
if (content) {
contentCacheBySha.set(cacheKey, content)
}
return content
})().finally(() => {
contentRequestBySha.delete(cacheKey)
})
contentRequestBySha.set(cacheKey, task)
return task
}
/** 遍历权限范围内的模板,找到持有该版本的模板并取回正文 */
async function findContentByVersionId(templateVersionId: string): Promise<string> {
const firstPage = await getDocuments({ page: 1, pageSize: 200, status: 'all' })
const pageSize = Math.max(firstPage.pageSize, 1)
const pageCount = Math.ceil(firstPage.total / pageSize)
const remainingPages =
pageCount > 1
? await Promise.all(
Array.from({ length: pageCount - 1 }, (_, index) =>
getDocuments({ page: index + 2, pageSize, status: 'all' }),
),
)
: []
const candidates = [firstPage, ...remainingPages].flatMap((page) => page.records)
const matched = candidates.find((template) => template.versionId === templateVersionId)
if (!matched) {
return ''
}
const version = await getTemplateVersionDetail(matched.id, templateVersionId)
return readVersionContentHtml(version)
}
/** 缓存里已有正文时跳过请求;供签署端按哈希判断是否需要补正文 */
export function hasCachedTemplateContent(contentSha256: string | undefined): boolean {
return Boolean(contentSha256 && contentCacheBySha.has(contentSha256))
}
function normalizePage<T>( function normalizePage<T>(
data: BackendPage<T> | T[], data: BackendPage<T> | T[],
fallbackPage: number, fallbackPage: number,
@@ -219,19 +360,54 @@ export async function getDocumentDetail(id: string): Promise<DocumentRecord | nu
export async function createTemplate(payload: CreateTemplateRequest): Promise<DocumentRecord> { export async function createTemplate(payload: CreateTemplateRequest): Promise<DocumentRecord> {
if (useMockData) { if (useMockData) {
throw new Error('Mock 模式不执行模板写操作') throw new Error('演示模式下请在文档库页面直接新建文书')
} }
const response = await request.post<ApiResponse<TemplateResponseDto>>('/v1/templates', payload) const response = await request.post<ApiResponse<TemplateResponseDto>>('/v1/templates', payload)
return mapDocument(unwrapApiResponse(response)) return mapDocument(unwrapApiResponse(response))
} }
export async function updateTemplate(
id: string,
payload: Partial<CreateTemplateRequest>,
): Promise<DocumentRecord> {
if (useMockData) {
throw new Error('演示模式下请在文档库页面直接编辑文书')
}
const response = await request.put<ApiResponse<TemplateResponseDto>>(
`/v1/templates/${encodeURIComponent(id)}`,
payload,
)
return mapDocument(unwrapApiResponse(response))
}
/**
* 删除模板。
* 契约里模板没有 DELETE,归档由版本工作流的 archive 承担:
* 「删除」在业务上等于「归档并停用」,不能真的抹掉已签署文书引用的模板版本,
* 否则历史任务的 templateVersionId 会指向不存在的模板,留痕链断裂。
*/
export async function deleteTemplate(id: string): Promise<void> {
if (useMockData) {
throw new Error('演示模式下请在文档库页面直接删除文书')
}
const template = await getDocumentDetail(id)
if (!template?.versionId) {
throw new Error('该模板没有可归档的当前版本')
}
await updateTemplateVersionStatus(id, template.versionId, 'archive')
}
export async function createTemplateVersion( export async function createTemplateVersion(
templateId: string, templateId: string,
payload: CreateTemplateVersionRequest, payload: CreateTemplateVersionRequest,
): Promise<TemplateVersionResponseDto> { ): Promise<TemplateVersionResponseDto> {
if (useMockData) { if (useMockData) {
throw new Error('Mock 模式不执行模板写操作') throw new Error('演示模式下请在文档库页面直接编辑文书')
} }
const response = await request.post<ApiResponse<TemplateVersionResponseDto>>( const response = await request.post<ApiResponse<TemplateVersionResponseDto>>(
@@ -279,6 +455,12 @@ export async function getTemplateVersionDetail(
export type TemplateVersionAction = export type TemplateVersionAction =
'submit-review' | 'reject' | 'approve' | 'publish' | 'disable' | 'archive' 'submit-review' | 'reject' | 'approve' | 'publish' | 'disable' | 'archive'
/**
* 版本工作流动作。
*
* 真实模式直接调用对应动作接口;文档库的「新增 / 编辑 / 归档」也走这里,
* 保证模板状态只能经后端工作流流转,前端不自行改写 status。
*/
export function updateTemplateVersionStatus( export function updateTemplateVersionStatus(
templateId: string, templateId: string,
versionId: string, versionId: string,
@@ -286,7 +468,7 @@ export function updateTemplateVersionStatus(
reason?: string, reason?: string,
): Promise<TemplateVersionResponseDto> { ): Promise<TemplateVersionResponseDto> {
if (useMockData) { if (useMockData) {
return Promise.reject(new Error('Mock 模式不执行模板版本工作流')) return Promise.reject(new Error('演示模式下请在文档库页面直接操作文书'))
} }
const requestData = action === 'reject' && reason?.trim() ? { comment: reason.trim() } : undefined const requestData = action === 'reject' && reason?.trim() ? { comment: reason.trim() } : undefined
@@ -298,3 +480,42 @@ export function updateTemplateVersionStatus(
) )
.then(unwrapApiResponse) .then(unwrapApiResponse)
} }
/**
* 创建模板并提交首个版本。
*
* 「新增文书」在后端是两步:先建模板拿 id,再给该模板建一个版本把正文存进去。
* 只建模板不建版本会得到一个没有正文、无法发起签署的空壳模板,
* 因此这里合成一个动作,调用方只需给一次表单。
*/
export async function createTemplateWithContent(
payload: CreateTemplateRequest,
version: Omit<CreateTemplateVersionRequest, 'signatureFields'>,
): Promise<DocumentRecord> {
const template = await createTemplate(payload)
await createTemplateVersion(template.id, {
...version,
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
})
// 版本创建后模板的 currentVersionId/versionSequence 才更新,重新取一次拿到准确版本号
const detail = await getDocumentDetail(template.id)
return detail ?? template
}
/**
* 更新模板正文:为模板追加一个新版本。
*
* 已发布的版本不可原地改写(已签署文书引用的是它),因此「编辑」落到新版本上,
* 这也是后端版本模型的本意。
*/
export async function updateTemplateContent(
templateId: string,
version: Omit<CreateTemplateVersionRequest, 'signatureFields'>,
): Promise<void> {
await createTemplateVersion(templateId, {
...version,
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
})
}
+104 -2
View File
@@ -1,5 +1,9 @@
import axios from 'axios' import axios from 'axios'
import {
fetchTemplateContentByVersion,
readCachedTemplateContent,
} from '@/api/management/documents'
import { unwrapApiResponse } from '@/utils/api-response' import { unwrapApiResponse } from '@/utils/api-response'
import { mockFlags } from '@/utils/mock-flags' import { mockFlags } from '@/utils/mock-flags'
import { readLocalSigningTemplates } from '@/utils/mock-document-library' import { readLocalSigningTemplates } from '@/utils/mock-document-library'
@@ -471,6 +475,8 @@ function mapVisit(dto: VisitResponseDto): PatientProfile['visits'][number] {
} }
function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): SigningTemplate { function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): SigningTemplate {
const cachedContent = readCachedTemplateContent(dto.contentSha256)
return { return {
id: dto.templateId, id: dto.templateId,
versionId: dto.templateVersionId, versionId: dto.templateVersionId,
@@ -484,9 +490,71 @@ function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): Signing
description: `已发布版本 ${dto.versionNo}`, description: `已发布版本 ${dto.versionNo}`,
// 可用模板接口没有返回通道 ACL;具体通道仍由后端在创建时校验。 // 可用模板接口没有返回通道 ACL;具体通道仍由后端在创建时校验。
supportedMethods: ['pad', 'sms'], supportedMethods: ['pad', 'sms'],
// 接口不返回正文,只能带出哈希;正文由 hydrateTemplateContents 按需补回
contentSha256: dto.contentSha256,
contentHtml: cachedContent || undefined,
} }
} }
/**
* 给可用模板补正文快照。
*
* available-versions 只返回 contentSha256,不返回正文,因此真实模式下
* 「发起签署」弹窗选中的模板没有正文,签出来的文书详情会退化成前端写死的回退纸样。
* 这里在拿到模板清单后按版本 id 把正文取回来(内部按哈希缓存,同一版本只取一次),
* 取不到的保持空,由调用方按"后端未提供正文"如实提示,绝不伪造正文。
*
* 逐份补而不是全量并发:模板数量有限,且正文对签署是必需的,
* 用 allSettled 隔离单份失败,避免一份模板取不到正文就拖垮整个弹窗。
*/
async function hydrateTemplateContents(templates: SigningTemplate[]): Promise<SigningTemplate[]> {
const pending = templates.filter((template) => !template.contentHtml && template.versionId)
if (!pending.length) {
return templates
}
const contents = await Promise.allSettled(
pending.map((template) =>
fetchTemplateContentByVersion(template.versionId, template.contentSha256),
),
)
const contentByVersionId = new Map<string, string>()
contents.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
contentByVersionId.set(pending[index].versionId, result.value)
}
})
if (!contentByVersionId.size) {
return templates
}
return templates.map((template) => {
if (template.contentHtml) {
return template
}
const contentHtml = contentByVersionId.get(template.versionId)
return contentHtml ? { ...template, contentHtml } : template
})
}
/** 单份模板补正文:签署弹窗从外部(文档库)带进来的模板走这里 */
export async function hydrateSigningTemplate(template: SigningTemplate): Promise<SigningTemplate> {
if (template.contentHtml || !template.versionId || useMockData) {
return template
}
const contentHtml = await fetchTemplateContentByVersion(
template.versionId,
template.contentSha256,
)
return contentHtml ? { ...template, contentHtml } : template
}
function findTemplate(templates: SigningTemplate[] | undefined, versionId: string) { function findTemplate(templates: SigningTemplate[] | undefined, versionId: string) {
return templates?.find((template) => template.versionId === versionId) return templates?.find((template) => template.versionId === versionId)
} }
@@ -524,9 +592,38 @@ function mapSigningTask(
backendStatus: dto.status, backendStatus: dto.status,
signedAt: formatApiDateTime(dto.signedAt) || undefined, signedAt: formatApiDateTime(dto.signedAt) || undefined,
voidReason: dto.voidReason ?? undefined, voidReason: dto.voidReason ?? undefined,
// 任务接口不返回正文,只有 templateContentSha256;正文由 hydrateTaskContent 按需补回。
// 带上哈希是为了让"任务固化的到底是哪一版正文"可校验,不拿别的版本顶替。
contentSha256: dto.templateContentSha256,
documentContentHtml: readCachedTemplateContent(dto.templateContentSha256) || undefined,
} }
} }
/**
* 给签署任务补正文快照。
*
* sign-tasks 只返回 templateContentSha256,不返回正文;缺正文时任务详情会退化成
* SigningDocumentPreview(一份与模板无关的回退纸样)。这里按任务的模板版本取回正文,
* 内部按哈希缓存,同一版本的任务只穿透一次。
*
* 取不到时保持 undefined,由详情组件如实显示"后端未提供正文",
* 不用前端拼一份假文书充数——那会让医务人员看到与真实模板无关的内容。
*/
export async function hydrateSigningTaskContent(
task: SigningTaskRecord,
): Promise<SigningTaskRecord> {
if (task.documentContentHtml || useMockData || !task.templateVersionId) {
return task
}
const contentHtml = await fetchTemplateContentByVersion(
task.templateVersionId,
task.contentSha256,
)
return contentHtml ? { ...task, documentContentHtml: contentHtml } : task
}
function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate { function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate {
return { return {
id: input.documentId, id: input.documentId,
@@ -750,7 +847,10 @@ export async function getSigningTaskDetail(
const response = await request.get<ApiResponseOf<SignTaskResponseDto>>( const response = await request.get<ApiResponseOf<SignTaskResponseDto>>(
`/v1/sign-tasks/${encodeURIComponent(id)}`, `/v1/sign-tasks/${encodeURIComponent(id)}`,
) )
return mapSigningTask(unwrapApiResponse(response), options) const task = mapSigningTask(unwrapApiResponse(response), options)
// 任务接口不返回正文,这里按哈希把正文快照补回;取不到就保持空,
// 由详情组件提示"后端未提供正文",不退化成前端写死的回退纸样
return hydrateSigningTaskContent(task)
} catch (error) { } catch (error) {
if (isNotFoundError(error)) { if (isNotFoundError(error)) {
return null return null
@@ -867,7 +967,9 @@ export async function getSigningTemplates(
const data = unwrapApiResponse(response) const data = unwrapApiResponse(response)
const records = Array.isArray(data) ? data : data.records const records = Array.isArray(data) ? data : data.records
return records.map(mapAvailableTemplate) // 可用模板接口只给哈希不给正文,这里按版本补回正文快照,
// 否则发起签署时文书没有正文,签出来的详情会退化成前端写死的回退纸样
return hydrateTemplateContents(records.map(mapAvailableTemplate))
} }
// 文档库是模板的唯一来源,两侧读同一份数据: // 文档库是模板的唯一来源,两侧读同一份数据:
+12
View File
@@ -62,6 +62,12 @@ export interface SigningTemplate {
supportedMethods: SigningMethod[] supportedMethods: SigningMethod[]
/** 原型:文书定稿 HTML 随模板携带,发起签署时进入任务快照。 */ /** 原型:文书定稿 HTML 随模板携带,发起签署时进入任务快照。 */
contentHtml?: string contentHtml?: string
/**
* 模板版本的正文哈希。
* 真实模式下可用模板接口只返回它、不返回正文(见 AvailableTemplateVersionResponseDto),
* 签署端要靠它判断本地缓存的正文快照是否还是同一份,避免版本更新后仍按旧正文签署。
*/
contentSha256?: string
} }
export interface WorkbenchSummary { export interface WorkbenchSummary {
@@ -215,6 +221,12 @@ export interface SigningTaskRecord extends WorkbenchTask {
voidReason?: string voidReason?: string
/** 原型:任务创建时固化的文书 HTML(对应真实实现的模板版本快照 + contentSha256)。 */ /** 原型:任务创建时固化的文书 HTML(对应真实实现的模板版本快照 + contentSha256)。 */
documentContentHtml?: string documentContentHtml?: string
/**
* 任务固化的模板版本正文哈希。
* 真实模式的任务接口只返回它、不返回正文(见 SignTaskResponseDto 的 templateContentSha256),
* 前端按它把正文快照取回来并校验,确保展示的正文就是签署时固化的那一版。
*/
contentSha256?: string
/** 原型:患者勾选结果,随签署一起留存(对应真实实现里随签署证据提交的勾选摘要)。 */ /** 原型:患者勾选结果,随签署一起留存(对应真实实现里随签署证据提交的勾选摘要)。 */
selectedItems?: string[] selectedItems?: string[]
/** /**
+89 -10
View File
@@ -17,30 +17,46 @@ import type { SigningMethod, SigningTemplate } from '@/api/workbench/types'
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from './mock-storage' import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from './mock-storage'
/** 与 views/management/documents/types.ts 的 DocumentStatus 保持一致 */ /**
type LocalDocumentStatus = 'enabled' | 'draft' | 'archived' | 'review' * 演示文书的状态。
* 取值与 api/management/types.ts 的 DocumentStatus 一致(对应后端 DRAFT / PENDING_REVIEW /
* APPROVED / PUBLISHED / DISABLED / ARCHIVED 的归一结果),不再使用后端没有的 'enabled'。
*/
type LocalDocumentStatus = 'draft' | 'review' | 'published' | 'archived'
/** 与 views/management/documents/types.ts 的 DocumentCampus 保持一致 */ /** 与 views/management/documents/types.ts 的 DocumentCampus 保持一致 */
type LocalCampus = 'all' | '本部院区' | '东院区' | '西院区' type LocalCampus = string
interface LocalDocument { export interface LocalDocument {
id: string id: string
name: string name: string
code: string /** 文书编码。与模板接口的 DocumentRecord.code 一样允许缺省,页面统一兜底显示 */
code?: string
version: string version: string
status: LocalDocumentStatus status: LocalDocumentStatus
description: string backendStatus?: string
/** 文书说明。来源同 code,允许缺省 */
description?: string
versionId?: string versionId?: string
departmentId?: string | null departmentId?: string | null
departmentName?: string
campusId?: string | null campusId?: string | null
campusName?: string
/**
* 演示数据的更新时间。
* 演示文库的种子不带时间,页面在这一层会显示成"—"而不是伪造一个时间戳;
* 页面模型要求这个字段必填,所以在演示数据创建时写一次真实时间。
*/
updatedAt?: string
contentHtml?: string contentHtml?: string
contentSha256?: string
} }
interface LocalCategory { export interface LocalCategory {
name: string name: string
documents: LocalDocument[] documents: LocalDocument[]
} }
interface LocalDepartment { export interface LocalDepartment {
key: string key: string
name: string name: string
campus: LocalCampus campus: LocalCampus
@@ -199,10 +215,62 @@ const DEMO_DOCUMENT_SPECS: DemoDocumentSpec[] = [
/** 数组顺序即文档库里的科室分组顺序,全院通用排在最前 */ /** 数组顺序即文档库里的科室分组顺序,全院通用排在最前 */
const DEMO_DEPARTMENT_ORDER = ['全院通用', '放射科', '消化内科', '心内科', '健康管理中心', '医务处'] const DEMO_DEPARTMENT_ORDER = ['全院通用', '放射科', '消化内科', '心内科', '健康管理中心', '医务处']
/**
* 演示院区字典。
*
* 文档库的「所属院区」下拉在真实模式下取 api/management/organization 的 getCampuses,
* 演示模式下没有接口可调,这里给一份同构的演示院区,键名与 CampusRecord 一致。
* 演示 id 带 mock- 前缀,一眼能看出不是真实 UUID,不会误当成可提交给后端的取值。
*/
export const demoCampuses = [
{ id: 'mock-campus-main', code: 'MAIN', name: '本部院区', status: 'ENABLED' as const, sortNo: 1 },
{
id: 'mock-campus-east',
code: 'EAST',
name: '东院区',
status: 'ENABLED' as const,
sortNo: 2,
},
]
/**
* 演示科室字典。
* 文档库的科室下拉在演示模式下用它,真实模式下改由 getDepartments(campusId) 提供。
* 科室名与演示文书库的分组名刻意保持一致(放射科、消化内科……),
* 否则会出现"下拉里选不到文书实际所属的科室"。
*/
export const demoDepartments = [
'医学影像科',
'医学检验科',
'临床遗传中心',
'放射科',
'消化内科',
'心内科',
'健康管理中心',
'医务处',
].map((name, index) => ({
id: `mock-department-${index + 1}`,
campusId: '',
code: `MOCK-DEPT-${index + 1}`,
name,
parentId: null,
status: 'ENABLED' as const,
sortNo: index + 1,
}))
function groupKeyOf(department: string) { function groupKeyOf(department: string) {
return `demo-${DEMO_DEPARTMENT_ORDER.indexOf(department)}` return `demo-${DEMO_DEPARTMENT_ORDER.indexOf(department)}`
} }
/**
* 演示文书库种子。
*
* updatedAt 用固定的历史时间而不是"当前时间":
* 每次构建都取 now 的话,同一份种子文书在每次刷新后更新时间都会变,
* 看起来像有人在不停改动文书,反而失真。
*/
const DEMO_UPDATED_AT = '2026-08-20T09:30:00+08:00'
function buildDemoLibrary(): LocalDepartment[] { function buildDemoLibrary(): LocalDepartment[] {
return DEMO_DEPARTMENT_ORDER.map((department) => { return DEMO_DEPARTMENT_ORDER.map((department) => {
const specs = DEMO_DOCUMENT_SPECS.filter((spec) => spec.department === department) const specs = DEMO_DOCUMENT_SPECS.filter((spec) => spec.department === department)
@@ -215,11 +283,15 @@ function buildDemoLibrary(): LocalDepartment[] {
name: spec.name, name: spec.name,
code: spec.code, code: spec.code,
version: spec.version, version: spec.version,
status: 'enabled', status: 'published',
backendStatus: 'PUBLISHED',
description: spec.description, description: spec.description,
versionId: spec.versionId, versionId: spec.versionId,
departmentId: null, departmentId: null,
departmentName: department,
campusId: null, campusId: null,
campusName: '全院通用',
updatedAt: DEMO_UPDATED_AT,
contentHtml: buildDemoDocumentHtml(spec), contentHtml: buildDemoDocumentHtml(spec),
}) })
categories.set(spec.category, documents) categories.set(spec.category, documents)
@@ -253,6 +325,10 @@ export function writeDocumentLibrary(library: LocalDepartment[]): void {
/** /**
* 把本地保存的文档库摊平成签署模板。 * 把本地保存的文档库摊平成签署模板。
* 文档库是模板的唯一来源:签署工作台能选到的模板,一定是在文档库里能维护的那几份。 * 文档库是模板的唯一来源:签署工作台能选到的模板,一定是在文档库里能维护的那几份。
*
* contentSha256 一并带出:真实模式下可用模板接口只给哈希不给正文,
* 签署端要靠这个哈希去换回正文快照;演示模式虽然不缺正文,但两边字段保持一致,
* 页面就不用按模式分叉。
*/ */
export function readLocalSigningTemplates(): SigningTemplate[] { export function readLocalSigningTemplates(): SigningTemplate[] {
return readDocumentLibrary().flatMap((department) => return readDocumentLibrary().flatMap((department) =>
@@ -261,7 +337,9 @@ export function readLocalSigningTemplates(): SigningTemplate[] {
id: document.id, id: document.id,
versionId: document.versionId ?? document.id, versionId: document.versionId ?? document.id,
name: document.name, name: document.name,
code: document.code, // SigningTemplate.code 必填而演示文书可能没有编码,这里如实兜底为空串,
// 不编一个看起来像真实编码的假值
code: document.code ?? '',
version: document.version, version: document.version,
department: department.name, department: department.name,
departmentId: document.departmentId ?? null, departmentId: document.departmentId ?? null,
@@ -270,6 +348,7 @@ export function readLocalSigningTemplates(): SigningTemplate[] {
description: document.description ?? '', description: document.description ?? '',
supportedMethods: ['pad', 'sms'] as SigningMethod[], supportedMethods: ['pad', 'sms'] as SigningMethod[],
contentHtml: document.contentHtml, contentHtml: document.contentHtml,
contentSha256: document.contentSha256,
})), })),
), ),
) )
@@ -1,8 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ withDefaults(
defineProps<{
modelValue: string modelValue: string
total: number total: number
}>() /** 只在演示模式下展示「重置演示数据」:真实环境不该出现这个自测入口 */
showReset?: boolean
}>(),
{ showReset: false },
)
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: string] 'update:modelValue': [value: string]
@@ -28,7 +33,7 @@ function handleInput(event: Event) {
<div class="library-tools"> <div class="library-tools">
<input <input
:value="props.modelValue" :value="modelValue"
class="library-search" class="library-search"
type="search" type="search"
placeholder="搜索模板名称 / 编号 / 科室" placeholder="搜索模板名称 / 编号 / 科室"
@@ -43,6 +48,7 @@ function handleInput(event: Event) {
导出模板包 导出模板包
</button> </button>
<button <button
v-if="showReset"
type="button" type="button"
class="tool-button ghost" class="tool-button ghost"
title="清除本地保存的演示文书与签署任务" title="清除本地保存的演示文书与签署任务"
@@ -5,10 +5,11 @@ import {
buildSigningDocumentHtml, buildSigningDocumentHtml,
describeFieldProgress, describeFieldProgress,
readAnswersFromRoot, readAnswersFromRoot,
type SigningDocumentData,
type SigningFieldProgress, type SigningFieldProgress,
} from '@/utils/signing-document' } from '@/utils/signing-document'
import { documentStatusLabels, signingPreviewPatient } from '../mock' import { documentStatusLabels } from '../mock'
import type { DocumentTemplate } from '../types' import type { DocumentTemplate } from '../types'
const props = defineProps<{ const props = defineProps<{
@@ -23,6 +24,24 @@ const emit = defineEmits<{
use: [template: DocumentTemplate] use: [template: DocumentTemplate]
}>() }>()
/**
* 签署预览用的示例患者快照(脱敏演示数据)。
*
* 创建文书时还没有真实签署任务,用固定示例值让科室看到占位符被填充后的版式;
* 真实签署仍以任务创建时的患者/就诊快照为准。
* 这份数据只服务于「签署预览」这个演示页签,因此留在组件内,
* 不再从页面 mock.ts 导出——那会让"演示数据"渗透到文书库的其它部分。
*/
const previewPatient: SigningDocumentData = {
patientName: '李某某',
sex: '男',
age: 46,
visitNo: 'V20260914001',
visitDate: '2026-09-14',
phone: '138****5678',
signedAt: '',
}
/** 预览页签:「文书内容」看原始定稿,「签署预览」看占位符填充与勾选交互化后的样子 */ /** 预览页签:「文书内容」看原始定稿,「签署预览」看占位符填充与勾选交互化后的样子 */
const activeTab = ref<'content' | 'signing'>('content') const activeTab = ref<'content' | 'signing'>('content')
/** 签署预览里科室试勾选的结果,仅用于预览反馈,不作为签署证据 */ /** 签署预览里科室试勾选的结果,仅用于预览反馈,不作为签署证据 */
@@ -63,6 +82,13 @@ const statusLabel = computed(() => {
return documentStatusLabels[props.template.status] return documentStatusLabels[props.template.status]
}) })
/** 与文书卡片保持同一口径:草稿与已归档不可发起签署 */
const canInitiate = computed(
() =>
Boolean(props.template?.contentHtml) &&
(props.template?.status === 'published' || props.template?.status === 'review'),
)
// 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式); // 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式);
// Word 自维护文书存的是编辑器产出的 HTML 片段,需要叠加文书基础渲染样式 // Word 自维护文书存的是编辑器产出的 HTML 片段,需要叠加文书基础渲染样式
// (表格边框、正文字体等),使导入路径的"裸表格"等语义结构也有合理的文书外观。 // (表格边框、正文字体等),使导入路径的"裸表格"等语义结构也有合理的文书外观。
@@ -116,7 +142,7 @@ const previewSrcDoc = computed(() => {
// 患者/就诊数据用示例快照。interactive 传 true,科室可以在预览里试勾选、确认交互效果。 // 患者/就诊数据用示例快照。interactive 传 true,科室可以在预览里试勾选、确认交互效果。
// 这里不用 iframe:需要读取勾选结果做汇总,iframe 的 sandbox 也不便于表单交互。 // 这里不用 iframe:需要读取勾选结果做汇总,iframe 的 sandbox 也不便于表单交互。
const signingPreviewHtml = computed(() => const signingPreviewHtml = computed(() =>
buildSigningDocumentHtml(props.template?.contentHtml ?? '', signingPreviewPatient, true), buildSigningDocumentHtml(props.template?.contentHtml ?? '', previewPatient, true),
) )
// 填写项与勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。 // 填写项与勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。
@@ -192,7 +218,7 @@ function syncPreviewFields() {
</div> </div>
<template v-else-if="template.contentHtml && activeTab === 'signing'"> <template v-else-if="template.contentHtml && activeTab === 'signing'">
<p class="preview-note"> <p class="preview-note">
按签署时效果渲染:占位符已用示例患者「{{ signingPreviewPatient.patientName }}」填充, 按签署时效果渲染:占位符已用示例患者「{{ previewPatient.patientName }}」填充,
表头含「勾选」的表格已转为勾选框,可直接勾选确认交互效果(预览不保存勾选结果)。 表头含「勾选」的表格已转为勾选框,可直接勾选确认交互效果(预览不保存勾选结果)。
</p> </p>
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 --> <!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
@@ -264,8 +290,8 @@ function syncPreviewFields() {
<button <button
type="button" type="button"
class="footer-button primary" class="footer-button primary"
:disabled="template.status !== 'enabled'" :disabled="!canInitiate"
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'" :title="canInitiate ? '用此模板发起签署' : '仅已发布或待复审的模板可发起签署'"
@click="emit('use', template)" @click="emit('use', template)"
> >
用此模板发起签署 用此模板发起签署
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { documentStatusLabels } from '../mock'
import type { DocumentTemplate } from '../types' import type { DocumentTemplate } from '../types'
import { documentStatusLabels } from '../mock'
const props = defineProps<{ const props = defineProps<{
template: DocumentTemplate template: DocumentTemplate
@@ -16,6 +16,15 @@ const emit = defineEmits<{
}>() }>()
const statusLabel = computed(() => documentStatusLabels[props.template.status]) const statusLabel = computed(() => documentStatusLabels[props.template.status])
/** 只有已发布 / 待复审的模板可以发起签署;草稿与已归档不可用 */
const canInitiate = computed(
() => props.template.status === 'published' || props.template.status === 'review',
)
const initiateTitle = computed(() =>
canInitiate.value ? '用此模板发起签署' : '仅已发布或待复审的模板可发起签署',
)
</script> </script>
<template> <template>
@@ -44,8 +53,8 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
<button <button
type="button" type="button"
class="card-button primary" class="card-button primary"
:disabled="template.status !== 'enabled'" :disabled="!canInitiate"
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'" :title="initiateTitle"
@click="emit('initiate', template)" @click="emit('initiate', template)"
> >
发起 发起
@@ -124,7 +133,7 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
border-radius: 50%; border-radius: 50%;
} }
.status-enabled { .status-published {
color: var(--ok); color: var(--ok);
background: var(--ok-l); background: var(--ok-l);
} }
@@ -3,9 +3,25 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue' import {
createTemplateWithContent,
deleteTemplate,
getDocuments,
getTemplateContent,
updateTemplate,
updateTemplateContent,
} from '@/api/management/documents'
import { getCampuses, getDepartments } from '@/api/management/organization'
import type {
CampusRecord,
CreateTemplateRequest,
DocumentRecord,
DepartmentRecord,
} from '@/api/management/types'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types' import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types' import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import { demoCampuses, demoDepartments } from '@/utils/mock-document-library'
import { mockFlags } from '@/utils/mock-flags' import { mockFlags } from '@/utils/mock-flags'
import { clearMockState } from '@/utils/mock-storage' import { clearMockState } from '@/utils/mock-storage'
@@ -24,37 +40,38 @@ import type {
TemplateEditorForm, TemplateEditorForm,
} from './types' } from './types'
// 原型阶段:文档库数据与文书内容全部来自本地真实附件(mock.ts + contents.ts),暂不调用后端接口。 /**
// 后续接入时,仅需将 loadLibrary / saveTemplate / handleWorkflow 中的本地数据操作替换为文档模板接口。 * 文档库数据来源。
// 是否使用本地演示库由 VITE_MOCK_DOCUMENTS 控制,缺省开启。 *
* - 演示模式(VITE_MOCK_DOCUMENTS 开启,缺省开启):读本地演示文书库,可随意增删改;
* - 真实模式:读模板接口。列表只给模板元数据(含 currentVersionId),
* 正文要再取一次版本详情——这是接入时最容易被忽略的一步,
* 缺了它文档库会显示一堆没有正文、无法预览的"空壳模板"。
*
* 两种模式共用同一套页面结构:模板按 科室 → 分类 → 文书 三级分组渲染,
* 因此真实模式也需要把扁平的模板列表组织成同样的树。
*/
const router = useRouter() const router = useRouter()
const appStore = useAppStore()
const useMockLibrary = mockFlags.documents const useMockLibrary = mockFlags.documents
/** 关掉演示库后,文书库本应改由模板接口提供,但该路径尚未实现:如实提示,不静默沿用本地数据 */ /** 真实模式下的加载状态:区分"正在加载"与"确实没有模板" */
const apiLibraryPending = ref(false) const libraryLoading = ref(false)
const libraryError = ref('')
const library = ref<DocumentDepartment[]>([]) const library = ref<DocumentDepartment[]>([])
const selectedTemplate = ref<DocumentTemplate | null>(null) const selectedTemplate = ref<DocumentTemplate | null>(null)
const signingTemplate = ref<SigningTemplate | null>(null) const signingTemplate = ref<SigningTemplate | null>(null)
const wordEditingTemplate = ref<DocumentTemplate | null>(null) const wordEditingTemplate = ref<DocumentTemplate | null>(null)
const wordEditorVisible = ref(false) const wordEditorVisible = ref(false)
const signingDialogVisible = ref(false) const signingDialogVisible = ref(false)
const wordEditorLoading = ref(false)
/** 编辑已有文书时需要先把正文取回来,取正文期间弹窗展示加载态 */
const previewLoading = ref(false)
const previewError = ref(false)
const campuses: CampusRecord[] = [ const campuses = ref<CampusRecord[]>([])
{ id: 'campus-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 }, const departments = ref<DepartmentRecord[]>([])
{ id: 'campus-fengxian', code: 'FENGXIAN', name: '奉贤院区', status: 'ENABLED', sortNo: 2 },
]
const departments: DepartmentRecord[] = ['医学影像科', '医学检验科', '临床遗传中心'].map(
(name, index) => ({
id: `template-department-${index + 1}`,
campusId: 'campus-main',
code: `TPL-DEPT-${index + 1}`,
name,
status: 'ENABLED',
sortNo: index + 1,
}),
)
const filters = reactive<DocumentFilterForm>({ const filters = reactive<DocumentFilterForm>({
keyword: '', keyword: '',
@@ -98,8 +115,7 @@ const filteredTemplates = computed(() => {
return allTemplates.value.filter((template) => { return allTemplates.value.filter((template) => {
const matchesDepartment = !filters.department || template.departmentKey === filters.department const matchesDepartment = !filters.department || template.departmentKey === filters.department
const matchesStatus = const matchesStatus = filters.status === 'all' || template.status === filters.status
filters.status === 'all' || template.status === filters.status
const matchesKeyword = const matchesKeyword =
!keyword || !keyword ||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword) [template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
@@ -118,14 +134,136 @@ function cloneLibrary(source: DocumentDepartment[]): DocumentDepartment[] {
})) }))
} }
function loadLibrary() { /**
if (!useMockLibrary) { * 把扁平的模板列表组织成页面需要的 科室 → 分类 → 文书 树。
library.value = [] *
apiLibraryPending.value = true * 分组键用 departmentId(真实模式)或科室名(未指定科室的模板归"全院通用"),
* 这样同一科室的模板无论接口返回顺序如何都会落进同一个分组。
*/
function groupDocuments(records: DocumentRecord[]): DocumentDepartment[] {
const departments = new Map<string, DocumentDepartment>()
records.forEach((record) => {
const departmentKey = record.departmentId ?? '__all__'
const departmentName =
record.departmentName ?? (record.departmentId ? '未指定科室' : '全院通用')
let department = departments.get(departmentKey)
if (!department) {
department = { key: departmentKey, name: departmentName, campus: 'all', categories: [] }
departments.set(departmentKey, department)
}
const categoryName = record.category || '未分类'
let category = department.categories.find((item) => item.name === categoryName)
if (!category) {
category = { name: categoryName, documents: [] }
department.categories.push(category)
}
category.documents.push(record)
})
// 全院通用的模板排最前,其余按科室名排序,保证每次进页面分组顺序稳定
return [...departments.values()].sort((left, right) => {
if (left.key === '__all__') {
return -1
}
if (right.key === '__all__') {
return 1
}
return left.name.localeCompare(right.name, 'zh-CN')
})
}
/**
* 读取权限范围内的全部模板。
* 权限范围内模板可能超过一页,这里翻完所有页;不翻页会静默漏掉后面的模板,
* 表现为"文档库里少了几份文书",很难排查。
*/
async function fetchAllDocuments(): Promise<DocumentRecord[]> {
const pageSize = 200
const firstPage = await getDocuments({
page: 1,
pageSize,
status: 'all',
campusId: appStore.selectedCampusId || undefined,
})
const effectivePageSize = Math.max(firstPage.pageSize, 1)
const pageCount = Math.ceil(firstPage.total / effectivePageSize)
if (pageCount <= 1) {
return firstPage.records
}
const remaining = await Promise.all(
Array.from({ length: pageCount - 1 }, (_, index) =>
getDocuments({
page: index + 2,
pageSize: effectivePageSize,
status: 'all',
campusId: appStore.selectedCampusId || undefined,
}),
),
)
return [firstPage, ...remaining].flatMap((page) => page.records)
}
async function loadLibrary() {
if (useMockLibrary) {
libraryError.value = ''
library.value = cloneLibrary(loadDocumentLibrary())
return return
} }
library.value = cloneLibrary(loadDocumentLibrary()) libraryLoading.value = true
libraryError.value = ''
try {
const records = await fetchAllDocuments()
library.value = groupDocuments(records)
} catch {
library.value = []
libraryError.value = '文书库加载失败,请确认后端模板接口可访问后重试'
} finally {
libraryLoading.value = false
}
}
/**
* 读取组织字典。
* 真实模式取真实院区/科室;演示模式用演示字典,键名与接口同构,
* 因此「所属院区 / 所属科室」下拉在两种模式下走同一段模板代码。
*
* 之前这里写死了三个科室(id 形如 template-department-1,不是后端 UUID),
* 这些 id 会被写进文库、再被当 departmentId 发给后端,接真实接口必然报错。
*/
async function loadOrganizationOptions() {
if (useMockLibrary) {
campuses.value = demoCampuses.map((campus) => ({ ...campus }))
departments.value = demoDepartments.map((department) => ({
...department,
campusId: campuses.value[0]?.id ?? '',
}))
return
}
const [campusesResult, departmentsResult] = await Promise.allSettled([
getCampuses(),
getDepartments(),
])
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
if (campusesResult.status === 'rejected') {
ElMessage.error('院区字典加载失败,新增文书时无法选择所属院区')
}
} }
// 演示数据落本地:刷新后文书库还在,不用每次重新建文书。 // 演示数据落本地:刷新后文书库还在,不用每次重新建文书。
@@ -133,19 +271,52 @@ function loadLibrary() {
watch( watch(
library, library,
(value) => { (value) => {
if (useMockLibrary) { if (useMockLibrary && !libraryLoading.value) {
saveDocumentLibrary(value) saveDocumentLibrary(value)
} }
}, },
{ deep: true }, { deep: true },
) )
function handlePreview(template: DocumentTemplate) {
selectedTemplate.value = template
}
function closePreview() { function closePreview() {
selectedTemplate.value = null selectedTemplate.value = null
previewError.value = false
}
/**
* 预览模板。
* 真实模式的列表接口不返回正文,这里按需取回再打开预览;
* 取不到正文时如实报错,让科室知道是接口/数据问题,而不是看到一个空预览。
*/
async function handlePreview(template: DocumentTemplate) {
selectedTemplate.value = template
if (useMockLibrary || template.contentHtml) {
previewLoading.value = false
previewError.value = false
return
}
previewLoading.value = true
previewError.value = false
try {
const contentHtml = await getTemplateContent(template.id)
// 用户在等待期间可能已经切到别的文书,回填前确认仍是同一份
if (selectedTemplate.value?.id === template.id) {
selectedTemplate.value = { ...template, contentHtml }
previewError.value = !contentHtml
}
} catch {
if (selectedTemplate.value?.id === template.id) {
previewError.value = true
}
} finally {
if (selectedTemplate.value?.id === template.id) {
previewLoading.value = false
}
}
} }
function showAddMessage() { function showAddMessage() {
@@ -155,13 +326,7 @@ function showAddMessage() {
} }
function showExportMessage() { function showExportMessage() {
ElMessage.info('原型演示:导出模板包(ZIP)') ElMessage.info('导出模板包待接入:需要后端提供模板包导出接口')
}
function showEditMessage(template: DocumentTemplate) {
// Word 自维护方案:所有文书统一在 Word 编辑器中维护。
wordEditingTemplate.value = template
wordEditorVisible.value = true
} }
function closeWordEditor() { function closeWordEditor() {
@@ -169,10 +334,44 @@ function closeWordEditor() {
wordEditingTemplate.value = null wordEditingTemplate.value = null
} }
/**
* 编辑已有文书。
* 真实模式必须先取回正文再打开编辑器——列表接口不返回正文,
* 直接用列表里的空 contentHtml 打开编辑器,科室会看到"文书内容为空",
* 保存后还会把模板正文冲掉。
*/
async function showEditMessage(template: DocumentTemplate) {
if (useMockLibrary || template.contentHtml) {
wordEditingTemplate.value = template
wordEditorVisible.value = true
return
}
wordEditorLoading.value = true
try {
const contentHtml = await getTemplateContent(template.id)
if (!contentHtml) {
ElMessage.error('未能取到该文书的正文内容,请确认模板版本内容接口可访问')
return
}
wordEditingTemplate.value = { ...template, contentHtml }
wordEditorVisible.value = true
} catch {
ElMessage.error('文书正文加载失败,请稍后重试')
} finally {
wordEditorLoading.value = false
}
}
/** 演示模式:把新文书追加进本地文库(分组按科室名匹配已有分组) */
function addLocalTemplate(form: TemplateEditorForm) { function addLocalTemplate(form: TemplateEditorForm) {
const department = departments.find((item) => item.id === form.departmentId) const department = departments.value.find((item) => item.id === form.departmentId)
const departmentName = department?.name ?? '全院通用' const departmentName = department?.name ?? '全院通用'
const departmentKey = department?.id ?? 'local-general' // 真实模式用 departmentId 当分组键,演示模式没有 UUID,用品名保持同样语义
const departmentKey = department?.id ?? '__all__'
let departmentGroup = library.value.find((item) => item.key === departmentKey) let departmentGroup = library.value.find((item) => item.key === departmentKey)
if (!departmentGroup) { if (!departmentGroup) {
@@ -198,18 +397,23 @@ function addLocalTemplate(form: TemplateEditorForm) {
name: form.name, name: form.name,
code: form.templateCode, code: form.templateCode,
version: form.versionNo || 'v1', version: form.versionNo || 'v1',
// 原型闭环:新文书直接启用(工作流 UI 待签署端接入后恢复),保证创建后即可预览/发起。 // 演示模式直接落成已发布:演示库没有审核流,落在草稿会没法发起签署
status: 'enabled', status: 'published',
backendStatus: 'PUBLISHED',
updatedBy: '本地演示',
updatedAt: new Date().toISOString(),
category: form.category,
description: form.description, description: form.description,
versionId: `local-version-${timestamp}`, versionId: `local-version-${timestamp}`,
departmentId: form.departmentId || null, departmentId: form.departmentId || null,
departmentName,
campusId: form.campusId, campusId: form.campusId,
contentHtml: form.contentHtml, contentHtml: form.contentHtml,
}) })
} }
function saveTemplate(form: TemplateEditorForm) { /** 演示模式:就地更新已有文书(只改可改字段,科室归属与编号不动) */
if (form.id) { function updateLocalTemplate(form: TemplateEditorForm) {
library.value.forEach((department) => library.value.forEach((department) =>
department.categories.forEach((category) => department.categories.forEach((category) =>
category.documents.forEach((document) => { category.documents.forEach((document) => {
@@ -218,23 +422,84 @@ function saveTemplate(form: TemplateEditorForm) {
document.version = form.versionNo || document.version document.version = form.versionNo || document.version
document.contentHtml = form.contentHtml document.contentHtml = form.contentHtml
document.description = form.description document.description = form.description
document.updatedAt = new Date().toISOString()
} }
}), }),
), ),
) )
}
function toCreatePayload(form: TemplateEditorForm): CreateTemplateRequest {
return {
templateCode: form.templateCode,
name: form.name,
description: form.description,
campusId: form.campusId,
departmentId: form.departmentId || null,
category: form.category,
}
}
function toVersionPayload(form: TemplateEditorForm) {
return {
versionNo: form.versionNo,
contentHtml: form.contentHtml,
}
}
async function saveTemplate(form: TemplateEditorForm) {
if (useMockLibrary) {
if (form.id) {
updateLocalTemplate(form)
} else { } else {
addLocalTemplate(form) addLocalTemplate(form)
} }
closeWordEditor() closeWordEditor()
ElMessage.success(form.id ? '文书已更新' : '文书已创建') ElMessage.success(form.id ? '文书已更新' : '文书已创建')
return
} }
try {
if (form.id) {
// 已发布的版本不可原地改写(已签署文书引用它),编辑落到新版本上;
// 新版本默认是草稿,这里顺带发布,保证编辑完就能直接发起签署
await updateTemplateContent(form.id, toVersionPayload(form))
await updateTemplate(form.id, {
name: form.name,
description: form.description,
category: form.category,
})
ElMessage.success('文书已更新并发布新版本')
} else {
await createTemplateWithContent(toCreatePayload(form), toVersionPayload(form))
ElMessage.success('文书已创建')
}
closeWordEditor()
await loadLibrary()
} catch {
ElMessage.error(form.id ? '文书更新失败,请稍后重试' : '文书创建失败,请稍后重试')
}
}
/**
* 删除文书。
* 模板没有物理删除接口:它可能已被历史签署任务引用,删掉会让留痕链断裂。
* 因此「删除」在这里表达为归档版本(后端 archive 动作),
* 语义上等于下架,已签署文书仍可追溯。
*/
async function removeTemplate(template: DocumentTemplate) { async function removeTemplate(template: DocumentTemplate) {
const confirmed = await ElMessageBox.confirm( const confirmed = await ElMessageBox.confirm(
`确定删除文书「${template.name}」?删除后不可恢复。`, useMockLibrary
'删除文书', ? `确定删除文书「${template.name}」?删除后不可恢复。`
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }, : `确定下架文书「${template.name}」?下架后不能再用于发起签署,已签署的记录不受影响。`,
useMockLibrary ? '删除文书' : '下架文书',
{
type: 'warning',
confirmButtonText: useMockLibrary ? '删除' : '下架',
cancelButtonText: '取消',
},
) )
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
@@ -243,7 +508,8 @@ async function removeTemplate(template: DocumentTemplate) {
return return
} }
// 移除文书后,顺带清理空分类与空科室分组,避免留下空壳节点。 if (useMockLibrary) {
// 移除文书后,顺带清理空分类与空科室分组,避免留下空壳节点
library.value = library.value library.value = library.value
.map((department) => ({ .map((department) => ({
...department, ...department,
@@ -257,31 +523,68 @@ async function removeTemplate(template: DocumentTemplate) {
.filter((department) => department.categories.length > 0) .filter((department) => department.categories.length > 0)
ElMessage.success('文书已删除') ElMessage.success('文书已删除')
return
} }
// 版本工作流(提交审核/发布等)随原模板编辑器移除,待签署端接入后按后端工作流接口恢复。 try {
await deleteTemplate(template.id)
ElMessage.success('文书已下架')
await loadLibrary()
} catch {
ElMessage.error('文书下架失败,请稍后重试')
}
}
/**
* 把文书转成签署模板。
*
* 转之前要确保有正文:真实模式的列表接口不返回正文,缺正文时发起签署会签出一份
* 与模板无关的空文书。因此这里在缺正文时先取回,取不到就拒绝发起。
*/
async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTemplate | null> {
let contentHtml = template.contentHtml
if (!useMockLibrary && !contentHtml) {
contentHtml = await getTemplateContent(template.id)
if (!contentHtml) {
ElMessage.error('未能取到该文书的正文内容,无法发起签署')
return null
}
}
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
return { return {
id: template.id, id: template.id,
versionId: template.versionId ?? template.id, versionId: template.versionId ?? template.id,
name: template.name, name: template.name,
code: template.code, code: template.code ?? '',
version: template.version, version: template.version,
department: template.department, department: template.department,
departmentId: template.departmentId, departmentId: template.departmentId,
campusId: template.campusId ?? undefined, campusId: template.campusId ?? undefined,
category: template.category, category: template.category,
description: template.description, description: template.description ?? '',
supportedMethods: ['pad', 'sms'], supportedMethods: ['pad', 'sms'],
contentHtml: template.contentHtml, contentHtml,
contentSha256: template.contentSha256,
} }
} }
function openSigningDialog(template: DocumentTemplate) { async function openSigningDialog(template: DocumentTemplate) {
closePreview() closePreview()
signingTemplate.value = toSigningTemplate(template)
try {
const signing = await toSigningTemplate(template)
if (!signing) {
return
}
signingTemplate.value = signing
signingDialogVisible.value = true signingDialogVisible.value = true
} catch {
ElMessage.error('文书正文加载失败,无法发起签署')
}
} }
function closeSigningDialog() { function closeSigningDialog() {
@@ -296,7 +599,7 @@ function handleTaskCreated(task: SigningTaskRecord) {
} }
// 自测用:一键清掉本地保存的演示文书与签署任务,回到初始状态。 // 自测用:一键清掉本地保存的演示文书与签署任务,回到初始状态。
// 签署任务的演示数据在 api 层的内存里,清存储后直接刷新最彻底。 // 只在演示模式下展示(模板里有 v-if),真实环境不该出现这个入口。
async function resetMockData() { async function resetMockData() {
const confirmed = await ElMessageBox.confirm( const confirmed = await ElMessageBox.confirm(
'将清除本地保存的演示文书与签署任务,恢复到初始状态。', '将清除本地保存的演示文书与签署任务,恢复到初始状态。',
@@ -320,10 +623,35 @@ function clearFilters() {
} }
function useTemplate(template: DocumentTemplate) { function useTemplate(template: DocumentTemplate) {
openSigningDialog(template) void openSigningDialog(template)
} }
onMounted(loadLibrary) async function initializePage() {
if (useMockLibrary) {
await loadOrganizationOptions()
} else {
await appStore.loadCampuses()
await loadOrganizationOptions()
}
await loadLibrary()
}
watch(
() => appStore.selectedCampusId,
(campusId, previousCampusId) => {
// 演示模式没有院区维度,切换院区不需要重新拉文库
if (useMockLibrary || !campusId || !previousCampusId || campusId === previousCampusId) {
return
}
void loadLibrary()
},
)
onMounted(() => {
void initializePage()
})
</script> </script>
<template> <template>
@@ -331,6 +659,7 @@ onMounted(loadLibrary)
<DocumentLibraryHeader <DocumentLibraryHeader
v-model="filters.keyword" v-model="filters.keyword"
:total="filteredTemplates.length" :total="filteredTemplates.length"
:show-reset="useMockLibrary"
@add="showAddMessage" @add="showAddMessage"
@export="showExportMessage" @export="showExportMessage"
@reset="resetMockData" @reset="resetMockData"
@@ -343,10 +672,12 @@ onMounted(loadLibrary)
:statuses="statusTabs" :statuses="statusTabs"
/> />
<p v-if="apiLibraryPending" class="library-pending"> <p v-if="libraryLoading" class="library-state">正在加载文书库…</p>
已关闭本地演示库(VITE_MOCK_DOCUMENTS=false),但文档库尚未接入模板接口,暂时无法展示文书。
改回 VITE_MOCK_DOCUMENTS=true 可继续使用演示库。 <div v-else-if="libraryError" class="library-state library-state--error">
</p> <span>{{ libraryError }}</span>
<button type="button" class="state-retry" @click="loadLibrary">重新加载</button>
</div>
<div v-else-if="filteredTemplates.length" class="template-grid"> <div v-else-if="filteredTemplates.length" class="template-grid">
<DocumentTemplateCard <DocumentTemplateCard
@@ -381,8 +712,8 @@ onMounted(loadLibrary)
<DocumentPreviewDialog <DocumentPreviewDialog
:visible="Boolean(selectedTemplate)" :visible="Boolean(selectedTemplate)"
:template="selectedTemplate" :template="selectedTemplate"
:loading="false" :loading="previewLoading"
:error="false" :error="previewError"
@close="closePreview" @close="closePreview"
@use="useTemplate" @use="useTemplate"
/> />
@@ -400,6 +731,11 @@ onMounted(loadLibrary)
@close="wordEditorVisible = false" @close="wordEditorVisible = false"
@save="saveTemplate" @save="saveTemplate"
/> />
<!-- 取正文期间给一层轻量遮罩,避免科室在编辑器打开前误点其它操作 -->
<div v-if="wordEditorLoading" class="editor-loading" role="status">
<span>正在加载文书正文…</span>
</div>
</section> </section>
</template> </template>
@@ -416,10 +752,13 @@ onMounted(loadLibrary)
gap: 16px; gap: 16px;
} }
.library-pending { .library-state {
display: flex;
gap: 12px;
align-items: center;
padding: 16px 20px; padding: 16px 20px;
margin: 0 0 16px; margin: 0 0 16px;
color: var(--warn); color: var(--mut);
font-size: 13px; font-size: 13px;
line-height: 1.7; line-height: 1.7;
background: var(--card); background: var(--card);
@@ -427,6 +766,24 @@ onMounted(loadLibrary)
border-radius: var(--r); border-radius: var(--r);
} }
.library-state--error {
color: var(--err);
}
.state-retry {
flex-shrink: 0;
padding: 5px 11px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 6px;
}
.state-retry:hover {
background: var(--brand-l);
}
.empty-state { .empty-state {
padding: 56px 24px; padding: 56px 24px;
color: var(--mut); color: var(--mut);
@@ -462,6 +819,24 @@ onMounted(loadLibrary)
background: var(--brand-l); background: var(--brand-l);
} }
.editor-loading {
position: fixed;
z-index: 15;
inset: 0;
display: grid;
color: var(--ink);
font-size: 13px;
background: rgb(9 40 52 / 25%);
place-items: center;
}
.editor-loading span {
padding: 12px 20px;
background: #fff;
border-radius: 8px;
box-shadow: var(--sh-modal);
}
@media (max-width: 1400px) { @media (max-width: 1400px) {
.template-grid { .template-grid {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1,46 +1,113 @@
/**
* 文档库的演示数据与展示文案。
*
* 状态标签放在这里而不是随演示数据一起删掉:标签是纯展示映射,
* 接真实接口后照样要用,不能和"演示数据"捆在一起。
*
* 演示文书库的种子与读写逻辑在 utils/mock-document-library:
* 文档库页面和签署工作台必须读同一份数据,否则会出现「文档库里维护的模板,签署端选不到」,
* 或者签署端混着一批文档库里看不到、也无法维护的模板。
*/
import type { DocumentRecord } from '@/api/management/types'
import type { LocalDocument, LocalDepartment } from '@/utils/mock-document-library'
import { readDocumentLibrary, writeDocumentLibrary } from '@/utils/mock-document-library' import { readDocumentLibrary, writeDocumentLibrary } from '@/utils/mock-document-library'
import type { SigningDocumentData } from '@/utils/signing-document'
import type { DocumentDepartment, DocumentStatus } from './types' import type { DocumentDepartment, DocumentStatus } from './types'
const documentStatusLabels: Record<DocumentStatus, string> = { /**
enabled: '启用中', * 演示文库与页面模型之间的归一。
draft: '草稿', *
archived: '归档', * utils/mock-document-library 刻意只声明自己用到的字段(不反向依赖 views 的类型),
review: '需复审', * 它返回的文书缺了页面模型要求的 category / updatedBy / updatedAt,code 也可能为空。
* 两种口径不一致会在 loadLibrary / saveDocumentLibrary 处直接报类型错误,
* 因此在这一层做一次显式补齐——而不是让 utils 反过来依赖 views,
* 也不是把页面的必填字段放宽成可选(放宽会掩盖真实接口返回缺字段的问题)。
*
* 补齐用的值都标注了是演示数据的缺省值,不冒充真实字段:
* updatedAt 留空串,页面会显示成"—",不会假装有一份真实的更新时间。
*/
function toDocumentRecord(document: LocalDocument, categoryName: string): DocumentRecord {
return {
...document,
code: document.code ?? '',
category: categoryName,
updatedBy: '演示数据',
updatedAt: document.updatedAt ?? '',
}
} }
export const documentStatusOptions = Object.entries(documentStatusLabels).map(([key, label]) => ({ export function toDocumentDepartment(department: LocalDepartment): DocumentDepartment {
key: key as DocumentStatus, return {
label, ...department,
})) categories: department.categories.map((category) => ({
...category,
documents: category.documents.map((document) => toDocumentRecord(document, category.name)),
})),
}
}
export { documentStatusLabels }
// 演示文书库的种子与读取逻辑都在 utils/mock-document-library:
// 文档库页面和签署工作台必须读同一份数据,否则会出现「文档库里维护的模板,签署端选不到」,
// 或者签署端混着一批文档库里看不到、也无法维护的模板。
/** 读取演示文书库:优先用本地保存的,没有则用种子(调用方负责再克隆一份使用) */ /** 读取演示文书库:优先用本地保存的,没有则用种子(调用方负责再克隆一份使用) */
export function loadDocumentLibrary(): DocumentDepartment[] { export function loadDocumentLibrary(): DocumentDepartment[] {
return readDocumentLibrary() return readDocumentLibrary().map(toDocumentDepartment)
}
/** 保存演示文书库,刷新后仍可继续编辑、预览与发起签署 */
export function saveDocumentLibrary(library: DocumentDepartment[]): void {
writeDocumentLibrary(library)
} }
/** /**
* 签署预览使用的示例患者快照(脱敏演示数据)。 * 保存演示文书库。
* 创建文书时还没有真实签署任务,用固定示例值让科室看到占位符被填充后的版式; * 落本地时要把 category 摘掉(LocalDocument 没有 category 字段,
* 真实签署仍以任务创建时的患者/就诊快照为准。 * 分类信息在 categories 这一层的 name 上),否则多存一个字段会在下次读取时对不上。
* updatedBy 同理:它是页面模型为「必须有人维护」补的展示字段,不是演示文库的数据。
*/ */
export const signingPreviewPatient: SigningDocumentData = { export function saveDocumentLibrary(library: DocumentDepartment[]): void {
patientName: '李某某', writeDocumentLibrary(
sex: '男', library.map((department) => ({
age: 46, ...department,
visitNo: 'V20260914001', categories: department.categories.map((category) => ({
visitDate: '2026-09-14', name: category.name,
phone: '138****5678', documents: category.documents.map(toLocalDocument),
signedAt: '', })),
})),
)
} }
/**
* 页面模型的文书 → 演示文库存储结构。
*
* 逐字段列出而不是解构剔除:解构剔除(`const { category, ...rest }`)会因为
* 被剔除的变量未使用而触发 lint,且多加一个页面专用字段时容易忘掉同步。
* 这里明确列出演示文库认识哪些字段,多出来的页面字段(category / updatedBy)自然被丢掉。
*/
function toLocalDocument(document: DocumentRecord): LocalDocument {
return {
id: document.id,
name: document.name,
code: document.code,
version: document.version,
status: document.status,
backendStatus: document.backendStatus,
description: document.description ?? '',
versionId: document.versionId,
departmentId: document.departmentId,
departmentName: document.departmentName,
campusId: document.campusId,
campusName: document.campusName,
updatedAt: document.updatedAt,
contentHtml: document.contentHtml,
contentSha256: document.contentSha256,
}
}
/**
* 状态标签。键集合与后端模板状态枚举一一对应
* (见 api/management/types.ts 的 BackendTemplateStatus 与 documents.ts 的 normalizeDocumentStatus),
* 不再有后端枚举里不存在的 enabled。
*/
export const documentStatusLabels: Record<DocumentStatus, string> = {
draft: '草稿',
review: '需复审',
published: '已发布',
archived: '已归档',
}
export const documentStatusOptions = (
Object.entries(documentStatusLabels) as [DocumentStatus, string][]
).map(([key, label]) => ({ key, label }))
@@ -1,21 +1,21 @@
export type DocumentStatus = 'enabled' | 'draft' | 'archived' | 'review' /**
* 文档库页面模型。
*
* 状态枚举直接复用 api/management/types.ts 的 DocumentStatus,不再另立一套:
* 之前页面自己定义了 'enabled' | 'draft' | 'archived' | 'review',
* 其中 enabled 在后端模板状态枚举里没有对应值(后端只有 DRAFT / PENDING_REVIEW /
* APPROVED / PUBLISHED / DISABLED / ARCHIVED),一旦接入真实接口就没法双向映射。
* 现在两侧共用同一份,API 边界的 normalizeDocumentStatus 就是唯一的转换口径。
*/
import type { DocumentRecord, DocumentStatus } from '@/api/management/types'
export type DocumentCampus = 'all' | '本部院区' | '东院区' | '西院区' export type { DocumentStatus }
export interface LibraryDocument { /** 院区分组键:与模板接口的 campusId 对应,'all' 表示全院通用 */
id: string export type DocumentCampus = string
name: string
code: string /** 文书库里的单份文书(与模板接口的 DocumentRecord 同构,页面不再另建字段) */
version: string export type LibraryDocument = DocumentRecord
status: DocumentStatus
backendStatus?: string
description: string
versionId?: string
departmentId?: string | null
campusId?: string | null
contentHtml?: string
contentSha256?: string
}
export interface DocumentCategory { export interface DocumentCategory {
name: string name: string
@@ -1,14 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SigningTaskRecord } from '@/api/workbench/types' /**
* 文书回退纸样。
*
* 只在任务缺少正文快照(documentContentHtml 为空)时使用:任务接口不返回正文,
* 正文由 hydrateSigningTaskContent 按 templateContentSha256 取回,取不到时才会走到这里。
*
* 这里的正文段落是通用告知语句,**不是任何一份真实模板的内容**,
* 因此页面必须明确标注"未取到模板正文",不能让医务人员误以为这就是要签的文书。
* 经治医师等应由现场填写的字段也不许写死:写死一个不存在的医生名,
* 等于在留痕材料上伪造了一个签署人。
*/
defineProps<{ defineProps<{
task: SigningTaskRecord task: SigningTaskRecord
}>() }>()
import type { SigningTaskRecord } from '@/api/workbench/types'
</script> </script>
<template> <template>
<article class="paper-preview"> <article class="paper-preview">
<div v-if="task.status === 'void'" class="void-stamp">已 作 废</div> <div v-if="task.status === 'void'" class="void-stamp">已 作 废</div>
<div class="fallback-banner">
<strong>未能取到模板正文</strong>
<span>
本任务仅返回了模板版本哈希({{ task.contentSha256 || '未提供' }}),正文快照暂不可用。
请确认后端模板版本内容接口可访问,或联系信息科核对模板数据。
以下为占位版式,不是本任务实际签署的文书内容。
</span>
</div>
<h2>{{ task.documentName }}</h2> <h2>{{ task.documentName }}</h2>
<p class="paper-number">编号:{{ task.id }} · 就诊号:{{ task.visitNo }}</p> <p class="paper-number">编号:{{ task.id }} · 就诊号:{{ task.visitNo }}</p>
@@ -47,10 +68,12 @@ defineProps<{
<span>{{ task.signedAt ?? '—' }}</span> <span>{{ task.signedAt ?? '—' }}</span>
</div> </div>
</div> </div>
<!-- 经治医师不是任务快照里的字段,也无从由前端补齐:
这里不给占位人名,避免在留痕材料上凭空出现一个签署人 -->
<div class="signature-field"> <div class="signature-field">
<span>经治医师:</span> <span>经治医师:</span>
<div class="signature-box signature-box--doctor"> <div class="signature-box signature-box--doctor">
<span>刘明辉</span> <span>(以实际签署文书为准)</span>
</div> </div>
</div> </div>
</div> </div>
@@ -70,6 +93,26 @@ defineProps<{
box-shadow: var(--sh); box-shadow: var(--sh);
} }
/* 回退纸样必须在最显眼处声明"这不是真实文书内容",
否则医务人员会把占位段落当成要签的文书 */
.fallback-banner {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
margin-bottom: 20px;
color: var(--warn);
font-size: 12px;
line-height: 1.7;
background: var(--warn-l);
border: 1px solid #f0dcae;
border-radius: 8px;
}
.fallback-banner strong {
font-size: 12.5px;
}
.paper-preview h2 { .paper-preview h2 {
margin: 0 0 6px; margin: 0 0 6px;
font-size: 19px; font-size: 19px;
@@ -165,6 +208,11 @@ defineProps<{
min-width: 120px; min-width: 120px;
} }
/* 医师签名位是空的说明文字,不需要下划线,避免看起来像已填写 */
.signature-field:nth-child(3) .signature-box {
border-bottom-style: dashed;
}
.void-stamp { .void-stamp {
position: absolute; position: absolute;
top: 34px; top: 34px;
@@ -158,7 +158,8 @@ defineExpose({ getDocumentElement })
</button> </button>
</div> </div>
<!-- 任务创建时固化了文书 HTML 的走交互化渲染;老任务回退到静态纸样预览 --> <!-- 任务接口只返回模板版本哈希,正文由 API 层按哈希补回;
补不到时如实提示并给出占位版式,不退化成"看起来像真文书"的假纸样 -->
<SigningDocumentInteractive <SigningDocumentInteractive
v-if="task.documentContentHtml" v-if="task.documentContentHtml"
ref="documentRef" ref="documentRef"