From 61b4b19f55355a7639f194c0df649e2d1faa5961 Mon Sep 17 00:00:00 2001 From: xy Date: Thu, 17 Sep 2026 16:33:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E5=AE=8C=E5=96=84=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=8E=A7=E5=88=B6=E4=B8=8E=E5=BC=BA=E5=88=B6=E6=94=B9?= =?UTF-8?q?=E5=AF=86=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/README.md | 3 + clinical-web/index.html | 1 + clinical-web/src/components/layout/Menu.vue | 24 ++- clinical-web/src/router/index.ts | 82 ++++++-- clinical-web/src/stores/login.ts | 28 ++- .../src/views/auth/ChangePasswordView.vue | 197 ++++++++++++++++++ clinical-web/src/views/auth/ForbiddenView.vue | 73 +++++++ clinical-web/src/views/auth/LoginView.vue | 21 +- .../management/document-permissions/index.vue | 5 +- .../components/DocumentLibraryHeader.vue | 8 +- .../components/DocumentTemplateCard.vue | 23 +- .../src/views/management/documents/index.vue | 51 +++++ .../reports/components/ReportFilterBar.vue | 9 +- .../src/views/management/reports/index.vue | 13 ++ .../src/views/management/users/index.vue | 22 +- .../components/SigningArtifactList.vue | 8 +- .../signing/components/SigningFilterBar.vue | 5 +- .../signing/components/SigningTaskDetail.vue | 31 ++- .../src/views/workbench/signing/index.vue | 54 ++++- patient-h5/README.md | 2 + patient-h5/index.html | 1 + patient-h5/src/views/entry/EntryView.vue | 16 +- 22 files changed, 618 insertions(+), 59 deletions(-) create mode 100644 clinical-web/src/views/auth/ChangePasswordView.vue create mode 100644 clinical-web/src/views/auth/ForbiddenView.vue diff --git a/clinical-web/README.md b/clinical-web/README.md index ac9e92c..61d8b3a 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -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}`:患者定位; diff --git a/clinical-web/index.html b/clinical-web/index.html index 8e7509e..387b3fd 100644 --- a/clinical-web/index.html +++ b/clinical-web/index.html @@ -2,6 +2,7 @@ + clinical-web diff --git a/clinical-web/src/components/layout/Menu.vue b/clinical-web/src/components/layout/Menu.vue index 8e73091..d7a15cf 100644 --- a/clinical-web/src/components/layout/Menu.vue +++ b/clinical-web/src/components/layout/Menu.vue @@ -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), diff --git a/clinical-web/src/router/index.ts b/clinical-web/src/router/index.ts index 889bfff..4bcd00a 100644 --- a/clinical-web/src/router/index.ts +++ b/clinical-web/src/router/index.ts @@ -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) { + 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 diff --git a/clinical-web/src/stores/login.ts b/clinical-web/src/stores/login.ts index 3fe143d..dfbcf7a 100644 --- a/clinical-web/src/stores/login.ts +++ b/clinical-web/src/stores/login.ts @@ -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 = { @@ -25,6 +35,7 @@ export const useLoginStore = defineStore('login', () => { let currentUserRequest: Promise | 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, } diff --git a/clinical-web/src/views/auth/ChangePasswordView.vue b/clinical-web/src/views/auth/ChangePasswordView.vue new file mode 100644 index 0000000..b91705a --- /dev/null +++ b/clinical-web/src/views/auth/ChangePasswordView.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/clinical-web/src/views/auth/ForbiddenView.vue b/clinical-web/src/views/auth/ForbiddenView.vue new file mode 100644 index 0000000..d0ddb21 --- /dev/null +++ b/clinical-web/src/views/auth/ForbiddenView.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/clinical-web/src/views/auth/LoginView.vue b/clinical-web/src/views/auth/LoginView.vue index 3027d68..0f50da3 100644 --- a/clinical-web/src/views/auth/LoginView.vue +++ b/clinical-web/src/views/auth/LoginView.vue @@ -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(() => { + + diff --git a/clinical-web/src/views/management/reports/index.vue b/clinical-web/src/views/management/reports/index.vue index 3cfe289..3570756 100644 --- a/clinical-web/src/views/management/reports/index.vue +++ b/clinical-web/src/views/management/reports/index.vue @@ -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(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" diff --git a/clinical-web/src/views/management/users/index.vue b/clinical-web/src/views/management/users/index.vue index 95555e5..67fead1 100644 --- a/clinical-web/src/views/management/users/index.vue +++ b/clinical-web/src/views/management/users/index.vue @@ -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[] = [ diff --git a/clinical-web/src/views/workbench/signing/components/SigningArtifactList.vue b/clinical-web/src/views/workbench/signing/components/SigningArtifactList.vue index 9e05b97..84533e3 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningArtifactList.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningArtifactList.vue @@ -5,6 +5,7 @@ defineProps<{ artifacts: SigningArtifact[] loading: boolean error: boolean + canDownload: boolean }>() const emit = defineEmits<{ @@ -51,7 +52,12 @@ function formatFileSize(size: number) { {{ artifact.fileName }} · {{ formatFileSize(artifact.sizeBytes) }} 生成于 {{ artifact.createdAt }} - diff --git a/clinical-web/src/views/workbench/signing/components/SigningFilterBar.vue b/clinical-web/src/views/workbench/signing/components/SigningFilterBar.vue index 8b7840b..cd95ea4 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningFilterBar.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningFilterBar.vue @@ -13,6 +13,7 @@ const props = defineProps<{ departments: SigningFilterOption[] documents: SigningFilterOption[] visitTypes: SigningFilterOption[] + canCreate: boolean }>() const emit = defineEmits<{ @@ -109,7 +110,9 @@ function submit() { - + diff --git a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue index fad915b..94160df 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue @@ -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 }) 转为短信发送 -