feat(auth): 接入 MEDISIGN 登录接口

- 新增认证 API、类型与本地会话存储
- 登录页改为真实账号密码登录并移除验证码
- 请求拦截器改用 X-Token 并处理 401 跳转
- 添加 Vite /api 开发代理配置
This commit is contained in:
yelan
2026-08-31 10:50:01 +08:00
parent 1e2ff79b0c
commit a5e0a80191
10 changed files with 359 additions and 75 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`
所有接口统一使用 `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,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 '登录失败,请稍后重试'
}

View File

@@ -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>

View File

@@ -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)
},
},
})

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> {
code: number
code: number | string
message: string
data: T
data: T | null
traceId?: string
timestamp?: 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,
} 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)
},
)
}

View File

@@ -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;

View File

@@ -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)),