feat(mobile): 添加登录页面+租户选择+路由守卫

This commit is contained in:
2026-06-19 12:48:57 +08:00
parent 38bc99ee14
commit 9d486c3742
5 changed files with 143 additions and 5 deletions

5
healthlink-his-mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env.local
.env.*.local
*.log

View File

@@ -5,7 +5,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"build:dev": "vite build",
"preview": "vite preview",
"lint": "echo 'No lint configured'"
},
"dependencies": {
"vue": "^3.4.0",

View File

@@ -1,4 +1,5 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({
baseURL: '/dev-api',
@@ -8,19 +9,41 @@ const request = axios.create({
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
const tenantId = localStorage.getItem('tenantId')
if (tenantId) config.headers['tenant-id'] = tenantId
return config
})
request.interceptors.response.use(
res => res.data,
err => { console.error(err); return Promise.reject(err) }
res => {
if (res.data?.code === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
return Promise.reject(new Error('登录已过期'))
}
return res.data
},
err => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
ElMessage.error(err.response?.data?.msg || '请求失败')
return Promise.reject(err)
}
)
export const authApi = {
login: (data) => request.post('/login', data),
getTenants: () => request.get('/tenant/list'),
logout: () => request.post('/logout')
}
export const nursingApi = {
getTasks: (params) => request.get('/mp/nursing/tasks', { params }),
completeTask: (id, data) => request.post(`/mp/nursing/tasks/${id}/complete`, data),
getPatientList: (params) => request.get('/mp/nursing/patient/list', { params }),
getPatientInfo: (id) => request.get(`/mp/nursing/patient/${id}`),
getPatientList: (params) => request.get('/mp/nursing/patient/list', { params }),
getOrders: (patientId) => request.get(`/mp/nursing/orders/${patientId}`),
getVitalSigns: (patientId) => request.get(`/mp/nursing/vital-signs/${patientId}`),
submitVitalSign: (data) => request.post('/mp/nursing/vital-sign', data),

View File

@@ -1,8 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', component: () => import('../views/Login.vue'), meta: { title: '登录' } },
{ path: '/', redirect: '/mobile/tasks' },
{ path: '/mobile', component: () => import('../views/MobileLayout.vue'), children: [
{ path: '/mobile', component: () => import('../views/MobileLayout.vue'), meta: { requiresAuth: true }, children: [
{ path: 'tasks', component: () => import('../views/TaskList.vue'), meta: { title: '任务' } },
{ path: 'patients', component: () => import('../views/PatientList.vue'), meta: { title: '患者' } },
{ path: 'patient-detail/:id', component: () => import('../views/PatientDetail.vue'), meta: { title: '患者详情' } },
@@ -13,4 +14,13 @@ const routes = [
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth) {
const token = localStorage.getItem('token')
if (!token) { next('/login'); return }
}
next()
})
export default router

View File

@@ -0,0 +1,98 @@
<template>
<div class="login-page">
<div class="login-header">
<div class="logo">🏥</div>
<h1>HealthLink 移动护理</h1>
<p>医院信息管理系统</p>
</div>
<div class="login-form">
<div class="form-item">
<label>租户</label>
<select v-model="form.tenantId" class="input">
<option value="">请选择租户</option>
<option v-for="t in tenants" :key="t.tenantId" :value="t.tenantId">{{ t.tenantName }}</option>
</select>
</div>
<div class="form-item">
<label>用户名</label>
<input v-model="form.username" type="text" placeholder="请输入用户名" class="input" />
</div>
<div class="form-item">
<label>密码</label>
<input v-model="form.password" type="password" placeholder="请输入密码" class="input" />
</div>
<button class="login-btn" @click="handleLogin" :disabled="loading">
{{ loading ? '登录中...' : '登 录' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import axios from 'axios'
const router = useRouter()
const loading = ref(false)
const tenants = ref([])
const form = ref({ tenantId: '', username: '', password: '' })
const loadTenants = async () => {
try {
const res = await axios.get('/dev-api/tenant/list')
tenants.value = res.data?.data || []
} catch (e) { console.error(e) }
}
const handleLogin = async () => {
if (!form.value.tenantId || !form.value.username || !form.value.password) {
ElMessage.warning('请填写完整信息')
return
}
loading.value = true
try {
const res = await axios.post('/dev-api/login', {
username: form.value.username,
password: form.value.password,
tenantId: form.value.tenantId
})
if (res.data?.code === 200) {
localStorage.setItem('token', res.data.token)
localStorage.setItem('userInfo', JSON.stringify(res.data.userInfo))
localStorage.setItem('tenantId', form.value.tenantId)
ElMessage.success('登录成功')
router.push('/mobile/tasks')
} else {
ElMessage.error(res.data?.msg || '登录失败')
}
} catch (e) {
ElMessage.error('登录失败: ' + (e.response?.data?.msg || e.message))
} finally {
loading.value = false
}
}
onMounted(() => {
const token = localStorage.getItem('token')
if (token) router.push('/mobile/tasks')
else loadTenants()
})
</script>
<style scoped>
.login-page { min-height: 100vh; background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; }
.login-header { text-align: center; color: #fff; margin-bottom: 40px; }
.logo { font-size: 60px; margin-bottom: 12px; }
.login-header h1 { font-size: 24px; margin: 0; }
.login-header p { font-size: 14px; opacity: 0.8; margin-top: 8px; }
.login-form { background: #fff; border-radius: 12px; padding: 24px; width: 100%; max-width: 360px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); }
.form-item { margin-bottom: 16px; }
.form-item label { display: block; font-size: 14px; color: #333; margin-bottom: 6px; font-weight: 500; }
.input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; outline: none; }
.input:focus { border-color: #1890ff; }
select.input { appearance: none; background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E") no-repeat right 12px center; }
.login-btn { width: 100%; padding: 14px; background: #1890ff; color: #fff; border: none; border-radius: 8px; font-size: 18px; font-weight: 600; cursor: pointer; }
.login-btn:disabled { background: #91d5ff; }
</style>