Compare commits

..

2 Commits

Author SHA1 Message Date
yelan
a261a978f2 refactor(auth): 将认证状态迁移至独立登录 store 2026-08-31 10:58:05 +08:00
yelan
a5e0a80191 feat(auth): 接入 MEDISIGN 登录接口
- 新增认证 API、类型与本地会话存储
- 登录页改为真实账号密码登录并移除验证码
- 请求拦截器改用 X-Token 并处理 401 跳转
- 添加 Vite /api 开发代理配置
2026-08-31 10:50:01 +08:00
12 changed files with 416 additions and 94 deletions

View File

@@ -127,4 +127,15 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
API 请求和响应类型按业务域放在 `api/workbench/types.ts``api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts` API 请求和响应类型按业务域放在 `api/workbench/types.ts``api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`
所有接口统一使用 `utils/request.ts` 导出的单例请求实例。当前 API 模块默认返回 mock 数据,设置 `VITE_USE_MOCK=false` 后切换为真实接口。 所有接口统一使用 `utils/request.ts` 导出的单例请求实例。登录接口已接入 MEDISIGN 后端:
- `POST /api/v1/auth/login`:账号密码登录;
- `GET /api/v1/auth/me`:查询当前用户;
- `POST /api/v1/auth/logout`:注销当前会话;
- 后续请求自动携带 `X-Token` 请求头。
开发环境默认使用 `/api` 作为同源接口前缀Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理配置后需要重启 Vite 开发服务。
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。
除登录外,当前业务 API 模块默认返回 mock 数据,设置 `VITE_USE_MOCK=false` 后切换为真实接口。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。

View File

