feat(auth): 接入 MEDISIGN 登录接口
- 新增认证 API、类型与本地会话存储 - 登录页改为真实账号密码登录并移除验证码 - 请求拦截器改用 X-Token 并处理 401 跳转 - 添加 Vite /api 开发代理配置
This commit is contained in:
@@ -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。
|
||||
|
||||
122
clinical-web/src/api/auth.ts
Normal file
122
clinical-web/src/api/auth.ts
Normal file
@@ -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<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),
|
||||
}
|
||||
|
||||
saveAuthSession(normalizedSession)
|
||||
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 '登录失败,请稍后重试'
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
|
||||
import { logout as logoutRequest } from '@/api/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
interface MenuItem {
|
||||
@@ -36,9 +37,15 @@ const menuGroups: MenuGroup[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function handleLogout() {
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logoutRequest()
|
||||
} catch {
|
||||
// 即使服务暂时不可用,也要清理本地会话。
|
||||
} finally {
|
||||
appStore.logout()
|
||||
void router.push('/login')
|
||||
await router.push('/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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<AuthenticatedUser['dataScope'], string> = {
|
||||
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: '原型演示环境',
|
||||
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)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
34
clinical-web/src/types/auth.ts
Normal file
34
clinical-web/src/types/auth.ts
Normal 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
|
||||
}
|
||||
@@ -13,9 +13,11 @@ export interface PageResult<T> {
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
code: number | string
|
||||
message: string
|
||||
data: T
|
||||
data: T | null
|
||||
traceId?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export interface SelectOption<T extends string = string> {
|
||||
|
||||
59
clinical-web/src/utils/auth-storage.ts
Normal file
59
clinical-web/src/utils/auth-storage.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -87,25 +99,12 @@ function handleLogin() {
|
||||
/>
|
||||
</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>
|
||||
|
||||
<button class="login-submit" type="submit">登 录</button>
|
||||
<p class="login-tip">原型演示:任意账号密码均可登录</p>
|
||||
<button class="login-submit" type="submit" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '登录中…' : '登 录' }}
|
||||
</button>
|
||||
<p class="login-tip">请使用医院分配的账号登录</p>
|
||||
</form>
|
||||
|
||||
<p class="login-footer">© 2026 医签通 MEDISIGN · 患者电子签署系统 V1.1 原型</p>
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user