diff --git a/.gitignore b/.gitignore index 23ddf1f..809809a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,9 @@ pnpm-debug.log* # TypeScript cache *.tsbuildinfo + +# 渲染验证的截图产物(scripts/verify-pages.cjs 生成) +screenshots/ + +# 本地 AI 工作区数据(含记忆、技能等),不随仓库分发 +.workbuddy-ai/ diff --git a/clinical-web/.env.example b/clinical-web/.env.example index 1986617..70e5114 100644 --- a/clinical-web/.env.example +++ b/clinical-web/.env.example @@ -4,7 +4,11 @@ VITE_API_BASE_URL=/api # Mock 开关:按业务模块(对齐侧边栏菜单)独立控制,取值只认 true / false。 # 留空或注释掉即使用该模块的缺省值,缺省值以 src/utils/mock-flags.ts 为准, # 目前是「签署工作台 + 文档库走演示数据,其余模块走真实接口」。 -# 只有想让某个模块偏离缺省时才需要在这里(或本地 .env.*.local)显式声明。 +# +# 建议在本地 .env.*.local 里显式声明这两个开关,而不是依赖缺省值: +# 缺省值只保证「不改代码时的默认行为」,缺省值一旦调整, +# 本地开发者会在无感知的情况下被切到真实后端(或反之),联调时表现为 +# 「接口报错」或「数据不对」,排查成本很高。 # VITE_MOCK_WORKBENCH_HOME=false # VITE_MOCK_SIGNING=true # VITE_MOCK_DOCUMENTS=true diff --git a/clinical-web/.gitignore b/clinical-web/.gitignore index a547bf3..d6f55d5 100644 --- a/clinical-web/.gitignore +++ b/clinical-web/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# 渲染验证脚本的运行产物:会话文件含真实 token,绝不能提交;验证码图片是临时文件 +scripts/.session.json +scripts/.captcha.json +scripts/.captcha.png # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/clinical-web/README.md b/clinical-web/README.md index 012eac8..ac9e92c 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -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、签名原图下载; - `POST /api/v1/sign-deliveries/{taskId}/sms/resend`:短信重发,自动携带幂等键; - `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/{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}`:管理员重置密码和用户导入流程; diff --git a/clinical-web/eslint.config.js b/clinical-web/eslint.config.js index 228d182..ef60e94 100644 --- a/clinical-web/eslint.config.js +++ b/clinical-web/eslint.config.js @@ -6,7 +6,15 @@ import tseslint from 'typescript-eslint' 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, ...tseslint.configs.recommended, diff --git a/clinical-web/scripts/README.md b/clinical-web/scripts/README.md new file mode 100644 index 0000000..261507e --- /dev/null +++ b/clinical-web/scripts/README.md @@ -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` | `/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//.workbuddy-ai/binaries/node/workspace +/npm.cmd install ws +``` + +运行脚本时指定 `NODE_PATH` 指向该目录: + +```bash +NODE_PATH=C:/Users//.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` 是临时文件,同样已忽略。 diff --git a/clinical-web/scripts/login.cjs b/clinical-web/scripts/login.cjs new file mode 100644 index 0000000..2f015d5 --- /dev/null +++ b/clinical-web/scripts/login.cjs @@ -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) +}) diff --git a/clinical-web/scripts/verify-pages.cjs b/clinical-web/scripts/verify-pages.cjs new file mode 100644 index 0000000..d74d897 --- /dev/null +++ b/clinical-web/scripts/verify-pages.cjs @@ -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 截图输出目录,默认 /screenshots + * CHROME_PATH Chrome 可执行文件路径 + * + * 依赖:ws(CDP 需要)。装到隔离 workspace: + * cd C:/Users//.workbuddy-ai/binaries/node/workspace + * /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//.workbuddy-ai/binaries/node/workspace') + console.error(' /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() + } +})() diff --git a/clinical-web/src/api/management/documents.ts b/clinical-web/src/api/management/documents.ts index 64b347d..6a8e2a7 100644 --- a/clinical-web/src/api/management/documents.ts +++ b/clinical-web/src/api/management/documents.ts @@ -3,6 +3,7 @@ import axios from 'axios' import { unwrapApiResponse } from '@/utils/api-response' import { mockFlags } from '@/utils/mock-flags' import { request } from '@/utils/request' +import { collectFieldDefs } from '@/utils/signing-fields' import type { ApiResponse, PageResult } from '@/types/common' 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 { + 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() +const contentRequestBySha = new Map>() + +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 { + 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 { + 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( data: BackendPage | T[], fallbackPage: number, @@ -219,19 +360,54 @@ export async function getDocumentDetail(id: string): Promise { if (useMockData) { - throw new Error('Mock 模式不执行模板写操作') + throw new Error('演示模式下请在文档库页面直接新建文书') } const response = await request.post>('/v1/templates', payload) return mapDocument(unwrapApiResponse(response)) } +export async function updateTemplate( + id: string, + payload: Partial, +): Promise { + if (useMockData) { + throw new Error('演示模式下请在文档库页面直接编辑文书') + } + + const response = await request.put>( + `/v1/templates/${encodeURIComponent(id)}`, + payload, + ) + return mapDocument(unwrapApiResponse(response)) +} + +/** + * 删除模板。 + * 契约里模板没有 DELETE,归档由版本工作流的 archive 承担: + * 「删除」在业务上等于「归档并停用」,不能真的抹掉已签署文书引用的模板版本, + * 否则历史任务的 templateVersionId 会指向不存在的模板,留痕链断裂。 + */ +export async function deleteTemplate(id: string): Promise { + 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( templateId: string, payload: CreateTemplateVersionRequest, ): Promise { if (useMockData) { - throw new Error('Mock 模式不执行模板写操作') + throw new Error('演示模式下请在文档库页面直接编辑文书') } const response = await request.post>( @@ -279,6 +455,12 @@ export async function getTemplateVersionDetail( export type TemplateVersionAction = 'submit-review' | 'reject' | 'approve' | 'publish' | 'disable' | 'archive' +/** + * 版本工作流动作。 + * + * 真实模式直接调用对应动作接口;文档库的「新增 / 编辑 / 归档」也走这里, + * 保证模板状态只能经后端工作流流转,前端不自行改写 status。 + */ export function updateTemplateVersionStatus( templateId: string, versionId: string, @@ -286,7 +468,7 @@ export function updateTemplateVersionStatus( reason?: string, ): Promise { if (useMockData) { - return Promise.reject(new Error('Mock 模式不执行模板版本工作流')) + return Promise.reject(new Error('演示模式下请在文档库页面直接操作文书')) } const requestData = action === 'reject' && reason?.trim() ? { comment: reason.trim() } : undefined @@ -298,3 +480,42 @@ export function updateTemplateVersionStatus( ) .then(unwrapApiResponse) } + +/** + * 创建模板并提交首个版本。 + * + * 「新增文书」在后端是两步:先建模板拿 id,再给该模板建一个版本把正文存进去。 + * 只建模板不建版本会得到一个没有正文、无法发起签署的空壳模板, + * 因此这里合成一个动作,调用方只需给一次表单。 + */ +export async function createTemplateWithContent( + payload: CreateTemplateRequest, + version: Omit, +): Promise { + 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, +): Promise { + await createTemplateVersion(templateId, { + ...version, + signatureFields: buildSignatureFieldsPayload(version.contentHtml), + }) +} diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index cf09b84..dece3f0 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -1,5 +1,9 @@ import axios from 'axios' +import { + fetchTemplateContentByVersion, + readCachedTemplateContent, +} from '@/api/management/documents' import { unwrapApiResponse } from '@/utils/api-response' import { mockFlags } from '@/utils/mock-flags' import { readLocalSigningTemplates } from '@/utils/mock-document-library' @@ -471,6 +475,8 @@ function mapVisit(dto: VisitResponseDto): PatientProfile['visits'][number] { } function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): SigningTemplate { + const cachedContent = readCachedTemplateContent(dto.contentSha256) + return { id: dto.templateId, versionId: dto.templateVersionId, @@ -484,9 +490,71 @@ function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): Signing description: `已发布版本 ${dto.versionNo}`, // 可用模板接口没有返回通道 ACL;具体通道仍由后端在创建时校验。 supportedMethods: ['pad', 'sms'], + // 接口不返回正文,只能带出哈希;正文由 hydrateTemplateContents 按需补回 + contentSha256: dto.contentSha256, + contentHtml: cachedContent || undefined, } } +/** + * 给可用模板补正文快照。 + * + * available-versions 只返回 contentSha256,不返回正文,因此真实模式下 + * 「发起签署」弹窗选中的模板没有正文,签出来的文书详情会退化成前端写死的回退纸样。 + * 这里在拿到模板清单后按版本 id 把正文取回来(内部按哈希缓存,同一版本只取一次), + * 取不到的保持空,由调用方按"后端未提供正文"如实提示,绝不伪造正文。 + * + * 逐份补而不是全量并发:模板数量有限,且正文对签署是必需的, + * 用 allSettled 隔离单份失败,避免一份模板取不到正文就拖垮整个弹窗。 + */ +async function hydrateTemplateContents(templates: SigningTemplate[]): Promise { + 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() + + 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 { + 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) { return templates?.find((template) => template.versionId === versionId) } @@ -524,9 +592,38 @@ function mapSigningTask( backendStatus: dto.status, signedAt: formatApiDateTime(dto.signedAt) || 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 { + 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 { return { id: input.documentId, @@ -750,7 +847,10 @@ export async function getSigningTaskDetail( const response = await request.get>( `/v1/sign-tasks/${encodeURIComponent(id)}`, ) - return mapSigningTask(unwrapApiResponse(response), options) + const task = mapSigningTask(unwrapApiResponse(response), options) + // 任务接口不返回正文,这里按哈希把正文快照补回;取不到就保持空, + // 由详情组件提示"后端未提供正文",不退化成前端写死的回退纸样 + return hydrateSigningTaskContent(task) } catch (error) { if (isNotFoundError(error)) { return null @@ -867,7 +967,9 @@ export async function getSigningTemplates( const data = unwrapApiResponse(response) const records = Array.isArray(data) ? data : data.records - return records.map(mapAvailableTemplate) + // 可用模板接口只给哈希不给正文,这里按版本补回正文快照, + // 否则发起签署时文书没有正文,签出来的详情会退化成前端写死的回退纸样 + return hydrateTemplateContents(records.map(mapAvailableTemplate)) } // 文档库是模板的唯一来源,两侧读同一份数据: diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index b30fe25..c260c01 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -62,6 +62,12 @@ export interface SigningTemplate { supportedMethods: SigningMethod[] /** 原型:文书定稿 HTML 随模板携带,发起签署时进入任务快照。 */ contentHtml?: string + /** + * 模板版本的正文哈希。 + * 真实模式下可用模板接口只返回它、不返回正文(见 AvailableTemplateVersionResponseDto), + * 签署端要靠它判断本地缓存的正文快照是否还是同一份,避免版本更新后仍按旧正文签署。 + */ + contentSha256?: string } export interface WorkbenchSummary { @@ -215,6 +221,12 @@ export interface SigningTaskRecord extends WorkbenchTask { voidReason?: string /** 原型:任务创建时固化的文书 HTML(对应真实实现的模板版本快照 + contentSha256)。 */ documentContentHtml?: string + /** + * 任务固化的模板版本正文哈希。 + * 真实模式的任务接口只返回它、不返回正文(见 SignTaskResponseDto 的 templateContentSha256), + * 前端按它把正文快照取回来并校验,确保展示的正文就是签署时固化的那一版。 + */ + contentSha256?: string /** 原型:患者勾选结果,随签署一起留存(对应真实实现里随签署证据提交的勾选摘要)。 */ selectedItems?: string[] /** diff --git a/clinical-web/src/utils/mock-document-library.ts b/clinical-web/src/utils/mock-document-library.ts index 02fcb20..9d3611b 100644 --- a/clinical-web/src/utils/mock-document-library.ts +++ b/clinical-web/src/utils/mock-document-library.ts @@ -17,30 +17,46 @@ import type { SigningMethod, SigningTemplate } from '@/api/workbench/types' 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 保持一致 */ -type LocalCampus = 'all' | '本部院区' | '东院区' | '西院区' +type LocalCampus = string -interface LocalDocument { +export interface LocalDocument { id: string name: string - code: string + /** 文书编码。与模板接口的 DocumentRecord.code 一样允许缺省,页面统一兜底显示 */ + code?: string version: string status: LocalDocumentStatus - description: string + backendStatus?: string + /** 文书说明。来源同 code,允许缺省 */ + description?: string versionId?: string departmentId?: string | null + departmentName?: string campusId?: string | null + campusName?: string + /** + * 演示数据的更新时间。 + * 演示文库的种子不带时间,页面在这一层会显示成"—"而不是伪造一个时间戳; + * 页面模型要求这个字段必填,所以在演示数据创建时写一次真实时间。 + */ + updatedAt?: string contentHtml?: string + contentSha256?: string } -interface LocalCategory { +export interface LocalCategory { name: string documents: LocalDocument[] } -interface LocalDepartment { +export interface LocalDepartment { key: string name: string campus: LocalCampus @@ -199,10 +215,62 @@ const DEMO_DOCUMENT_SPECS: DemoDocumentSpec[] = [ /** 数组顺序即文档库里的科室分组顺序,全院通用排在最前 */ 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) { return `demo-${DEMO_DEPARTMENT_ORDER.indexOf(department)}` } +/** + * 演示文书库种子。 + * + * updatedAt 用固定的历史时间而不是"当前时间": + * 每次构建都取 now 的话,同一份种子文书在每次刷新后更新时间都会变, + * 看起来像有人在不停改动文书,反而失真。 + */ +const DEMO_UPDATED_AT = '2026-08-20T09:30:00+08:00' + function buildDemoLibrary(): LocalDepartment[] { return DEMO_DEPARTMENT_ORDER.map((department) => { const specs = DEMO_DOCUMENT_SPECS.filter((spec) => spec.department === department) @@ -215,11 +283,15 @@ function buildDemoLibrary(): LocalDepartment[] { name: spec.name, code: spec.code, version: spec.version, - status: 'enabled', + status: 'published', + backendStatus: 'PUBLISHED', description: spec.description, versionId: spec.versionId, departmentId: null, + departmentName: department, campusId: null, + campusName: '全院通用', + updatedAt: DEMO_UPDATED_AT, contentHtml: buildDemoDocumentHtml(spec), }) categories.set(spec.category, documents) @@ -253,6 +325,10 @@ export function writeDocumentLibrary(library: LocalDepartment[]): void { /** * 把本地保存的文档库摊平成签署模板。 * 文档库是模板的唯一来源:签署工作台能选到的模板,一定是在文档库里能维护的那几份。 + * + * contentSha256 一并带出:真实模式下可用模板接口只给哈希不给正文, + * 签署端要靠这个哈希去换回正文快照;演示模式虽然不缺正文,但两边字段保持一致, + * 页面就不用按模式分叉。 */ export function readLocalSigningTemplates(): SigningTemplate[] { return readDocumentLibrary().flatMap((department) => @@ -261,7 +337,9 @@ export function readLocalSigningTemplates(): SigningTemplate[] { id: document.id, versionId: document.versionId ?? document.id, name: document.name, - code: document.code, + // SigningTemplate.code 必填而演示文书可能没有编码,这里如实兜底为空串, + // 不编一个看起来像真实编码的假值 + code: document.code ?? '', version: document.version, department: department.name, departmentId: document.departmentId ?? null, @@ -270,6 +348,7 @@ export function readLocalSigningTemplates(): SigningTemplate[] { description: document.description ?? '', supportedMethods: ['pad', 'sms'] as SigningMethod[], contentHtml: document.contentHtml, + contentSha256: document.contentSha256, })), ), ) diff --git a/clinical-web/src/views/management/documents/components/DocumentLibraryHeader.vue b/clinical-web/src/views/management/documents/components/DocumentLibraryHeader.vue index e7e0173..7c96b20 100644 --- a/clinical-web/src/views/management/documents/components/DocumentLibraryHeader.vue +++ b/clinical-web/src/views/management/documents/components/DocumentLibraryHeader.vue @@ -1,8 +1,13 @@