feat: add user authentication system
- User model (users table) with bcrypt password hashing - JWT-based login API (POST /api/auth/login, GET /api/auth/me) - Default admin account (admin/admin123) auto-created on startup - Login page with centered dark-themed login card - Route guards + Axios interceptors for token management - Login page renders without sidebar (standalone layout) - Gunicorn --preload to avoid worker DDL race condition
This commit is contained in:
+50
-3
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<div id="app-container">
|
||||
<!-- Login page: standalone without sidebar -->
|
||||
<template v-if="isLoginPage">
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<!-- App pages: with sidebar -->
|
||||
<template v-else>
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
@@ -46,22 +53,44 @@
|
||||
<header class="topbar">
|
||||
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||
<div class="topbar-right">
|
||||
<span class="version-badge">v1.0</span>
|
||||
<template v-if="isLoggedIn">
|
||||
<span class="user-name">{{ userName }}</span>
|
||||
<button class="btn-logout" @click="handleLogout">退出</button>
|
||||
</template>
|
||||
<span v-else class="version-badge">v1.0</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const currentRoute = computed(() => route.path)
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
const isLoggedIn = computed(() => !!localStorage.getItem('token'))
|
||||
const userName = ref('')
|
||||
|
||||
// Load user info from localStorage
|
||||
try {
|
||||
const userData = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
userName.value = userData.display_name || userData.username || ''
|
||||
} catch {}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const routeTitles = {
|
||||
'/': '仪表盘',
|
||||
@@ -195,6 +224,24 @@ const pageTitle = computed(() => routeTitles[route.path] || 'TaskPulse')
|
||||
color: rgba(255,255,255,0.4);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn-logout {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-logout:hover {
|
||||
color: #f87171;
|
||||
border-color: rgba(239,68,68,0.2);
|
||||
background: rgba(239,68,68,0.08);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
|
||||
@@ -5,6 +5,34 @@ const api = axios.create({
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// Auto-attach token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Auto-redirect on 401
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
// Auth
|
||||
export const login = (data) => api.post('/auth/login', data)
|
||||
export const getMe = () => api.get('/auth/me')
|
||||
|
||||
// Dashboard
|
||||
export const getDashboardSummary = () => api.get('/dashboard/summary')
|
||||
export const getDashboardTasks = () => api.get('/dashboard/tasks')
|
||||
|
||||
@@ -5,14 +5,16 @@ import TaskDetail from '../views/TaskDetail.vue'
|
||||
import Agents from '../views/Agents.vue'
|
||||
import Alerts from '../views/Alerts.vue'
|
||||
import Settings from '../views/Settings.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/tasks', component: Tasks },
|
||||
{ path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail' },
|
||||
{ path: '/agents', component: Agents },
|
||||
{ path: '/alerts', component: Alerts },
|
||||
{ path: '/settings', component: Settings },
|
||||
{ path: '/login', component: Login, meta: { public: true } },
|
||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
||||
{ path: '/tasks', component: Tasks, meta: { requiresAuth: true } },
|
||||
{ path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail', meta: { requiresAuth: true } },
|
||||
{ path: '/agents', component: Agents, meta: { requiresAuth: true } },
|
||||
{ path: '/alerts', component: Alerts, meta: { requiresAuth: true } },
|
||||
{ path: '/settings', component: Settings, meta: { requiresAuth: true } },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
@@ -20,4 +22,15 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && token) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>TaskPulse</h1>
|
||||
<p>AI Agent Task Monitor</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="field">
|
||||
<label>用户名</label>
|
||||
<input v-model="username" class="input" placeholder="admin" autocomplete="username" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>密码</label>
|
||||
<input v-model="password" type="password" class="input" placeholder="admin123" autocomplete="current-password" />
|
||||
</div>
|
||||
<p v-if="error" class="login-error">{{ error }}</p>
|
||||
<button type="submit" class="btn-login" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { login } from '../api/index.js'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
if (!username.value || !password.value) {
|
||||
error.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await login({ username: username.value, password: password.value })
|
||||
const { access_token, user } = res.data
|
||||
localStorage.setItem('token', access_token)
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
if (e.response?.status === 401) {
|
||||
error.value = '用户名或密码错误'
|
||||
} else {
|
||||
error.value = '登录失败,请检查网络连接'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0a0a0f;
|
||||
}
|
||||
.login-card {
|
||||
width: 380px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px;
|
||||
}
|
||||
.login-header { text-align: center; margin-bottom: 32px; }
|
||||
.login-logo {
|
||||
width: 56px; height: 56px;
|
||||
margin: 0 auto 12px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.login-header h1 {
|
||||
font-size: 22px; font-weight: 700;
|
||||
background: linear-gradient(135deg, #a8b4ff, #c084fc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.login-header p { font-size: 13px; color: var(--text-muted); margin: 0; }
|
||||
|
||||
.login-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 12px; color: var(--text-secondary); }
|
||||
.input {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.input:focus { border-color: var(--accent-1); }
|
||||
.input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.login-error { font-size: 13px; color: #f87171; margin: 0; }
|
||||
|
||||
.btn-login {
|
||||
padding: 11px 0;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-login:hover { opacity: 0.9; }
|
||||
.btn-login:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user