fix(auth): 完善权限控制与强制改密流程

This commit is contained in:
xy
2026-09-17 16:33:09 +08:00
parent 81922d774f
commit 61b4b19f55
22 changed files with 618 additions and 59 deletions
+3
View File
@@ -159,9 +159,12 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
- `POST /api/v1/auth/login`:账号密码登录;
- `GET /api/v1/auth/captcha`:获取按需启用的图形验证码;
- `GET /api/v1/auth/me`:查询当前用户;
- `POST /api/v1/auth/password/change`:修改当前用户密码;首次登录必须完成修改,成功后旧 Token 失效并重新登录;
- `POST /api/v1/auth/logout`:注销当前会话;
- 后续请求自动携带 `X-Token` 请求头。
路由和菜单按后端权限码控制可见性,页面中的创建、修改、投递、作废、下载和导出等动作也使用对应权限码单独控制。`READ_ONLY_ALL` 账号即使被误配了写权限码,前端仍不会展示写操作;最终授权结果始终以后端校验为准。系统设置尚无正式后端权限契约,生产默认隐藏,仅在显式开启 `VITE_MOCK_SETTINGS=true` 时用于演示。
签署工作台已接入的真实接口包括:
- `GET /api/v1/patients`、`GET /api/v1/patients/{id}`:患者定位;
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>clinical-web</title>
+18 -6
View File
@@ -3,11 +3,13 @@ import { computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useLoginStore } from '@/stores/login'
import { mockFlags } from '@/utils/mock-flags'
interface MenuItem {
path: string
label: string
permission?: string
available?: boolean
}
interface MenuGroup {
@@ -22,22 +24,30 @@ const menuGroups: MenuGroup[] = [
{
label: '工作台',
items: [
{ path: '/workbench/home', label: '首页' },
{ path: '/workbench/signing', label: '签署工作台' },
{ path: '/workbench/home', label: '首页', permission: 'sign:task:query' },
{ path: '/workbench/signing', label: '签署工作台', permission: 'sign:task:query' },
],
},
{
label: '管理',
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/document-permissions',
label: '文档权限',
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) => ({
...group,
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),
+65 -17
View File
@@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
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 DocumentPermissionsView from '@/views/management/document-permissions/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 { useLoginStore } from '@/stores/login'
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({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -21,6 +40,18 @@ const router = createRouter({
component: LoginView,
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: '/',
name: 'clinical',
@@ -36,13 +67,13 @@ const router = createRouter({
path: 'home',
name: 'workbench-home',
component: DashboardView,
meta: { title: '首页', group: '工作台' },
meta: { title: '首页', group: '工作台', permission: 'sign:task:query' },
},
{
path: 'signing',
name: 'workbench-signing',
component: ConsentTasksView,
meta: { title: '签署工作台', group: '工作台' },
meta: { title: '签署工作台', group: '工作台', permission: 'sign:task:query' },
},
],
},
@@ -54,13 +85,13 @@ const router = createRouter({
path: 'documents',
name: 'management-documents',
component: DocumentsView,
meta: { title: '文档库管理', group: '管理' },
meta: { title: '文档库管理', group: '管理', permission: 'sign:template:query' },
},
{
path: 'reports',
name: 'management-reports',
component: ReportsView,
meta: { title: '报表分析', group: '管理' },
meta: { title: '报表分析', group: '管理', permission: 'report:signing:query' },
},
{
path: 'users',
@@ -101,7 +132,7 @@ const router = createRouter({
],
})
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const loginStore = useLoginStore()
if (to.name === 'login' && loginStore.isLoggedIn) {
@@ -110,7 +141,11 @@ router.beforeEach((to) => {
return true
}
return { name: 'workbench-home' }
return {
name: loginStore.user?.passwordChangeRequired
? 'change-password'
: firstAuthorizedRoute(loginStore),
}
}
if (to.meta.requiresAuth && !loginStore.isLoggedIn) {
@@ -121,21 +156,34 @@ router.beforeEach((to) => {
}
if (to.meta.requiresAuth) {
return loginStore
.ensureCurrentUser()
.then(() => {
const requiredPermission = to.meta.permission as string | string[] | undefined
try {
const user = await loginStore.ensureCurrentUser()
if (requiredPermission && !loginStore.hasPermission(requiredPermission)) {
return { name: 'workbench-home' }
}
if (user?.passwordChangeRequired && to.name !== 'change-password') {
return { name: 'change-password' }
}
return true
})
.catch(() => ({
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)) {
return { name: firstAuthorizedRoute(loginStore) }
}
return true
} catch {
loginStore.clearSession()
return {
name: 'login',
query: { redirect: to.fullPath },
}))
}
}
}
return true
+26 -2
View File
@@ -1,8 +1,18 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { getCurrentUser, login as loginRequest, logout as logoutRequest } from '@/api/auth'
import type { AuthenticatedUser, LoginRequest, LoginResponse } from '@/types/auth'
import {
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'
const dataScopeLabels: Record<AuthenticatedUser['dataScope'], string> = {
@@ -25,6 +35,7 @@ export const useLoginStore = defineStore('login', () => {
let currentUserRequest: Promise<AuthenticatedUser> | null = null
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 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) {
saveAuthSession(session)
token.value = session.token
@@ -117,17 +132,26 @@ export const useLoginStore = defineStore('login', () => {
}
}
async function changePassword(payload: PasswordChangeRequest) {
await changePasswordRequest(payload)
// 服务端会递增 authEpoch;当前令牌随即失效,必须使用新密码重新登录。
clearSession()
}
return {
token,
expiresAt,
user,
isLoggedIn,
isReadOnly,
userName,
department,
login,
refreshCurrentUser,
ensureCurrentUser,
hasPermission,
hasWritePermission,
changePassword,
logout,
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 errorMessage = ref('')
const isSubmitting = ref(false)
const passwordChanged = route.query.passwordChanged === '1'
async function loadCaptcha() {
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) {
errorMessage.value = getAuthErrorMessage(error)
@@ -116,6 +119,10 @@ onMounted(() => {
<p class="login-subtitle">安全留痕 · 高效签署 · 医患无忧</p>
<p v-if="passwordChanged" class="login-success" role="status">
密码修改成功,请使用新密码重新登录
</p>
<div class="login-field">
<label for="login-username">账号</label>
<input
@@ -301,6 +308,16 @@ onMounted(() => {
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 {
width: 100%;
margin-top: 6px;
@@ -98,7 +98,10 @@ const canViewPolicy = computed(
() => isPermissionMockEnabled || hasAnyPermission(policyReadPermissions),
)
const canEditPolicy = computed(
() => isPermissionMockEnabled || hasAnyPermission(policyWritePermissions),
() =>
!loginStore.isReadOnly &&
(isPermissionMockEnabled ||
policyWritePermissions.some((code) => loginStore.hasWritePermission(code))),
)
const canViewTemplateExceptions = computed(() => canViewPolicy.value)
const canEditTemplateExceptions = computed(() => canEditPolicy.value)
@@ -5,8 +5,10 @@ withDefaults(
total: number
/** 只在演示模式下展示「重置演示数据」:真实环境不该出现这个自测入口 */
showReset?: boolean
canCreate?: boolean
canExport?: boolean
}>(),
{ showReset: false },
{ showReset: false, canCreate: false, canExport: false },
)
const emit = defineEmits<{
@@ -40,11 +42,11 @@ function handleInput(event: Event) {
aria-label="搜索模板名称、编号或科室"
@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>
新增文档
</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
@@ -6,6 +6,9 @@ import { documentStatusLabels } from '../mock'
const props = defineProps<{
template: DocumentTemplate
canEdit: boolean
canInitiate: boolean
canRemove: boolean
}>()
const emit = defineEmits<{
@@ -18,12 +21,18 @@ const emit = defineEmits<{
const statusLabel = computed(() => documentStatusLabels[props.template.status])
/** 只有已发布 / 待复审的模板可以发起签署;草稿与已归档不可用 */
const canInitiate = computed(
() => props.template.status === 'published' || props.template.status === 'review',
const canInitiateTemplate = computed(
() =>
props.canInitiate &&
(props.template.status === 'published' || props.template.status === 'review'),
)
const initiateTitle = computed(() =>
canInitiate.value ? '用此模板发起签署' : '仅已发布或待复审的模板可发起签署',
canInitiateTemplate.value
? '用此模板发起签署'
: props.canInitiate
? '仅已发布或待复审的模板可发起签署'
: '当前账号没有发起签署任务的权限',
)
</script>
@@ -49,17 +58,21 @@ const initiateTitle = computed(() =>
</span>
<div class="template-actions">
<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
v-if="canInitiate"
type="button"
class="card-button primary"
:disabled="!canInitiate"
:disabled="!canInitiateTemplate"
:title="initiateTitle"
@click="emit('initiate', template)"
>
发起
</button>
<button
v-if="canRemove"
type="button"
class="card-button danger"
title="删除此文书"
@@ -21,6 +21,7 @@ import type {
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import { demoCampuses, demoDepartments } from '@/utils/mock-document-library'
import { mockFlags } from '@/utils/mock-flags'
import { clearMockState } from '@/utils/mock-storage'
@@ -53,8 +54,29 @@ import type {
*/
const router = useRouter()
const appStore = useAppStore()
const loginStore = useLoginStore()
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 libraryError = ref('')
@@ -320,6 +342,10 @@ async function handlePreview(template: DocumentTemplate) {
}
function showAddMessage() {
if (!canCreateTemplate.value) {
ElMessage.warning('当前账号没有创建文书模板的权限')
return
}
// Word 自维护方案:新增文书直接进入 Word 编辑器(粘贴/导入 .docx)。
wordEditingTemplate.value = null
wordEditorVisible.value = true
@@ -341,6 +367,11 @@ function closeWordEditor() {
* 保存后还会把模板正文冲掉。
*/
async function showEditMessage(template: DocumentTemplate) {
if (!canEditTemplate.value) {
ElMessage.warning('当前账号没有创建模板版本的权限')
return
}
if (useMockLibrary || template.contentHtml) {
wordEditingTemplate.value = template
wordEditorVisible.value = true
@@ -448,6 +479,11 @@ function toVersionPayload(form: TemplateEditorForm) {
}
async function saveTemplate(form: TemplateEditorForm) {
if ((!form.id && !canCreateTemplate.value) || (form.id && !canEditTemplate.value)) {
ElMessage.warning('当前账号没有保存此文书的权限')
return
}
if (useMockLibrary) {
if (form.id) {
updateLocalTemplate(form)
@@ -490,6 +526,11 @@ async function saveTemplate(form: TemplateEditorForm) {
* 语义上等于下架,已签署文书仍可追溯。
*/
async function removeTemplate(template: DocumentTemplate) {
if (!canArchiveTemplate.value) {
ElMessage.warning('当前账号没有下架文书的权限')
return
}
const confirmed = await ElMessageBox.confirm(
useMockLibrary
? `确定删除文书「${template.name}」?删除后不可恢复。`
@@ -571,6 +612,11 @@ async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTem
}
async function openSigningDialog(template: DocumentTemplate) {
if (!canInitiateSigning.value) {
ElMessage.warning('当前账号没有发起签署任务的权限')
return
}
closePreview()
try {
@@ -660,6 +706,8 @@ onMounted(() => {
v-model="filters.keyword"
:total="filteredTemplates.length"
:show-reset="useMockLibrary"
:can-create="canCreateTemplate"
:can-export="useMockLibrary"
@add="showAddMessage"
@export="showExportMessage"
@reset="resetMockData"
@@ -684,6 +732,9 @@ onMounted(() => {
v-for="template in filteredTemplates"
:key="template.id"
:template="template"
:can-edit="canEditTemplate"
:can-initiate="canInitiateSigning"
:can-remove="canArchiveTemplate"
@preview="handlePreview"
@edit="showEditMessage"
@initiate="openSigningDialog"
@@ -23,6 +23,7 @@ defineProps<{
methodOptions: ReportSelectOption<ReportMethodFilter>[]
exportLoading: boolean
exportStatus: string
canExport: boolean
}>()
const emit = defineEmits<{
@@ -149,7 +150,13 @@ function handleKeywordChange(event: Event) {
<span class="filter-spacer" />
<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 ? '导出中…' : '导出' }}
</button>
</div>
@@ -21,6 +21,7 @@ import type {
ReportTaskResponseDto,
ReportTasksQueryDto,
} from '@/api/management/types'
import { useLoginStore } from '@/stores/login'
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
import ReportDetailTable from './components/ReportDetailTable.vue'
@@ -77,6 +78,12 @@ const exportDownloadLoading = ref(false)
const exportJob = ref<ReportExportJobResponseDto | null>(null)
const exportErrorMessage = ref('')
const router = useRouter()
const loginStore = useLoginStore()
const canExportReport = computed(
() =>
!loginStore.isReadOnly &&
(isReportsMockEnabled || loginStore.hasWritePermission('report:signing:export')),
)
let reportRequestId = 0
let taskRequestId = 0
@@ -850,6 +857,11 @@ function handleRetryTasks() {
}
async function handleExport() {
if (!canExportReport.value) {
ElMessage.warning('当前账号没有导出签署报表的权限')
return
}
if (exportLoading.value) {
return
}
@@ -1056,6 +1068,7 @@ onUnmounted(() => {
:method-options="methodOptions"
:export-loading="exportLoading"
:export-status="exportStatusText"
:can-export="canExportReport"
@update:period="updatePeriod"
@update:campus="updateCampus"
@update:department="updateDepartment"
@@ -80,19 +80,29 @@ const rolePermissionDrawerVisible = ref(false)
let userRequestId = 0
const canEditUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:update'),
() =>
!loginStore.isReadOnly &&
(isUserMockEnabled || loginStore.hasWritePermission('system:user:update')),
)
const canCreateUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:create'),
() =>
!loginStore.isReadOnly &&
(isUserMockEnabled || loginStore.hasWritePermission('system:user:create')),
)
const canDeactivateUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:disable'),
() =>
!loginStore.isReadOnly &&
(isUserMockEnabled || loginStore.hasWritePermission('system:user:disable')),
)
const canResetPasswords = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:password:reset'),
() =>
!loginStore.isReadOnly &&
(isUserMockEnabled || loginStore.hasWritePermission('system:user:password:reset')),
)
const canImportUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:import'),
() =>
!loginStore.isReadOnly &&
(isUserMockEnabled || loginStore.hasWritePermission('system:user:import')),
)
const canViewRoleApiPermissions = computed(
() =>
@@ -100,7 +110,7 @@ const canViewRoleApiPermissions = computed(
loginStore.hasPermission(['system:permission:query', 'system:role:permission:query']),
)
const canEditRoleApiPermissions = computed(
() => !isUserMockEnabled && loginStore.hasPermission('system:role:permission:update'),
() => !isUserMockEnabled && loginStore.hasWritePermission('system:role:permission:update'),
)
const mockCampuses: CampusRecord[] = [
@@ -5,6 +5,7 @@ defineProps<{
artifacts: SigningArtifact[]
loading: boolean
error: boolean
canDownload: boolean
}>()
const emit = defineEmits<{
@@ -51,7 +52,12 @@ function formatFileSize(size: number) {
<span>{{ artifact.fileName }} · {{ formatFileSize(artifact.sizeBytes) }}</span>
<small v-if="artifact.createdAt">生成于 {{ artifact.createdAt }}</small>
</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>
</li>
@@ -13,6 +13,7 @@ const props = defineProps<{
departments: SigningFilterOption[]
documents: SigningFilterOption[]
visitTypes: SigningFilterOption<VisitType | 'all'>[]
canCreate: boolean
}>()
const emit = defineEmits<{
@@ -109,7 +110,9 @@ function submit() {
<span class="filter-spacer" />
<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>
</template>
@@ -27,6 +27,12 @@ defineProps<{
artifactsError: boolean
padSessionStatus: string | null
padSessionLoading: boolean
canDeliveryWrite: boolean
canReopen: boolean
canVoid: boolean
canViewAudit: boolean
canViewArtifacts: boolean
canDownloadArtifact: boolean
}>()
const emit = defineEmits<{
@@ -70,7 +76,7 @@ defineExpose({ getDocumentElement })
转为短信发送
</button>
</template>
<template v-else>
<template v-else-if="canDeliveryWrite">
<button
type="button"
class="action-button"
@@ -105,6 +111,7 @@ defineExpose({ getDocumentElement })
转手写板签署
</button>
<button
v-if="canDeliveryWrite"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'resend-sms')"
@@ -113,13 +120,13 @@ defineExpose({ getDocumentElement })
</button>
</template>
<template v-else-if="task.status === 'expired'">
<template v-else-if="task.status === 'expired' && canReopen">
<button type="button" class="action-button" @click="emit('action', 'reopen')">
↻ 重新发起
</button>
</template>
<template v-else-if="task.status === 'signed'">
<template v-else-if="task.status === 'signed' && canDownloadArtifact">
<button
type="button"
class="action-button action-button--ghost"
@@ -145,10 +152,11 @@ defineExpose({ getDocumentElement })
<button
v-if="
task.status === 'pending' ||
task.status === 'expired' ||
task.status === 'signing' ||
task.status === 'failed'
canVoid &&
(task.status === 'pending' ||
task.status === 'expired' ||
task.status === 'signing' ||
task.status === 'failed')
"
type="button"
class="action-button action-button--danger"
@@ -169,11 +177,18 @@ defineExpose({ getDocumentElement })
@field-change="(answers, progress) => emit('fieldChange', answers, progress)"
/>
<SigningDocumentPreview v-else :task="task" />
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
<TaskAuditTimeline
v-if="canViewAudit"
:events="auditEvents"
:loading="auditLoading"
:error="auditError"
/>
<SigningArtifactList
v-if="canViewArtifacts"
:artifacts="artifacts"
:loading="artifactsLoading"
:error="artifactsError"
:can-download="canDownloadArtifact"
@download="emit('downloadArtifact', $event)"
/>
</template>
@@ -33,6 +33,7 @@ import type {
} from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import type { SigningFieldProgress } from '@/utils/signing-document'
import { downloadSigningPdf } from '@/utils/signing-pdf'
@@ -46,6 +47,7 @@ import type { SigningFilterForm, SigningFilterOption, SigningTaskAction } from '
const route = useRoute()
const appStore = useAppStore()
const loginStore = useLoginStore()
const STATUS_VALUES: SigningTaskStatus[] = [
'pending',
@@ -113,6 +115,24 @@ const pdfExporting = ref(false)
let listRequestId = 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'>[]>(() => [
{ label: '全部院区', value: 'all' },
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
@@ -244,9 +264,9 @@ async function loadTaskDetail(id: string | null) {
}
detailLoading.value = true
eventsLoading.value = true
eventsLoading.value = canViewEvents.value
eventsError.value = false
artifactsLoading.value = true
artifactsLoading.value = canViewArtifacts.value
artifactsError.value = false
taskEvents.value = []
artifacts.value = []
@@ -255,8 +275,8 @@ async function loadTaskDetail(id: string | null) {
try {
const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([
getSigningTaskDetail(id, signingApiOptions.value),
getSigningTaskEvents(id),
getSigningArtifacts(id),
canViewEvents.value ? getSigningTaskEvents(id) : Promise.resolve([]),
canViewArtifacts.value ? getSigningArtifacts(id) : Promise.resolve([]),
])
if (requestId === detailRequestId) {
@@ -410,6 +430,20 @@ async function handleTaskAction(action: SigningTaskAction) {
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 (!isSigningMockEnabled) {
ElMessage.info('手写板设备签署接口待接入')
@@ -564,6 +598,11 @@ async function exportSignedPdf() {
}
async function downloadArtifact(artifact: SigningArtifact) {
if (!canDownloadArtifacts.value) {
ElMessage.warning('当前账号没有下载签署文件的权限')
return
}
try {
const blob = await downloadSigningArtifact(artifact.id)
const url = URL.createObjectURL(blob)
@@ -708,6 +747,7 @@ onMounted(() => {
:departments="departmentOptions"
:documents="documentOptions"
:visit-types="visitTypeOptions"
:can-create="canCreateTask"
@add="newDialogVisible = true"
@search="handleSearch"
/>
@@ -736,6 +776,12 @@ onMounted(() => {
:artifacts-error="artifactsError"
:pad-session-status="padSessionStatus"
: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"
@download-artifact="downloadArtifact"
@selection-change="handleSelectionChange"
+2
View File
@@ -65,6 +65,8 @@ Vant、Zod、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` 查询参数的签署链接。
## 与医护端的关系
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>patient-h5</title>
+14 -2
View File
@@ -25,8 +25,20 @@ async function initialize() {
getQueryValue(route.query.signToken) ||
getQueryValue(route.query.t)
await initializeSignFlow(token)
loading.value = false
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)
} finally {
loading.value = false
}
}
function startSigning() {