465 lines
9.9 KiB
Vue
465 lines
9.9 KiB
Vue
<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>
|