Compare commits
2 commits
1526c27b49
...
7a33038af8
Author | SHA1 | Date | |
---|---|---|---|
7a33038af8 | |||
79912925db |
25 changed files with 1482 additions and 220 deletions
|
@ -216,14 +216,23 @@ user_me:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
minLength: 3
|
||||||
|
maxLength: 32
|
||||||
|
display_name:
|
||||||
|
type: string
|
||||||
|
maxLength: 64
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
current_password:
|
current_password:
|
||||||
type: string
|
type: string
|
||||||
|
description: 当修改密码时必填
|
||||||
new_password:
|
new_password:
|
||||||
type: string
|
type: string
|
||||||
minLength: 8
|
minLength: 8
|
||||||
|
description: 新密码,如果要修改密码则必填
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: 用户信息更新成功
|
description: 用户信息更新成功
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type UpdateCurrentUserRequest struct {
|
type UpdateCurrentUserRequest struct {
|
||||||
|
Username string `json:"username,omitempty" binding:"omitempty,min=3,max=32"`
|
||||||
Email string `json:"email,omitempty" binding:"omitempty,email"`
|
Email string `json:"email,omitempty" binding:"omitempty,email"`
|
||||||
CurrentPassword string `json:"current_password,omitempty"`
|
CurrentPassword string `json:"current_password,omitempty"`
|
||||||
NewPassword string `json:"new_password,omitempty" binding:"omitempty,min=8"`
|
NewPassword string `json:"new_password,omitempty" binding:"omitempty,min=8"`
|
||||||
|
@ -211,12 +212,20 @@ func (h *Handler) GetCurrentUser(c *gin.Context) {
|
||||||
// UpdateCurrentUser updates the current user's information
|
// UpdateCurrentUser updates the current user's information
|
||||||
func (h *Handler) UpdateCurrentUser(c *gin.Context) {
|
func (h *Handler) UpdateCurrentUser(c *gin.Context) {
|
||||||
// 从上下文中获取用户ID(由认证中间件设置)
|
// 从上下文中获取用户ID(由认证中间件设置)
|
||||||
userID, exists := c.Get("user_id")
|
userIDStr, exists := c.Get("user_id")
|
||||||
if !exists {
|
if !exists {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将用户ID转换为整数
|
||||||
|
userID, err := strconv.Atoi(userIDStr.(string))
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to convert user ID to integer")
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user ID format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req UpdateCurrentUserRequest
|
var req UpdateCurrentUserRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
@ -231,20 +240,25 @@ func (h *Handler) UpdateCurrentUser(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证当前密码
|
// 验证当前密码
|
||||||
if err := h.service.VerifyPassword(c.Request.Context(), userID.(int), req.CurrentPassword); err != nil {
|
if err := h.service.VerifyPassword(c.Request.Context(), userID, req.CurrentPassword); err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid current password"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid current password"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新用户信息
|
// 更新用户信息
|
||||||
user, err := h.service.UpdateUser(c.Request.Context(), userID.(int), &types.UpdateUserInput{
|
user, err := h.service.UpdateUser(c.Request.Context(), userID, &types.UpdateUserInput{
|
||||||
|
Username: req.Username,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
Password: req.NewPassword,
|
Password: req.NewPassword,
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to update user")
|
log.Error().Err(err).Msg("Failed to update user")
|
||||||
|
if err.Error() == "username already taken" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Username already taken"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user information"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user information"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,6 +40,25 @@ func (s *serviceImpl) UpdateUser(ctx context.Context, userID int, input *types.U
|
||||||
// Start building the update
|
// Start building the update
|
||||||
update := s.client.User.UpdateOneID(userID)
|
update := s.client.User.UpdateOneID(userID)
|
||||||
|
|
||||||
|
// Update username if provided
|
||||||
|
if input.Username != "" {
|
||||||
|
// Check if username is already taken
|
||||||
|
exists, err := s.client.User.Query().
|
||||||
|
Where(user.And(
|
||||||
|
user.UsernameEQ(input.Username),
|
||||||
|
user.IDNEQ(userID),
|
||||||
|
)).
|
||||||
|
Exist(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to check username availability")
|
||||||
|
return nil, fmt.Errorf("failed to check username availability: %w", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil, fmt.Errorf("username already taken")
|
||||||
|
}
|
||||||
|
update.SetUsername(input.Username)
|
||||||
|
}
|
||||||
|
|
||||||
// Update email if provided
|
// Update email if provided
|
||||||
if input.Email != "" {
|
if input.Email != "" {
|
||||||
update.SetEmail(input.Email)
|
update.SetEmail(input.Email)
|
||||||
|
|
|
@ -2,6 +2,7 @@ package types
|
||||||
|
|
||||||
// UpdateUserInput defines the input for updating a user
|
// UpdateUserInput defines the input for updating a user
|
||||||
type UpdateUserInput struct {
|
type UpdateUserInput struct {
|
||||||
|
Username string
|
||||||
Email string
|
Email string
|
||||||
Password string
|
Password string
|
||||||
Role string
|
Role string
|
||||||
|
|
|
@ -14,19 +14,23 @@
|
||||||
"search": "Search"
|
"search": "Search"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"light": "Light Mode",
|
"light": "Light",
|
||||||
"dark": "Dark Mode",
|
"dark": "Dark",
|
||||||
"system": "System Mode"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright": "TSS Rocks. All rights reserved."
|
"copyright": "TSS Rocks. All rights reserved."
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"common": {
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"upload": "Upload",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"published": "Published",
|
"published": "Published",
|
||||||
|
@ -43,19 +47,68 @@
|
||||||
"lastLogin": "Last Login",
|
"lastLogin": "Last Login",
|
||||||
"joinDate": "Join Date",
|
"joinDate": "Join Date",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"logout": "Logout"
|
"logout": "Logout",
|
||||||
|
"language": "Language",
|
||||||
|
"theme": {
|
||||||
|
"light": "Light Mode",
|
||||||
|
"dark": "Dark Mode",
|
||||||
|
"system": "System"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"totalPosts": "Total Posts",
|
||||||
|
"totalCategories": "Total Categories",
|
||||||
|
"totalUsers": "Total Users",
|
||||||
|
"totalContributors": "Total Contributors"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Admin Login",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"remember": "Remember me",
|
||||||
|
"submit": "Sign in",
|
||||||
|
"loading": "Signing in...",
|
||||||
|
"error": {
|
||||||
|
"failed": "Login failed",
|
||||||
|
"retry": "Login failed, please try again later"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"posts": "Posts",
|
"posts": "Posts",
|
||||||
|
"daily": "Daily Quotes",
|
||||||
|
"medias": "Media",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"contributors": "Contributors"
|
"contributors": "Contributors",
|
||||||
|
"settings": "Settings"
|
||||||
|
},
|
||||||
|
"daily": {
|
||||||
|
"title": "Title",
|
||||||
|
"publishDate": "Publish Date",
|
||||||
|
"status": "Status"
|
||||||
|
},
|
||||||
|
"medias": {
|
||||||
|
"name": "File Name",
|
||||||
|
"type": "Type",
|
||||||
|
"size": "Size",
|
||||||
|
"uploadDate": "Upload Date"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"admin": "Administrator",
|
"admin": "Administrator",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"contributor": "Contributor"
|
"contributor": "Contributor"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"username": "Username",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"email": "Email",
|
||||||
|
"changePassword": "Change Password",
|
||||||
|
"currentPassword": "Current Password",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"passwordMismatch": "Passwords do not match",
|
||||||
|
"passwordTooShort": "Password must be at least 8 characters long"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,8 @@
|
||||||
"search": "搜索"
|
"search": "搜索"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"light": "浅色模式",
|
"light": "浅色",
|
||||||
"dark": "深色模式",
|
"dark": "深色",
|
||||||
"system": "跟随系统"
|
"system": "跟随系统"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
|
@ -23,10 +23,12 @@
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"common": {
|
"common": {
|
||||||
|
"loading": "正在加载...",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"create": "新建",
|
"create": "新建",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
|
"upload": "上传",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"published": "已发布",
|
"published": "已发布",
|
||||||
|
@ -43,19 +45,64 @@
|
||||||
"lastLogin": "最后登录",
|
"lastLogin": "最后登录",
|
||||||
"joinDate": "加入时间",
|
"joinDate": "加入时间",
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
"logout": "退出"
|
"logout": "退出登录",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中..."
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"totalPosts": "文章总数",
|
||||||
|
"totalCategories": "分类总数",
|
||||||
|
"totalUsers": "用户总数",
|
||||||
|
"totalContributors": "贡献者总数"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "管理员登录",
|
||||||
|
"username": "用户名",
|
||||||
|
"password": "密码",
|
||||||
|
"remember": "记住我",
|
||||||
|
"submit": "登录",
|
||||||
|
"loading": "登录中...",
|
||||||
|
"error": {
|
||||||
|
"failed": "登录失败",
|
||||||
|
"retry": "登录失败,请稍后重试"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "仪表盘",
|
"dashboard": "仪表板",
|
||||||
"posts": "文章",
|
"posts": "文章管理",
|
||||||
"categories": "分类",
|
"daily": "每日一句",
|
||||||
"users": "用户",
|
"medias": "媒体管理",
|
||||||
"contributors": "作者"
|
"categories": "分类管理",
|
||||||
|
"users": "用户管理",
|
||||||
|
"contributors": "贡献者管理",
|
||||||
|
"settings": "个人设置"
|
||||||
|
},
|
||||||
|
"daily": {
|
||||||
|
"title": "标题",
|
||||||
|
"publishDate": "发布日期",
|
||||||
|
"status": "状态"
|
||||||
|
},
|
||||||
|
"medias": {
|
||||||
|
"name": "文件名",
|
||||||
|
"type": "类型",
|
||||||
|
"size": "大小",
|
||||||
|
"uploadDate": "上传日期"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"admin": "管理员",
|
"admin": "管理员",
|
||||||
"user": "普通用户",
|
"user": "普通用户",
|
||||||
"contributor": "作者"
|
"contributor": "作者"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"username": "用户名",
|
||||||
|
"displayName": "显示名称",
|
||||||
|
"email": "邮箱",
|
||||||
|
"changePassword": "修改密码",
|
||||||
|
"currentPassword": "当前密码",
|
||||||
|
"newPassword": "新密码",
|
||||||
|
"confirmPassword": "确认密码",
|
||||||
|
"passwordMismatch": "两次输入的密码不一致",
|
||||||
|
"passwordTooShort": "密码长度不能少于8个字符"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,8 @@
|
||||||
"search": "搜尋"
|
"search": "搜尋"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"light": "淺色模式",
|
"light": "淺色",
|
||||||
"dark": "深色模式",
|
"dark": "深色",
|
||||||
"system": "跟隨系統"
|
"system": "跟隨系統"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
|
@ -23,10 +23,12 @@
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"common": {
|
"common": {
|
||||||
|
"loading": "正在載入...",
|
||||||
"search": "搜尋",
|
"search": "搜尋",
|
||||||
"create": "新建",
|
"create": "新建",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
|
"upload": "上傳",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"published": "已發布",
|
"published": "已發布",
|
||||||
|
@ -43,19 +45,70 @@
|
||||||
"lastLogin": "最後登入",
|
"lastLogin": "最後登入",
|
||||||
"joinDate": "加入時間",
|
"joinDate": "加入時間",
|
||||||
"username": "用戶名",
|
"username": "用戶名",
|
||||||
"logout": "退出"
|
"logout": "退出登錄",
|
||||||
|
"language": "語言",
|
||||||
|
"theme": {
|
||||||
|
"light": "淺色模式",
|
||||||
|
"dark": "深色模式",
|
||||||
|
"system": "跟隨系統"
|
||||||
|
},
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中..."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "儀表板",
|
"dashboard": "儀表板",
|
||||||
"posts": "文章",
|
"posts": "文章管理",
|
||||||
"categories": "分類",
|
"daily": "每日一句",
|
||||||
"users": "用戶",
|
"medias": "媒體管理",
|
||||||
"contributors": "作者"
|
"categories": "分類管理",
|
||||||
|
"users": "用戶管理",
|
||||||
|
"contributors": "貢獻者管理",
|
||||||
|
"settings": "個人設置"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"totalPosts": "文章總數",
|
||||||
|
"totalCategories": "分類總數",
|
||||||
|
"totalUsers": "用戶總數",
|
||||||
|
"totalContributors": "貢獻者總數"
|
||||||
|
},
|
||||||
|
"daily": {
|
||||||
|
"title": "標題",
|
||||||
|
"publishDate": "發布日期",
|
||||||
|
"status": "狀態"
|
||||||
|
},
|
||||||
|
"medias": {
|
||||||
|
"name": "檔案名",
|
||||||
|
"type": "類型",
|
||||||
|
"size": "大小",
|
||||||
|
"uploadDate": "上傳日期"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "管理員登錄",
|
||||||
|
"username": "用戶名",
|
||||||
|
"password": "密碼",
|
||||||
|
"remember": "記住我",
|
||||||
|
"submit": "登錄",
|
||||||
|
"loading": "登錄中...",
|
||||||
|
"error": {
|
||||||
|
"failed": "登錄失敗",
|
||||||
|
"retry": "登錄失敗,請稍後重試"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"admin": "管理員",
|
"admin": "管理員",
|
||||||
"user": "普通用戶",
|
"user": "普通用戶",
|
||||||
"contributor": "作者"
|
"contributor": "作者"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"username": "用戶名",
|
||||||
|
"displayName": "顯示名稱",
|
||||||
|
"email": "郵箱",
|
||||||
|
"changePassword": "修改密碼",
|
||||||
|
"currentPassword": "當前密碼",
|
||||||
|
"newPassword": "新密碼",
|
||||||
|
"confirmPassword": "確認密碼",
|
||||||
|
"passwordMismatch": "兩次輸入的密碼不一致",
|
||||||
|
"passwordTooShort": "密碼長度不能少於8個字符"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,63 +1,19 @@
|
||||||
import React from 'react';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { Suspense } from 'react';
|
||||||
import { Header } from './components/layout/Header';
|
|
||||||
import { Suspense, lazy } from 'react';
|
|
||||||
import Footer from './components/Footer';
|
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
import { UserProvider } from './contexts/UserContext';
|
||||||
// Lazy load pages
|
import router from './router';
|
||||||
const Home = lazy(() => import('./pages/Home'));
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
const Daily = lazy(() => import('./pages/Daily'));
|
|
||||||
const Article = lazy(() => import('./pages/Article'));
|
|
||||||
const AdminLayout = lazy(() => import('./pages/admin/layout/AdminLayout'));
|
|
||||||
|
|
||||||
// 管理页面懒加载
|
|
||||||
const PostsManagement = lazy(() => import('./pages/admin/posts/PostsManagement'));
|
|
||||||
const CategoriesManagement = lazy(() => import('./pages/admin/categories/CategoriesManagement'));
|
|
||||||
const UsersManagement = lazy(() => import('./pages/admin/users/UsersManagement'));
|
|
||||||
const ContributorsManagement = lazy(() => import('./pages/admin/contributors/ContributorsManagement'));
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<UserProvider>
|
||||||
<div className="flex flex-col min-h-screen bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100">
|
<RouterProvider router={router} />
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
</UserProvider>
|
||||||
<Routes>
|
|
||||||
{/* Admin routes */}
|
|
||||||
<Route path="/admin" element={<AdminLayout />}>
|
|
||||||
<Route path="posts" element={<PostsManagement />} />
|
|
||||||
<Route path="categories" element={<CategoriesManagement />} />
|
|
||||||
<Route path="users" element={<UsersManagement />} />
|
|
||||||
<Route path="contributors" element={<ContributorsManagement />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Public routes */}
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<>
|
|
||||||
<Header />
|
|
||||||
{/* 页眉分隔线 */}
|
|
||||||
<div className="w-[95%] mx-auto">
|
|
||||||
<div className="border-t-2 border-gray-900 dark:border-gray-100 w-full mb-2" />
|
|
||||||
</div>
|
|
||||||
<main className="flex-1 w-[95%] mx-auto py-8">
|
|
||||||
<Routes>
|
|
||||||
<Route index element={<Home />} />
|
|
||||||
<Route path="/daily" element={<Daily />} />
|
|
||||||
<Route path="/posts/:articleId" element={<Article />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</BrowserRouter>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
32
frontend/src/components/LoadingSpinner.tsx
Normal file
32
frontend/src/components/LoadingSpinner.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
fullScreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadingSpinner({ fullScreen = false }: LoadingSpinnerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="w-10 h-10 border-4 border-indigo-200 dark:border-indigo-900 border-t-indigo-500 dark:border-t-indigo-400 rounded-full animate-spin" />
|
||||||
|
<div className="text-slate-600 dark:text-slate-300 text-sm font-medium">
|
||||||
|
{t('admin.common.loading')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullScreen) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm z-50">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
27
frontend/src/components/admin/TableActions.tsx
Normal file
27
frontend/src/components/admin/TableActions.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface TableActionsProps {
|
||||||
|
/**
|
||||||
|
* 按钮文本的翻译键,默认为 'create'
|
||||||
|
*/
|
||||||
|
actionKey?: 'create' | 'upload';
|
||||||
|
/**
|
||||||
|
* 点击按钮时的回调函数
|
||||||
|
*/
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TableActions({ actionKey = 'create', onClick }: TableActionsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="px-4 py-2 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors"
|
||||||
|
>
|
||||||
|
{t(`admin.common.${actionKey}`)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -12,7 +12,7 @@ const LANGUAGES = [
|
||||||
{ code: 'zh-Hant', nativeName: '繁體中文' },
|
{ code: 'zh-Hant', nativeName: '繁體中文' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Header() {
|
function Header() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
@ -238,3 +238,5 @@ export function Header() {
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
|
|
81
frontend/src/contexts/UserContext.tsx
Normal file
81
frontend/src/contexts/UserContext.tsx
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
display_name?: string;
|
||||||
|
email: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
edges: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserContextType {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserContext = createContext<UserContextType>({
|
||||||
|
user: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fetchUser: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function UserProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
console.log('Fetching current user info...');
|
||||||
|
const response = await fetch('/api/v1/users/me', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch user info: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('API Response:', result);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
console.log('Setting user data:', result);
|
||||||
|
setUser(result);
|
||||||
|
} else {
|
||||||
|
throw new Error('No user data received');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching user info:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch user info');
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem('token')) {
|
||||||
|
fetchUser();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={{ user, loading, error, fetchUser }}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser() {
|
||||||
|
return useContext(UserContext);
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
export default function Article() {
|
export default function Article() {
|
||||||
const { articleId } = useParams();
|
const { articleId } = useParams();
|
||||||
|
@ -9,13 +10,19 @@ export default function Article() {
|
||||||
content: string;
|
content: string;
|
||||||
metadata: any;
|
metadata: any;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// In a real application, we would fetch the article content here
|
// In a real application, we would fetch the article content here
|
||||||
// based on the articleId and current language
|
// based on the articleId and current language
|
||||||
console.log(`Fetching article ${articleId} in ${i18n.language}`);
|
console.log(`Fetching article ${articleId} in ${i18n.language}`);
|
||||||
|
setLoading(false);
|
||||||
}, [articleId, i18n.language]);
|
}, [articleId, i18n.language]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
import { FC, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||||
import Table from '../../../components/admin/Table';
|
import Table from '../../../components/admin/Table';
|
||||||
|
import TableActions from '../../../components/admin/TableActions';
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
slug: string;
|
||||||
|
postCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CategoriesManagement: FC = () => {
|
const CategoriesManagement: FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 这里后续会通过 API 获取数据
|
// 这里后续会通过 API 获取数据
|
||||||
|
@ -19,7 +21,8 @@ const CategoriesManagement: FC = () => {
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
name: '新闻',
|
name: '新闻',
|
||||||
description: '新闻分类',
|
slug: 'news',
|
||||||
|
postCount: 10,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -31,14 +34,22 @@ const CategoriesManagement: FC = () => {
|
||||||
console.log('Delete category:', category);
|
console.log('Delete category:', category);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
// TODO: 实现创建分类的逻辑
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name' as keyof Category,
|
||||||
title: t('admin.common.name'),
|
title: t('admin.common.name'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'description',
|
key: 'slug' as keyof Category,
|
||||||
title: t('admin.common.description'),
|
title: t('admin.common.slug'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'postCount' as keyof Category,
|
||||||
|
title: t('admin.common.postCount'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -56,15 +67,12 @@ const CategoriesManagement: FC = () => {
|
||||||
/>
|
/>
|
||||||
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
||||||
</div>
|
</div>
|
||||||
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
|
<TableActions onClick={handleCreate} />
|
||||||
<RiAddLine className="text-xl" />
|
|
||||||
<span>{t('admin.common.create')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Table
|
<Table<Category>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={categories}
|
data={categories}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|
|
@ -2,13 +2,15 @@ import { FC, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||||
import Table from '../../../components/admin/Table';
|
import Table from '../../../components/admin/Table';
|
||||||
|
import TableActions from '../../../components/admin/TableActions';
|
||||||
|
|
||||||
interface Contributor {
|
interface Contributor {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
bio: string;
|
email: string;
|
||||||
articles: number;
|
role: 'editor' | 'contributor';
|
||||||
joinDate: string;
|
status: 'active' | 'inactive' | 'banned';
|
||||||
|
postCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContributorsManagement: FC = () => {
|
const ContributorsManagement: FC = () => {
|
||||||
|
@ -21,9 +23,10 @@ const ContributorsManagement: FC = () => {
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
name: '李四',
|
name: '李四',
|
||||||
bio: '这是李四的简介',
|
email: 'lisi@example.com',
|
||||||
articles: 5,
|
role: 'contributor',
|
||||||
joinDate: '2024-02-20',
|
status: 'active',
|
||||||
|
postCount: 5,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -37,28 +40,55 @@ const ContributorsManagement: FC = () => {
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name' as keyof Contributor,
|
||||||
title: t('admin.common.name'),
|
title: t('admin.contributors.name'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'bio',
|
key: 'email' as keyof Contributor,
|
||||||
title: t('admin.common.bio'),
|
title: t('admin.contributors.email'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'articles',
|
key: 'role' as keyof Contributor,
|
||||||
title: t('admin.common.articles'),
|
title: t('admin.contributors.role'),
|
||||||
render: (value: number) => (
|
render: (value: Contributor['role']) => (
|
||||||
<span className="px-3 py-1 bg-stone-100 dark:bg-stone-700 text-stone-600 dark:text-stone-300 rounded-md text-sm">
|
<span
|
||||||
{value}
|
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
|
value === 'editor'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||||
|
: 'bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.roles.${value}`)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'joinDate',
|
key: 'status' as keyof Contributor,
|
||||||
title: t('admin.common.joinDate'),
|
title: t('admin.contributors.status'),
|
||||||
|
render: (value: Contributor['status']) => (
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
|
value === 'active'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: value === 'inactive'
|
||||||
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.common.${value}`)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'postCount' as keyof Contributor,
|
||||||
|
title: t('admin.contributors.postCount'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
// TODO: 实现创建贡献者的逻辑
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="divide-y divide-stone-200 dark:divide-stone-700">
|
<div className="divide-y divide-stone-200 dark:divide-stone-700">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
@ -73,15 +103,12 @@ const ContributorsManagement: FC = () => {
|
||||||
/>
|
/>
|
||||||
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
||||||
</div>
|
</div>
|
||||||
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
|
<TableActions onClick={handleCreate} />
|
||||||
<RiAddLine className="text-xl" />
|
|
||||||
<span>{t('admin.common.create')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Table
|
<Table<Contributor>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={contributors}
|
data={contributors}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
@ -92,10 +119,11 @@ const ContributorsManagement: FC = () => {
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-stone-200 dark:border-stone-700">
|
<tr className="border-b border-stone-200 dark:border-stone-700">
|
||||||
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tl-md">{t('admin.common.name')}</th>
|
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tl-md">{t('admin.contributors.name')}</th>
|
||||||
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.common.bio')}</th>
|
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.contributors.email')}</th>
|
||||||
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.common.articles')}</th>
|
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.contributors.role')}</th>
|
||||||
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.common.joinDate')}</th>
|
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.contributors.status')}</th>
|
||||||
|
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.contributors.postCount')}</th>
|
||||||
<th className="text-right py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
|
<th className="text-right py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -103,13 +131,32 @@ const ContributorsManagement: FC = () => {
|
||||||
{data.map((contributor) => (
|
{data.map((contributor) => (
|
||||||
<tr key={contributor.id} className="border-b border-stone-200 dark:border-stone-700 last:border-0">
|
<tr key={contributor.id} className="border-b border-stone-200 dark:border-stone-700 last:border-0">
|
||||||
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.name}</td>
|
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.name}</td>
|
||||||
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.bio}</td>
|
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.email}</td>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
<span className="px-3 py-1 bg-stone-100 dark:bg-stone-700 text-stone-600 dark:text-stone-300 rounded-md text-sm">
|
<span
|
||||||
{contributor.articles}
|
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
|
contributor.role === 'editor'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||||
|
: 'bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.roles.${contributor.role}`)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.joinDate}</td>
|
<td className="py-4 px-6">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
|
contributor.status === 'active'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: contributor.status === 'inactive'
|
||||||
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.common.${contributor.status}`)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.postCount}</td>
|
||||||
<td className="py-4 px-6 text-right">
|
<td className="py-4 px-6 text-right">
|
||||||
<button
|
<button
|
||||||
className="text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-200 px-2 py-1 rounded-md transition-colors"
|
className="text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-200 px-2 py-1 rounded-md transition-colors"
|
||||||
|
|
47
frontend/src/pages/admin/daily/DailyManagement.tsx
Normal file
47
frontend/src/pages/admin/daily/DailyManagement.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Table from '../../../components/admin/Table';
|
||||||
|
import TableActions from '../../../components/admin/TableActions';
|
||||||
|
|
||||||
|
interface Daily {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
publishDate: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DailyManagement() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('admin.daily.title'),
|
||||||
|
key: 'title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.daily.publishDate'),
|
||||||
|
key: 'publishDate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.daily.status'),
|
||||||
|
key: 'status',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
// TODO: 实现创建每日一句的逻辑
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<TableActions onClick={handleCreate} />
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={[]}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
51
frontend/src/pages/admin/dashboard/Dashboard.tsx
Normal file
51
frontend/src/pages/admin/dashboard/Dashboard.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* 文章统计 */}
|
||||||
|
<div className="bg-white dark:bg-slate-700/50 rounded-lg p-4 shadow-sm">
|
||||||
|
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||||
|
{t('admin.dashboard.totalPosts')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分类统计 */}
|
||||||
|
<div className="bg-white dark:bg-slate-700/50 rounded-lg p-4 shadow-sm">
|
||||||
|
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||||
|
{t('admin.dashboard.totalCategories')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用户统计 */}
|
||||||
|
<div className="bg-white dark:bg-slate-700/50 rounded-lg p-4 shadow-sm">
|
||||||
|
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||||
|
{t('admin.dashboard.totalUsers')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 贡献者统计 */}
|
||||||
|
<div className="bg-white dark:bg-slate-700/50 rounded-lg p-4 shadow-sm">
|
||||||
|
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||||
|
{t('admin.dashboard.totalContributors')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { FC } from 'react';
|
import { FC, useState, useEffect } from 'react';
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
RiFileTextLine,
|
RiFileTextLine,
|
||||||
|
@ -10,18 +10,26 @@ import {
|
||||||
RiSunLine,
|
RiSunLine,
|
||||||
RiMoonLine,
|
RiMoonLine,
|
||||||
RiComputerLine,
|
RiComputerLine,
|
||||||
RiGlobalLine
|
RiGlobalLine,
|
||||||
|
RiCalendarLine,
|
||||||
|
RiImageLine,
|
||||||
|
RiSettings3Line,
|
||||||
} from 'react-icons/ri';
|
} from 'react-icons/ri';
|
||||||
import { useTheme } from '../../../hooks/useTheme';
|
import { useTheme } from '../../../hooks/useTheme';
|
||||||
import type { Theme } from '../../../hooks/useTheme';
|
import { Suspense } from 'react';
|
||||||
|
import LoadingSpinner from '../../../components/LoadingSpinner';
|
||||||
|
import { useUser } from '../../../contexts/UserContext';
|
||||||
|
|
||||||
interface AdminLayoutProps {}
|
interface AdminLayoutProps {}
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ path: '/admin/posts', icon: RiFileTextLine, label: 'admin.nav.posts' },
|
{ path: '/admin/posts', icon: RiFileTextLine, label: 'admin.nav.posts' },
|
||||||
|
{ path: '/admin/daily', icon: RiCalendarLine, label: 'admin.nav.daily' },
|
||||||
|
{ path: '/admin/medias', icon: RiImageLine, label: 'admin.nav.medias' },
|
||||||
{ path: '/admin/categories', icon: RiFolderLine, label: 'admin.nav.categories' },
|
{ path: '/admin/categories', icon: RiFolderLine, label: 'admin.nav.categories' },
|
||||||
{ path: '/admin/users', icon: RiUserLine, label: 'admin.nav.users' },
|
{ path: '/admin/users', icon: RiUserLine, label: 'admin.nav.users' },
|
||||||
{ path: '/admin/contributors', icon: RiTeamLine, label: 'admin.nav.contributors' },
|
{ path: '/admin/contributors', icon: RiTeamLine, label: 'admin.nav.contributors' },
|
||||||
|
{ path: '/admin/settings', icon: RiSettings3Line, label: 'admin.nav.settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
|
@ -49,16 +57,29 @@ const languageMap: LanguageMap = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const AdminLayout: FC<AdminLayoutProps> = () => {
|
const AdminLayout: FC<AdminLayoutProps> = () => {
|
||||||
const location = useLocation();
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { user, loading, error } = useUser();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('AdminLayout user:', user);
|
||||||
|
console.log('AdminLayout loading:', loading);
|
||||||
|
console.log('AdminLayout error:', error);
|
||||||
|
}, [user, loading, error]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
navigate('/admin/login');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-100 dark:bg-slate-900 py-6 flex">
|
<div className="min-h-screen bg-slate-100 dark:bg-slate-900 py-6 flex">
|
||||||
{/* Background Overlay */}
|
{/* Background Overlay */}
|
||||||
<div className="fixed inset-0 bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-800 dark:to-slate-900 backdrop-blur-xl -z-10" />
|
<div className="fixed inset-0 bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-800 dark:to-slate-900 backdrop-blur-xl -z-10" />
|
||||||
|
|
||||||
<div className="w-full max-w-[98%] mx-auto flex gap-4">
|
<div className="container max-w-[1920px] mx-auto px-2 flex gap-2">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-64 bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg rounded-lg shadow-lg flex flex-col">
|
<aside className="w-64 bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg rounded-lg shadow-lg flex flex-col">
|
||||||
<div className="h-16 px-6 border-b border-slate-200/80 dark:border-slate-700/80 flex items-center justify-center">
|
<div className="h-16 px-6 border-b border-slate-200/80 dark:border-slate-700/80 flex items-center justify-center">
|
||||||
|
@ -103,7 +124,7 @@ const AdminLayout: FC<AdminLayoutProps> = () => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors whitespace-nowrap"
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors whitespace-nowrap"
|
||||||
onClick={() => {/* TODO: Implement logout */}}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
<RiLogoutBoxRLine className="text-xl flex-shrink-0" />
|
<RiLogoutBoxRLine className="text-xl flex-shrink-0" />
|
||||||
<span className="text-sm">{t('admin.common.logout')}</span>
|
<span className="text-sm">{t('admin.common.logout')}</span>
|
||||||
|
@ -145,18 +166,21 @@ const AdminLayout: FC<AdminLayoutProps> = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-full flex items-center justify-center text-slate-600 dark:text-slate-300">
|
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-full flex items-center justify-center text-slate-600 dark:text-slate-300">
|
||||||
<span className="text-lg">A</span>
|
<span className="text-lg">{(user?.display_name?.[0] || user?.username?.[0] || '?').toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-slate-800 dark:text-white">管理员</div>
|
<div className="text-slate-800 dark:text-white">
|
||||||
<div className="text-sm text-slate-500 dark:text-slate-400">Administrator</div>
|
{loading ? 'Loading...' : user?.display_name || user?.username || 'Guest'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 p-6">
|
<div className="flex-1 p-6">
|
||||||
<div className="h-full bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200/60 dark:border-slate-700/60">
|
<div className="h-full bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200/60 dark:border-slate-700/60">
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
243
frontend/src/pages/admin/login.tsx
Normal file
243
frontend/src/pages/admin/login.tsx
Normal file
|
@ -0,0 +1,243 @@
|
||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { FiUser, FiLock, FiSun, FiMoon, FiMonitor, FiGlobe } from 'react-icons/fi';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import { Menu } from '@headlessui/react';
|
||||||
|
|
||||||
|
interface LoginFormData {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
remember: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ code: 'en', nativeName: 'English' },
|
||||||
|
{ code: 'zh-Hans', nativeName: '简体中文' },
|
||||||
|
{ code: 'zh-Hant', nativeName: '繁體中文' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const THEMES = [
|
||||||
|
{ value: 'light' as const, icon: FiSun, label: 'theme.light' },
|
||||||
|
{ value: 'dark' as const, icon: FiMoon, label: 'theme.dark' },
|
||||||
|
{ value: 'system' as const, icon: FiMonitor, label: 'theme.system' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [formData, setFormData] = useState<LoginFormData>({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
remember: false,
|
||||||
|
});
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || t('admin.login.error.failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 token 和用户信息
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
if (formData.remember) {
|
||||||
|
localStorage.setItem('username', formData.username);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到管理面板
|
||||||
|
navigate('/admin');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('admin.login.error.retry'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 relative overflow-hidden">
|
||||||
|
{/* Decorative Background */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -inset-[10px] bg-gradient-to-br from-indigo-50 via-slate-50 to-slate-100 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800 blur-3xl opacity-80" />
|
||||||
|
<div className="absolute right-0 top-0 -mt-16 -mr-16 h-96 w-96 rounded-full bg-gradient-to-br from-indigo-100 to-indigo-50 dark:from-indigo-900/20 dark:to-indigo-900/10 blur-2xl" />
|
||||||
|
<div className="absolute left-0 bottom-0 -mb-16 -ml-16 h-96 w-96 rounded-full bg-gradient-to-tr from-indigo-100 to-indigo-50 dark:from-indigo-900/20 dark:to-indigo-900/10 blur-2xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme & Language Switcher */}
|
||||||
|
<div className="fixed top-4 right-4 flex items-center gap-2 z-50">
|
||||||
|
{/* Theme Switcher */}
|
||||||
|
<Menu as="div" className="relative">
|
||||||
|
<Menu.Button className="flex items-center justify-center w-10 h-10 rounded-lg text-slate-600 dark:text-slate-400 hover:bg-white/80 dark:hover:bg-slate-800/80 transition-colors backdrop-blur-sm">
|
||||||
|
{THEMES.find(t => t.value === theme)?.icon({ className: 'w-5 h-5' })}
|
||||||
|
</Menu.Button>
|
||||||
|
<Menu.Items className="absolute right-0 mt-2 w-48 origin-top-right bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm rounded-lg shadow-lg ring-1 ring-black/5 focus:outline-none">
|
||||||
|
<div className="py-1">
|
||||||
|
{THEMES.map(({ value, icon: Icon, label }) => (
|
||||||
|
<Menu.Item key={value}>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
className={`${
|
||||||
|
active ? 'bg-slate-100/80 dark:bg-slate-700/50' : ''
|
||||||
|
} ${
|
||||||
|
theme === value ? 'text-indigo-600 dark:text-indigo-400' : 'text-slate-700 dark:text-slate-300'
|
||||||
|
} group flex items-center w-full px-4 py-2.5 text-sm transition-colors`}
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
>
|
||||||
|
<Icon className="mr-3 h-5 w-5" />
|
||||||
|
{t(label)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<Menu as="div" className="relative">
|
||||||
|
<Menu.Button className="flex items-center justify-center w-10 h-10 rounded-lg text-slate-600 dark:text-slate-400 hover:bg-white/80 dark:hover:bg-slate-800/80 transition-colors backdrop-blur-sm">
|
||||||
|
<FiGlobe className="w-5 h-5" />
|
||||||
|
</Menu.Button>
|
||||||
|
<Menu.Items className="absolute right-0 mt-2 w-48 origin-top-right bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm rounded-lg shadow-lg ring-1 ring-black/5 focus:outline-none">
|
||||||
|
<div className="py-1">
|
||||||
|
{LANGUAGES.map(({ code, nativeName }) => (
|
||||||
|
<Menu.Item key={code}>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
className={`${
|
||||||
|
active ? 'bg-slate-100/80 dark:bg-slate-700/50' : ''
|
||||||
|
} ${
|
||||||
|
i18n.language === code ? 'text-indigo-600 dark:text-indigo-400' : 'text-slate-700 dark:text-slate-300'
|
||||||
|
} group flex items-center w-full px-4 py-2.5 text-sm transition-colors`}
|
||||||
|
onClick={() => i18n.changeLanguage(code)}
|
||||||
|
>
|
||||||
|
{nativeName}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="bg-white/70 dark:bg-slate-800/70 backdrop-blur-xl shadow-xl shadow-slate-200/20 dark:shadow-slate-900/30 rounded-2xl p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500 to-indigo-600 shadow-lg shadow-indigo-500/30 dark:shadow-indigo-800/30 mb-4">
|
||||||
|
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{t('admin.login.title')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 dark:bg-red-500/10 p-4 mb-6">
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
{t('admin.login.username')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="block w-full px-3 py-2 border-0 text-slate-900 dark:text-white bg-white dark:bg-slate-800 rounded-lg shadow-sm ring-1 ring-slate-200 dark:ring-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 sm:text-sm"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, username: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
{t('admin.login.password')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="block w-full px-3 py-2 border-0 text-slate-900 dark:text-white bg-white dark:bg-slate-800 rounded-lg shadow-sm ring-1 ring-slate-200 dark:ring-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 sm:text-sm"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, password: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember"
|
||||||
|
name="remember"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-600 rounded transition-colors"
|
||||||
|
checked={formData.remember}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, remember: e.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="remember"
|
||||||
|
className="ml-2 block text-sm text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{t('admin.login.remember')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-indigo-600 hover:from-indigo-600 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-slate-800 transition-all duration-150 ease-in-out transform hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{isLoading ? t('admin.login.loading') : t('admin.login.submit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
52
frontend/src/pages/admin/medias/MediasManagement.tsx
Normal file
52
frontend/src/pages/admin/medias/MediasManagement.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Table from '../../../components/admin/Table';
|
||||||
|
import TableActions from '../../../components/admin/TableActions';
|
||||||
|
|
||||||
|
interface Media {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
uploadDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediasManagement() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('admin.medias.name'),
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.medias.type'),
|
||||||
|
key: 'type',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.medias.size'),
|
||||||
|
key: 'size',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.medias.uploadDate'),
|
||||||
|
key: 'uploadDate',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
// TODO: 实现上传媒体文件的逻辑
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<TableActions actionKey="upload" onClick={handleUpload} />
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={[]}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,14 +1,15 @@
|
||||||
import { FC, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Table from '../../../components/admin/Table';
|
import Table from '../../../components/admin/Table';
|
||||||
|
import TableActions from '../../../components/admin/TableActions';
|
||||||
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
category: string;
|
||||||
status: 'draft' | 'published';
|
|
||||||
publishDate: string;
|
publishDate: string;
|
||||||
|
status: 'draft' | 'published';
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostsManagement: FC = () => {
|
const PostsManagement: FC = () => {
|
||||||
|
@ -21,9 +22,9 @@ const PostsManagement: FC = () => {
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
title: '示例文章标题',
|
title: '示例文章标题',
|
||||||
author: '张三',
|
category: '示例分类',
|
||||||
status: 'published',
|
|
||||||
publishDate: '2024-02-20',
|
publishDate: '2024-02-20',
|
||||||
|
status: 'published',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -37,34 +38,38 @@ const PostsManagement: FC = () => {
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'title',
|
key: 'title' as keyof Post,
|
||||||
title: t('admin.common.title'),
|
title: t('admin.posts.title'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'author',
|
key: 'category' as keyof Post,
|
||||||
title: t('admin.common.author'),
|
title: t('admin.posts.category'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'publishDate' as keyof Post,
|
||||||
title: t('admin.common.status'),
|
title: t('admin.posts.publishDate'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status' as keyof Post,
|
||||||
|
title: t('admin.posts.status'),
|
||||||
render: (value: Post['status']) => (
|
render: (value: Post['status']) => (
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-3 py-1 text-xs rounded-md ${
|
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
value === 'published'
|
value === 'published'
|
||||||
? 'bg-slate-900 dark:bg-slate-700 text-white'
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200'
|
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`admin.common.${value}`)}
|
{t(`admin.common.${value}`)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'publishDate',
|
|
||||||
title: t('admin.common.date'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
// TODO: 实现创建文章的逻辑
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="divide-y divide-slate-200 dark:divide-slate-700">
|
<div className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
@ -79,15 +84,12 @@ const PostsManagement: FC = () => {
|
||||||
/>
|
/>
|
||||||
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
||||||
</div>
|
</div>
|
||||||
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
|
<TableActions onClick={handleCreate} />
|
||||||
<RiAddLine className="text-xl" />
|
|
||||||
<span>{t('admin.common.create')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Table
|
<Table<Post>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={posts}
|
data={posts}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
@ -99,9 +101,9 @@ const PostsManagement: FC = () => {
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-200 dark:border-slate-700">
|
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium rounded-tl-md">{t('admin.common.title')}</th>
|
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium rounded-tl-md">{t('admin.common.title')}</th>
|
||||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.author')}</th>
|
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.category')}</th>
|
||||||
|
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.publishDate')}</th>
|
||||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.status')}</th>
|
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.status')}</th>
|
||||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.date')}</th>
|
|
||||||
<th className="text-right py-4 px-6 text-slate-600 dark:text-slate-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
|
<th className="text-right py-4 px-6 text-slate-600 dark:text-slate-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -109,17 +111,17 @@ const PostsManagement: FC = () => {
|
||||||
{data.map((post) => (
|
{data.map((post) => (
|
||||||
<tr key={post.id} className="border-b border-slate-200 dark:border-slate-700 last:border-0">
|
<tr key={post.id} className="border-b border-slate-200 dark:border-slate-700 last:border-0">
|
||||||
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.title}</td>
|
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.title}</td>
|
||||||
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.author}</td>
|
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.category}</td>
|
||||||
|
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.publishDate}</td>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
<span className={`px-3 py-1 text-sm rounded-md ${
|
<span className={`px-2 py-1 text-sm font-medium rounded-full ${
|
||||||
post.status === 'published'
|
post.status === 'published'
|
||||||
? 'bg-slate-900 dark:bg-slate-700 text-white'
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200'
|
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||||
}`}>
|
}`}>
|
||||||
{t(`admin.common.${post.status}`)}
|
{t(`admin.common.${post.status}`)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.publishDate}</td>
|
|
||||||
<td className="py-4 px-6 text-right">
|
<td className="py-4 px-6 text-right">
|
||||||
<button
|
<button
|
||||||
className="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 px-2 py-1 rounded-md transition-colors"
|
className="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 px-2 py-1 rounded-md transition-colors"
|
||||||
|
|
224
frontend/src/pages/admin/settings/Settings.tsx
Normal file
224
frontend/src/pages/admin/settings/Settings.tsx
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useUser } from '../../../contexts/UserContext';
|
||||||
|
|
||||||
|
interface UserSettings {
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PasswordSettings {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { user } = useUser();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [settings, setSettings] = useState<UserSettings>({
|
||||||
|
username: '',
|
||||||
|
display_name: '',
|
||||||
|
email: '',
|
||||||
|
});
|
||||||
|
const [passwords, setPasswords] = useState<PasswordSettings>({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
const [passwordError, setPasswordError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setSettings({
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name || '',
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const validatePasswords = () => {
|
||||||
|
if (passwords.newPassword && passwords.newPassword.length < 8) {
|
||||||
|
setPasswordError(t('admin.settings.passwordTooShort'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (passwords.newPassword !== passwords.confirmPassword) {
|
||||||
|
setPasswordError(t('admin.settings.passwordMismatch'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setPasswordError('');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (passwords.newPassword && !validatePasswords()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/users/me', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: settings.username,
|
||||||
|
display_name: settings.display_name || undefined,
|
||||||
|
email: settings.email,
|
||||||
|
...(passwords.newPassword ? {
|
||||||
|
current_password: passwords.currentPassword,
|
||||||
|
new_password: passwords.newPassword,
|
||||||
|
} : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空密码字段
|
||||||
|
setPasswords({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save settings:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<form onSubmit={handleSubmit} className="max-w-xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||||
|
>
|
||||||
|
{t('admin.settings.username')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
value={settings.username}
|
||||||
|
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
|
||||||
|
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
|
||||||
|
minLength={3}
|
||||||
|
maxLength={32}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="display_name"
|
||||||
|
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||||
|
>
|
||||||
|
{t('admin.settings.displayName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="display_name"
|
||||||
|
value={settings.display_name}
|
||||||
|
onChange={(e) => setSettings({ ...settings, display_name: e.target.value })}
|
||||||
|
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||||
|
>
|
||||||
|
{t('admin.settings.email')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
value={settings.email}
|
||||||
|
onChange={(e) => setSettings({ ...settings, email: e.target.value })}
|
||||||
|
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white">
|
||||||
|
{t('admin.settings.changePassword')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="current_password"
|
||||||
|
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||||
|
>
|
||||||
|
{t('admin.settings.currentPassword')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="current_password"
|
||||||
|
value={passwords.currentPassword}
|
||||||
|
onChange={(e) => setPasswords({ ...passwords, currentPassword: e.target.value })}
|
||||||
|
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="new_password"
|
||||||
|
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||||
|
>
|
||||||
|
{t('admin.settings.newPassword')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="new_password"
|
||||||
|
value={passwords.newPassword}
|
||||||
|
onChange={(e) => setPasswords({ ...passwords, newPassword: e.target.value })}
|
||||||
|
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="confirm_password"
|
||||||
|
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||||
|
>
|
||||||
|
{t('admin.settings.confirmPassword')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirm_password"
|
||||||
|
value={passwords.confirmPassword}
|
||||||
|
onChange={(e) => setPasswords({ ...passwords, confirmPassword: e.target.value })}
|
||||||
|
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passwordError && (
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400">{passwordError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? t('admin.common.saving') : t('admin.common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,54 +1,104 @@
|
||||||
import { FC, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
import { useState, useEffect } from 'react';
|
||||||
import Table from '../../../components/admin/Table';
|
import Table from '../../../components/admin/Table';
|
||||||
|
import TableActions from '../../../components/admin/TableActions';
|
||||||
|
import { RiSearchLine } from 'react-icons/ri';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'admin' | 'user';
|
role: 'admin' | 'editor' | 'contributor';
|
||||||
lastLogin: string;
|
status: 'active' | 'inactive' | 'banned';
|
||||||
}
|
}
|
||||||
|
|
||||||
const UsersManagement: FC = () => {
|
export default function UsersManagement() {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 这里后续会通过 API 获取数据
|
const fetchUsers = async () => {
|
||||||
const users: User[] = [
|
setLoading(true);
|
||||||
{
|
setError(null);
|
||||||
id: '1',
|
try {
|
||||||
username: '张三',
|
console.log('Fetching users list...');
|
||||||
email: 'zhangsan@example.com',
|
const response = await fetch('/api/v1/users', {
|
||||||
role: 'admin',
|
headers: {
|
||||||
lastLogin: '2024-02-20',
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
},
|
},
|
||||||
];
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch users list: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await response.json();
|
||||||
|
console.log('Users list:', data);
|
||||||
|
setUsers(data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching users list:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch users list');
|
||||||
|
setUsers([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleEdit = (user: User) => {
|
const handleEdit = (user: User) => {
|
||||||
console.log('Edit user:', user);
|
console.log('Edit user:', user);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (user: User) => {
|
const handleDelete = async (user: User) => {
|
||||||
console.log('Delete user:', user);
|
console.log('Delete user:', user);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/users/${user.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete user');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting user:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
// TODO: 实现创建用户的逻辑
|
||||||
|
console.log('Create user clicked');
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'username',
|
key: 'username' as keyof User,
|
||||||
title: t('admin.common.username'),
|
title: t('admin.users.username'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
key: 'email' as keyof User,
|
||||||
title: t('admin.common.role'),
|
title: t('admin.users.email'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'role' as keyof User,
|
||||||
|
title: t('admin.users.role'),
|
||||||
render: (value: User['role']) => (
|
render: (value: User['role']) => (
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-3 py-1 text-xs rounded-md ${
|
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
value === 'admin'
|
value === 'admin'
|
||||||
? 'bg-slate-900 dark:bg-slate-700 text-white'
|
? 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300'
|
||||||
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200'
|
: value === 'editor'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||||
|
: 'bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`admin.roles.${value}`)}
|
{t(`admin.roles.${value}`)}
|
||||||
|
@ -56,37 +106,49 @@ const UsersManagement: FC = () => {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'joinDate',
|
key: 'status' as keyof User,
|
||||||
title: t('admin.common.joinDate'),
|
title: t('admin.users.status'),
|
||||||
},
|
render: (value: User['status']) => (
|
||||||
{
|
<span
|
||||||
key: 'lastLogin',
|
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
title: t('admin.common.lastLogin'),
|
value === 'active'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: value === 'inactive'
|
||||||
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.common.${value}`)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-red-600 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="divide-y divide-slate-200 dark:divide-slate-700">
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="mb-6 flex justify-between items-center">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
className="pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500"
|
||||||
placeholder={t('admin.common.search')}
|
placeholder={t('admin.common.search')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-10 pr-4 py-2 border-b-2 border-slate-200 dark:border-slate-700 focus:border-slate-900 dark:focus:border-slate-500 bg-transparent outline-none w-64 text-slate-800 dark:text-white placeholder-slate-400 dark:placeholder-slate-500"
|
|
||||||
/>
|
/>
|
||||||
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500" />
|
||||||
</div>
|
|
||||||
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
|
|
||||||
<RiAddLine className="text-xl" />
|
|
||||||
<span>{t('admin.common.create')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<TableActions onClick={handleCreate} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table
|
<Table<User>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={users}
|
data={users}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
@ -95,6 +157,4 @@ const UsersManagement: FC = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default UsersManagement;
|
|
||||||
|
|
|
@ -1,13 +1,207 @@
|
||||||
import { createBrowserRouter } from 'react-router-dom';
|
import { createBrowserRouter, Navigate, useLocation, Outlet } from 'react-router-dom';
|
||||||
import AdminLayout from './pages/admin/layout/AdminLayout';
|
import { lazy, Suspense } from 'react';
|
||||||
import Dashboard from './pages/admin/Dashboard';
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
|
|
||||||
|
// 懒加载组件
|
||||||
|
const Home = lazy(() => import('./pages/Home'));
|
||||||
|
const Daily = lazy(() => import('./pages/Daily'));
|
||||||
|
const Article = lazy(() => import('./pages/Article'));
|
||||||
|
const AdminLayout = lazy(() => import('./pages/admin/layout/AdminLayout'));
|
||||||
|
const Login = lazy(() => import('./pages/admin/login'));
|
||||||
|
const Header = lazy(() => import('./components/layout/Header'));
|
||||||
|
const Footer = lazy(() => import('./components/Footer'));
|
||||||
|
|
||||||
|
// 管理页面组件
|
||||||
|
const Dashboard = lazy(() => import('./pages/admin/dashboard/Dashboard'));
|
||||||
|
const PostsManagement = lazy(() => import('./pages/admin/posts/PostsManagement'));
|
||||||
|
const DailyManagement = lazy(() => import('./pages/admin/daily/DailyManagement'));
|
||||||
|
const MediasManagement = lazy(() => import('./pages/admin/medias/MediasManagement'));
|
||||||
|
const CategoriesManagement = lazy(() => import('./pages/admin/categories/CategoriesManagement'));
|
||||||
|
const UsersManagement = lazy(() => import('./pages/admin/users/UsersManagement'));
|
||||||
|
const ContributorsManagement = lazy(() => import('./pages/admin/contributors/ContributorsManagement'));
|
||||||
|
const Settings = lazy(() => import('./pages/admin/settings/Settings'));
|
||||||
|
|
||||||
|
// 检查是否已登录
|
||||||
|
const checkAuth = () => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
console.debug('[Auth] Token exists:', !!token);
|
||||||
|
return !!token;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 受保护的路由组件
|
||||||
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const isAuthenticated = checkAuth();
|
||||||
|
|
||||||
|
console.debug('[Router] Current location:', location.pathname);
|
||||||
|
console.debug('[Router] Is authenticated:', isAuthenticated);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
console.debug('[Router] Redirecting to login');
|
||||||
|
return <Navigate to="/admin/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 登录路由组件
|
||||||
|
const LoginRoute = () => {
|
||||||
|
const isAuthenticated = checkAuth();
|
||||||
|
|
||||||
|
console.debug('[Login] Checking auth status');
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
console.debug('[Login] Already authenticated, redirecting to admin');
|
||||||
|
return <Navigate to="/admin" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Login />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 页面布局组件
|
||||||
|
const PageLayout = () => (
|
||||||
|
<div className="flex flex-col min-h-screen bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100">
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Header />
|
||||||
|
<div className="w-[95%] mx-auto">
|
||||||
|
<div className="border-t-2 border-gray-900 dark:border-gray-100 w-full mb-2" />
|
||||||
|
</div>
|
||||||
|
<main className="flex-1 w-[95%] mx-auto py-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/',
|
||||||
element: <AdminLayout><Dashboard /></AdminLayout>,
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<PageLayout />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Home />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'daily',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Daily />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'posts/:articleId',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Article />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
element: <LoginRoute />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||||
|
<AdminLayout />
|
||||||
|
</Suspense>
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Dashboard />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'posts',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<PostsManagement />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'daily',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<DailyManagement />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'medias',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<MediasManagement />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'categories',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<CategoriesManagement />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'users',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<UsersManagement />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'contributors',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<ContributorsManagement />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Settings />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
element: <Navigate to="/" />,
|
||||||
},
|
},
|
||||||
// Add more routes here as we develop them
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
@ -11,4 +11,13 @@ export default defineConfig({
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['lucide-react'],
|
exclude: ['lucide-react'],
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue