diff --git a/clinical-web/README.md b/clinical-web/README.md index 87c9782..c49ca24 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -127,4 +127,15 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench` 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。 diff --git a/clinical-web/src/api/auth.ts b/clinical-web/src/api/auth.ts new file mode 100644 index 0000000..4188064 --- /dev/null +++ b/clinical-web/src/api/auth.ts @@ -0,0 +1,122 @@ +import axios from 'axios' + +import { request } from '@/utils/request' +import { saveAuthSession } from '@/utils/auth-storage' +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) { + 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(response: ApiResponse): 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 { + const response = await request.post>('/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), + } + + saveAuthSession(normalizedSession) + return normalizedSession +} + +export async function getCurrentUser(): Promise { + const response = await request.get>('/v1/auth/me') + const user = unwrapApiResponse(response) + + return { + ...user, + permissions: normalizePermissions(user.permissions), + } +} + +export async function logout(): Promise { + const response = await request.post>>('/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 '登录失败,请稍后重试' +} diff --git a/clinical-web/src/components/layout/Menu.vue b/clinical-web/src/components/layout/Menu.vue index 2274a85..d4528a3 100644 --- a/clinical-web/src/components/layout/Menu.vue +++ b/clinical-web/src/components/layout/Menu.vue @@ -1,6 +1,7 @@ diff --git a/clinical-web/src/stores/app.ts b/clinical-web/src/stores/app.ts index 7d94e00..0e4579d 100644 --- a/clinical-web/src/stores/app.ts +++ b/clinical-web/src/stores/app.ts @@ -1,23 +1,60 @@ import { defineStore } from 'pinia' import type { WorkbenchCampus } from '@/api/workbench/types' +import type { AuthenticatedUser, LoginResponse } from '@/types/auth' +import { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage' + +const dataScopeLabels: Record = { + ALL: '全院', + CAMPUS: '院区', + DEPARTMENT: '科室', + READ_ONLY_ALL: '全院只读', +} + +function getUserContext(user: AuthenticatedUser | null) { + return user ? `${dataScopeLabels[user.dataScope]}数据范围` : '医签通用户' +} export const useAppStore = defineStore('app', { - state: () => ({ - isLoggedIn: false, - userName: '张文静', - department: '医务管理 · 主任医师', - environment: '原型演示环境', - campuses: ['本部院区', '东院区', '西院区'] as WorkbenchCampus[], - selectedCampus: '本部院区' as WorkbenchCampus, - }), + state: () => { + const session = readStoredAuthSession() + const user = session?.user ?? null + + return { + isLoggedIn: Boolean(session?.token), + token: session?.token ?? '', + expiresAt: session?.expiresAt ?? '', + user, + userName: user?.displayName || user?.username || '医护用户', + department: getUserContext(user), + environment: '接口联调环境', + campuses: ['本部院区', '东院区', '西院区'] as WorkbenchCampus[], + selectedCampus: '本部院区' as WorkbenchCampus, + } + }, actions: { - login(userName: string) { - this.userName = userName || '张文静' + login(session: LoginResponse) { + saveAuthSession(session) + this.token = session.token + this.expiresAt = session.expiresAt + this.user = session.user + this.userName = session.user.displayName || session.user.username || '医护用户' + this.department = getUserContext(session.user) this.isLoggedIn = true }, + updateCurrentUser(user: AuthenticatedUser) { + this.user = user + this.userName = user.displayName || user.username || '医护用户' + this.department = getUserContext(user) + }, logout() { + clearAuthStorage() this.isLoggedIn = false + this.token = '' + this.expiresAt = '' + this.user = null + this.userName = '医护用户' + this.department = getUserContext(null) }, }, }) diff --git a/clinical-web/src/types/auth.ts b/clinical-web/src/types/auth.ts new file mode 100644 index 0000000..362e811 --- /dev/null +++ b/clinical-web/src/types/auth.ts @@ -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 +} diff --git a/clinical-web/src/types/common.ts b/clinical-web/src/types/common.ts index 9388ef0..b8779f2 100644 --- a/clinical-web/src/types/common.ts +++ b/clinical-web/src/types/common.ts @@ -13,9 +13,11 @@ export interface PageResult { } export interface ApiResponse { - code: number + code: number | string message: string - data: T + data: T | null + traceId?: string + timestamp?: string } export interface SelectOption { diff --git a/clinical-web/src/utils/auth-storage.ts b/clinical-web/src/utils/auth-storage.ts new file mode 100644 index 0000000..a9412a6 --- /dev/null +++ b/clinical-web/src/utils/auth-storage.ts @@ -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) +} diff --git a/clinical-web/src/utils/request.ts b/clinical-web/src/utils/request.ts index 718bf61..99700b5 100644 --- a/clinical-web/src/utils/request.ts +++ b/clinical-web/src/utils/request.ts @@ -5,23 +5,38 @@ import axios, { type InternalAxiosRequestConfig, } from 'axios' +import { clearAuthStorage, AUTH_TOKEN_KEY } from '@/utils/auth-storage' + 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 { 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({ baseURL, timeout, - withCredentials: true, + withCredentials: false, }) this.instance.interceptors.request.use((config: InternalAxiosRequestConfig) => { - const token = localStorage.getItem('token') + const token = localStorage.getItem(AUTH_TOKEN_KEY) if (token) { - config.headers.set('Authorization', 'Bearer ' + token) + config.headers.set('X-Token', token) } return config @@ -29,7 +44,14 @@ class Request { this.instance.interceptors.response.use( (response: AxiosResponse) => response, - (error: unknown) => Promise.reject(error), + (error: unknown) => { + if (axios.isAxiosError(error) && error.response?.status === 401) { + clearAuthStorage() + redirectToLogin() + } + + return Promise.reject(error) + }, ) } diff --git a/clinical-web/src/views/auth/LoginView.vue b/clinical-web/src/views/auth/LoginView.vue index df853a1..bd17a68 100644 --- a/clinical-web/src/views/auth/LoginView.vue +++ b/clinical-web/src/views/auth/LoginView.vue @@ -2,37 +2,49 @@ import { ref } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { getAuthErrorMessage, login as loginRequest } from '@/api/auth' import { useAppStore } from '@/stores/app' const route = useRoute() const router = useRouter() const appStore = useAppStore() -const username = ref('zhangwj') -const password = ref('******') -const captcha = ref('8362') +const username = ref('') +const password = ref('') const errorMessage = ref('') -const captchaCode = '8362' +const isSubmitting = ref(false) function getRedirectPath() { 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() { - if (!username.value.trim() || !password.value.trim() || !captcha.value.trim()) { - errorMessage.value = '请输入完整的账号、密码和验证码' - return - } +async function handleLogin() { + const normalizedUsername = username.value.trim() - if (captcha.value.trim() !== captchaCode) { - errorMessage.value = '验证码错误,请重新输入' + if (!normalizedUsername || !password.value) { + errorMessage.value = '请输入账号和密码' return } errorMessage.value = '' - appStore.login(username.value.trim()) - void router.replace(getRedirectPath()) + isSubmitting.value = true + + try { + const session = await loginRequest({ + username: normalizedUsername, + password: password.value, + }) + + appStore.login(session) + await router.replace(getRedirectPath()) + } catch (error: unknown) { + errorMessage.value = getAuthErrorMessage(error) + } finally { + isSubmitting.value = false + } } @@ -87,25 +99,12 @@ function handleLogin() { /> - - - - + + @@ -194,29 +193,6 @@ function handleLogin() { 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 { margin: -4px 0 10px; color: var(--err); @@ -238,6 +214,11 @@ function handleLogin() { background: var(--brand-d); } +.login-submit:disabled { + cursor: wait; + opacity: 0.65; +} + .login-tip { margin: 12px 0 0; padding: 7px; diff --git a/clinical-web/vite.config.ts b/clinical-web/vite.config.ts index e00cb02..4b0b48c 100644 --- a/clinical-web/vite.config.ts +++ b/clinical-web/vite.config.ts @@ -6,6 +6,15 @@ import { defineConfig } from 'vite' // https://vite.dev/config/ export default defineConfig({ plugins: [vue()], + server: { + proxy: { + '/api': { + target: 'https://ipad.shenynet.com', + changeOrigin: true, + secure: true, + }, + }, + }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)),