feat: 添加用户登录认证功能
- 新增登录页面(使用 shadcn-svelte 组件) - 后端添加 JWT 认证 API (/api/auth/login, /api/auth/validate) - 用户账号通过 server/config.yaml 配置 - 前端路由保护(未登录跳转登录页) - 侧边栏显示当前用户信息 - 支持退出登录功能
This commit is contained in:
@@ -39,3 +39,22 @@ mongodb:
|
|||||||
# 清洗后数据集合
|
# 清洗后数据集合
|
||||||
cleaned: bills_cleaned
|
cleaned: bills_cleaned
|
||||||
|
|
||||||
|
# 用户认证配置
|
||||||
|
auth:
|
||||||
|
# JWT 密钥(生产环境请使用更复杂的密钥)
|
||||||
|
jwt_secret: "billai-secret-key-2026"
|
||||||
|
# Token 过期时间(小时)
|
||||||
|
token_expiry: 168 # 7天
|
||||||
|
# 用户列表
|
||||||
|
users:
|
||||||
|
- username: admin
|
||||||
|
password: admin123
|
||||||
|
name: 管理员
|
||||||
|
email: admin@billai.com
|
||||||
|
role: admin
|
||||||
|
- username: user
|
||||||
|
password: user123
|
||||||
|
name: 普通用户
|
||||||
|
email: user@billai.com
|
||||||
|
role: user
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,26 @@ type Config struct {
|
|||||||
MongoDatabase string // 数据库名称
|
MongoDatabase string // 数据库名称
|
||||||
MongoRawCollection string // 原始数据集合名称
|
MongoRawCollection string // 原始数据集合名称
|
||||||
MongoCleanedCollection string // 清洗后数据集合名称
|
MongoCleanedCollection string // 清洗后数据集合名称
|
||||||
|
|
||||||
|
// 认证配置
|
||||||
|
JWTSecret string // JWT 密钥
|
||||||
|
TokenExpiry int // Token 过期时间(小时)
|
||||||
|
Users []UserInfo // 用户列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserInfo 用户信息
|
||||||
|
type UserInfo struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"-"` // 不序列化密码
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// configFile YAML 配置文件结构
|
// configFile YAML 配置文件结构
|
||||||
type configFile struct {
|
type configFile struct {
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
Server struct {
|
Server struct {
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
} `yaml:"server"`
|
} `yaml:"server"`
|
||||||
Python struct {
|
Python struct {
|
||||||
@@ -56,6 +70,17 @@ type configFile struct {
|
|||||||
Cleaned string `yaml:"cleaned"`
|
Cleaned string `yaml:"cleaned"`
|
||||||
} `yaml:"collections"`
|
} `yaml:"collections"`
|
||||||
} `yaml:"mongodb"`
|
} `yaml:"mongodb"`
|
||||||
|
Auth struct {
|
||||||
|
JWTSecret string `yaml:"jwt_secret"`
|
||||||
|
TokenExpiry int `yaml:"token_expiry"` // 小时
|
||||||
|
Users []struct {
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Email string `yaml:"email"`
|
||||||
|
Role string `yaml:"role"`
|
||||||
|
} `yaml:"users"`
|
||||||
|
} `yaml:"auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global 全局配置实例
|
// Global 全局配置实例
|
||||||
@@ -186,6 +211,23 @@ func Load() {
|
|||||||
if cfg.MongoDB.Collections.Cleaned != "" {
|
if cfg.MongoDB.Collections.Cleaned != "" {
|
||||||
Global.MongoCleanedCollection = cfg.MongoDB.Collections.Cleaned
|
Global.MongoCleanedCollection = cfg.MongoDB.Collections.Cleaned
|
||||||
}
|
}
|
||||||
|
// 认证配置
|
||||||
|
if cfg.Auth.JWTSecret != "" {
|
||||||
|
Global.JWTSecret = cfg.Auth.JWTSecret
|
||||||
|
}
|
||||||
|
if cfg.Auth.TokenExpiry > 0 {
|
||||||
|
Global.TokenExpiry = cfg.Auth.TokenExpiry
|
||||||
|
}
|
||||||
|
// 用户列表
|
||||||
|
for _, u := range cfg.Auth.Users {
|
||||||
|
Global.Users = append(Global.Users, UserInfo{
|
||||||
|
Username: u.Username,
|
||||||
|
Password: u.Password,
|
||||||
|
Name: u.Name,
|
||||||
|
Email: u.Email,
|
||||||
|
Role: u.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 环境变量覆盖
|
// 环境变量覆盖
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/golang/snappy v0.0.1 // indirect
|
github.com/golang/snappy v0.0.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.13.6 // indirect
|
github.com/klauspost/compress v1.13.6 // indirect
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
|
|||||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
|||||||
177
server/handler/auth.go
Normal file
177
server/handler/auth.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"billai-server/config"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginRequest 登录请求
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResponse 登录响应
|
||||||
|
type LoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User config.UserInfo `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claims JWT claims
|
||||||
|
type Claims struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashPassword 对密码进行 SHA-256 哈希
|
||||||
|
func hashPassword(password string) string {
|
||||||
|
hash := sha256.Sum256([]byte(password))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 用户登录
|
||||||
|
func Login(c *gin.Context) {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "请输入用户名和密码",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找用户
|
||||||
|
var foundUser *config.UserInfo
|
||||||
|
for _, user := range config.Global.Users {
|
||||||
|
if user.Username == req.Username {
|
||||||
|
foundUser = &user
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundUser == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "用户名或密码错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码(支持明文比较和哈希比较)
|
||||||
|
passwordMatch := foundUser.Password == req.Password ||
|
||||||
|
foundUser.Password == hashPassword(req.Password)
|
||||||
|
|
||||||
|
if !passwordMatch {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "用户名或密码错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 JWT Token
|
||||||
|
expiry := config.Global.TokenExpiry
|
||||||
|
if expiry <= 0 {
|
||||||
|
expiry = 168 // 默认 7 天
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &Claims{
|
||||||
|
Username: foundUser.Username,
|
||||||
|
Name: foundUser.Name,
|
||||||
|
Role: foundUser.Role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Subject: foundUser.Username,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := config.Global.JWTSecret
|
||||||
|
if secret == "" {
|
||||||
|
secret = "billai-default-secret"
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString([]byte(secret))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "生成 Token 失败",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": LoginResponse{
|
||||||
|
Token: tokenString,
|
||||||
|
User: config.UserInfo{
|
||||||
|
Username: foundUser.Username,
|
||||||
|
Name: foundUser.Name,
|
||||||
|
Email: foundUser.Email,
|
||||||
|
Role: foundUser.Role,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken 验证 Token
|
||||||
|
func ValidateToken(c *gin.Context) {
|
||||||
|
tokenString := c.GetHeader("Authorization")
|
||||||
|
if tokenString == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "未提供 Token",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 "Bearer " 前缀
|
||||||
|
if len(tokenString) > 7 && tokenString[:7] == "Bearer " {
|
||||||
|
tokenString = tokenString[7:]
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := config.Global.JWTSecret
|
||||||
|
if secret == "" {
|
||||||
|
secret = "billai-default-secret"
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Token 无效或已过期",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Token 解析失败",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": config.UserInfo{
|
||||||
|
Username: claims.Username,
|
||||||
|
Name: claims.Name,
|
||||||
|
Role: claims.Role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -30,9 +30,9 @@ type CreateManualBillsRequest struct {
|
|||||||
|
|
||||||
// CreateManualBillsResponse 批量创建手动账单响应
|
// CreateManualBillsResponse 批量创建手动账单响应
|
||||||
type CreateManualBillsResponse struct {
|
type CreateManualBillsResponse struct {
|
||||||
Result bool `json:"result"`
|
Result bool `json:"result"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Data *CreateManualBillsData `json:"data,omitempty"`
|
Data *CreateManualBillsData `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateManualBillsData 批量创建手动账单数据
|
// CreateManualBillsData 批量创建手动账单数据
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ func healthCheck(version string) gin.HandlerFunc {
|
|||||||
func setupAPIRoutes(r *gin.Engine) {
|
func setupAPIRoutes(r *gin.Engine) {
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
|
// 认证相关(无需登录)
|
||||||
|
api.POST("/auth/login", handler.Login)
|
||||||
|
api.GET("/auth/validate", handler.ValidateToken)
|
||||||
|
|
||||||
// 账单上传
|
// 账单上传
|
||||||
api.POST("/upload", handler.Upload)
|
api.POST("/upload", handler.Upload)
|
||||||
|
|
||||||
|
|||||||
179
web/src/lib/stores/auth.ts
Normal file
179
web/src/lib/stores/auth.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 基础路径
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
// 从 localStorage 恢复状态
|
||||||
|
function getInitialState(): AuthState {
|
||||||
|
if (browser) {
|
||||||
|
const stored = localStorage.getItem('auth');
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(stored);
|
||||||
|
if (state.isAuthenticated && state.token) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
localStorage.removeItem('auth');
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('auth');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAuthStore() {
|
||||||
|
const { subscribe, set } = writable<AuthState>(getInitialState());
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
// 通过后端 API 登录验证
|
||||||
|
loginAsync: async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
return { success: false, error: result.error || '登录失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, user } = result.data;
|
||||||
|
|
||||||
|
const state: AuthState = {
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: {
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('auth', JSON.stringify(state));
|
||||||
|
}
|
||||||
|
set(state);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return { success: false, error: '网络错误,请稍后重试' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 验证 token 是否有效
|
||||||
|
validateToken: async (): Promise<boolean> => {
|
||||||
|
if (!browser) return false;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem('auth');
|
||||||
|
if (!stored) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(stored);
|
||||||
|
if (!state.token) return false;
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/auth/validate`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${state.token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
localStorage.removeItem('auth');
|
||||||
|
set({ isAuthenticated: false, user: null, token: null });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 兼容旧的同步登录方法
|
||||||
|
login: (user: User, token: string) => {
|
||||||
|
const state: AuthState = {
|
||||||
|
isAuthenticated: true,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('auth', JSON.stringify(state));
|
||||||
|
}
|
||||||
|
set(state);
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
if (browser) {
|
||||||
|
localStorage.removeItem('auth');
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查是否已登录
|
||||||
|
check: (): boolean => {
|
||||||
|
if (browser) {
|
||||||
|
const stored = localStorage.getItem('auth');
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(stored);
|
||||||
|
return state.isAuthenticated === true && !!state.token;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取当前 token
|
||||||
|
getToken: (): string | null => {
|
||||||
|
if (browser) {
|
||||||
|
const stored = localStorage.getItem('auth');
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(stored);
|
||||||
|
return state.token;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = createAuthStore();
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { checkHealth } from '$lib/api';
|
import { checkHealth } from '$lib/api';
|
||||||
|
import { auth, type User as AuthUser } from '$lib/stores/auth';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Avatar from '$lib/components/ui/avatar';
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
@@ -37,6 +40,17 @@
|
|||||||
let themeMode = $state<ThemeMode>('system');
|
let themeMode = $state<ThemeMode>('system');
|
||||||
let serverOnline = $state(true);
|
let serverOnline = $state(true);
|
||||||
let checkingHealth = $state(true);
|
let checkingHealth = $state(true);
|
||||||
|
let isAuthenticated = $state(false);
|
||||||
|
let currentUser = $state<AuthUser | null>(null);
|
||||||
|
|
||||||
|
// 订阅认证状态
|
||||||
|
$effect(() => {
|
||||||
|
const unsubscribe = auth.subscribe(state => {
|
||||||
|
isAuthenticated = state.isAuthenticated;
|
||||||
|
currentUser = state.user;
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
|
||||||
async function checkServerHealth() {
|
async function checkServerHealth() {
|
||||||
checkingHealth = true;
|
checkingHealth = true;
|
||||||
@@ -44,10 +58,23 @@
|
|||||||
checkingHealth = false;
|
checkingHealth = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
function handleLogout() {
|
||||||
|
auth.logout();
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
themeMode = loadThemeFromStorage();
|
themeMode = loadThemeFromStorage();
|
||||||
applyThemeToDocument(themeMode);
|
applyThemeToDocument(themeMode);
|
||||||
|
|
||||||
|
// 检查登录状态,未登录则跳转到登录页
|
||||||
|
const pathname = $page.url.pathname;
|
||||||
|
if (!auth.check() && pathname !== '/login' && pathname !== '/health') {
|
||||||
|
goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查服务器状态
|
// 检查服务器状态
|
||||||
checkServerHealth();
|
checkServerHealth();
|
||||||
// 每 30 秒检查一次
|
// 每 30 秒检查一次
|
||||||
@@ -83,12 +110,12 @@
|
|||||||
{ href: '/help', label: '帮助', icon: HelpCircle },
|
{ href: '/help', label: '帮助', icon: HelpCircle },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 用户数据
|
// 用户数据(从认证状态中获取)
|
||||||
const user = {
|
let user = $derived({
|
||||||
name: '用户',
|
name: currentUser?.username || '用户',
|
||||||
email: 'user@example.com',
|
email: currentUser?.email || 'user@example.com',
|
||||||
avatar: ''
|
avatar: currentUser?.avatar || ''
|
||||||
};
|
});
|
||||||
|
|
||||||
function isActive(href: string, pathname: string): boolean {
|
function isActive(href: string, pathname: string): boolean {
|
||||||
if (href === '/') return pathname === '/';
|
if (href === '/') return pathname === '/';
|
||||||
@@ -106,8 +133,14 @@
|
|||||||
};
|
};
|
||||||
return titles[pathname] || 'BillAI';
|
return titles[pathname] || 'BillAI';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否是登录页面(登录页不显示侧边栏)
|
||||||
|
let isLoginPage = $derived($page.url.pathname === '/login');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if isLoginPage}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
<Sidebar.Provider>
|
<Sidebar.Provider>
|
||||||
<Sidebar.Root collapsible="offcanvas">
|
<Sidebar.Root collapsible="offcanvas">
|
||||||
<!-- Header: Logo + App Name -->
|
<!-- Header: Logo + App Name -->
|
||||||
@@ -249,7 +282,7 @@
|
|||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item>
|
<DropdownMenu.Item onclick={handleLogout}>
|
||||||
<LogOut class="mr-2 size-4" />
|
<LogOut class="mr-2 size-4" />
|
||||||
退出登录
|
退出登录
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -266,7 +299,7 @@
|
|||||||
<Sidebar.Trigger class="-ml-1" />
|
<Sidebar.Trigger class="-ml-1" />
|
||||||
<Separator orientation="vertical" class="mr-2 h-4" />
|
<Separator orientation="vertical" class="mr-2 h-4" />
|
||||||
<h1 class="text-lg font-semibold">{getPageTitle($page.url.pathname)}</h1>
|
<h1 class="text-lg font-semibold">{getPageTitle($page.url.pathname)}</h1>
|
||||||
<div class="flex-1" />
|
<div class="flex-1"></div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity"
|
class="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity"
|
||||||
@@ -299,3 +332,4 @@
|
|||||||
</main>
|
</main>
|
||||||
</Sidebar.Inset>
|
</Sidebar.Inset>
|
||||||
</Sidebar.Provider>
|
</Sidebar.Provider>
|
||||||
|
{/if}
|
||||||
|
|||||||
165
web/src/routes/login/+page.svelte
Normal file
165
web/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { auth } from '$lib/stores/auth';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import Wallet from '@lucide/svelte/icons/wallet';
|
||||||
|
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||||
|
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||||
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
|
import EyeOff from '@lucide/svelte/icons/eye-off';
|
||||||
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
|
|
||||||
|
let username = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let showPassword = $state(false);
|
||||||
|
|
||||||
|
async function handleLogin(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!username.trim() || !password.trim()) {
|
||||||
|
errorMessage = '请输入用户名和密码';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用验证登录
|
||||||
|
const result = await auth.loginAsync(username.trim(), password);
|
||||||
|
|
||||||
|
console.log('Login result:', result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// 跳转到首页
|
||||||
|
await goto('/');
|
||||||
|
} else {
|
||||||
|
errorMessage = result.error || '登录失败';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
errorMessage = '登录过程中发生错误';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePasswordVisibility() {
|
||||||
|
showPassword = !showPassword;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>登录 - BillAI</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<!-- Logo 和标题 -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-orange-500 to-amber-500 text-white mb-4 shadow-lg">
|
||||||
|
<Wallet class="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">BillAI</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">智能账单管理系统</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录卡片 -->
|
||||||
|
<Card.Root class="shadow-xl border-0">
|
||||||
|
<Card.Header class="space-y-1 pb-4">
|
||||||
|
<Card.Title class="text-2xl text-center">欢迎回来</Card.Title>
|
||||||
|
<Card.Description class="text-center">
|
||||||
|
请输入您的账号登录系统
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<form onsubmit={handleLogin} class="space-y-4">
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
{#if errorMessage}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<AlertCircle class="h-4 w-4 shrink-0" />
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 用户名 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="username">用户名</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
bind:value={username}
|
||||||
|
disabled={isLoading}
|
||||||
|
autocomplete="username"
|
||||||
|
class="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 密码 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="password">密码</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
bind:value={password}
|
||||||
|
disabled={isLoading}
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="h-11 pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onclick={togglePasswordVisibility}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{#if showPassword}
|
||||||
|
<EyeOff class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 记住我 & 忘记密码 -->
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" class="rounded border-gray-300" />
|
||||||
|
<span class="text-muted-foreground">记住我</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="text-primary hover:underline">忘记密码?</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录按钮 -->
|
||||||
|
<Button type="submit" class="w-full h-11" disabled={isLoading}>
|
||||||
|
{#if isLoading}
|
||||||
|
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
登录中...
|
||||||
|
{:else}
|
||||||
|
登录
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- 安全提示 -->
|
||||||
|
<div class="flex items-center justify-center gap-2 mt-4 text-xs text-muted-foreground">
|
||||||
|
<Lock class="h-3 w-3" />
|
||||||
|
<span>密码已加密传输</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部信息 -->
|
||||||
|
<p class="text-center text-sm text-muted-foreground mt-4">
|
||||||
|
© 2026 BillAI. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user