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:
parent
521cf0269c
commit
cd41e59afc
@ -100,8 +100,21 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("username", sa.String(64), unique=True, nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(256), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(128), default=""),
|
||||||
|
sa.Column("email", sa.String(128), default=""),
|
||||||
|
sa.Column("is_active", sa.Boolean, default=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
op.drop_table("users")
|
||||||
op.drop_table("system_config")
|
op.drop_table("system_config")
|
||||||
op.drop_table("alerts")
|
op.drop_table("alerts")
|
||||||
op.drop_table("alert_rules")
|
op.drop_table("alert_rules")
|
||||||
|
|||||||
39
backend/app/api/auth.py
Normal file
39
backend/app/api/auth.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""API router — Auth (login, me)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.schemas import LoginRequest, TokenResponse, UserInfo, UserOut
|
||||||
|
from app.services.auth import AuthService, create_access_token, decode_access_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
svc = AuthService(db)
|
||||||
|
user = await svc.authenticate(body.username, body.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, detail="用户名或密码错误")
|
||||||
|
token = create_access_token(user.id, user.username)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
user=UserInfo(id=user.id, username=user.username,
|
||||||
|
display_name=user.display_name, email=user.email),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
async def get_me(authorization: str = Header("", alias="Authorization"),
|
||||||
|
db: AsyncSession = Depends(get_db)):
|
||||||
|
if not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(401, detail="未登录")
|
||||||
|
payload = decode_access_token(authorization[7:])
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(401, detail="登录已过期,请重新登录")
|
||||||
|
svc = AuthService(db)
|
||||||
|
user = await svc.get_user_by_id(int(payload["sub"]))
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(401, detail="用户不存在或已停用")
|
||||||
|
return UserOut(**user.__dict__)
|
||||||
@ -10,14 +10,16 @@ from fastapi.responses import HTMLResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.api.agents import router as agents_router
|
from app.api.agents import router as agents_router
|
||||||
|
from app.api.auth import router as auth_router
|
||||||
from app.api.tasks import router as tasks_router
|
from app.api.tasks import router as tasks_router
|
||||||
from app.api.executions import router as executions_router
|
from app.api.executions import router as executions_router
|
||||||
from app.api.notifications import router as notifications_router
|
from app.api.notifications import router as notifications_router
|
||||||
from app.api.dashboard import router as dashboard_router
|
from app.api.dashboard import router as dashboard_router
|
||||||
from app.api.system import router as system_router
|
from app.api.system import router as system_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine, Base
|
from app.database import engine, Base, async_session_factory
|
||||||
from app.services.scheduler import SchedulerService
|
from app.services.scheduler import SchedulerService
|
||||||
|
from app.services.auth import AuthService
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
logger = logging.getLogger("taskpulse")
|
logger = logging.getLogger("taskpulse")
|
||||||
@ -31,6 +33,9 @@ async def lifespan(app: FastAPI):
|
|||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
logger.info("Database tables ensured")
|
logger.info("Database tables ensured")
|
||||||
|
# Ensure default admin
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
await AuthService.ensure_default_admin(db)
|
||||||
# Start background scheduler
|
# Start background scheduler
|
||||||
task = asyncio.create_task(scheduler.start())
|
task = asyncio.create_task(scheduler.start())
|
||||||
yield
|
yield
|
||||||
@ -49,6 +54,7 @@ app = FastAPI(
|
|||||||
|
|
||||||
# API routers
|
# API routers
|
||||||
app.include_router(agents_router)
|
app.include_router(agents_router)
|
||||||
|
app.include_router(auth_router)
|
||||||
app.include_router(tasks_router)
|
app.include_router(tasks_router)
|
||||||
app.include_router(executions_router)
|
app.include_router(executions_router)
|
||||||
app.include_router(notifications_router)
|
app.include_router(notifications_router)
|
||||||
|
|||||||
@ -5,5 +5,7 @@ from app.models.task import ScheduledTask # noqa: F401
|
|||||||
from app.models.execution import TaskExecution # noqa: F401
|
from app.models.execution import TaskExecution # noqa: F401
|
||||||
from app.models.notification import Alert, AlertRule, NotificationChannel # noqa: F401
|
from app.models.notification import Alert, AlertRule, NotificationChannel # noqa: F401
|
||||||
from app.models.system_config import SystemConfig # noqa: F401
|
from app.models.system_config import SystemConfig # noqa: F401
|
||||||
|
from app.models.user import User # noqa: F401
|
||||||
|
|
||||||
__all__ = ["Agent", "ScheduledTask", "TaskExecution", "NotificationChannel", "AlertRule", "Alert", "SystemConfig"]
|
__all__ = ["Agent", "ScheduledTask", "TaskExecution", "NotificationChannel",
|
||||||
|
"AlertRule", "Alert", "SystemConfig", "User"]
|
||||||
|
|||||||
25
backend/app/models/user.py
Normal file
25
backend/app/models/user.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"""SQLAlchemy models — User (dashboard login)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""Dashboard user for login authentication."""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="登录用户名")
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(256), nullable=False, comment="bcrypt 密码哈希")
|
||||||
|
display_name: Mapped[str] = mapped_column(String(128), default="", comment="显示名称")
|
||||||
|
email: Mapped[str] = mapped_column(String(128), default="", comment="邮箱")
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.username}>"
|
||||||
@ -1,6 +1,7 @@
|
|||||||
"""Pydantic schema registry."""
|
"""Pydantic schema registry."""
|
||||||
|
|
||||||
from app.schemas.agent import AgentBatchRegister, AgentBatchRegisterResult, AgentCreate, AgentHeartbeat, AgentOut, AgentUpdate # noqa: F401
|
from app.schemas.agent import AgentBatchRegister, AgentBatchRegisterResult, AgentCreate, AgentHeartbeat, AgentOut, AgentUpdate # noqa: F401
|
||||||
|
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo, UserOut # noqa: F401
|
||||||
from app.schemas.execution import ExecutionOut, ExecutionReport # noqa: F401
|
from app.schemas.execution import ExecutionOut, ExecutionReport # noqa: F401
|
||||||
from app.schemas.notification import ( # noqa: F401
|
from app.schemas.notification import ( # noqa: F401
|
||||||
AlertAcknowledge,
|
AlertAcknowledge,
|
||||||
|
|||||||
37
backend/app/schemas/auth.py
Normal file
37
backend/app/schemas/auth.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"""Pydantic schemas — Auth."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str = Field(..., max_length=64)
|
||||||
|
password: str = Field(..., max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class UserInfo(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: UserInfo
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
email: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
"""Service layer __init__."""
|
"""Service layer __init__."""
|
||||||
|
|
||||||
from app.services.agent import AgentService # noqa: F401
|
from app.services.agent import AgentService # noqa: F401
|
||||||
|
from app.services.auth import AuthService # noqa: F401
|
||||||
from app.services.task import TaskService # noqa: F401
|
from app.services.task import TaskService # noqa: F401
|
||||||
from app.services.execution import ExecutionService # noqa: F401
|
from app.services.execution import ExecutionService # noqa: F401
|
||||||
from app.services.scheduler import SchedulerService # noqa: F401
|
from app.services.scheduler import SchedulerService # noqa: F401
|
||||||
@ -8,6 +9,7 @@ from app.services.notification import NotificationService # noqa: F401
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentService",
|
"AgentService",
|
||||||
|
"AuthService",
|
||||||
"TaskService",
|
"TaskService",
|
||||||
"ExecutionService",
|
"ExecutionService",
|
||||||
"SchedulerService",
|
"SchedulerService",
|
||||||
|
|||||||
70
backend/app/services/auth.py
Normal file
70
backend/app/services/auth.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""Auth service — JWT token handling + password hashing."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: int, username: str) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
|
||||||
|
payload = {"sub": str(user_id), "username": username, "exp": expire}
|
||||||
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def authenticate(self, username: str, password: str) -> User | None:
|
||||||
|
stmt = select(User).where(User.username == username, User.is_active == True)
|
||||||
|
result = await self.db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and verify_password(password, user.password_hash):
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_user_by_id(self, user_id: int) -> User | None:
|
||||||
|
return await self.db.get(User, user_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def ensure_default_admin(db: AsyncSession):
|
||||||
|
"""Create default admin if no users exist."""
|
||||||
|
stmt = select(User).limit(1)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
if result.scalar_one_or_none() is None:
|
||||||
|
admin = User(
|
||||||
|
username="admin",
|
||||||
|
password_hash=hash_password("admin123"),
|
||||||
|
display_name="Administrator",
|
||||||
|
email="admin@taskpulse.local",
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
@ -12,3 +12,6 @@ apscheduler==3.10.4
|
|||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
pycryptodome==3.20.0
|
pycryptodome==3.20.0
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app-container">
|
<div id="app-container">
|
||||||
|
<!-- Login page: standalone without sidebar -->
|
||||||
|
<template v-if="isLoginPage">
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- App pages: with sidebar -->
|
||||||
|
<template v-else>
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
@ -46,22 +53,44 @@
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h2 class="page-title">{{ pageTitle }}</h2>
|
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||||
<div class="topbar-right">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const currentRoute = computed(() => route.path)
|
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 = {
|
const routeTitles = {
|
||||||
'/': '仪表盘',
|
'/': '仪表盘',
|
||||||
@ -195,6 +224,24 @@ const pageTitle = computed(() => routeTitles[route.path] || 'TaskPulse')
|
|||||||
color: rgba(255,255,255,0.4);
|
color: rgba(255,255,255,0.4);
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
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 {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@ -5,6 +5,34 @@ const api = axios.create({
|
|||||||
timeout: 10000,
|
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
|
// Dashboard
|
||||||
export const getDashboardSummary = () => api.get('/dashboard/summary')
|
export const getDashboardSummary = () => api.get('/dashboard/summary')
|
||||||
export const getDashboardTasks = () => api.get('/dashboard/tasks')
|
export const getDashboardTasks = () => api.get('/dashboard/tasks')
|
||||||
|
|||||||
@ -5,14 +5,16 @@ import TaskDetail from '../views/TaskDetail.vue'
|
|||||||
import Agents from '../views/Agents.vue'
|
import Agents from '../views/Agents.vue'
|
||||||
import Alerts from '../views/Alerts.vue'
|
import Alerts from '../views/Alerts.vue'
|
||||||
import Settings from '../views/Settings.vue'
|
import Settings from '../views/Settings.vue'
|
||||||
|
import Login from '../views/Login.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', component: Dashboard },
|
{ path: '/login', component: Login, meta: { public: true } },
|
||||||
{ path: '/tasks', component: Tasks },
|
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
||||||
{ path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail' },
|
{ path: '/tasks', component: Tasks, meta: { requiresAuth: true } },
|
||||||
{ path: '/agents', component: Agents },
|
{ path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail', meta: { requiresAuth: true } },
|
||||||
{ path: '/alerts', component: Alerts },
|
{ path: '/agents', component: Agents, meta: { requiresAuth: true } },
|
||||||
{ path: '/settings', component: Settings },
|
{ path: '/alerts', component: Alerts, meta: { requiresAuth: true } },
|
||||||
|
{ path: '/settings', component: Settings, meta: { requiresAuth: true } },
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@ -20,4 +22,15 @@ const router = createRouter({
|
|||||||
routes,
|
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
|
export default router
|
||||||
|
|||||||
133
frontend/src/views/Login.vue
Normal file
133
frontend/src/views/Login.vue
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user