Files
xh-medical-sign-web/clinical-web/src/views/auth/LoginView.vue
T

465 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getAuthErrorMessage, getCaptcha } from '@/api/auth'
import hospitalLogo from '@/assets/hospital-logo.png'
import type { CaptchaResponse, LoginRequest } from '@/types/auth'
import { useLoginStore } from '@/stores/login'
const route = useRoute()
const router = useRouter()
const loginStore = useLoginStore()
const username = ref('')
const password = ref('')
const captcha = ref<CaptchaResponse | null>(null)
const captchaCode = ref('')
const captchaLoading = ref(false)
const errorMessage = ref('')
const isSubmitting = ref(false)
const passwordChanged = route.query.passwordChanged === '1'
async function loadCaptcha() {
captchaLoading.value = true
try {
captcha.value = await getCaptcha()
captchaCode.value = ''
} catch {
// 验证码由服务端按需启用;接口不可用时保持账号密码登录兼容。
captcha.value = null
captchaCode.value = ''
} finally {
captchaLoading.value = false
}
}
function getRedirectPath() {
const redirect = route.query.redirect
return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
? redirect
: '/workbench/home'
}
async function handleLogin() {
const normalizedUsername = username.value.trim()
if (!normalizedUsername || !password.value) {
errorMessage.value = '请输入账号和密码'
return
}
if (captcha.value && !captchaCode.value.trim()) {
errorMessage.value = '请输入图形验证码'
return
}
errorMessage.value = ''
isSubmitting.value = true
try {
const payload: LoginRequest = {
username: normalizedUsername,
password: password.value,
...(captcha.value
? {
captchaId: captcha.value.captchaId,
captchaCode: captchaCode.value.trim(),
}
: {}),
}
const session = await loginStore.login(payload)
const redirect = getRedirectPath()
await router.replace(
session.user.passwordChangeRequired
? { name: 'change-password', query: { redirect } }
: redirect,
)
} catch (error: unknown) {
errorMessage.value = getAuthErrorMessage(error)
if (captcha.value) {
void loadCaptcha()
}
} finally {
isSubmitting.value = false
}
}
onMounted(() => {
void loadCaptcha()
})
</script>
<template>
<main class="login-page">
<div class="login-visual" aria-hidden="true"></div>
<div class="login-shade" aria-hidden="true"></div>
<div class="login-caption">
<span class="login-badge">
<img :src="hospitalLogo" alt="新华医院" />
</span>
<div class="login-caption-text">
<b>上海交通大学医学院附属新华医院</b>
<span>Xinhua Hospital Affiliated To Shanghai Jiao Tong University School Of Medicine</span>
</div>
</div>
<form class="login-card" novalidate @submit.prevent="handleLogin">
<div class="login-brand">
<div class="login-brand-icon" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path
d="M5.6 2.6v4.2M3.5 4.7h4.2"
stroke="#fff"
stroke-width="1.7"
stroke-linecap="round"
/>
<path d="M9.7 16.3l1.1-3.1 6.3-6.3a1.41 1.41 0 0 1 2 2l-6.3 6.3-3.1 1.1z" fill="#fff" />
<path d="M15.5 8.5l2 2" stroke="#9bd8e9" stroke-width="1.2" stroke-linecap="round" />
<path
d="M2.8 21.2c1.7 0 1.7-1.3 3.4-1.3s1.7 1.3 3.4 1.3 1.7-1.3 3.4-1.3 1.7 1.3 3.4 1.3 1.7-1.3 3.4-1.3"
stroke="#fff"
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
</div>
<h1>医签通</h1>
</div>
<p v-if="passwordChanged" class="login-success" role="status">
密码修改成功,请使用新密码重新登录
</p>
<div class="login-field">
<label for="login-username">账号</label>
<input
id="login-username"
v-model="username"
type="text"
autocomplete="username"
placeholder="请输入工号 / 账号"
/>
</div>
<div class="login-field">
<label for="login-password">密码</label>
<input
id="login-password"
v-model="password"
type="password"
autocomplete="current-password"
placeholder="请输入密码"
/>
</div>
<div class="login-field login-captcha">
<label for="login-captcha">验证码</label>
<div class="captcha-row">
<input
id="login-captcha"
v-model="captchaCode"
type="text"
autocomplete="one-time-code"
inputmode="text"
maxlength="8"
placeholder="请输入验证码"
/>
<button
type="button"
class="captcha-image-button"
:disabled="captchaLoading"
aria-label="刷新验证码"
@click="loadCaptcha"
>
<span v-if="captchaLoading" class="captcha-spinner" aria-hidden="true" />
<img v-else-if="captcha" :src="captcha.imageBase64" alt="图形验证码,点击刷新" />
<span v-else class="captcha-retry">点击重试</span>
</button>
</div>
</div>
<p v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</p>
<button class="login-submit" type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '登录中…' : '登 录' }}
</button>
</form>
<p class="login-footer">© 2026 上海交通大学医学院附属新华医院 · 医签通 MEDISIGN V1.1</p>
</main>
</template>
<style scoped>
.login-page {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #0e6e8c;
}
.login-visual {
position: absolute;
inset: 0;
background: url('@/assets/login-bg.jpg') center / cover no-repeat;
}
.login-shade {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgb(6 40 54 / 38%) 0%,
rgb(6 40 54 / 0%) 28%,
rgb(6 40 54 / 0%) 55%,
rgb(6 40 54 / 52%) 100%
);
}
.login-caption {
position: absolute;
top: 28px;
left: 34px;
z-index: 2;
display: flex;
gap: 12px;
align-items: center;
color: #fff;
text-shadow: 0 2px 12px rgb(0 0 0 / 50%);
}
.login-badge {
display: flex;
width: 48px;
height: 48px;
flex: none;
align-items: center;
justify-content: center;
padding: 3px;
background: rgb(255 255 255 / 95%);
border-radius: 50%;
box-shadow: 0 4px 16px rgb(6 40 54 / 40%);
}
.login-badge img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 50%;
}
.login-caption-text b {
display: block;
font-size: 19px;
font-weight: 700;
letter-spacing: 1.5px;
}
.login-caption-text span {
display: block;
margin-top: 5px;
font-size: 10.5px;
letter-spacing: 0.4px;
opacity: 0.85;
}
.login-card {
position: relative;
z-index: 2;
width: 380px;
padding: 34px 34px 30px;
background: #fff;
border-radius: 14px;
box-shadow:
0 24px 64px rgb(6 40 54 / 38%),
0 4px 16px rgb(6 40 54 / 22%);
}
.login-brand {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.login-brand-icon {
display: grid;
width: 46px;
height: 46px;
flex: none;
place-items: center;
border-radius: 12px;
background: linear-gradient(135deg, var(--brand), #18a0c0);
}
.login-brand h1 {
margin: 0;
color: var(--ink);
font-size: 22px;
letter-spacing: 1px;
}
.login-field {
margin-bottom: 14px;
}
.login-field label {
display: block;
margin-bottom: 6px;
color: var(--mut);
font-size: 12px;
}
.login-field input {
width: 100%;
padding: 7px 10px;
color: var(--ink);
font-size: 13px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
outline: none;
}
.login-field input:focus {
border-color: var(--brand);
}
.captcha-row {
display: flex;
gap: 8px;
align-items: center;
}
.captcha-row input {
flex: 1;
}
.captcha-image-button {
display: grid;
width: 112px;
height: 34px;
flex-shrink: 0;
padding: 0;
overflow: hidden;
background: #f4f7f9;
border: 1px solid var(--line);
border-radius: 6px;
place-items: center;
}
.captcha-image-button:hover:not(:disabled) {
border-color: var(--brand);
}
.captcha-image-button:disabled {
cursor: wait;
}
.captcha-image-button img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.login-error {
margin: -4px 0 10px;
color: var(--err);
font-size: 12px;
}
.login-success {
margin: 0 0 12px;
padding: 8px 10px;
color: #16794d;
font-size: 12px;
text-align: center;
background: #edf9f3;
border-radius: 7px;
}
.login-submit {
width: 100%;
margin-top: 10px;
padding: 10px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.login-submit:hover {
background: var(--brand-d);
}
.login-submit:disabled {
cursor: wait;
opacity: 0.65;
}
.captcha-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--line);
border-top-color: var(--brand);
border-radius: 50%;
animation: captcha-spin 0.7s linear infinite;
}
.captcha-retry {
color: var(--brand);
font-size: 12px;
}
@keyframes captcha-spin {
to {
transform: rotate(360deg);
}
}
.login-footer {
position: absolute;
bottom: 16px;
z-index: 2;
width: 100%;
margin: 0;
color: rgb(255 255 255 / 82%);
font-size: 11.5px;
text-align: center;
text-shadow: 0 1px 6px rgb(0 0 0 / 45%);
}
@media (max-width: 820px) {
.login-caption-text span {
display: none;
}
}
@media (max-width: 520px) {
.login-caption {
top: 18px;
left: 18px;
}
.login-caption-text b {
font-size: 16px;
letter-spacing: 1px;
}
.login-card {
width: calc(100% - 32px);
padding: 28px 22px;
}
}
</style>