This commit is contained in:
yelan
2026-09-17 16:48:59 +08:00
22 changed files with 620 additions and 66 deletions
+3
View File
@@ -159,9 +159,12 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
- `POST /api/v1/auth/login`:账号密码登录; - `POST /api/v1/auth/login`:账号密码登录;
- `GET /api/v1/auth/captcha`:获取按需启用的图形验证码; - `GET /api/v1/auth/captcha`:获取按需启用的图形验证码;
- `GET /api/v1/auth/me`:查询当前用户; - `GET /api/v1/auth/me`:查询当前用户;
- `POST /api/v1/auth/password/change`:修改当前用户密码;首次登录必须完成修改,成功后旧 Token 失效并重新登录;
- `POST /api/v1/auth/logout`:注销当前会话; - `POST /api/v1/auth/logout`:注销当前会话;
- 后续请求自动携带 `X-Token` 请求头。 - 后续请求自动携带 `X-Token` 请求头。
路由和菜单按后端权限码控制可见性,页面中的创建、修改、投递、作废、下载和导出等动作也使用对应权限码单独控制。`READ_ONLY_ALL` 账号即使被误配了写权限码,前端仍不会展示写操作;最终授权结果始终以后端校验为准。系统设置尚无正式后端权限契约,生产默认隐藏,仅在显式开启 `VITE_MOCK_SETTINGS=true` 时用于演示。
签署工作台已接入的真实接口包括: 签署工作台已接入的真实接口包括:
- `GET /api/v1/patients`、`GET /api/v1/patients/{id}`:患者定位; - `GET /api/v1/patients`、`GET /api/v1/patients/{id}`:患者定位;
+1
View File
@@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>clinical-web</title> <title>clinical-web</title>
+18 -6
View File
@@ -3,11 +3,13 @@ import { computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { useLoginStore } from '@/stores/login' import { useLoginStore } from '@/stores/login'
import { mockFlags } from '@/utils/mock-flags'
interface MenuItem { interface MenuItem {
path: string path: string
label: string label: string
permission?: string permission?: string
available?: boolean
} }
interface MenuGroup { interface MenuGroup {
@@ -22,22 +24,30 @@ const menuGroups: MenuGroup[] = [
{ {
label: '工作台', label: '工作台',
items: [ items: [
{ path: '/workbench/home', label: '首页' }, { path: '/workbench/home', label: '首页', permission: 'sign:task:query' },
{ path: '/workbench/signing', label: '签署工作台' }, { path: '/workbench/signing', label: '签署工作台', permission: 'sign:task:query' },
], ],
}, },
{ {
label: '管理', label: '管理',
items: [ items: [
{ path: '/management/documents', label: '文档库管理' }, {
{ path: '/management/reports', label: '报表分析' }, path: '/management/documents',
label: '文档库管理',
permission: 'sign:template:query',
},
{
path: '/management/reports',
label: '报表分析',
permission: 'report:signing:query',
},
{ path: '/management/users', label: '用户与权限', permission: 'system:user:query' }, { path: '/management/users', label: '用户与权限', permission: 'system:user:query' },
{ {
path: '/management/document-permissions', path: '/management/document-permissions',
label: '文档权限', label: '文档权限',
permission: 'sign:template:permission:query', permission: 'sign:template:permission:query',
}, },
{ path: '/management/settings', label: '系统设置' }, { path: '/management/settings', label: '系统设置', available: mockFlags.settings },
], ],
}, },
] ]
@@ -47,7 +57,9 @@ const visibleMenuGroups = computed(() =>
.map((group) => ({ .map((group) => ({
...group, ...group,
items: group.items.filter( items: group.items.filter(
(item) => !item.permission || loginStore.hasPermission(item.permission), (item) =>
item.available !== false &&
(!item.permission || loginStore.hasPermission(item.permission)),
), ),
})) }))
.filter((group) => group.items.length), .filter((group) => group.items.length),
+62 -14
View File
@@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import ClinicalLayout from '@/layouts/ClinicalLayout.vue' import ClinicalLayout from '@/layouts/ClinicalLayout.vue'
import ChangePasswordView from '@/views/auth/ChangePasswordView.vue'
import ForbiddenView from '@/views/auth/ForbiddenView.vue'
import LoginView from '@/views/auth/LoginView.vue' import LoginView from '@/views/auth/LoginView.vue'
import DocumentPermissionsView from '@/views/management/document-permissions/index.vue' import DocumentPermissionsView from '@/views/management/document-permissions/index.vue'
import DocumentsView from '@/views/management/documents/index.vue' import DocumentsView from '@/views/management/documents/index.vue'
@@ -11,6 +13,23 @@ import ConsentTasksView from '@/views/workbench/signing/index.vue'
import DashboardView from '@/views/workbench/home/index.vue' import DashboardView from '@/views/workbench/home/index.vue'
import { useLoginStore } from '@/stores/login' import { useLoginStore } from '@/stores/login'
import { readStoredAuthSession } from '@/utils/auth-storage' import { readStoredAuthSession } from '@/utils/auth-storage'
import { mockFlags } from '@/utils/mock-flags'
const authorizedLandingRoutes = [
{ name: 'workbench-home', permission: 'sign:task:query' },
{ name: 'workbench-signing', permission: 'sign:task:query' },
{ name: 'management-documents', permission: 'sign:template:query' },
{ name: 'management-reports', permission: 'report:signing:query' },
{ name: 'management-users', permission: 'system:user:query' },
{ name: 'management-document-permissions', permission: 'sign:template:permission:query' },
] as const
function firstAuthorizedRoute(loginStore: ReturnType<typeof useLoginStore>) {
return (
authorizedLandingRoutes.find((route) => loginStore.hasPermission(route.permission))?.name ??
'forbidden'
)
}
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@@ -21,6 +40,18 @@ const router = createRouter({
component: LoginView, component: LoginView,
meta: { title: '登录' }, meta: { title: '登录' },
}, },
{
path: '/change-password',
name: 'change-password',
component: ChangePasswordView,
meta: { title: '修改密码', requiresAuth: true },
},
{
path: '/forbidden',
name: 'forbidden',
component: ForbiddenView,
meta: { title: '无访问权限', requiresAuth: true },
},
{ {
path: '/', path: '/',
name: 'clinical', name: 'clinical',
@@ -36,13 +67,13 @@ const router = createRouter({
path: 'home', path: 'home',
name: 'workbench-home', name: 'workbench-home',
component: DashboardView, component: DashboardView,
meta: { title: '首页', group: '工作台' }, meta: { title: '首页', group: '工作台', permission: 'sign:task:query' },
}, },
{ {
path: 'signing', path: 'signing',
name: 'workbench-signing', name: 'workbench-signing',
component: ConsentTasksView, component: ConsentTasksView,
meta: { title: '签署工作台', group: '工作台' }, meta: { title: '签署工作台', group: '工作台', permission: 'sign:task:query' },
}, },
], ],
}, },
@@ -54,13 +85,13 @@ const router = createRouter({
path: 'documents', path: 'documents',
name: 'management-documents', name: 'management-documents',
component: DocumentsView, component: DocumentsView,
meta: { title: '文档库管理', group: '管理' }, meta: { title: '文档库管理', group: '管理', permission: 'sign:template:query' },
}, },
{ {
path: 'reports', path: 'reports',
name: 'management-reports', name: 'management-reports',
component: ReportsView, component: ReportsView,
meta: { title: '报表分析', group: '管理' }, meta: { title: '报表分析', group: '管理', permission: 'report:signing:query' },
}, },
{ {
path: 'users', path: 'users',
@@ -101,7 +132,7 @@ const router = createRouter({
], ],
}) })
router.beforeEach((to) => { router.beforeEach(async (to) => {
const loginStore = useLoginStore() const loginStore = useLoginStore()
if (to.name === 'login' && loginStore.isLoggedIn) { if (to.name === 'login' && loginStore.isLoggedIn) {
@@ -110,7 +141,11 @@ router.beforeEach((to) => {
return true return true
} }
return { name: 'workbench-home' } return {
name: loginStore.user?.passwordChangeRequired
? 'change-password'
: firstAuthorizedRoute(loginStore),
}
} }
if (to.meta.requiresAuth && !loginStore.isLoggedIn) { if (to.meta.requiresAuth && !loginStore.isLoggedIn) {
@@ -121,21 +156,34 @@ router.beforeEach((to) => {
} }
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
return loginStore try {
.ensureCurrentUser() const user = await loginStore.ensureCurrentUser()
.then(() => {
const requiredPermission = to.meta.permission as string | string[] | undefined
if (user?.passwordChangeRequired && to.name !== 'change-password') {
return { name: 'change-password' }
}
if (!user?.passwordChangeRequired && to.name === 'change-password') {
return { name: firstAuthorizedRoute(loginStore) }
}
if (to.name === 'management-settings' && !mockFlags.settings) {
return { name: 'forbidden' }
}
const requiredPermission = to.meta.permission as string | string[] | undefined
if (requiredPermission && !loginStore.hasPermission(requiredPermission)) { if (requiredPermission && !loginStore.hasPermission(requiredPermission)) {
return { name: 'workbench-home' } return { name: firstAuthorizedRoute(loginStore) }
} }
return true return true
}) } catch {
.catch(() => ({ loginStore.clearSession()
return {
name: 'login', name: 'login',
query: { redirect: to.fullPath }, query: { redirect: to.fullPath },
})) }
}
} }
return true return true
+26 -2
View File
@@ -1,8 +1,18 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { getCurrentUser, login as loginRequest, logout as logoutRequest } from '@/api/auth' import {
import type { AuthenticatedUser, LoginRequest, LoginResponse } from '@/types/auth' changePassword as changePasswordRequest,
getCurrentUser,
login as loginRequest,
logout as logoutRequest,
} from '@/api/auth'
import type {
AuthenticatedUser,
LoginRequest,
LoginResponse,
PasswordChangeRequest,
} from '@/types/auth'
import { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage' import { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage'
const dataScopeLabels: Record<AuthenticatedUser['dataScope'], string> = { const dataScopeLabels: Record<AuthenticatedUser['dataScope'], string> = {
@@ -25,6 +35,7 @@ export const useLoginStore = defineStore('login', () => {
let currentUserRequest: Promise<AuthenticatedUser> | null = null let currentUserRequest: Promise<AuthenticatedUser> | null = null
const isLoggedIn = computed(() => Boolean(token.value)) const isLoggedIn = computed(() => Boolean(token.value))
const isReadOnly = computed(() => user.value?.dataScope === 'READ_ONLY_ALL')
const userName = computed(() => user.value?.displayName || user.value?.username || '医护用户') const userName = computed(() => user.value?.displayName || user.value?.username || '医护用户')
const department = computed(() => getUserContext(user.value)) const department = computed(() => getUserContext(user.value))
@@ -41,6 +52,10 @@ export const useLoginStore = defineStore('login', () => {
) )
} }
function hasWritePermission(required: string | string[]) {
return !isReadOnly.value && hasPermission(required)
}
function setSession(session: LoginResponse) { function setSession(session: LoginResponse) {
saveAuthSession(session) saveAuthSession(session)
token.value = session.token token.value = session.token
@@ -117,17 +132,26 @@ export const useLoginStore = defineStore('login', () => {
} }
} }
async function changePassword(payload: PasswordChangeRequest) {
await changePasswordRequest(payload)
// 服务端会递增 authEpoch;当前令牌随即失效,必须使用新密码重新登录。
clearSession()
}
return { return {
token, token,
expiresAt, expiresAt,
user, user,
isLoggedIn, isLoggedIn,
isReadOnly,
userName, userName,
department, department,
login, login,
refreshCurrentUser, refreshCurrentUser,
ensureCurrentUser, ensureCurrentUser,
hasPermission, hasPermission,
hasWritePermission,
changePassword,
logout, logout,
clearSession, clearSession,
} }
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useLoginStore } from '@/stores/login'
const router = useRouter()
const loginStore = useLoginStore()
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const errorMessage = ref('')
const isSubmitting = ref(false)
function validatePassword() {
if (!currentPassword.value || !newPassword.value || !confirmPassword.value) {
return '请完整填写当前密码、新密码和确认密码'
}
if (newPassword.value.length < 12 || newPassword.value.length > 128) {
return '新密码长度必须为 12 至 128 位'
}
if (
!/[A-Z]/.test(newPassword.value) ||
!/[a-z]/.test(newPassword.value) ||
!/\d/.test(newPassword.value) ||
!/[^A-Za-z0-9]/.test(newPassword.value)
) {
return '新密码必须包含大写字母、小写字母、数字和特殊字符'
}
if (newPassword.value !== confirmPassword.value) {
return '两次输入的新密码不一致'
}
if (newPassword.value === currentPassword.value) {
return '新密码不能与当前密码相同'
}
return ''
}
async function handleSubmit() {
const validationMessage = validatePassword()
if (validationMessage) {
errorMessage.value = validationMessage
return
}
errorMessage.value = ''
isSubmitting.value = true
try {
await loginStore.changePassword({
currentPassword: currentPassword.value,
newPassword: newPassword.value,
})
await router.replace({ name: 'login', query: { passwordChanged: '1' } })
} catch (error: unknown) {
errorMessage.value =
error instanceof Error && error.message ? error.message : '密码修改失败,请稍后重试'
} finally {
isSubmitting.value = false
}
}
async function handleLogout() {
try {
await loginStore.logout()
} catch {
loginStore.clearSession()
}
await router.replace({ name: 'login' })
}
</script>
<template>
<main class="password-page">
<form class="password-card" novalidate @submit.prevent="handleSubmit">
<div class="password-brand">医签通</div>
<h1>首次登录,请修改密码</h1>
<p class="password-description">为保护患者信息,设置新密码后需要重新登录。</p>
<label>
<span>当前密码</span>
<input v-model="currentPassword" type="password" autocomplete="current-password" />
</label>
<label>
<span>新密码</span>
<input v-model="newPassword" type="password" autocomplete="new-password" />
</label>
<label>
<span>确认新密码</span>
<input v-model="confirmPassword" type="password" autocomplete="new-password" />
</label>
<p class="password-policy">12–128 位,须同时包含大小写字母、数字和特殊字符。</p>
<p v-if="errorMessage" class="password-error" role="alert">{{ errorMessage }}</p>
<button class="password-submit" type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '修改中…' : '修改密码并重新登录' }}
</button>
<button class="password-logout" type="button" :disabled="isSubmitting" @click="handleLogout">
退出当前账号
</button>
</form>
</main>
</template>
<style scoped>
.password-page {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: var(--login-gradient);
}
.password-card {
width: min(420px, calc(100% - 32px));
padding: 32px 36px;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-login);
}
.password-brand {
color: var(--brand);
font-size: 15px;
font-weight: 800;
text-align: center;
}
h1 {
margin: 8px 0;
color: var(--ink);
font-size: 22px;
text-align: center;
}
.password-description,
.password-policy {
color: var(--mut);
font-size: 12px;
line-height: 1.6;
text-align: center;
}
label {
display: block;
margin-top: 14px;
}
label span {
display: block;
margin-bottom: 6px;
color: var(--mut);
font-size: 12px;
}
input {
width: 100%;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 6px;
outline: none;
}
input:focus {
border-color: var(--brand);
}
.password-error {
color: var(--err);
font-size: 12px;
}
.password-submit,
.password-logout {
width: 100%;
padding: 10px;
border-radius: 7px;
}
.password-submit {
color: #fff;
font-weight: 700;
background: var(--brand);
}
.password-logout {
margin-top: 8px;
color: var(--mut);
background: transparent;
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useLoginStore } from '@/stores/login'
const router = useRouter()
const loginStore = useLoginStore()
async function handleLogout() {
try {
await loginStore.logout()
} catch {
loginStore.clearSession()
}
await router.replace({ name: 'login' })
}
</script>
<template>
<main class="forbidden-page">
<section class="forbidden-card">
<span class="forbidden-code">403</span>
<h1>当前账号没有可访问的功能</h1>
<p>请联系系统管理员分配相应权限,或退出后更换账号。</p>
<button type="button" @click="handleLogout">退出登录</button>
</section>
</main>
</template>
<style scoped>
.forbidden-page {
display: grid;
min-height: 100vh;
padding: 24px;
background: var(--bg);
place-items: center;
}
.forbidden-card {
max-width: 460px;
padding: 36px;
text-align: center;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-login);
}
.forbidden-code {
color: var(--brand);
font-size: 36px;
font-weight: 800;
}
h1 {
color: var(--ink);
font-size: 20px;
}
p {
color: var(--mut);
font-size: 13px;
line-height: 1.7;
}
button {
margin-top: 12px;
padding: 9px 24px;
color: #fff;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
</style>
+19 -2
View File
@@ -17,6 +17,7 @@ const captchaCode = ref('')
const captchaLoading = ref(false) const captchaLoading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const isSubmitting = ref(false) const isSubmitting = ref(false)
const passwordChanged = route.query.passwordChanged === '1'
async function loadCaptcha() { async function loadCaptcha() {
captchaLoading.value = true captchaLoading.value = true
@@ -68,9 +69,11 @@ async function handleLogin() {
: {}), : {}),
} }
await loginStore.login(payload) const session = await loginStore.login(payload)
await router.replace(getRedirectPath()) await router.replace(
session.user.passwordChangeRequired ? { name: 'change-password' } : getRedirectPath(),
)
} catch (error: unknown) { } catch (error: unknown) {
errorMessage.value = getAuthErrorMessage(error) errorMessage.value = getAuthErrorMessage(error)
@@ -116,6 +119,10 @@ onMounted(() => {
<p class="login-subtitle">安全留痕 · 高效签署 · 医患无忧</p> <p class="login-subtitle">安全留痕 · 高效签署 · 医患无忧</p>
<p v-if="passwordChanged" class="login-success" role="status">
密码修改成功,请使用新密码重新登录
</p>
<div class="login-field"> <div class="login-field">
<label for="login-username">账号</label> <label for="login-username">账号</label>
<input <input
@@ -301,6 +308,16 @@ onMounted(() => {
font-size: 12px; font-size: 12px;
} }
.login-success {
margin: 0 0 12px;
padding: 8px 10px;
color: #16794d;
font-size: 12px;
text-align: center;
background: #edf9f3;
border-radius: 7px;
}
.login-submit { .login-submit {
width: 100%; width: 100%;
margin-top: 6px; margin-top: 6px;
@@ -98,7 +98,10 @@ const canViewPolicy = computed(
() => isPermissionMockEnabled || hasAnyPermission(policyReadPermissions), () => isPermissionMockEnabled || hasAnyPermission(policyReadPermissions),
) )
const canEditPolicy = computed( const canEditPolicy = computed(
() => isPermissionMockEnabled || hasAnyPermission(policyWritePermissions), () =>
!loginStore.isReadOnly &&
(isPermissionMockEnabled ||
policyWritePermissions.some((code) => loginStore.hasWritePermission(code))),
) )
const canViewTemplateExceptions = computed(() => canViewPolicy.value) const canViewTemplateExceptions = computed(() => canViewPolicy.value)
const canEditTemplateExceptions = computed(() => canEditPolicy.value) const canEditTemplateExceptions = computed(() => canEditPolicy.value)
@@ -5,8 +5,10 @@ withDefaults(
total: number total: number
/** 只在演示模式下展示「重置演示数据」:真实环境不该出现这个自测入口 */ /** 只在演示模式下展示「重置演示数据」:真实环境不该出现这个自测入口 */
showReset?: boolean showReset?: boolean
canCreate?: boolean
canExport?: boolean
}>(), }>(),
{ showReset: false }, { showReset: false, canCreate: false, canExport: false },
) )
const emit = defineEmits<{ const emit = defineEmits<{
@@ -40,11 +42,11 @@ function handleInput(event: Event) {
aria-label="搜索模板名称、编号或科室" aria-label="搜索模板名称、编号或科室"
@input="handleInput" @input="handleInput"
/> />
<button type="button" class="tool-button primary" @click="emit('add')"> <button v-if="canCreate" type="button" class="tool-button primary" @click="emit('add')">
<span aria-hidden="true">+</span> <span aria-hidden="true">+</span>
新增文档 新增文档
</button> </button>
<button type="button" class="tool-button secondary" @click="emit('export')"> <button v-if="canExport" type="button" class="tool-button secondary" @click="emit('export')">
导出模板包 导出模板包
</button> </button>
<button <button
@@ -8,6 +8,9 @@ import { readStatusLabel, readStatusTone } from '../mock'
const props = defineProps<{ const props = defineProps<{
template: DocumentTemplate template: DocumentTemplate
canEdit: boolean
canInitiate: boolean
canRemove: boolean
/** 演示模式下「删除」是真删;真实模式下语义是下架(模板无 DELETE 接口,走归档) */ /** 演示模式下「删除」是真删;真实模式下语义是下架(模板无 DELETE 接口,走归档) */
isMock: boolean isMock: boolean
}>() }>()
@@ -26,17 +29,19 @@ const statusLabel = computed(() => readStatusLabel(props.template))
/** 配色按后端状态分组取(待审核与已驳回不能共用一个颜色) */ /** 配色按后端状态分组取(待审核与已驳回不能共用一个颜色) */
const statusTone = computed(() => readStatusTone(props.template)) const statusTone = computed(() => readStatusTone(props.template))
/** /** 只有已发布 / 待复审的模板可以发起签署;草稿与已归档不可用 */
* 只有已发布(后端 APPROVED / PUBLISHED)的模板可以发起签署。 const canInitiateTemplate = computed(
* () =>
* 这里不再放行 review:review 同时承载后端的 PENDING_REVIEW(待审核)与 props.canInitiate &&
* REJECTED(已驳回),两者都还没通过审核。允许它们发起签署, (props.template.status === 'published' || props.template.status === 'review'),
* 等于让未定稿的文书进入签署流程,签出来的文书也无法追溯是哪一版定稿。 )
*/
const canInitiate = computed(() => props.template.status === 'published')
const initiateTitle = computed(() => const initiateTitle = computed(() =>
canInitiate.value ? '用此模板发起签署' : '仅已发布(审核通过)的模板可发起签署', canInitiateTemplate.value
? '用此模板发起签署'
: props.canInitiate
? '仅已发布或待复审的模板可发起签署'
: '当前账号没有发起签署任务的权限',
) )
/** /**
@@ -111,8 +116,11 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
</span> </span>
<div class="template-actions"> <div class="template-actions">
<button type="button" class="card-button" @click="emit('preview', template)">预览</button> <button type="button" class="card-button" @click="emit('preview', template)">预览</button>
<button type="button" class="card-button" @click="emit('edit', template)">编辑</button> <button v-if="canEdit" type="button" class="card-button" @click="emit('edit', template)">
编辑
</button>
<button <button
v-if="canInitiate"
type="button" type="button"
class="card-button" class="card-button"
title="查看该文书的版本历史" title="查看该文书的版本历史"
@@ -124,6 +132,7 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
v-if="canInitiate" v-if="canInitiate"
type="button" type="button"
class="card-button primary" class="card-button primary"
:disabled="!canInitiateTemplate"
:title="initiateTitle" :title="initiateTitle"
@click="emit('initiate', template)" @click="emit('initiate', template)"
> >
@@ -150,6 +159,7 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
{{ action.label }} {{ action.label }}
</button> </button>
<button <button
v-if="canRemove"
type="button" type="button"
class="card-button danger" class="card-button danger"
:title="removeTitle" :title="removeTitle"
@@ -25,6 +25,7 @@ import type {
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types' import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue' import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import { demoCampuses, demoDepartments } from '@/utils/mock-document-library' 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'
@@ -65,8 +66,29 @@ import type {
*/ */
const router = useRouter() const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
const loginStore = useLoginStore()
const useMockLibrary = mockFlags.documents const useMockLibrary = mockFlags.documents
const canCreateTemplate = computed(
() =>
!loginStore.isReadOnly &&
(useMockLibrary ||
loginStore.hasWritePermission(['sign:template:create', 'sign:template:version:create'])),
)
const canEditTemplate = computed(
() =>
!loginStore.isReadOnly &&
(useMockLibrary || loginStore.hasWritePermission('sign:template:version:create')),
)
const canArchiveTemplate = computed(
() =>
!loginStore.isReadOnly &&
(useMockLibrary || loginStore.hasWritePermission('sign:template:archive')),
)
const canInitiateSigning = computed(
() =>
!loginStore.isReadOnly && (useMockLibrary || loginStore.hasWritePermission('sign:task:create')),
)
/** 真实模式下的加载状态:区分"正在加载"与"确实没有模板" */ /** 真实模式下的加载状态:区分"正在加载"与"确实没有模板" */
const libraryLoading = ref(false) const libraryLoading = ref(false)
const libraryError = ref('') const libraryError = ref('')
@@ -361,6 +383,10 @@ async function handlePreview(template: DocumentTemplate) {
} }
function showAddMessage() { function showAddMessage() {
if (!canCreateTemplate.value) {
ElMessage.warning('当前账号没有创建文书模板的权限')
return
}
// Word 自维护方案:新增文书直接进入 Word 编辑器(粘贴/导入 .docx)。 // Word 自维护方案:新增文书直接进入 Word 编辑器(粘贴/导入 .docx)。
wordEditingTemplate.value = null wordEditingTemplate.value = null
wordEditorVisible.value = true wordEditorVisible.value = true
@@ -382,6 +408,11 @@ function closeWordEditor() {
* 保存后还会把模板正文冲掉。 * 保存后还会把模板正文冲掉。
*/ */
async function showEditMessage(template: DocumentTemplate) { async function showEditMessage(template: DocumentTemplate) {
if (!canEditTemplate.value) {
ElMessage.warning('当前账号没有创建模板版本的权限')
return
}
if (useMockLibrary || template.contentHtml) { if (useMockLibrary || template.contentHtml) {
wordEditingTemplate.value = template wordEditingTemplate.value = template
wordEditorVisible.value = true wordEditorVisible.value = true
@@ -489,6 +520,11 @@ function toVersionPayload(form: TemplateEditorForm) {
} }
async function saveTemplate(form: TemplateEditorForm) { async function saveTemplate(form: TemplateEditorForm) {
if ((!form.id && !canCreateTemplate.value) || (form.id && !canEditTemplate.value)) {
ElMessage.warning('当前账号没有保存此文书的权限')
return
}
if (useMockLibrary) { if (useMockLibrary) {
if (form.id) { if (form.id) {
updateLocalTemplate(form) updateLocalTemplate(form)
@@ -684,6 +720,11 @@ function closeVersions() {
* 语义上等于下架,已签署文书仍可追溯。 * 语义上等于下架,已签署文书仍可追溯。
*/ */
async function removeTemplate(template: DocumentTemplate) { async function removeTemplate(template: DocumentTemplate) {
if (!canArchiveTemplate.value) {
ElMessage.warning('当前账号没有下架文书的权限')
return
}
const confirmed = await ElMessageBox.confirm( const confirmed = await ElMessageBox.confirm(
useMockLibrary useMockLibrary
? `确定删除文书「${template.name}」?删除后不可恢复。` ? `确定删除文书「${template.name}」?删除后不可恢复。`
@@ -765,6 +806,11 @@ async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTem
} }
async function openSigningDialog(template: DocumentTemplate) { async function openSigningDialog(template: DocumentTemplate) {
if (!canInitiateSigning.value) {
ElMessage.warning('当前账号没有发起签署任务的权限')
return
}
closePreview() closePreview()
try { try {
@@ -856,6 +902,8 @@ onMounted(() => {
v-model="filters.keyword" v-model="filters.keyword"
:total="filteredTemplates.length" :total="filteredTemplates.length"
:show-reset="useMockLibrary" :show-reset="useMockLibrary"
:can-create="canCreateTemplate"
:can-export="useMockLibrary"
@add="showAddMessage" @add="showAddMessage"
@export="showExportMessage" @export="showExportMessage"
@reset="resetMockData" @reset="resetMockData"
@@ -880,6 +928,9 @@ onMounted(() => {
v-for="template in filteredTemplates" v-for="template in filteredTemplates"
:key="template.id" :key="template.id"
:template="template" :template="template"
:can-edit="canEditTemplate"
:can-initiate="canInitiateSigning"
:can-remove="canArchiveTemplate"
:is-mock="useMockLibrary" :is-mock="useMockLibrary"
@preview="handlePreview" @preview="handlePreview"
@edit="showEditMessage" @edit="showEditMessage"
@@ -23,6 +23,7 @@ defineProps<{
methodOptions: ReportSelectOption<ReportMethodFilter>[] methodOptions: ReportSelectOption<ReportMethodFilter>[]
exportLoading: boolean exportLoading: boolean
exportStatus: string exportStatus: string
canExport: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -149,7 +150,13 @@ function handleKeywordChange(event: Event) {
<span class="filter-spacer" /> <span class="filter-spacer" />
<span v-if="exportStatus" class="export-status" role="status">{{ exportStatus }}</span> <span v-if="exportStatus" class="export-status" role="status">{{ exportStatus }}</span>
<button type="button" class="export-button" :disabled="exportLoading" @click="emit('export')"> <button
v-if="canExport"
type="button"
class="export-button"
:disabled="exportLoading"
@click="emit('export')"
>
{{ exportLoading ? '导出中…' : '导出' }} {{ exportLoading ? '导出中…' : '导出' }}
</button> </button>
</div> </div>
@@ -21,6 +21,7 @@ import type {
ReportTaskResponseDto, ReportTaskResponseDto,
ReportTasksQueryDto, ReportTasksQueryDto,
} from '@/api/management/types' } from '@/api/management/types'
import { useLoginStore } from '@/stores/login'
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue' import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
import ReportDetailTable from './components/ReportDetailTable.vue' import ReportDetailTable from './components/ReportDetailTable.vue'
@@ -77,6 +78,12 @@ const exportDownloadLoading = ref(false)
const exportJob = ref<ReportExportJobResponseDto | null>(null) const exportJob = ref<ReportExportJobResponseDto | null>(null)
const exportErrorMessage = ref('') const exportErrorMessage = ref('')
const router = useRouter() const router = useRouter()
const loginStore = useLoginStore()
const canExportReport = computed(
() =>
!loginStore.isReadOnly &&
(isReportsMockEnabled || loginStore.hasWritePermission('report:signing:export')),
)
let reportRequestId = 0 let reportRequestId = 0
let taskRequestId = 0 let taskRequestId = 0
@@ -850,6 +857,11 @@ function handleRetryTasks() {
} }
async function handleExport() { async function handleExport() {
if (!canExportReport.value) {
ElMessage.warning('当前账号没有导出签署报表的权限')
return
}
if (exportLoading.value) { if (exportLoading.value) {
return return
} }
@@ -1056,6 +1068,7 @@ onUnmounted(() => {
:method-options="methodOptions" :method-options="methodOptions"
:export-loading="exportLoading" :export-loading="exportLoading"
:export-status="exportStatusText" :export-status="exportStatusText"
:can-export="canExportReport"
@update:period="updatePeriod" @update:period="updatePeriod"
@update:campus="updateCampus" @update:campus="updateCampus"
@update:department="updateDepartment" @update:department="updateDepartment"
@@ -80,19 +80,29 @@ const rolePermissionDrawerVisible = ref(false)
let userRequestId = 0 let userRequestId = 0
const canEditUsers = computed( const canEditUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:update'), () =>
!loginStore.isReadOnly &&
(isUserMockEnabled || loginStore.hasWritePermission('system:user:update')),
) )
const canCreateUsers = computed( const canCreateUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:create'), () =>
!loginStore.isReadOnly &&
(isUserMockEnabled || loginStore.hasWritePermission('system:user:create')),
) )
const canDeactivateUsers = computed( const canDeactivateUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:disable'), () =>
!loginStore.isReadOnly &&
(isUserMockEnabled || loginStore.hasWritePermission('system:user:disable')),
) )
const canResetPasswords = computed( const canResetPasswords = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:password:reset'), () =>
!loginStore.isReadOnly &&
(isUserMockEnabled || loginStore.hasWritePermission('system:user:password:reset')),
) )
const canImportUsers = computed( const canImportUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:import'), () =>
!loginStore.isReadOnly &&
(isUserMockEnabled || loginStore.hasWritePermission('system:user:import')),
) )
const canViewRoleApiPermissions = computed( const canViewRoleApiPermissions = computed(
() => () =>
@@ -100,7 +110,7 @@ const canViewRoleApiPermissions = computed(
loginStore.hasPermission(['system:permission:query', 'system:role:permission:query']), loginStore.hasPermission(['system:permission:query', 'system:role:permission:query']),
) )
const canEditRoleApiPermissions = computed( const canEditRoleApiPermissions = computed(
() => !isUserMockEnabled && loginStore.hasPermission('system:role:permission:update'), () => !isUserMockEnabled && loginStore.hasWritePermission('system:role:permission:update'),
) )
const mockCampuses: CampusRecord[] = [ const mockCampuses: CampusRecord[] = [
@@ -5,6 +5,7 @@ defineProps<{
artifacts: SigningArtifact[] artifacts: SigningArtifact[]
loading: boolean loading: boolean
error: boolean error: boolean
canDownload: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -51,7 +52,12 @@ function formatFileSize(size: number) {
<span>{{ artifact.fileName }} · {{ formatFileSize(artifact.sizeBytes) }}</span> <span>{{ artifact.fileName }} · {{ formatFileSize(artifact.sizeBytes) }}</span>
<small v-if="artifact.createdAt">生成于 {{ artifact.createdAt }}</small> <small v-if="artifact.createdAt">生成于 {{ artifact.createdAt }}</small>
</div> </div>
<button type="button" class="artifact-download" @click="emit('download', artifact)"> <button
v-if="canDownload"
type="button"
class="artifact-download"
@click="emit('download', artifact)"
>
下载 下载
</button> </button>
</li> </li>
@@ -13,6 +13,7 @@ const props = defineProps<{
departments: SigningFilterOption[] departments: SigningFilterOption[]
documents: SigningFilterOption[] documents: SigningFilterOption[]
visitTypes: SigningFilterOption<VisitType | 'all'>[] visitTypes: SigningFilterOption<VisitType | 'all'>[]
canCreate: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -109,7 +110,9 @@ function submit() {
<span class="filter-spacer" /> <span class="filter-spacer" />
<button type="submit" class="action-button action-button--ghost">查询</button> <button type="submit" class="action-button action-button--ghost">查询</button>
<button type="button" class="action-button" @click="emit('add')">+ 新增签署任务</button> <button v-if="canCreate" type="button" class="action-button" @click="emit('add')">
+ 新增签署任务
</button>
</form> </form>
</template> </template>
@@ -27,6 +27,12 @@ defineProps<{
artifactsError: boolean artifactsError: boolean
padSessionStatus: string | null padSessionStatus: string | null
padSessionLoading: boolean padSessionLoading: boolean
canDeliveryWrite: boolean
canReopen: boolean
canVoid: boolean
canViewAudit: boolean
canViewArtifacts: boolean
canDownloadArtifact: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -70,7 +76,7 @@ defineExpose({ getDocumentElement })
转为短信发送 转为短信发送
</button> </button>
</template> </template>
<template v-else> <template v-else-if="canDeliveryWrite">
<button <button
type="button" type="button"
class="action-button" class="action-button"
@@ -105,6 +111,7 @@ defineExpose({ getDocumentElement })
转手写板签署 转手写板签署
</button> </button>
<button <button
v-if="canDeliveryWrite"
type="button" type="button"
class="action-button action-button--ghost" class="action-button action-button--ghost"
@click="emit('action', 'resend-sms')" @click="emit('action', 'resend-sms')"
@@ -113,15 +120,13 @@ defineExpose({ getDocumentElement })
</button> </button>
</template> </template>
<!-- 处理失败与已超时都需要「重新发起」这条出路:只给「作废」的话, <template v-else-if="task.status === 'expired' && canReopen">
一次偶发的后端处理失败就得把整个任务废掉重来 -->
<template v-else-if="task.status === 'expired' || task.status === 'failed'">
<button type="button" class="action-button" @click="emit('action', 'reopen')"> <button type="button" class="action-button" @click="emit('action', 'reopen')">
↻ 重新发起 ↻ 重新发起
</button> </button>
</template> </template>
<template v-else-if="task.status === 'signed'"> <template v-else-if="task.status === 'signed' && canDownloadArtifact">
<button <button
type="button" type="button"
class="action-button action-button--ghost" class="action-button action-button--ghost"
@@ -147,10 +152,11 @@ defineExpose({ getDocumentElement })
<button <button
v-if=" v-if="
task.status === 'pending' || canVoid &&
(task.status === 'pending' ||
task.status === 'expired' || task.status === 'expired' ||
task.status === 'signing' || task.status === 'signing' ||
task.status === 'failed' task.status === 'failed')
" "
type="button" type="button"
class="action-button action-button--danger" class="action-button action-button--danger"
@@ -171,11 +177,18 @@ defineExpose({ getDocumentElement })
@field-change="(answers, progress) => emit('fieldChange', answers, progress)" @field-change="(answers, progress) => emit('fieldChange', answers, progress)"
/> />
<SigningDocumentPreview v-else :task="task" /> <SigningDocumentPreview v-else :task="task" />
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" /> <TaskAuditTimeline
v-if="canViewAudit"
:events="auditEvents"
:loading="auditLoading"
:error="auditError"
/>
<SigningArtifactList <SigningArtifactList
v-if="canViewArtifacts"
:artifacts="artifacts" :artifacts="artifacts"
:loading="artifactsLoading" :loading="artifactsLoading"
:error="artifactsError" :error="artifactsError"
:can-download="canDownloadArtifact"
@download="emit('downloadArtifact', $event)" @download="emit('downloadArtifact', $event)"
/> />
</template> </template>
@@ -37,6 +37,7 @@ import type {
} from '@/api/workbench/types' } from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue' import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import type { SigningFieldProgress } from '@/utils/signing-document' import type { SigningFieldProgress } from '@/utils/signing-document'
import { downloadSigningPdf } from '@/utils/signing-pdf' import { downloadSigningPdf } from '@/utils/signing-pdf'
@@ -50,6 +51,7 @@ import type { SigningFilterForm, SigningFilterOption, SigningTaskAction } from '
const route = useRoute() const route = useRoute()
const appStore = useAppStore() const appStore = useAppStore()
const loginStore = useLoginStore()
const STATUS_VALUES: SigningTaskStatus[] = [ const STATUS_VALUES: SigningTaskStatus[] = [
'pending', 'pending',
@@ -117,6 +119,24 @@ const pdfExporting = ref(false)
let listRequestId = 0 let listRequestId = 0
let detailRequestId = 0 let detailRequestId = 0
function canWrite(permission: string) {
return (
!loginStore.isReadOnly && (isSigningMockEnabled || loginStore.hasWritePermission(permission))
)
}
function canRead(permission: string) {
return isSigningMockEnabled || loginStore.hasPermission(permission)
}
const canCreateTask = computed(() => canWrite('sign:task:create'))
const canDeliveryWrite = computed(() => canWrite('sign:delivery:write'))
const canReopenTask = computed(() => canWrite('sign:task:reopen'))
const canVoidTask = computed(() => canWrite('sign:task:void'))
const canViewEvents = computed(() => canRead('sign:task:event:query'))
const canViewArtifacts = computed(() => canRead('sign:artifact:query'))
const canDownloadArtifacts = computed(() => canRead('sign:artifact:download'))
const campusOptions = computed<SigningFilterOption<WorkbenchCampus | 'all'>[]>(() => [ const campusOptions = computed<SigningFilterOption<WorkbenchCampus | 'all'>[]>(() => [
{ label: '全部院区', value: 'all' }, { label: '全部院区', value: 'all' },
...appStore.campuses.map((campus) => ({ label: campus, value: campus })), ...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
@@ -248,9 +268,9 @@ async function loadTaskDetail(id: string | null) {
} }
detailLoading.value = true detailLoading.value = true
eventsLoading.value = true eventsLoading.value = canViewEvents.value
eventsError.value = false eventsError.value = false
artifactsLoading.value = true artifactsLoading.value = canViewArtifacts.value
artifactsError.value = false artifactsError.value = false
taskEvents.value = [] taskEvents.value = []
artifacts.value = [] artifacts.value = []
@@ -259,8 +279,8 @@ async function loadTaskDetail(id: string | null) {
try { try {
const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([ const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([
getSigningTaskDetail(id, signingApiOptions.value), getSigningTaskDetail(id, signingApiOptions.value),
getSigningTaskEvents(id), canViewEvents.value ? getSigningTaskEvents(id) : Promise.resolve([]),
getSigningArtifacts(id), canViewArtifacts.value ? getSigningArtifacts(id) : Promise.resolve([]),
]) ])
if (requestId === detailRequestId) { if (requestId === detailRequestId) {
@@ -431,6 +451,20 @@ async function handleTaskAction(action: SigningTaskAction) {
return return
} }
const allowed =
isSigningMockEnabled ||
(action === 'create-pad-session' && canDeliveryWrite.value) ||
(action === 'resend-sms' && canDeliveryWrite.value) ||
(action === 'reopen' && canReopenTask.value) ||
(action === 'void' && canVoidTask.value) ||
((action === 'download-pdf' || action === 'download-signature' || action === 'print') &&
canDownloadArtifacts.value)
if (!allowed) {
ElMessage.warning('当前账号没有执行此操作的权限')
return
}
if (action === 'pad-sign') { if (action === 'pad-sign') {
if (!isSigningMockEnabled) { if (!isSigningMockEnabled) {
ElMessage.info('手写板设备签署接口待接入') ElMessage.info('手写板设备签署接口待接入')
@@ -585,6 +619,11 @@ async function exportSignedPdf() {
} }
async function downloadArtifact(artifact: SigningArtifact) { async function downloadArtifact(artifact: SigningArtifact) {
if (!canDownloadArtifacts.value) {
ElMessage.warning('当前账号没有下载签署文件的权限')
return
}
try { try {
const blob = await downloadSigningArtifact(artifact.id) const blob = await downloadSigningArtifact(artifact.id)
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
@@ -729,6 +768,7 @@ onMounted(() => {
:departments="departmentOptions" :departments="departmentOptions"
:documents="documentOptions" :documents="documentOptions"
:visit-types="visitTypeOptions" :visit-types="visitTypeOptions"
:can-create="canCreateTask"
@add="newDialogVisible = true" @add="newDialogVisible = true"
@search="handleSearch" @search="handleSearch"
/> />
@@ -757,6 +797,12 @@ onMounted(() => {
:artifacts-error="artifactsError" :artifacts-error="artifactsError"
:pad-session-status="padSessionStatus" :pad-session-status="padSessionStatus"
:pad-session-loading="padSessionLoading" :pad-session-loading="padSessionLoading"
:can-delivery-write="canDeliveryWrite"
:can-reopen="canReopenTask"
:can-void="canVoidTask"
:can-view-audit="canViewEvents"
:can-view-artifacts="canViewArtifacts"
:can-download-artifact="canDownloadArtifacts"
@action="handleTaskAction" @action="handleTaskAction"
@download-artifact="downloadArtifact" @download-artifact="downloadArtifact"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
+2
View File
@@ -65,6 +65,8 @@ Vant、Zod、PDF 预览、第三方签名组件和自动化测试暂不接入;
真实流程会在进入页面时消费 Token,并在提交时携带 `deliveryId`、`uploadToken` 和幂等键。Token 消费接口目前只返回投递凭证,不返回正式文书内容,因此真实模式会停在安全提示页,不会让患者在未阅读正式文书时提交签名。待后端提供患者端文书读取接口后,再接入 PDF/文书展示和正式提交闭环。 真实流程会在进入页面时消费 Token,并在提交时携带 `deliveryId`、`uploadToken` 和幂等键。Token 消费接口目前只返回投递凭证,不返回正式文书内容,因此真实模式会停在安全提示页,不会让患者在未阅读正式文书时提交签名。待后端提供患者端文书读取接口后,再接入 PDF/文书展示和正式提交闭环。
入口页会在消费前立即从地址栏移除 `token`、`signToken`、`t` 参数,并通过 `Referrer-Policy: no-referrer` 的页面元信息阻止凭证随后续请求外泄。部署网关仍应配置同名 HTTP 响应头,作为浏览器解析 HTML 前的保护。
默认 `VITE_USE_MOCK=true` 时可以无 Token 演示完整页面交互;联调真实 Token 流程时设置 `VITE_USE_MOCK=false`,或直接使用带 `token`/`signToken`/`t` 查询参数的签署链接。 默认 `VITE_USE_MOCK=true` 时可以无 Token 演示完整页面交互;联调真实 Token 流程时设置 `VITE_USE_MOCK=false`,或直接使用带 `token`/`signToken`/`t` 查询参数的签署链接。
## 与医护端的关系 ## 与医护端的关系
+1
View File
@@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>patient-h5</title> <title>patient-h5</title>
+12
View File
@@ -25,8 +25,20 @@ async function initialize() {
getQueryValue(route.query.signToken) || getQueryValue(route.query.signToken) ||
getQueryValue(route.query.t) getQueryValue(route.query.t)
if (token) {
const cleanQuery = { ...route.query }
delete cleanQuery.token
delete cleanQuery.signToken
delete cleanQuery.t
// 在发起接口请求前清理地址栏,避免一次性凭证进入历史记录、截图或后续 Referer。
await router.replace({ path: route.path, query: cleanQuery, hash: route.hash })
}
try {
await initializeSignFlow(token) await initializeSignFlow(token)
} finally {
loading.value = false loading.value = false
}
} }
function startSigning() { function startSigning() {