feat(auth): 添加登录页与演示认证逻辑

This commit is contained in:
yelan
2026-08-27 13:22:45 +08:00
parent 4720f7e1a7
commit 779f15d29f
8 changed files with 333 additions and 4 deletions

View File

@@ -84,11 +84,14 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
## 当前状态
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS 和医护端整体布局。当前所有业务 View 暂时保留文字占位,尚未接入真实后端。
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页和医护端整体布局。当前所有业务 View 暂时保留文字占位,登录使用演示认证逻辑,尚未接入真实后端。
## 当前路由结构
```text
公开页
└─ /login 登录页
ClinicalLayout
├─ 工作台
│ ├─ /workbench/home 首页

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
@@ -14,6 +14,7 @@ interface MenuGroup {
}
const appStore = useAppStore()
const router = useRouter()
const menuGroups: MenuGroup[] = [
{
@@ -34,6 +35,11 @@ const menuGroups: MenuGroup[] = [
],
},
]
function handleLogout() {
appStore.logout()
void router.push('/login')
}
</script>
<template>
@@ -84,7 +90,7 @@ const menuGroups: MenuGroup[] = [
<strong>{{ appStore.userName }}</strong>
<span>{{ appStore.department }}</span>
</div>
<button class="menu-logout" type="button">退出</button>
<button class="menu-logout" type="button" @click="handleLogout">退出</button>
</div>
</aside>
</template>

View File

@@ -7,4 +7,7 @@ import App from './App.vue'
import router from './router'
import './styles/index.css'
createApp(App).use(router).use(createPinia()).use(ElementPlus).mount('#app')
const app = createApp(App)
const pinia = createPinia()
app.use(pinia).use(router).use(ElementPlus).mount('#app')

View File

@@ -1,10 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAppStore } from '@/stores/app'
import ClinicalLayout from '@/layouts/ClinicalLayout.vue'
import ConsentTasksView from '@/views/consent-tasks/ConsentTasksView.vue'
import DashboardView from '@/views/dashboard/DashboardView.vue'
import DocumentPermissionsView from '@/views/document-permissions/DocumentPermissionsView.vue'
import DocumentsView from '@/views/documents/DocumentsView.vue'
import LoginView from '@/views/auth/LoginView.vue'
import ReportsView from '@/views/reports/ReportsView.vue'
import SettingsView from '@/views/settings/SettingsView.vue'
import UserPermissionsView from '@/views/users/UserPermissionsView.vue'
@@ -12,10 +14,18 @@ import UserPermissionsView from '@/views/users/UserPermissionsView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: LoginView,
meta: { title: '登录' },
},
{
path: '/',
name: 'clinical',
component: ClinicalLayout,
redirect: '/workbench/home',
meta: { requiresAuth: true },
children: [
{
path: 'workbench',
@@ -23,11 +33,13 @@ const router = createRouter({
children: [
{
path: 'home',
name: 'workbench-home',
component: DashboardView,
meta: { title: '首页', group: '工作台' },
},
{
path: 'signing',
name: 'workbench-signing',
component: ConsentTasksView,
meta: { title: '签署工作台', group: '工作台' },
},
@@ -39,26 +51,31 @@ const router = createRouter({
children: [
{
path: 'documents',
name: 'management-documents',
component: DocumentsView,
meta: { title: '文档库管理', group: '管理' },
},
{
path: 'reports',
name: 'management-reports',
component: ReportsView,
meta: { title: '报表分析', group: '管理' },
},
{
path: 'users',
name: 'management-users',
component: UserPermissionsView,
meta: { title: '用户与权限', group: '管理' },
},
{
path: 'document-permissions',
name: 'management-document-permissions',
component: DocumentPermissionsView,
meta: { title: '文档权限', group: '管理' },
},
{
path: 'settings',
name: 'management-settings',
component: SettingsView,
meta: { title: '系统设置', group: '管理' },
},
@@ -75,4 +92,21 @@ const router = createRouter({
],
})
router.beforeEach((to) => {
const appStore = useAppStore()
if (to.name === 'login' && appStore.isLoggedIn) {
return { name: 'workbench-home' }
}
if (to.meta.requiresAuth && !appStore.isLoggedIn) {
return {
name: 'login',
query: { redirect: to.fullPath },
}
}
return true
})
export default router

View File

@@ -2,8 +2,18 @@ import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
isLoggedIn: false,
userName: '张文静',
department: '医务管理 · 主任医师',
environment: '原型演示环境',
}),
actions: {
login(userName: string) {
this.userName = userName || '张文静'
this.isLoggedIn = true
},
logout() {
this.isLoggedIn = false
},
},
})

View File

@@ -365,3 +365,160 @@ a {
padding: 16px;
}
}
/* 原型登录页 */
.login-page {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--login-gradient);
}
.login-card {
width: 380px;
padding: 38px 36px;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-login);
}
.login-brand {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.login-brand-icon {
display: grid;
width: 46px;
height: 46px;
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-brand p {
margin: 2px 0 0;
color: #8aa7b3;
font-size: 11px;
letter-spacing: 2px;
}
.login-subtitle {
margin: 0 0 26px;
color: var(--mut);
font-size: 12px;
text-align: center;
}
.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);
}
.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);
font-size: 12px;
}
.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-tip {
margin: 16px 0 0;
padding: 8px;
color: var(--mut);
font-size: 12px;
text-align: center;
background: var(--brand-l);
border-radius: 8px;
}
.login-footer {
position: absolute;
bottom: 18px;
width: 100%;
margin: 0;
color: rgb(255 255 255 / 55%);
font-size: 12px;
text-align: center;
}
@media (max-width: 520px) {
body {
min-width: 0;
}
.login-card {
width: calc(100% - 32px);
padding: 32px 24px;
}
}

View File

@@ -2,6 +2,7 @@
--brand: #0e6e8c;
--brand-d: #0a4f66;
--brand-l: #e3f0f4;
--brand-m: #1487a5;
--side: #0a3d51;
--bg: #f1f5f7;
--card: #ffffff;
@@ -20,6 +21,8 @@
--sh: 0 2px 10px rgb(13 62 81 / 7%);
--sh-hover: 0 6px 20px rgb(14 110 140 / 18%);
--sh-modal: 0 20px 60px rgb(0 0 0 / 30%);
--sh-login: 0 20px 60px rgb(0 0 0 / 30%);
--login-gradient: linear-gradient(135deg, #0a3d51 0%, var(--brand) 55%, var(--brand-m) 100%);
--font-sans:
'Microsoft YaHei', 'PingFang SC', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
sans-serif;

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 errorMessage = ref('')
const captchaCode = '8362'
function getRedirectPath() {
const redirect = route.query.redirect
return typeof redirect === 'string' && redirect.startsWith('/') ? redirect : '/workbench/home'
}
function handleLogin() {
if (!username.value.trim() || !password.value.trim() || !captcha.value.trim()) {
errorMessage.value = '请输入完整的账号、密码和验证码'
return
}
if (captcha.value.trim() !== captchaCode) {
errorMessage.value = '验证码错误,请重新输入'
return
}
errorMessage.value = ''
appStore.login(username.value.trim())
void router.replace(getRedirectPath())
}
</script>
<template>
<main class="login-page">
<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="M14 3v5h5M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5z"
stroke="#fff"
stroke-width="1.7"
stroke-linejoin="round"
/>
<path
d="m9 13.5 2.5 2.5 4-4.5"
stroke="#fff"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<div>
<h1>医签通</h1>
<p>MEDISIGN · 患者电子签系统</p>
</div>
</div>
<p class="login-subtitle">安全留痕 · 高效签署 · 医患无忧</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">
<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>
</form>
<p class="login-footer">© 2026 医签通 MEDISIGN · 患者电子签署系统 V1.1 原型</p>
</main>
</template>