fix(auth): 完善权限控制与强制改密流程
This commit is contained in:
@@ -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}`:患者定位;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { documentStatusLabels } from '../mock'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
template: DocumentTemplate
|
template: DocumentTemplate
|
||||||
|
canEdit: boolean
|
||||||
|
canInitiate: boolean
|
||||||
|
canRemove: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -18,12 +21,18 @@ const emit = defineEmits<{
|
|||||||
const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||||
|
|
||||||
/** 只有已发布 / 待复审的模板可以发起签署;草稿与已归档不可用 */
|
/** 只有已发布 / 待复审的模板可以发起签署;草稿与已归档不可用 */
|
||||||
const canInitiate = computed(
|
const canInitiateTemplate = computed(
|
||||||
() => props.template.status === 'published' || props.template.status === 'review',
|
() =>
|
||||||
|
props.canInitiate &&
|
||||||
|
(props.template.status === 'published' || props.template.status === 'review'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const initiateTitle = computed(() =>
|
const initiateTitle = computed(() =>
|
||||||
canInitiate.value ? '用此模板发起签署' : '仅已发布或待复审的模板可发起签署',
|
canInitiateTemplate.value
|
||||||
|
? '用此模板发起签署'
|
||||||
|
: props.canInitiate
|
||||||
|
? '仅已发布或待复审的模板可发起签署'
|
||||||
|
: '当前账号没有发起签署任务的权限',
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -49,17 +58,21 @@ const initiateTitle = computed(() =>
|
|||||||
</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 primary"
|
class="card-button primary"
|
||||||
:disabled="!canInitiate"
|
:disabled="!canInitiateTemplate"
|
||||||
:title="initiateTitle"
|
:title="initiateTitle"
|
||||||
@click="emit('initiate', template)"
|
@click="emit('initiate', template)"
|
||||||
>
|
>
|
||||||
发起
|
发起
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canRemove"
|
||||||
type="button"
|
type="button"
|
||||||
class="card-button danger"
|
class="card-button danger"
|
||||||
title="删除此文书"
|
title="删除此文书"
|
||||||
|
|||||||
@@ -21,6 +21,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'
|
||||||
@@ -53,8 +54,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('')
|
||||||
@@ -320,6 +342,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
|
||||||
@@ -341,6 +367,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
|
||||||
@@ -448,6 +479,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)
|
||||||
@@ -490,6 +526,11 @@ async function saveTemplate(form: TemplateEditorForm) {
|
|||||||
* 语义上等于下架,已签署文书仍可追溯。
|
* 语义上等于下架,已签署文书仍可追溯。
|
||||||
*/
|
*/
|
||||||
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}」?删除后不可恢复。`
|
||||||
@@ -571,6 +612,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 {
|
||||||
@@ -660,6 +706,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"
|
||||||
@@ -684,6 +732,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"
|
||||||
@preview="handlePreview"
|
@preview="handlePreview"
|
||||||
@edit="showEditMessage"
|
@edit="showEditMessage"
|
||||||
@initiate="openSigningDialog"
|
@initiate="openSigningDialog"
|
||||||
|
|||||||
@@ -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,13 +120,13 @@ defineExpose({ getDocumentElement })
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</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 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"
|
||||||
@@ -145,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"
|
||||||
@@ -169,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>
|
||||||
|
|||||||
@@ -33,6 +33,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'
|
||||||
|
|
||||||
@@ -46,6 +47,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',
|
||||||
@@ -113,6 +115,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 })),
|
||||||
@@ -244,9 +264,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 = []
|
||||||
@@ -255,8 +275,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) {
|
||||||
@@ -410,6 +430,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('手写板设备签署接口待接入')
|
||||||
@@ -564,6 +598,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)
|
||||||
@@ -708,6 +747,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"
|
||||||
/>
|
/>
|
||||||
@@ -736,6 +776,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"
|
||||||
|
|||||||
@@ -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` 查询参数的签署链接。
|
||||||
|
|
||||||
## 与医护端的关系
|
## 与医护端的关系
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -25,9 +25,21 @@ 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() {
|
||||||
if (!canStartSigning.value) {
|
if (!canStartSigning.value) {
|
||||||
|
|||||||
Reference in New Issue
Block a user