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`。
|
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">
|
<script setup lang="ts">
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { logout as logoutRequest } from '@/api/auth'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
@@ -36,9 +37,15 @@ const menuGroups: MenuGroup[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await logoutRequest()
|
||||||
|
} catch {
|
||||||
|
// 即使服务暂时不可用,也要清理本地会话。
|
||||||
|
} finally {
|
||||||
appStore.logout()
|
appStore.logout()
|
||||||
void router.push('/login')
|
await router.push('/login')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,60 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
import type { WorkbenchCampus } from '@/api/workbench/types'
|
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', {
|
export const useAppStore = defineStore('app', {
|
||||||
state: () => ({
|
state: () => {
|
||||||
isLoggedIn: false,
|
const session = readStoredAuthSession()
|
||||||
userName: '张文静',
|
const user = session?.user ?? null
|
||||||
department: '医务管理 · 主任医师',
|
|
||||||
environment: '原型演示环境',
|
return {
|
||||||
|
isLoggedIn: Boolean(session?.token),
|
||||||
|
token: session?.token ?? '',
|
||||||
|
expiresAt: session?.expiresAt ?? '',
|
||||||
|
user,
|
||||||
|
userName: user?.displayName || user?.username || '医护用户',
|
||||||
|
department: getUserContext(user),
|
||||||
|
environment: '接口联调环境',
|
||||||
campuses: ['本部院区', '东院区', '西院区'] as WorkbenchCampus[],
|
campuses: ['本部院区', '东院区', '西院区'] as WorkbenchCampus[],
|
||||||
selectedCampus: '本部院区' as WorkbenchCampus,
|
selectedCampus: '本部院区' as WorkbenchCampus,
|
||||||
}),
|
}
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
login(userName: string) {
|
login(session: LoginResponse) {
|
||||||
this.userName = userName || '张文静'
|
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
|
this.isLoggedIn = true
|
||||||
},
|
},
|
||||||
|
updateCurrentUser(user: AuthenticatedUser) {
|
||||||
|
this.user = user
|
||||||
|
this.userName = user.displayName || user.username || '医护用户'
|
||||||
|
this.department = getUserContext(user)
|
||||||
|
},
|
||||||
logout() {
|
logout() {
|
||||||
|
clearAuthStorage()
|
||||||
this.isLoggedIn = false
|
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> {
|
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> {
|
||||||
|
|||||||
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,
|
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)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,37 +2,49 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { getAuthErrorMessage, login as loginRequest } from '@/api/auth'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
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 {
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -87,25 +99,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 +193,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 +214,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;
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
Reference in New Issue
Block a user