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:
2026-06-14 23:22:53 +08:00
parent 521cf0269c
commit cd41e59afc
14 changed files with 430 additions and 11 deletions
+50 -3
View File
@@ -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;
+28
View File
@@ -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')
+19 -6
View File
@@ -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
+133
View File
@@ -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>