@@ -0,0 +1,120 @@
import axios from 'axios'
import { request } from '@/utils/request'
import type {
AuthenticatedUser,
CurrentUserResponse,
LoginRequest,
LoginResponse,
} from '@/types/auth'
import type { ApiResponse } from '@/types/common'
export class ApiResponseError extends Error {
readonly code: number | string
readonly traceId?: string
constructor(response: ApiResponse<unknown>) {
super(response.message || '接口请求失败')
this.name = 'ApiResponseError'
this.code = response.code
this.traceId = response.traceId
}
}
function isSuccessCode(code: number | string) {
return code === 0 || code === '0'
}
function unwrapApiResponse<T>(response: ApiResponse<T>): T {
if (!isSuccessCode(response.code)) {
throw new ApiResponseError(response)
}
if (response.data === null) {
throw new Error(response.message || '接口未返回业务数据')
}
return response.data
}
function normalizePermissions(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string')
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string')
: []
} catch {
return []
}
}
return []
}
function normalizeAuthenticatedUser(user: AuthenticatedUser): AuthenticatedUser {
return {
...user,
permissions: normalizePermissions(user.permissions),
}
}
export async function login(payload: LoginRequest): Promise<LoginResponse> {
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/login', payload)
const session = unwrapApiResponse(response)
if (!session.token || !session.user) {
throw new Error('登录接口响应缺少 token 或用户信息')
}
const normalizedSession: LoginResponse = {
...session,
expiresAt: session.expiresAt || '',
user: normalizeAuthenticatedUser(session.user),
}
return normalizedSession
}
export async function getCurrentUser(): Promise<CurrentUserResponse> {
const response = await request.get<ApiResponse<CurrentUserResponse>>('/v1/auth/me')
const user = unwrapApiResponse(response)
return {
...user,
permissions: normalizePermissions(user.permissions),
}
}
export async function logout(): Promise<void> {
const response = await request.post<ApiResponse<Record<string, never>>>('/v1/auth/logout')
unwrapApiResponse(response)
}
export function getAuthErrorMessage(error: unknown) {
if (error instanceof ApiResponseError) {
return error.message
}
if (axios.isAxiosError(error)) {
if (!error.response) {
return '无法连接登录服务,请检查网络或接口地址'
}
if (error.response.status === 401) {
return '账号或密码错误'
}
return `登录失败HTTP ${error.response.status}`
}
if (error instanceof Error && error.message) {
return error.message
}
return '登录失败,请稍后重试'
}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app' import { useLoginStore } from '@/stores/login'
interface MenuItem { interface MenuItem {
path: string path: string
@@ -13,7 +13,7 @@ interface MenuGroup {
items: MenuItem[] items: MenuItem[]
} }
const appStore = useAppStore() const loginStore = useLoginStore()
const router = useRouter() const router = useRouter()
const menuGroups: MenuGroup[] = [ const menuGroups: MenuGroup[] = [
@@ -36,9 +36,14 @@ const menuGroups: MenuGroup[] = [
}, },
] ]
function handleLogout() { async function handleLogout() {
appStore.logout() try {
void router.push('/login') await loginStore.logout()
} catch {
// 即使服务暂时不可用,也要清理本地会话。
} finally {
await router.push('/login')
}
} }
</script> </script>
@@ -85,10 +90,10 @@ function handleLogout() {
</nav> </nav>
<div class="menu-profile"> <div class="menu-profile">
<div class="menu-profile-avatar">{{ appStore.userName.slice(0, 1) }}</div> <div class="menu-profile-avatar">{{ loginStore.userName.slice(0, 1) }}</div>
<div class="menu-profile-copy"> <div class="menu-profile-copy">
<strong>{{ appStore.userName }}</strong> <strong>{{ loginStore.userName }}</strong>
<span>{{ appStore.department }}</span> <span>{{ loginStore.department }}</span>
</div> </div>
<button class="menu-logout" type="button" @click="handleLogout">退出</button> <button class="menu-logout" type="button" @click="handleLogout">退出</button>
</div> </div>

View File

@@ -1,6 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAppStore } from '@/stores/app'
import ClinicalLayout from '@/layouts/ClinicalLayout.vue' import ClinicalLayout from '@/layouts/ClinicalLayout.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'
@@ -10,6 +9,7 @@ import SettingsView from '@/views/management/settings/index.vue'
import UserPermissionsView from '@/views/management/users/index.vue' import UserPermissionsView from '@/views/management/users/index.vue'
import ConsentTasksView from '@/views/workbench/signing/index.vue' 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'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@@ -93,13 +93,13 @@ const router = createRouter({
}) })
router.beforeEach((to) => { router.beforeEach((to) => {
const appStore = useAppStore() const loginStore = useLoginStore()
if (to.name === 'login' && appStore.isLoggedIn) { if (to.name === 'login' && loginStore.isLoggedIn) {
return { name: 'workbench-home' } return { name: 'workbench-home' }
} }
if (to.meta.requiresAuth && !appStore.isLoggedIn) { if (to.meta.requiresAuth && !loginStore.isLoggedIn) {
return { return {
name: 'login', name: 'login',
query: { redirect: to.fullPath }, query: { redirect: to.fullPath },

View File

@@ -1,23 +1,16 @@
import { ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { WorkbenchCampus } from '@/api/workbench/types' import type { WorkbenchCampus } from '@/api/workbench/types'
export const useAppStore = defineStore('app', { export const useAppStore = defineStore('app', () => {
state: () => ({ const environment = ref('接口联调环境')
isLoggedIn: false, const campuses = ref<WorkbenchCampus[]>(['本部院区', '东院区', '西院区'])
userName: '张文静', const selectedCampus = ref<WorkbenchCampus>('本部院区')
department: '医务管理 · 主任医师',
environment: '原型演示环境', return {
campuses: ['本部院区', '东院区', '西院区'] as WorkbenchCampus[], environment,
selectedCampus: '本部院区' as WorkbenchCampus, campuses,
}), selectedCampus,
actions: { }
login(userName: string) {
this.userName = userName || '张文静'
this.isLoggedIn = true
},
logout() {
this.isLoggedIn = false
},
},
}) })

View File

@@ -0,0 +1,87 @@
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 { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage'
const dataScopeLabels: Record<AuthenticatedUser['dataScope'], string> = {
ALL: '全院',
CAMPUS: '院区',
DEPARTMENT: '科室',
READ_ONLY_ALL: '全院只读',
}
function getUserContext(user: AuthenticatedUser | null) {
return user ? `${dataScopeLabels[user.dataScope]}数据范围` : '医签通用户'
}
export const useLoginStore = defineStore('login', () => {
const storedSession = readStoredAuthSession()
const token = ref(storedSession?.token ?? '')
const expiresAt = ref(storedSession?.expiresAt ?? '')
const user = ref<AuthenticatedUser | null>(storedSession?.user ?? null)
const isLoggedIn = computed(() => Boolean(token.value))
const userName = computed(() => user.value?.displayName || user.value?.username || '医护用户')
const department = computed(() => getUserContext(user.value))
function setSession(session: LoginResponse) {
saveAuthSession(session)
token.value = session.token
expiresAt.value = session.expiresAt
user.value = session.user
}
function setCurrentUser(nextUser: AuthenticatedUser) {
user.value = nextUser
if (token.value) {
saveAuthSession({
token: token.value,
expiresAt: expiresAt.value,
user: nextUser,
})
}
}
function clearSession() {
clearAuthStorage()
token.value = ''
expiresAt.value = ''
user.value = null
}
async function login(payload: LoginRequest) {
const session = await loginRequest(payload)
setSession(session)
return session
}
async function refreshCurrentUser() {
const currentUser = await getCurrentUser()
setCurrentUser(currentUser)
return currentUser
}
async function logout() {
try {
await logoutRequest()
} finally {
clearSession()
}
}
return {
token,
expiresAt,
user,
isLoggedIn,
userName,
department,
login,
refreshCurrentUser,
logout,
clearSession,
}
})

View File

@@ -0,0 +1,34 @@
export type AuthDataScope = 'ALL' | 'CAMPUS' | 'DEPARTMENT' | 'READ_ONLY_ALL'
export type AuthUserStatus = 'ENABLED' | 'DISABLED'
export interface LoginRequest {
username: string
password: string
}
export interface AuthenticatedUser {
id: string
username: string
displayName: string
campusId: string
departmentId: string | null
dataScope: AuthDataScope
permissions: string[]
}
export interface LoginResponse {
token: string
expiresAt: string
user: AuthenticatedUser
}
export interface CurrentUserResponse extends AuthenticatedUser {
employeeNo: string | null
phone: string | null
email: string | null
status: AuthUserStatus
lastLoginAt: string | null
createdAt: string
updatedAt: string
}

View File

@@ -13,9 +13,11 @@ export interface PageResult<T> {
} }
export interface ApiResponse<T> { export interface ApiResponse<T> {
code: number code: number | string
message: string message: string
data: T data: T | null
traceId?: string
timestamp?: string
} }
export interface SelectOption<T extends string = string> { export interface SelectOption<T extends string = string> {

View File

@@ -0,0 +1,59 @@
import type { AuthenticatedUser, LoginResponse } from '@/types/auth'
export const AUTH_TOKEN_KEY = 'token'
export const AUTH_USER_KEY = 'userInfo'
export const AUTH_EXPIRES_AT_KEY = 'tokenExpiresAt'
export interface StoredAuthSession {
token: string
user: AuthenticatedUser | null
expiresAt: string | null
}
function readStoredUser(): AuthenticatedUser | null {
const rawUser = localStorage.getItem(AUTH_USER_KEY)
if (!rawUser) {
return null
}
try {
return JSON.parse(rawUser) as AuthenticatedUser
} catch {
localStorage.removeItem(AUTH_USER_KEY)
return null
}
}
export function readStoredAuthSession(): StoredAuthSession | null {
const token = localStorage.getItem(AUTH_TOKEN_KEY)
if (!token) {
return null
}
const expiresAt = localStorage.getItem(AUTH_EXPIRES_AT_KEY)
if (expiresAt && Date.parse(expiresAt) <= Date.now()) {
clearAuthStorage()
return null
}
return {
token,
user: readStoredUser(),
expiresAt,
}
}
export function saveAuthSession(session: LoginResponse) {
localStorage.setItem(AUTH_TOKEN_KEY, session.token)
localStorage.setItem(AUTH_EXPIRES_AT_KEY, session.expiresAt)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(session.user))
}
export function clearAuthStorage() {
localStorage.removeItem(AUTH_TOKEN_KEY)
localStorage.removeItem(AUTH_EXPIRES_AT_KEY)
localStorage.removeItem(AUTH_USER_KEY)
}

View File

@@ -5,23 +5,38 @@ import axios, {
type InternalAxiosRequestConfig, type InternalAxiosRequestConfig,
} from 'axios' } from 'axios'
import { clearAuthStorage, AUTH_TOKEN_KEY } from '@/utils/auth-storage'
const DEFAULT_TIMEOUT = 15_000 const DEFAULT_TIMEOUT = 15_000
const DEFAULT_API_BASE_URL = '/api'
function redirectToLogin() {
if (typeof window === 'undefined' || window.location.pathname === '/login') {
return
}
const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}`
window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`)
}
class Request { class Request {
private readonly instance: AxiosInstance private readonly instance: AxiosInstance
constructor(baseURL = import.meta.env.VITE_API_BASE_URL || '/api', timeout = DEFAULT_TIMEOUT) { constructor(
baseURL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL,
timeout = DEFAULT_TIMEOUT,
) {
this.instance = axios.create({ this.instance = axios.create({
baseURL, baseURL,
timeout, timeout,
withCredentials: true, withCredentials: false,
}) })
this.instance.interceptors.request.use((config: InternalAxiosRequestConfig) => { this.instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('token') const token = localStorage.getItem(AUTH_TOKEN_KEY)
if (token) { if (token) {
config.headers.set('Authorization', 'Bearer ' + token) config.headers.set('X-Token', token)
} }
return config return config
@@ -29,7 +44,14 @@ class Request {
this.instance.interceptors.response.use( this.instance.interceptors.response.use(
(response: AxiosResponse) => response, (response: AxiosResponse) => response,
(error: unknown) => Promise.reject(error), (error: unknown) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
clearAuthStorage()
redirectToLogin()
}
return Promise.reject(error)
},
) )
} }

View File

@@ -2,37 +2,48 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app' import { getAuthErrorMessage } from '@/api/auth'
import { useLoginStore } from '@/stores/login'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const appStore = useAppStore() const loginStore = useLoginStore()
const username = ref('zhangwj') const username = ref('')
const password = ref('******') const password = ref('')
const captcha = ref('8362')
const errorMessage = ref('') const errorMessage = ref('')
const captchaCode = '8362' const isSubmitting = ref(false)
function getRedirectPath() { function getRedirectPath() {
const redirect = route.query.redirect const redirect = route.query.redirect
return typeof redirect === 'string' && redirect.startsWith('/') ? redirect : '/workbench/home' return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
? redirect
: '/workbench/home'
} }
function handleLogin() { async function handleLogin() {
if (!username.value.trim() || !password.value.trim() || !captcha.value.trim()) { const normalizedUsername = username.value.trim()
errorMessage.value = '请输入完整的账号、密码和验证码'
return
}
if (captcha.value.trim() !== captchaCode) { if (!normalizedUsername || !password.value) {
errorMessage.value = '验证码错误,请重新输入' errorMessage.value = '请输入账号和密码'
return return
} }
errorMessage.value = '' errorMessage.value = ''
appStore.login(username.value.trim()) isSubmitting.value = true
void router.replace(getRedirectPath())
try {
await loginStore.login({
username: normalizedUsername,
password: password.value,
})
await router.replace(getRedirectPath())
} catch (error: unknown) {
errorMessage.value = getAuthErrorMessage(error)
} finally {
isSubmitting.value = false
}
} }
</script> </script>
@@ -87,25 +98,12 @@ function handleLogin() {
/> />
</div> </div>
<div class="login-field">
<label for="login-captcha">验证码</label>
<div class="login-captcha-row">
<input
id="login-captcha"
v-model="captcha"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="验证码"
/>
<span class="login-captcha-image" aria-label="验证码">{{ captchaCode }}</span>
</div>
</div>
<p v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</p> <p v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</p>
<button class="login-submit" type="submit"> </button> <button class="login-submit" type="submit" :disabled="isSubmitting">
<p class="login-tip">原型演示任意账号密码均可登录</p> {{ isSubmitting ? '登录中…' : '登 录' }}
</button>
<p class="login-tip">请使用医院分配的账号登录</p>
</form> </form>
<p class="login-footer">© 2026 医签通 MEDISIGN · 患者电子签署系统 V1.1 原型</p> <p class="login-footer">© 2026 医签通 MEDISIGN · 患者电子签署系统 V1.1 原型</p>
@@ -194,29 +192,6 @@ function handleLogin() {
border-color: var(--brand); border-color: var(--brand);
} }
.login-captcha-row {
display: flex;
gap: 8px;
align-items: center;
}
.login-captcha-row input {
flex: 1;
}
.login-captcha-image {
flex-shrink: 0;
padding: 8px 14px;
color: #5c7d8a;
font-size: 13px;
font-style: italic;
font-weight: 700;
letter-spacing: 6px;
user-select: none;
background: linear-gradient(120deg, #dfeef3, #c8dfe8);
border-radius: 6px;
}
.login-error { .login-error {
margin: -4px 0 10px; margin: -4px 0 10px;
color: var(--err); color: var(--err);
@@ -238,6 +213,11 @@ function handleLogin() {
background: var(--brand-d); background: var(--brand-d);
} }
.login-submit:disabled {
cursor: wait;
opacity: 0.65;
}
.login-tip { .login-tip {
margin: 12px 0 0; margin: 12px 0 0;
padding: 7px; padding: 7px;

View File

@@ -6,6 +6,15 @@ import { defineConfig } from 'vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'https://ipad.shenynet.com',
changeOrigin: true,
secure: true,
},
},
},
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),