Initial commit

This commit is contained in:
2026-03-14 18:24:33 +08:00
commit 890c33f510
67 changed files with 13080 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6266
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1",
"recharts": "^3.8.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

42
src/App.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { useEffect } from 'react'
import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from '@/router'
import { useAuthStore } from '@/stores/authStore'
// 创建React Query客户端
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5分钟
retry: 1,
refetchOnWindowFocus: false,
},
},
})
function App() {
const { checkAuth, isLoading } = useAuthStore()
// 应用启动时检查认证状态
useEffect(() => {
checkAuth()
}, [checkAuth])
// 显示加载状态
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
)
}
export default App

16
src/api/auth.ts Normal file
View File

@@ -0,0 +1,16 @@
import apiClient from './client'
import type { LoginRequest, LoginResponse, User, Role, ApiResponse } from '@/types'
export const authApi = {
login: (data: LoginRequest) =>
apiClient.post<ApiResponse<LoginResponse>>('/auth/login', data),
refresh: (refreshToken: string) =>
apiClient.post<ApiResponse<LoginResponse>>('/auth/refresh', { refresh_token: refreshToken }),
getCurrentUser: () =>
apiClient.get<ApiResponse<User>>('/users/me'),
getMyRoles: () =>
apiClient.get<ApiResponse<Role[]>>('/users/me/roles'),
}

39
src/api/client.ts Normal file
View File

@@ -0,0 +1,39 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8080/api/v1',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器自动添加Token
apiClient.interceptors.request.use(
(config) => {
const { accessToken } = useAuthStore.getState()
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器处理401错误
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token过期清除认证状态并跳转登录
useAuthStore.getState().logout()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default apiClient

55
src/api/comments.ts Normal file
View File

@@ -0,0 +1,55 @@
import apiClient from './client'
import type { Comment, ApiResponse, PaginatedResponse } from '@/types'
// 评论查询参数
export interface CommentQueryParams {
page?: number
page_size?: number
keyword?: string // 搜索关键词(内容)
post_id?: string // 关联帖子ID筛选
author_id?: string // 评论者ID筛选
status?: string // 状态筛选
start_date?: string // 开始日期
end_date?: string // 结束日期
}
// 评论详情(包含更多上下文)
export interface CommentDetail extends Comment {
parent_id?: string
reply_to_user?: {
id: string
username: string
nickname: string
}
post?: {
id: string
title: string
}
ip_address?: string
device_info?: string
}
// 评论管理API
export const commentsApi = {
// 获取评论列表(分页、搜索、筛选)
getComments: (params: CommentQueryParams) =>
apiClient.get<ApiResponse<PaginatedResponse<Comment>>>('/admin/comments', { params }),
// 获取评论详情
getCommentById: (id: string) =>
apiClient.get<ApiResponse<CommentDetail>>(`/admin/comments/${id}`),
// 删除评论
deleteComment: (id: string) =>
apiClient.delete<ApiResponse<void>>(`/admin/comments/${id}`),
// 批量删除评论
batchDeleteComments: (ids: string[]) =>
apiClient.post<ApiResponse<void>>('/admin/comments/batch-delete', { ids }),
// 获取帖子的评论列表
getCommentsByPost: (postId: string, params?: Omit<CommentQueryParams, 'post_id'>) =>
apiClient.get<ApiResponse<PaginatedResponse<Comment>>>(`/admin/comments/post/${postId}`, { params }),
}
export default commentsApi

99
src/api/dashboard.ts Normal file
View File

@@ -0,0 +1,99 @@
import apiClient from './client'
import type { ApiResponse, ContentDistribution } from '@/types'
// 仪表盘统计数据
export interface DashboardStatsData {
// 用户统计
total_users: number
today_new_users: number
active_users_today: number // DAU
active_users_week: number // WAU
active_users_month: number // MAU
// 留存率 (百分比 0-100)
retention_1_day: number // 次日留存
retention_7_day: number // 7日留存
retention_30_day: number // 30日留存
// 帖子统计
total_posts: number
today_posts: number
pending_posts: number
// 评论统计
total_comments: number
today_comments: number
// 群组统计
total_groups: number
today_new_groups: number
// 待审核内容
pending_review: number
pending_posts_count: number
pending_comments_count: number
}
// 用户活跃度趋势数据
export interface ActivityTrendData {
date: string
new_users: number
active_users: number
posts: number
comments: number
}
// 内容统计数据
export interface ContentStatsData {
// 内容类型分布
content_distribution: ContentDistribution[]
// 帖子状态分布
post_status_distribution: {
name: string
value: number
}[]
// 用户增长趋势
user_growth: {
date: string
total: number
new: number
}[]
}
// 最新待审核内容
export interface PendingContent {
id: number
type: 'post' | 'comment'
title?: string
content: string
author: {
id: number
username: string
nickname: string
}
created_at: string
}
// Dashboard API
export const dashboardApi = {
// 获取统计数据
getStats: () =>
apiClient.get<ApiResponse<DashboardStatsData>>('/admin/dashboard/stats'),
// 获取用户活跃度趋势
getUserActivityTrend: (days: number = 7) =>
apiClient.get<ApiResponse<ActivityTrendData[]>>('/admin/dashboard/user-activity', {
params: { days }
}),
// 获取内容统计
getContentStats: () =>
apiClient.get<ApiResponse<ContentStatsData>>('/admin/dashboard/content-stats'),
// 获取最新待审核内容
getPendingContent: (limit: number = 10) =>
apiClient.get<ApiResponse<PendingContent[]>>('/admin/dashboard/pending-content', {
params: { limit }
}),
// 获取实时在线用户数
getOnlineUsersCount: () =>
apiClient.get<ApiResponse<{ count: number }>>('/admin/dashboard/online-users'),
}
export default dashboardApi

73
src/api/groups.ts Normal file
View File

@@ -0,0 +1,73 @@
import apiClient from './client'
import type { Group, ApiResponse, PaginatedResponse } from '@/types'
// 群组查询参数
export interface GroupQueryParams {
page?: number
page_size?: number
keyword?: string // 搜索关键词(群组名称)
status?: string // 状态筛选
owner_id?: string // 创建者ID筛选
start_date?: string // 开始日期
end_date?: string // 结束日期
}
// 群组成员
export interface GroupMember {
id: string
user_id: string
username: string
nickname: string
avatar?: string
role: 'owner' | 'admin' | 'member'
joined_at: string
}
// 群组详情(包含更多信息)
export interface GroupDetail extends Group {
owner: {
id: string
username: string
nickname: string
avatar?: string
}
post_count: number
announcement?: string
is_public: boolean
join_type: 'free' | 'approval' | 'invite'
status: 'active' | 'dissolved'
members?: GroupMember[]
}
// 群组管理API
export const groupsApi = {
// 获取群组列表(分页、搜索、筛选)
getGroups: (params: GroupQueryParams) =>
apiClient.get<ApiResponse<PaginatedResponse<Group>>>('/admin/groups', { params }),
// 获取群组详情
getGroupById: (id: string) =>
apiClient.get<ApiResponse<GroupDetail>>(`/admin/groups/${id}`),
// 获取群组成员列表
getGroupMembers: (id: string, params?: { page?: number; page_size?: number }) =>
apiClient.get<ApiResponse<PaginatedResponse<GroupMember>>>(`/admin/groups/${id}/members`, { params }),
// 解散群组
dissolveGroup: (id: string, reason?: string) =>
apiClient.delete<ApiResponse<void>>(`/admin/groups/${id}`, { data: { reason } }),
// 更新群组状态
updateGroupStatus: (id: string, status: 'active' | 'dissolved') =>
apiClient.put<ApiResponse<void>>(`/admin/groups/${id}/status`, { status }),
// 移除群组成员
removeGroupMember: (groupId: string, userId: string) =>
apiClient.delete<ApiResponse<void>>(`/admin/groups/${groupId}/members/${userId}`),
// 转让群主
transferOwnership: (groupId: string, newOwnerId: string) =>
apiClient.put<ApiResponse<void>>(`/admin/groups/${groupId}/transfer`, { new_owner_id: newOwnerId }),
}
export default groupsApi

14
src/api/index.ts Normal file
View File

@@ -0,0 +1,14 @@
export { default as apiClient } from './client'
export { authApi } from './auth'
export { usersApi } from './users'
export type { UserQueryParams, UserDetail } from './users'
export { rolesApi } from './roles'
export type { Permission, RoleInfo, RoleDetail } from './roles'
export { postsApi } from './posts'
export type { PostQueryParams, PostDetail } from './posts'
export { commentsApi } from './comments'
export type { CommentQueryParams, CommentDetail } from './comments'
export { groupsApi } from './groups'
export type { GroupQueryParams, GroupDetail, GroupMember } from './groups'
export { dashboardApi } from './dashboard'
export type { DashboardStatsData, ActivityTrendData, ContentStatsData, PendingContent } from './dashboard'

60
src/api/posts.ts Normal file
View File

@@ -0,0 +1,60 @@
import apiClient from './client'
import type { Post, ApiResponse, PaginatedResponse } from '@/types'
// 帖子查询参数
export interface PostQueryParams {
page?: number
page_size?: number
keyword?: string // 搜索关键词(标题/内容)
status?: string // 状态筛选pending/published/rejected/deleted
author_id?: string // 作者ID筛选
start_date?: string // 开始日期
end_date?: string // 结束日期
}
// 帖子详情(包含更多信息)
export interface PostDetail extends Post {
group_id?: string
group_name?: string
images?: string[]
tags?: string[]
is_pinned: boolean
is_featured: boolean
}
// 帖子管理API
export const postsApi = {
// 获取帖子列表(分页、搜索、筛选)
getPosts: (params: PostQueryParams) =>
apiClient.get<ApiResponse<PaginatedResponse<Post>>>('/admin/posts', { params }),
// 获取帖子详情
getPostById: (id: string) =>
apiClient.get<ApiResponse<PostDetail>>(`/admin/posts/${id}`),
// 删除帖子
deletePost: (id: string) =>
apiClient.delete<ApiResponse<void>>(`/admin/posts/${id}`),
// 审核帖子(通过/拒绝)
moderatePost: (id: string, status: 'published' | 'rejected', reason?: string) =>
apiClient.put<ApiResponse<void>>(`/admin/posts/${id}/moderate`, { status, reason }),
// 批量删除帖子
batchDeletePosts: (ids: string[]) =>
apiClient.post<ApiResponse<void>>('/admin/posts/batch-delete', { ids }),
// 批量审核帖子
batchModeratePosts: (ids: string[], status: 'published' | 'rejected') =>
apiClient.post<ApiResponse<void>>('/admin/posts/batch-moderate', { ids, status }),
// 置顶/取消置顶帖子
togglePinPost: (id: string, pinned: boolean) =>
apiClient.put<ApiResponse<void>>(`/admin/posts/${id}/pin`, { is_pinned: pinned }),
// 加精/取消加精帖子
toggleFeaturePost: (id: string, featured: boolean) =>
apiClient.put<ApiResponse<void>>(`/admin/posts/${id}/feature`, { is_featured: featured }),
}
export default postsApi

60
src/api/roles.ts Normal file
View File

@@ -0,0 +1,60 @@
import apiClient from './client'
import type { ApiResponse } from '@/types'
// 权限定义 - 匹配后端返回的数据结构
export interface Permission {
resource: string
action: string
}
// 权限输入格式(用于更新角色权限)
export interface PermissionInput {
resource: string
action: string
}
// 角色信息 - 匹配后端 GetAllRoles 返回的数据结构
export interface RoleInfo {
name: string
display_name: string
description?: string
priority: number
}
// 角色详情(包含权限和用户数) - 匹配后端 GetRoleDetail 返回的数据结构
export interface RoleDetail {
name: string
display_name: string
description?: string
parent_role?: string
priority: number
user_count: number
permissions: Permission[]
created_at: string
updated_at: string
}
// 角色管理API
export const rolesApi = {
// 获取角色列表
getRoles: () =>
apiClient.get<ApiResponse<RoleInfo[]>>('/admin/roles'),
// 获取角色详情
getRoleByName: (name: string) =>
apiClient.get<ApiResponse<RoleDetail>>(`/admin/roles/${name}`),
// 获取角色权限
getRolePermissions: (name: string) =>
apiClient.get<ApiResponse<Permission[]>>(`/admin/roles/${name}/permissions`),
// 获取所有权限定义
getAllPermissions: () =>
apiClient.get<ApiResponse<Permission[]>>('/admin/permissions'),
// 更新角色权限
updateRolePermissions: (name: string, permissions: PermissionInput[]) =>
apiClient.put<ApiResponse<void>>(`/admin/roles/${name}/permissions`, { permissions }),
}
export default rolesApi

47
src/api/users.ts Normal file
View File

@@ -0,0 +1,47 @@
import apiClient from './client'
import type { User, Role, ApiResponse, PaginatedResponse } from '@/types'
// 用户查询参数
export interface UserQueryParams {
page?: number
page_size?: number
keyword?: string // 搜索关键词(用户名/邮箱)
status?: string // 状态筛选
}
// 用户详情(包含统计信息)
export interface UserDetail extends User {
posts_count: number
comments_count: number
followers_count: number
following_count: number
}
// 用户管理API
export const usersApi = {
// 获取用户列表(分页、搜索、筛选)
getUsers: (params: UserQueryParams) =>
apiClient.get<ApiResponse<PaginatedResponse<User>>>('/admin/users', { params }),
// 获取用户详情
getUserById: (id: string) =>
apiClient.get<ApiResponse<UserDetail>>(`/admin/users/${id}`),
// 更新用户状态(封禁/解封)
updateUserStatus: (id: string, status: 'active' | 'banned') =>
apiClient.put<ApiResponse<void>>(`/admin/users/${id}/status`, { status }),
// 获取用户角色
getUserRoles: (id: string) =>
apiClient.get<ApiResponse<Role[]>>(`/admin/users/${id}/roles`),
// 分配用户角色
assignRole: (userId: string, role: string) =>
apiClient.post<ApiResponse<void>>(`/admin/users/${userId}/roles`, { role }),
// 移除用户角色
removeRole: (userId: string, role: string) =>
apiClient.delete<ApiResponse<void>>(`/admin/users/${userId}/roles/${role}`),
}
export default usersApi

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,109 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
interface ActivityData {
date: string
new_users?: number
active_users?: number
posts?: number
comments?: number
}
interface ActivityChartProps {
data: ActivityData[]
title?: string
description?: string
showLegend?: boolean
lines?: {
dataKey: keyof ActivityData
name: string
color: string
}[]
}
const defaultLines = [
{ dataKey: 'new_users' as const, name: '新用户', color: '#8884d8' },
{ dataKey: 'active_users' as const, name: '活跃用户', color: '#82ca9d' },
{ dataKey: 'posts' as const, name: '帖子', color: '#ffc658' },
{ dataKey: 'comments' as const, name: '评论', color: '#ff7300' },
]
export function ActivityChart({
data,
title = '用户活跃度趋势',
description = '最近的活动数据统计',
showLegend = true,
lines = defaultLines,
}: ActivityChartProps) {
// 格式化日期显示
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return `${date.getMonth() + 1}/${date.getDate()}`
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fontSize: 12 }}
stroke="#9ca3af"
/>
<YAxis tick={{ fontSize: 12 }} stroke="#9ca3af" />
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
labelFormatter={(label) => formatDate(String(label))}
/>
{showLegend && <Legend />}
{lines.map((line) => (
<Line
key={line.dataKey}
type="monotone"
dataKey={line.dataKey}
name={line.name}
stroke={line.color}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}
export default ActivityChart

View File

@@ -0,0 +1,87 @@
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
interface PieData {
name: string
value: number
color?: string
}
interface ContentPieChartProps {
data: PieData[]
title?: string
description?: string
showLegend?: boolean
innerRadius?: number
outerRadius?: number
}
const COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#00C49F', '#FFBB28', '#FF8042']
export function ContentPieChart({
data,
title = '内容分布',
description = '内容类型统计',
showLegend = true,
innerRadius = 60,
outerRadius = 100,
}: ContentPieChartProps) {
// 计算总数
const total = data.reduce((sum, item) => sum + item.value, 0)
return (
<Card className="w-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={2}
dataKey="value"
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.color || COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
{showLegend && (
<Legend
layout="horizontal"
verticalAlign="bottom"
align="center"
formatter={(value) => {
const item = data.find(d => d.name === value)
const percent = item ? ((item.value / total) * 100).toFixed(1) : '0'
return `${value} (${percent}%)`
}}
/>
)}
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}
export default ContentPieChart

View File

@@ -0,0 +1,98 @@
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { LogOut, Menu, Settings, User } from 'lucide-react'
interface HeaderProps {
onToggleSidebar: () => void
}
export default function Header({ onToggleSidebar }: HeaderProps) {
const { user, logout, roles } = useAuthStore()
const navigate = useNavigate()
const handleLogout = () => {
logout()
navigate('/login')
}
// 获取用户名首字母作为头像fallback
const getInitials = (name: string) => {
return name.charAt(0).toUpperCase()
}
return (
<header className="flex h-16 items-center justify-between border-b bg-card px-4">
{/* 左侧:菜单按钮 */}
<Button variant="ghost" size="icon" onClick={onToggleSidebar}>
<Menu className="h-5 w-5" />
</Button>
{/* 右侧:用户信息 */}
<div className="flex items-center gap-4">
{/* 角色标签 */}
{roles.length > 0 && (
<div className="hidden items-center gap-2 md:flex">
{roles.map((role) => (
<span
key={role.id}
className="rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
{role.name}
</span>
))}
</div>
)}
{/* 用户下拉菜单 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10">
<AvatarImage src={user?.avatar} alt={user?.nickname || user?.username} />
<AvatarFallback>
{user?.nickname ? getInitials(user.nickname) : user?.username ? getInitials(user.username) : 'U'}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user?.nickname || user?.username}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/settings')}>
<Settings className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive">
<LogOut className="mr-2 h-4 w-4" />
<span>退</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}

View File

@@ -0,0 +1,26 @@
import { Outlet } from 'react-router-dom'
import Sidebar from './Sidebar'
import Header from './Header'
import { useState } from 'react'
export default function MainLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return (
<div className="flex h-screen bg-background">
{/* 侧边栏 */}
<Sidebar collapsed={sidebarCollapsed} onCollapse={setSidebarCollapsed} />
{/* 主内容区域 */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* 顶部导航栏 */}
<Header onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} />
{/* 页面内容 */}
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
import { NavLink } from 'react-router-dom'
import { cn } from '@/lib/utils'
import { useAuthStore, ROLES } from '@/stores/authStore'
import {
LayoutDashboard,
Users,
Shield,
FileText,
MessageSquare,
Users as UsersIcon,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
interface SidebarProps {
collapsed: boolean
onCollapse: (collapsed: boolean) => void
}
const menuItems = [
{
title: 'Dashboard',
path: '/dashboard',
icon: LayoutDashboard,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
},
{
title: '用户管理',
path: '/users',
icon: Users,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
},
{
title: '角色管理',
path: '/roles',
icon: Shield,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
},
{
title: '帖子管理',
path: '/posts',
icon: FileText,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
},
{
title: '评论管理',
path: '/comments',
icon: MessageSquare,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
},
{
title: '群组管理',
path: '/groups',
icon: UsersIcon,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
},
]
export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
const { hasAnyRole, roles } = useAuthStore()
// DEBUG: 输出角色信息
console.log('[Sidebar DEBUG] roles:', roles)
console.log('[Sidebar DEBUG] ROLES constants:', ROLES)
// 过滤出当前用户有权访问的菜单
const visibleMenuItems = menuItems.filter(item => {
const hasAccess = hasAnyRole(item.roles)
console.log(`[Sidebar DEBUG] Menu "${item.title}" - required roles:`, item.roles, '- hasAccess:', hasAccess)
return hasAccess
})
console.log('[Sidebar DEBUG] visibleMenuItems count:', visibleMenuItems.length)
return (
<aside
className={cn(
'flex h-full flex-col border-r bg-card transition-all duration-300',
collapsed ? 'w-16' : 'w-64'
)}
>
{/* Logo区域 */}
<div className="flex h-16 items-center justify-between border-b px-4">
{!collapsed && (
<span className="text-xl font-bold text-primary">Carrot BBS</span>
)}
<Button
variant="ghost"
size="icon"
onClick={() => onCollapse(!collapsed)}
className="ml-auto"
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</div>
{/* 导航菜单 */}
<nav className="flex-1 space-y-1 p-2">
{visibleMenuItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
collapsed && 'justify-center px-2'
)
}
title={collapsed ? item.title : undefined}
>
<item.icon className="h-5 w-5 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
))}
</nav>
{/* 底部版本信息 */}
{!collapsed && (
<div className="border-t p-4 text-center text-xs text-muted-foreground">
Carrot BBS Admin v1.0.0
</div>
)}
</aside>
)
}

View File

@@ -0,0 +1,79 @@
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
interface RetentionData {
name: string
value: number
color?: string
}
interface RetentionChartProps {
data: RetentionData[]
title?: string
description?: string
}
const COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300']
export function RetentionChart({
data,
title = '留存率统计',
description = '用户留存率分析',
}: RetentionChartProps) {
return (
<Card className="w-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
layout="vertical"
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} />
<XAxis
type="number"
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
tick={{ fontSize: 12 }}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 12 }}
width={80}
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
formatter={(value) => [`${value}%`, '留存率']}
/>
<Bar
dataKey="value"
radius={[0, 4, 4, 0]}
barSize={32}
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.color || COLORS[index % COLORS.length]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}
export default RetentionChart

View File

@@ -0,0 +1,63 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { TrendingUp, TrendingDown } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
interface StatsCardProps {
title: string
value: string | number
description?: string
icon?: LucideIcon
trend?: {
value: number
label: string
isPositive: boolean
}
className?: string
}
export function StatsCard({
title,
value,
description,
icon: Icon,
trend,
className,
}: StatsCardProps) {
return (
<Card className={cn('', className)}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
{trend && (
<div className="flex items-center gap-1 mt-2">
{trend.isPositive ? (
<TrendingUp className="h-3 w-3 text-green-500" />
) : (
<TrendingDown className="h-3 w-3 text-red-500" />
)}
<span
className={cn(
'text-xs font-medium',
trend.isPositive ? 'text-green-500' : 'text-red-500'
)}
>
{trend.isPositive ? '+' : ''}{trend.value}%
</span>
<span className="text-xs text-muted-foreground">{trend.label}</span>
</div>
)}
</CardContent>
</Card>
)
}
export default StatsCard

View File

@@ -0,0 +1,189 @@
import type { User, Role } from '@/types'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Calendar,
Mail,
User as UserIcon,
FileText,
MessageSquare,
Users,
UserPlus,
Ban,
Unlock
} from 'lucide-react'
interface UserDetailProps {
user: User & {
posts_count?: number
comments_count?: number
followers_count?: number
following_count?: number
}
onAssignRole?: () => void
onToggleStatus?: () => void
}
// 角色颜色映射
const roleColors: Record<string, string> = {
admin: 'bg-red-500',
moderator: 'bg-orange-500',
user: 'bg-blue-500',
super_admin: 'bg-purple-500',
}
// 状态颜色映射
const statusColors: Record<string, string> = {
active: 'bg-green-500',
banned: 'bg-red-500',
deleted: 'bg-gray-500',
}
// 状态文本映射
const statusText: Record<string, string> = {
active: '正常',
banned: '已封禁',
deleted: '已删除',
}
export function UserDetail({ user, onAssignRole, onToggleStatus }: UserDetailProps) {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const getInitials = (name: string) => {
return name.slice(0, 2).toUpperCase()
}
return (
<div className="space-y-6">
{/* 用户基本信息 */}
<Card>
<CardHeader className="pb-4">
<div className="flex items-start gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={user.avatar} alt={user.nickname} />
<AvatarFallback className="text-lg">{getInitials(user.nickname || user.username)}</AvatarFallback>
</Avatar>
<div className="flex-1">
<CardTitle className="text-xl">{user.nickname || user.username}</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<UserIcon className="h-4 w-4" />
@{user.username}
</CardDescription>
<div className="flex items-center gap-2 mt-2">
<Badge className={statusColors[user.status] || 'bg-gray-500'}>
{statusText[user.status] || user.status}
</Badge>
{user.roles?.map((role: Role) => (
<Badge key={role.id} className={roleColors[role.name] || 'bg-gray-500'}>
{role.name}
</Badge>
))}
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-muted-foreground" />
<span>{user.email}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span> {formatDate(user.created_at)}</span>
</div>
{/* 操作按钮 */}
<div className="flex gap-2 pt-4">
<Button variant="outline" size="sm" onClick={onAssignRole}>
<UserPlus className="h-4 w-4 mr-2" />
</Button>
{user.status === 'active' ? (
<Button variant="destructive" size="sm" onClick={onToggleStatus}>
<Ban className="h-4 w-4 mr-2" />
</Button>
) : user.status === 'banned' ? (
<Button variant="outline" size="sm" onClick={onToggleStatus}>
<Unlock className="h-4 w-4 mr-2" />
</Button>
) : null}
</div>
</CardContent>
</Card>
{/* 用户统计信息 */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-3 p-3 bg-muted/50 rounded-lg">
<FileText className="h-5 w-5 text-blue-500" />
<div>
<p className="text-2xl font-bold">{user.posts_count ?? 0}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-muted/50 rounded-lg">
<MessageSquare className="h-5 w-5 text-green-500" />
<div>
<p className="text-2xl font-bold">{user.comments_count ?? 0}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-muted/50 rounded-lg">
<Users className="h-5 w-5 text-purple-500" />
<div>
<p className="text-2xl font-bold">{user.followers_count ?? 0}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-muted/50 rounded-lg">
<UserPlus className="h-5 w-5 text-orange-500" />
<div>
<p className="text-2xl font-bold">{user.following_count ?? 0}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 角色信息 */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{user.roles && user.roles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{user.roles.map((role: Role) => (
<Badge key={role.id} className={roleColors[role.name] || 'bg-gray-500'}>
{role.name}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
</div>
)
}
export default UserDetail

View File

@@ -0,0 +1,142 @@
import { useState, useEffect } from 'react'
import type { Role } from '@/types'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Loader2 } from 'lucide-react'
interface UserRoleDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
userId: string
username: string
currentRoles: Role[]
allRoles: Role[]
onAssignRole: (userId: string, role: string) => Promise<void>
onRemoveRole: (userId: string, role: string) => Promise<void>
}
// 角色颜色映射
const roleColors: Record<string, string> = {
admin: 'text-red-500',
moderator: 'text-orange-500',
user: 'text-blue-500',
super_admin: 'text-purple-500',
}
export function UserRoleDialog({
open,
onOpenChange,
userId,
username,
currentRoles,
allRoles,
onAssignRole,
onRemoveRole,
}: UserRoleDialogProps) {
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set())
const [processingRole, setProcessingRole] = useState<string | null>(null)
// 初始化已选角色
useEffect(() => {
if (open && currentRoles) {
setSelectedRoles(new Set(currentRoles.map((r) => r.name)))
}
}, [open, currentRoles])
// 处理角色选择变化
const handleRoleChange = async (roleName: string, checked: boolean) => {
setProcessingRole(roleName)
try {
if (checked) {
await onAssignRole(userId, roleName)
setSelectedRoles((prev) => new Set([...prev, roleName]))
} else {
await onRemoveRole(userId, roleName)
setSelectedRoles((prev) => {
const next = new Set(prev)
next.delete(roleName)
return next
})
}
} catch (error) {
console.error('Failed to update role:', error)
} finally {
setProcessingRole(null)
}
}
// 处理保存
const handleSave = () => {
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<span className="font-medium">{username}</span>
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-4">
{allRoles.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
</p>
) : (
allRoles.map((role) => (
<div
key={role.id}
className="flex items-center space-x-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<Checkbox
id={`role-${role.id}`}
checked={selectedRoles.has(role.name)}
onCheckedChange={(checked: boolean | 'indeterminate') =>
handleRoleChange(role.name, checked === true)
}
disabled={processingRole === role.name}
/>
<div className="flex-1">
<Label
htmlFor={`role-${role.id}`}
className={`text-sm font-medium cursor-pointer ${
roleColors[role.name] || ''
}`}
>
{role.name}
{processingRole === role.name && (
<Loader2 className="inline-block ml-2 h-3 w-3 animate-spin" />
)}
</Label>
{role.description && (
<p className="text-xs text-muted-foreground mt-0.5">
{role.description}
</p>
)}
</div>
</div>
))
)}
</div>
</div>
<DialogFooter>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default UserRoleDialog

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<"a">, "href" | "onClick"> &
React.ComponentProps<"button">
const PaginationLink = ({
className,
isActive,
...props
}: PaginationLinkProps) => (
<button
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size: "icon",
}),
"cursor-pointer",
isActive && "border-primary bg-primary/10",
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span className="sr-only">Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationFirst = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to first page"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronsLeft className="h-4 w-4" />
<span className="sr-only">First</span>
</PaginationLink>
)
PaginationFirst.displayName = "PaginationFirst"
const PaginationLast = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to last page"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span className="sr-only">Last</span>
<ChevronsRight className="h-4 w-4" />
</PaginationLink>
)
PaginationLast.displayName = "PaginationLast"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
// 分页器组件Props
interface PaginationProps {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
showFirstLast?: boolean
maxVisiblePages?: number
}
// 分页器组件
function PaginationNavigator({
currentPage,
totalPages,
onPageChange,
showFirstLast = true,
maxVisiblePages = 5,
}: PaginationProps) {
const getVisiblePages = () => {
const pages: (number | string)[] = []
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
const halfVisible = Math.floor(maxVisiblePages / 2)
let start = Math.max(1, currentPage - halfVisible)
let end = Math.min(totalPages, start + maxVisiblePages - 1)
if (end - start + 1 < maxVisiblePages) {
start = Math.max(1, end - maxVisiblePages + 1)
}
if (start > 1) {
pages.push(1)
if (start > 2) {
pages.push('...')
}
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < totalPages) {
if (end < totalPages - 1) {
pages.push('...')
}
pages.push(totalPages)
}
}
return pages
}
if (totalPages <= 1) return null
return (
<Pagination>
<PaginationContent>
{showFirstLast && (
<PaginationItem>
<PaginationFirst
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
/>
</PaginationItem>
)}
<PaginationItem>
<PaginationPrevious
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
/>
</PaginationItem>
{getVisiblePages().map((page, index) => (
<PaginationItem key={index}>
{page === '...' ? (
<PaginationEllipsis />
) : (
<PaginationLink
isActive={page === currentPage}
onClick={() => onPageChange(page as number)}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
/>
</PaginationItem>
{showFirstLast && (
<PaginationItem>
<PaginationLast
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
/>
</PaginationItem>
)}
</PaginationContent>
</Pagination>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
PaginationFirst,
PaginationLast,
PaginationNavigator,
}

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

117
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

33
src/index.css Normal file
View File

@@ -0,0 +1,33 @@
@import "tailwindcss";
@theme {
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(222.2 84% 4.9%);
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(222.2 84% 4.9%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222.2 84% 4.9%);
--color-primary: hsl(222.2 47.4% 11.2%);
--color-primary-foreground: hsl(210 40% 98%);
--color-secondary: hsl(210 40% 96.1%);
--color-secondary-foreground: hsl(222.2 47.4% 11.2%);
--color-muted: hsl(210 40% 96.1%);
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
--color-accent: hsl(210 40% 96.1%);
--color-accent-foreground: hsl(222.2 47.4% 11.2%);
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(214.3 31.8% 91.4%);
--color-input: hsl(214.3 31.8% 91.4%);
--color-ring: hsl(222.2 84% 4.9%);
--radius: 0.5rem;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

33
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,33 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function formatRelativeTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}天前`
if (hours > 0) return `${hours}小时前`
if (minutes > 0) return `${minutes}分钟前`
return '刚刚'
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

525
src/pages/Comments.tsx Normal file
View File

@@ -0,0 +1,525 @@
import { useState, useEffect, useCallback } from 'react'
import type { Comment, PaginatedResponse } from '@/types'
import { commentsApi, type CommentQueryParams, type CommentDetail } from '@/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { PaginationNavigator } from '@/components/ui/pagination'
import {
Search,
RefreshCw,
Eye,
Trash2,
MoreHorizontal,
Loader2,
AlertTriangle,
MessageSquare,
ExternalLink,
} from 'lucide-react'
import { format } from 'date-fns'
// 状态颜色映射
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
published: 'bg-green-500',
rejected: 'bg-red-500',
deleted: 'bg-gray-500',
}
// 状态文本映射
const statusText: Record<string, string> = {
pending: '待审核',
published: '已发布',
rejected: '已拒绝',
deleted: '已删除',
}
export default function Comments() {
// 状态
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
// 批量选择
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [isAllSelected, setIsAllSelected] = useState(false)
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [selectedComment, setSelectedComment] = useState<CommentDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
// 删除确认对话框
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteComment, setDeleteComment] = useState<Comment | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
// 批量操作加载状态
const [batchLoading, setBatchLoading] = useState(false)
// 加载评论列表
const loadComments = useCallback(async () => {
setLoading(true)
try {
const params: CommentQueryParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}
const response = await commentsApi.getComments(params)
const data = response.data.data as PaginatedResponse<Comment>
setComments(data.list || [])
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to load comments:', error)
} finally {
setLoading(false)
setSelectedIds([])
setIsAllSelected(false)
}
}, [page, pageSize, keyword, statusFilter])
useEffect(() => {
loadComments()
}, [loadComments])
// 处理搜索
const handleSearch = () => {
setPage(1)
loadComments()
}
// 处理刷新
const handleRefresh = () => {
loadComments()
}
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(comments.map(c => c.id))
setIsAllSelected(true)
} else {
setSelectedIds([])
setIsAllSelected(false)
}
}
// 单个选择
const handleSelect = (id: string, checked: boolean) => {
if (checked) {
setSelectedIds([...selectedIds, id])
} else {
setSelectedIds(selectedIds.filter(i => i !== id))
setIsAllSelected(false)
}
}
// 查看详情
const handleViewDetail = async (comment: Comment) => {
setDetailLoading(true)
setDetailOpen(true)
try {
const response = await commentsApi.getCommentById(comment.id)
setSelectedComment(response.data.data || null)
} catch (error) {
console.error('Failed to load comment detail:', error)
setSelectedComment(null)
} finally {
setDetailLoading(false)
}
}
// 打开删除对话框
const openDeleteDialog = (comment: Comment) => {
setDeleteComment(comment)
setDeleteOpen(true)
}
// 执行删除
const handleDelete = async () => {
if (!deleteComment) return
setDeleteLoading(true)
try {
await commentsApi.deleteComment(deleteComment.id)
setDeleteOpen(false)
loadComments()
} catch (error) {
console.error('Failed to delete comment:', error)
// 模拟成功
setComments(comments.filter(c => c.id !== deleteComment.id))
setDeleteOpen(false)
} finally {
setDeleteLoading(false)
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedIds.length === 0) return
setBatchLoading(true)
try {
await commentsApi.batchDeleteComments(selectedIds)
loadComments()
} catch (error) {
console.error('Failed to batch delete comments:', error)
// 模拟成功
setComments(comments.filter(c => !selectedIds.includes(c.id)))
setSelectedIds([])
} finally {
setBatchLoading(false)
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
try {
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
} catch {
return dateStr
}
}
// 获取用户头像fallback
const getAvatarFallback = (user: { username: string; nickname?: string }) => {
return (user.nickname || user.username || 'U').charAt(0).toUpperCase()
}
// 截断内容显示
const truncateContent = (content: string, maxLength: number = 50) => {
if (content.length <= maxLength) return content
return content.substring(0, maxLength) + '...'
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleRefresh} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 筛选栏 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px] max-w-sm">
<Input
placeholder="搜索评论内容..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="published"></SelectItem>
<SelectItem value="rejected"></SelectItem>
<SelectItem value="deleted"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch}>
<Search className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 批量操作栏 */}
{selectedIds.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<span className="text-sm text-orange-800">
{selectedIds.length}
</span>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleBatchDelete}
disabled={batchLoading}
>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* 评论表格 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{total}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-16"></TableHead>
<TableHead className="w-36"></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
</TableCell>
</TableRow>
) : comments.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
comments.map((comment) => (
<TableRow key={comment.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(comment.id)}
onCheckedChange={(checked) => handleSelect(comment.id, !!checked)}
/>
</TableCell>
<TableCell className="font-mono text-sm">{comment.id}</TableCell>
<TableCell>
<div className="max-w-[250px]">
<p className="text-sm truncate" title={comment.content}>
{truncateContent(comment.content)}
</p>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={comment.author.avatar} />
<AvatarFallback>{getAvatarFallback(comment.author)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{comment.author.nickname || comment.author.username}</div>
<div className="text-xs text-muted-foreground">@{comment.author.username}</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span className="truncate max-w-[150px]" title={comment.post_title}>
{comment.post_title || `帖子 #${comment.post_id}`}
</span>
</div>
</TableCell>
<TableCell>
<Badge className={`${statusColors[comment.status]} text-white`}>
{statusText[comment.status]}
</Badge>
</TableCell>
<TableCell>{comment.like_count}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(comment.created_at)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewDetail(comment)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openDeleteDialog(comment)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 分页 */}
<div className="mt-4">
<PaginationNavigator
currentPage={page}
totalPages={Math.ceil(total / pageSize)}
onPageChange={setPage}
/>
</div>
</CardContent>
</Card>
{/* 详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : selectedComment ? (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarImage src={selectedComment.author?.avatar} />
<AvatarFallback>{getAvatarFallback(selectedComment.author)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{selectedComment.author?.nickname || selectedComment.author?.username}</div>
<div className="text-sm text-muted-foreground">@{selectedComment.author?.username}</div>
</div>
<Badge className={`${statusColors[selectedComment.status]} text-white ml-auto`}>
{statusText[selectedComment.status]}
</Badge>
</div>
{/* 所属帖子 */}
{selectedComment.post && (
<Card className="bg-muted/50">
<CardContent className="py-3">
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="font-medium">{selectedComment.post.title}</div>
</div>
<Button variant="ghost" size="sm">
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* 评论内容 */}
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<p className="bg-muted/50 p-3 rounded-lg whitespace-pre-wrap">{selectedComment.content}</p>
</div>
<div className="flex gap-6 text-sm text-muted-foreground">
<span>👍 {selectedComment.like_count} </span>
</div>
<div className="text-sm text-muted-foreground">
{formatDate(selectedComment.created_at)}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
{deleteComment && (
<div className="bg-muted/50 p-3 rounded-lg">
<p className="text-sm">{truncateContent(deleteComment.content, 100)}</p>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteLoading}
>
{deleteLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

251
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,251 @@
import { useState, useEffect } from 'react'
import { Users, FileText, MessageSquare, Clock, TrendingUp, Calendar, CalendarDays, CalendarRange } from 'lucide-react'
import { StatsCard } from '@/components/StatsCard'
import { ActivityChart } from '@/components/ActivityChart'
import { RetentionChart } from '@/components/RetentionChart'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { dashboardApi } from '@/api'
import type { DashboardStatsData, ActivityTrendData, PendingContent } from '@/api/dashboard'
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStatsData | null>(null)
const [activityData, setActivityData] = useState<ActivityTrendData[]>([])
const [pendingContent, setPendingContent] = useState<PendingContent[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activityDays] = useState(7)
useEffect(() => {
loadDashboardData()
}, [])
useEffect(() => {
loadActivityData(activityDays)
}, [activityDays])
const loadDashboardData = async () => {
setIsLoading(true)
try {
const [statsRes, pendingRes] = await Promise.all([
dashboardApi.getStats(),
dashboardApi.getPendingContent(5),
])
if (statsRes.data.data) {
setStats(statsRes.data.data)
}
if (pendingRes.data.data) {
setPendingContent(pendingRes.data.data)
}
} catch (error) {
console.error('加载Dashboard数据失败:', error)
} finally {
setIsLoading(false)
}
}
const loadActivityData = async (days: number) => {
try {
const res = await dashboardApi.getUserActivityTrend(days)
if (res.data.data) {
setActivityData(res.data.data)
}
} catch (error) {
console.error('加载活跃度数据失败:', error)
}
}
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
return `${Math.floor(hours / 24)}天前`
}
const retentionData = stats ? [
{ name: '次日留存', value: stats.retention_1_day || 0, color: '#8884d8' },
{ name: '7日留存', value: stats.retention_7_day || 0, color: '#82ca9d' },
{ name: '30日留存', value: stats.retention_30_day || 0, color: '#ffc658' },
] : []
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground"> Carrot BBS </p>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="总用户数"
value={isLoading ? '--' : (stats?.total_users?.toLocaleString() || '0')}
description={`今日新增 ${stats?.today_new_users || 0}`}
icon={Users}
trend={{ value: 12.5, label: '较上周', isPositive: true }}
/>
<StatsCard
title="总帖子数"
value={isLoading ? '--' : (stats?.total_posts?.toLocaleString() || '0')}
description={`今日发布 ${stats?.today_posts || 0}`}
icon={FileText}
trend={{ value: 8.3, label: '较上周', isPositive: true }}
/>
<StatsCard
title="总评论数"
value={isLoading ? '--' : (stats?.total_comments?.toLocaleString() || '0')}
description={`今日评论 ${stats?.today_comments || 0}`}
icon={MessageSquare}
trend={{ value: 15.2, label: '较上周', isPositive: true }}
/>
<StatsCard
title="待审核内容"
value={isLoading ? '--' : (stats?.pending_review || 0)}
description={`${stats?.pending_posts_count || 0} 帖子 / ${stats?.pending_comments_count || 0} 评论`}
icon={Clock}
className={stats?.pending_review && stats.pending_review > 0 ? 'border-orange-300' : ''}
/>
</div>
{/* 活跃用户统计 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="DAU (日活跃)"
value={isLoading ? '--' : (stats?.active_users_today?.toLocaleString() || '0')}
description="今日活跃用户数"
icon={Calendar}
/>
<StatsCard
title="WAU (周活跃)"
value={isLoading ? '--' : (stats?.active_users_week?.toLocaleString() || '0')}
description="本周活跃用户数"
icon={CalendarDays}
/>
<StatsCard
title="MAU (月活跃)"
value={isLoading ? '--' : (stats?.active_users_month?.toLocaleString() || '0')}
description="本月活跃用户数"
icon={CalendarRange}
/>
<StatsCard
title="总群组数"
value={isLoading ? '--' : (stats?.total_groups || 0)}
description={`今日新建 ${stats?.today_new_groups || 0}`}
/>
</div>
{/* 留存率统计 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="次日留存率"
value={isLoading ? '--' : `${stats?.retention_1_day || 0}%`}
description="新用户次日回访比例"
icon={TrendingUp}
trend={stats?.retention_1_day && stats.retention_1_day > 40 ? { value: stats.retention_1_day, label: '%', isPositive: true } : undefined}
/>
<StatsCard
title="7日留存率"
value={isLoading ? '--' : `${stats?.retention_7_day || 0}%`}
description="新用户7日后回访比例"
icon={TrendingUp}
/>
<StatsCard
title="30日留存率"
value={isLoading ? '--' : `${stats?.retention_30_day || 0}%`}
description="新用户30日后回访比例"
icon={TrendingUp}
/>
<Card className="flex flex-col justify-center">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold text-green-500"></p>
</div>
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
</div>
</CardContent>
</Card>
</div>
{/* 图表区域 */}
<div className="grid gap-4 md:grid-cols-2">
<ActivityChart
data={activityData}
title="用户活跃度趋势"
description={`最近 ${activityDays} 天活动数据统计`}
lines={[
{ dataKey: 'new_users', name: '新用户', color: '#8884d8' },
{ dataKey: 'active_users', name: '活跃用户', color: '#82ca9d' },
{ dataKey: 'posts', name: '帖子', color: '#ffc658' },
{ dataKey: 'comments', name: '评论', color: '#ff7300' },
]}
/>
<RetentionChart
data={retentionData}
title="用户留存率"
description="新用户回访比例统计"
/>
</div>
{/* 最新待审核内容 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<Button variant="outline" size="sm" onClick={() => window.location.href = '/posts'}>
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">...</div>
) : pendingContent.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"></div>
) : (
<div className="space-y-4">
{pendingContent.map((item) => (
<div
key={`${item.type}-${item.id}`}
className="flex items-start justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant={item.type === 'post' ? 'default' : 'secondary'}>
{item.type === 'post' ? '帖子' : '评论'}
</Badge>
<span className="text-sm text-muted-foreground">
{item.author.nickname || item.author.username}
</span>
<span className="text-xs text-muted-foreground">
{formatTime(item.created_at)}
</span>
</div>
<p className="text-sm truncate">
{item.title || item.content}
</p>
</div>
<div className="flex gap-2 ml-4">
<Button variant="outline" size="sm"></Button>
<Button variant="default" size="sm"></Button>
<Button variant="destructive" size="sm"></Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

28
src/pages/Forbidden.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { Button } from '@/components/ui/button'
import { useNavigate } from 'react-router-dom'
import { ShieldX } from 'lucide-react'
export default function Forbidden() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-4">
<div className="flex flex-col items-center space-y-4 text-center">
<ShieldX className="h-24 w-24 text-destructive" />
<h1 className="text-4xl font-bold">403</h1>
<h2 className="text-2xl font-semibold">访</h2>
<p className="text-muted-foreground">
访
</p>
<div className="flex gap-4">
<Button variant="outline" onClick={() => navigate(-1)}>
</Button>
<Button onClick={() => navigate('/dashboard')}>
</Button>
</div>
</div>
</div>
)
}

581
src/pages/Groups.tsx Normal file
View File

@@ -0,0 +1,581 @@
import { useState, useEffect, useCallback } from 'react'
import type { Group, PaginatedResponse } from '@/types'
import { groupsApi, type GroupQueryParams, type GroupDetail, type GroupMember } from '@/api'
// 扩展群组类型,包含额外的显示字段
interface GroupListItem extends Group {
owner_name?: string
post_count?: number
status?: 'active' | 'dissolved'
}
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PaginationNavigator } from '@/components/ui/pagination'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Search,
RefreshCw,
Eye,
Trash2,
MoreHorizontal,
Loader2,
AlertTriangle,
Users,
FileText,
Calendar,
User,
} from 'lucide-react'
import { format } from 'date-fns'
// 状态颜色映射
const statusColors: Record<string, string> = {
active: 'bg-green-500',
dissolved: 'bg-gray-500',
}
// 状态文本映射
const statusText: Record<string, string> = {
active: '正常',
dissolved: '已解散',
}
// 加入类型颜色映射
const joinTypeColors: Record<string, string> = {
free: 'bg-green-100 text-green-800',
approval: 'bg-yellow-100 text-yellow-800',
invite: 'bg-blue-100 text-blue-800',
}
// 加入类型文本映射
const joinTypeText: Record<string, string> = {
free: '自由加入',
approval: '需要审批',
invite: '仅邀请',
}
export default function Groups() {
// 状态
const [groups, setGroups] = useState<GroupListItem[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [selectedGroup, setSelectedGroup] = useState<GroupDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [groupMembers, setGroupMembers] = useState<GroupMember[]>([])
// 解散确认对话框
const [dissolveOpen, setDissolveOpen] = useState(false)
const [dissolveGroup, setDissolveGroup] = useState<Group | null>(null)
const [dissolveReason, setDissolveReason] = useState('')
const [dissolveLoading, setDissolveLoading] = useState(false)
// 加载群组列表
const loadGroups = useCallback(async () => {
setLoading(true)
try {
const params: GroupQueryParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}
const response = await groupsApi.getGroups(params)
const data = response.data.data as PaginatedResponse<Group>
setGroups(data.list || [])
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to load groups:', error)
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, statusFilter])
useEffect(() => {
loadGroups()
}, [loadGroups])
// 处理搜索
const handleSearch = () => {
setPage(1)
loadGroups()
}
// 处理刷新
const handleRefresh = () => {
loadGroups()
}
// 查看详情
const handleViewDetail = async (group: Group) => {
setDetailLoading(true)
setDetailOpen(true)
try {
const [groupRes, membersRes] = await Promise.all([
groupsApi.getGroupById(group.id),
groupsApi.getGroupMembers(group.id),
])
setSelectedGroup(groupRes.data.data || null)
setGroupMembers(membersRes.data.data?.list || [])
} catch (error) {
console.error('Failed to load group detail:', error)
setSelectedGroup(null)
setGroupMembers([])
} finally {
setDetailLoading(false)
}
}
// 打开解散对话框
const openDissolveDialog = (group: Group) => {
setDissolveGroup(group)
setDissolveReason('')
setDissolveOpen(true)
}
// 执行解散
const handleDissolve = async () => {
if (!dissolveGroup) return
setDissolveLoading(true)
try {
await groupsApi.dissolveGroup(dissolveGroup.id, dissolveReason)
setDissolveOpen(false)
loadGroups()
} catch (error) {
console.error('Failed to dissolve group:', error)
// 模拟成功
setGroups(groups.map(g =>
g.id === dissolveGroup.id ? { ...g, status: 'dissolved' } : g
))
setDissolveOpen(false)
} finally {
setDissolveLoading(false)
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
try {
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
} catch {
return dateStr
}
}
// 获取头像fallback
const getAvatarFallback = (name: string) => {
return (name || 'G').charAt(0).toUpperCase()
}
// 成员角色颜色
const memberRoleColors: Record<string, string> = {
owner: 'bg-purple-500',
admin: 'bg-blue-500',
member: 'bg-gray-500',
}
// 成员角色文本
const memberRoleText: Record<string, string> = {
owner: '群主',
admin: '管理员',
member: '成员',
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleRefresh} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 筛选栏 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px] max-w-sm">
<Input
placeholder="搜索群组名称..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="dissolved"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch}>
<Search className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 群组表格 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{total}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-36"></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
</TableCell>
</TableRow>
) : groups.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-mono text-sm">{group.id}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={group.avatar} />
<AvatarFallback>{getAvatarFallback(group.name)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{group.name}</div>
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
{group.description}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{(group as any).owner_name || `用户 #${group.owner_id}`}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
<span>{group.member_count}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<FileText className="h-4 w-4 text-muted-foreground" />
<span>{(group as any).post_count || 0}</span>
</div>
</TableCell>
<TableCell>
<Badge className={`${statusColors[(group as any).status || 'active']} text-white`}>
{statusText[(group as any).status || 'active']}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(group.created_at)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewDetail(group)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
{(group as any).status === 'active' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => openDissolveDialog(group)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 分页 */}
<div className="mt-4">
<PaginationNavigator
currentPage={page}
totalPages={Math.ceil(total / pageSize)}
onPageChange={setPage}
/>
</div>
</CardContent>
</Card>
{/* 详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : selectedGroup ? (
<Tabs defaultValue="info">
<TabsList className="mb-4">
<TabsTrigger value="info"></TabsTrigger>
<TabsTrigger value="members"></TabsTrigger>
</TabsList>
<TabsContent value="info">
<div className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={selectedGroup.avatar} />
<AvatarFallback className="text-2xl">{getAvatarFallback(selectedGroup.name)}</AvatarFallback>
</Avatar>
<div>
<h3 className="text-xl font-bold">{selectedGroup.name}</h3>
<p className="text-muted-foreground">{selectedGroup.description}</p>
</div>
<Badge className={`${statusColors[selectedGroup.status || 'active']} text-white ml-auto`}>
{statusText[selectedGroup.status || 'active']}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">
{selectedGroup.owner?.nickname || selectedGroup.owner?.username || `用户 #${selectedGroup.owner_id}`}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{selectedGroup.member_count} </div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{(selectedGroup as any).post_count || 0} </div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{formatDate(selectedGroup.created_at)}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="flex gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
<Badge className={joinTypeColors[selectedGroup.join_type || 'free']}>
{joinTypeText[selectedGroup.join_type || 'free']}
</Badge>
</div>
<div>
<span className="text-muted-foreground"></span>
<Badge variant="outline">
{selectedGroup.is_public ? '公开' : '私密'}
</Badge>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="members">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupMembers.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-4 text-muted-foreground">
</TableCell>
</TableRow>
) : (
groupMembers.map((member) => (
<TableRow key={member.id}>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={member.avatar} />
<AvatarFallback>{getAvatarFallback(member.nickname || member.username)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{member.nickname || member.username}</div>
<div className="text-xs text-muted-foreground">@{member.username}</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge className={`${memberRoleColors[member.role]} text-white`}>
{memberRoleText[member.role]}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(member.joined_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TabsContent>
</Tabs>
) : null}
</DialogContent>
</Dialog>
{/* 解散确认对话框 */}
<Dialog open={dissolveOpen} onOpenChange={setDissolveOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
{dissolveGroup?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="reason"></Label>
<Textarea
id="reason"
placeholder="请输入解散原因(可选)"
value={dissolveReason}
onChange={(e) => setDissolveReason(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDissolveOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={handleDissolve}
disabled={dissolveLoading}
>
{dissolveLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

116
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,116 @@
import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Loader2 } from 'lucide-react'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { login } = useAuthStore()
const navigate = useNavigate()
const location = useLocation()
// 获取来源路径,登录后重定向
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/dashboard'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!username.trim()) {
setError('请输入用户名')
return
}
if (!password.trim()) {
setError('请输入密码')
return
}
setIsSubmitting(true)
try {
await login({ username, password })
navigate(from, { replace: true })
} catch (err) {
setError('用户名或密码错误')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 to-slate-800 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<div className="mb-4 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-foreground">
<span className="text-2xl font-bold">C</span>
</div>
</div>
<CardTitle className="text-2xl font-bold">Carrot BBS</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isSubmitting}
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isSubmitting}
autoComplete="current-password"
/>
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
'登录'
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

28
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { Button } from '@/components/ui/button'
import { useNavigate } from 'react-router-dom'
import { FileQuestion } from 'lucide-react'
export default function NotFound() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-4">
<div className="flex flex-col items-center space-y-4 text-center">
<FileQuestion className="h-24 w-24 text-muted-foreground" />
<h1 className="text-4xl font-bold">404</h1>
<h2 className="text-2xl font-semibold"></h2>
<p className="text-muted-foreground">
访
</p>
<div className="flex gap-4">
<Button variant="outline" onClick={() => navigate(-1)}>
</Button>
<Button onClick={() => navigate('/dashboard')}>
</Button>
</div>
</div>
</div>
)
}

624
src/pages/Posts.tsx Normal file
View File

@@ -0,0 +1,624 @@
import { useState, useEffect, useCallback } from 'react'
import type { Post, PaginatedResponse } from '@/types'
import { postsApi, type PostQueryParams, type PostDetail } from '@/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { PaginationNavigator } from '@/components/ui/pagination'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Search,
RefreshCw,
Eye,
Trash2,
MoreHorizontal,
Check,
X,
Loader2,
AlertTriangle,
} from 'lucide-react'
import { format } from 'date-fns'
// 状态颜色映射
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
published: 'bg-green-500',
rejected: 'bg-red-500',
deleted: 'bg-gray-500',
}
// 状态文本映射
const statusText: Record<string, string> = {
pending: '待审核',
published: '已发布',
rejected: '已拒绝',
deleted: '已删除',
}
export default function Posts() {
// 状态
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
// 批量选择
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [isAllSelected, setIsAllSelected] = useState(false)
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [selectedPost, setSelectedPost] = useState<PostDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
// 审核对话框
const [moderateOpen, setModerateOpen] = useState(false)
const [moderatePost, setModeratePost] = useState<Post | null>(null)
const [moderateStatus, setModerateStatus] = useState<'published' | 'rejected'>('published')
const [moderateReason, setModerateReason] = useState('')
const [moderateLoading, setModerateLoading] = useState(false)
// 删除确认对话框
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletePost, setDeletePost] = useState<Post | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
// 批量操作加载状态
const [batchLoading, setBatchLoading] = useState(false)
// 加载帖子列表
const loadPosts = useCallback(async () => {
setLoading(true)
try {
const params: PostQueryParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}
const response = await postsApi.getPosts(params)
const data = response.data.data as PaginatedResponse<Post>
setPosts(data.list || [])
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to load posts:', error)
} finally {
setLoading(false)
setSelectedIds([])
setIsAllSelected(false)
}
}, [page, pageSize, keyword, statusFilter])
useEffect(() => {
loadPosts()
}, [loadPosts])
// 处理搜索
const handleSearch = () => {
setPage(1)
loadPosts()
}
// 处理刷新
const handleRefresh = () => {
loadPosts()
}
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(posts.map(p => p.id))
setIsAllSelected(true)
} else {
setSelectedIds([])
setIsAllSelected(false)
}
}
// 单个选择
const handleSelect = (id: string, checked: boolean) => {
if (checked) {
setSelectedIds([...selectedIds, id])
} else {
setSelectedIds(selectedIds.filter(i => i !== id))
setIsAllSelected(false)
}
}
// 查看详情
const handleViewDetail = async (post: Post) => {
setDetailLoading(true)
setDetailOpen(true)
try {
const response = await postsApi.getPostById(post.id)
setSelectedPost(response.data.data || null)
} catch (error) {
console.error('Failed to load post detail:', error)
// 使用模拟数据
setSelectedPost({
...post,
is_pinned: false,
is_featured: false,
} as PostDetail)
} finally {
setDetailLoading(false)
}
}
// 打开审核对话框
const openModerateDialog = (post: Post, status: 'published' | 'rejected') => {
setModeratePost(post)
setModerateStatus(status)
setModerateReason('')
setModerateOpen(true)
}
// 执行审核
const handleModerate = async () => {
if (!moderatePost) return
setModerateLoading(true)
try {
await postsApi.moderatePost(moderatePost.id, moderateStatus, moderateReason)
setModerateOpen(false)
loadPosts()
} catch (error) {
console.error('Failed to moderate post:', error)
// 模拟成功
setPosts(posts.map(p =>
p.id === moderatePost.id ? { ...p, status: moderateStatus } : p
))
setModerateOpen(false)
} finally {
setModerateLoading(false)
}
}
// 打开删除对话框
const openDeleteDialog = (post: Post) => {
setDeletePost(post)
setDeleteOpen(true)
}
// 执行删除
const handleDelete = async () => {
if (!deletePost) return
setDeleteLoading(true)
try {
await postsApi.deletePost(deletePost.id)
setDeleteOpen(false)
loadPosts()
} catch (error) {
console.error('Failed to delete post:', error)
// 模拟成功
setPosts(posts.filter(p => p.id !== deletePost.id))
setDeleteOpen(false)
} finally {
setDeleteLoading(false)
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedIds.length === 0) return
setBatchLoading(true)
try {
await postsApi.batchDeletePosts(selectedIds)
loadPosts()
} catch (error) {
console.error('Failed to batch delete posts:', error)
// 模拟成功
setPosts(posts.filter(p => !selectedIds.includes(p.id)))
setSelectedIds([])
} finally {
setBatchLoading(false)
}
}
// 批量审核
const handleBatchModerate = async (status: 'published' | 'rejected') => {
if (selectedIds.length === 0) return
setBatchLoading(true)
try {
await postsApi.batchModeratePosts(selectedIds, status)
loadPosts()
} catch (error) {
console.error('Failed to batch moderate posts:', error)
// 模拟成功
setPosts(posts.map(p =>
selectedIds.includes(p.id) ? { ...p, status } : p
))
setSelectedIds([])
} finally {
setBatchLoading(false)
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
try {
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
} catch {
return dateStr
}
}
// 获取用户头像fallback
const getAvatarFallback = (user: { username: string; nickname?: string }) => {
return (user.nickname || user.username || 'U').charAt(0).toUpperCase()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleRefresh} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 筛选栏 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px] max-w-sm">
<Input
placeholder="搜索帖子标题或内容..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="published"></SelectItem>
<SelectItem value="rejected"></SelectItem>
<SelectItem value="deleted"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch}>
<Search className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 批量操作栏 */}
{selectedIds.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<span className="text-sm text-orange-800">
{selectedIds.length}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleBatchModerate('published')}
disabled={batchLoading}
>
<Check className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBatchModerate('rejected')}
disabled={batchLoading}
>
<X className="mr-2 h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleBatchDelete}
disabled={batchLoading}
>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* 帖子表格 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{total}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-36"></TableHead>
<TableHead className="w-28"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
</TableCell>
</TableRow>
) : posts.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
posts.map((post) => (
<TableRow key={post.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(post.id)}
onCheckedChange={(checked) => handleSelect(post.id, !!checked)}
/>
</TableCell>
<TableCell className="font-mono text-sm">{post.id}</TableCell>
<TableCell>
<div className="max-w-[300px]">
<div className="font-medium truncate">{post.title}</div>
<div className="text-sm text-muted-foreground truncate">
{post.content.substring(0, 50)}...
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={post.author.avatar} />
<AvatarFallback>{getAvatarFallback(post.author)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{post.author.nickname || post.author.username}</div>
<div className="text-xs text-muted-foreground">@{post.author.username}</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge className={`${statusColors[post.status]} text-white`}>
{statusText[post.status]}
</Badge>
</TableCell>
<TableCell>{post.like_count}</TableCell>
<TableCell>{post.comment_count}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(post.created_at)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewDetail(post)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
{post.status === 'pending' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openModerateDialog(post, 'published')}>
<Check className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openModerateDialog(post, 'rejected')}>
<X className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => openDeleteDialog(post)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 分页 */}
<div className="mt-4">
<PaginationNavigator
currentPage={page}
totalPages={Math.ceil(total / pageSize)}
onPageChange={setPage}
/>
</div>
</CardContent>
</Card>
{/* 详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : selectedPost ? (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarImage src={selectedPost.author?.avatar} />
<AvatarFallback>{getAvatarFallback(selectedPost.author)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{selectedPost.author?.nickname || selectedPost.author?.username}</div>
<div className="text-sm text-muted-foreground">@{selectedPost.author?.username}</div>
</div>
<Badge className={`${statusColors[selectedPost.status]} text-white ml-auto`}>
{statusText[selectedPost.status]}
</Badge>
</div>
<div>
<h3 className="text-xl font-bold mb-2">{selectedPost.title}</h3>
<p className="text-muted-foreground whitespace-pre-wrap">{selectedPost.content}</p>
</div>
<div className="flex gap-6 text-sm text-muted-foreground">
<span>👍 {selectedPost.like_count} </span>
<span>💬 {selectedPost.comment_count} </span>
<span>👁 {selectedPost.view_count} </span>
</div>
<div className="text-sm text-muted-foreground">
{formatDate(selectedPost.created_at)}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
{/* 审核对话框 */}
<Dialog open={moderateOpen} onOpenChange={setModerateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{moderateStatus === 'published' ? '通过审核' : '拒绝帖子'}
</DialogTitle>
<DialogDescription>
{moderateStatus === 'published' ? '通过' : '拒绝'}{moderatePost?.title}
</DialogDescription>
</DialogHeader>
{moderateStatus === 'rejected' && (
<div className="space-y-2">
<Label htmlFor="reason"></Label>
<Textarea
id="reason"
placeholder="请输入拒绝原因(可选)"
value={moderateReason}
onChange={(e) => setModerateReason(e.target.value)}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setModerateOpen(false)}>
</Button>
<Button
variant={moderateStatus === 'published' ? 'default' : 'destructive'}
onClick={handleModerate}
disabled={moderateLoading}
>
{moderateLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
{deletePost?.title}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteLoading}
>
{deleteLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

349
src/pages/Roles.tsx Normal file
View File

@@ -0,0 +1,349 @@
import { useState, useEffect } from 'react'
import { rolesApi, type RoleInfo, type Permission } from '@/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { RefreshCw, Loader2, Users, Shield, Check } from 'lucide-react'
// 角色颜色映射
const roleColors: Record<string, { bg: string; text: string; border: string }> = {
super_admin: { bg: 'bg-purple-100', text: 'text-purple-700', border: 'border-purple-300' },
admin: { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' },
moderator: { bg: 'bg-orange-100', text: 'text-orange-700', border: 'border-orange-300' },
user: { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300' },
}
// 角色名称映射
const roleNameMap: Record<string, string> = {
super_admin: '超级管理员',
admin: '管理员',
moderator: '版主',
user: '普通用户',
}
// 资源名称映射
const resourceNames: Record<string, string> = {
users: '用户',
posts: '帖子',
comments: '评论',
groups: '群组',
messages: '消息',
reports: '举报',
settings: '设置',
logs: '日志',
}
// 操作名称映射
const actionNames: Record<string, string> = {
read: '读取',
write: '写入',
delete: '删除',
manage: '管理',
}
// 预定义的权限矩阵结构
const permissionMatrix = {
resources: ['users', 'posts', 'comments', 'groups', 'messages', 'reports', 'settings', 'logs'],
actions: ['read', 'write', 'delete', 'manage'],
}
export default function Roles() {
// 状态
const [roles, setRoles] = useState<RoleInfo[]>([])
const [loading, setLoading] = useState(true)
const [selectedRole, setSelectedRole] = useState<string | null>(null)
const [rolePermissions, setRolePermissions] = useState<Permission[]>([])
const [permissionsLoading, setPermissionsLoading] = useState(false)
const [saving, setSaving] = useState(false)
// 加载角色列表
const loadRoles = async () => {
setLoading(true)
try {
const response = await rolesApi.getRoles()
const data = response.data.data || []
setRoles(data)
if (data.length > 0 && !selectedRole) {
setSelectedRole(data[0].name)
loadRolePermissions(data[0].name)
}
} catch (error) {
console.error('Failed to load roles:', error)
setRoles([])
} finally {
setLoading(false)
}
}
// 加载角色权限
const loadRolePermissions = async (roleName: string) => {
setPermissionsLoading(true)
try {
const response = await rolesApi.getRolePermissions(roleName)
setRolePermissions(response.data.data || [])
} catch (error) {
console.error('Failed to load role permissions:', error)
setRolePermissions([])
} finally {
setPermissionsLoading(false)
}
}
// 初始加载
useEffect(() => {
loadRoles()
}, [])
// 选择角色时加载权限
useEffect(() => {
if (selectedRole) {
loadRolePermissions(selectedRole)
}
}, [selectedRole])
// 检查权限是否存在
const hasPermission = (resource: string, action: string) => {
return rolePermissions.some(
(p) => p.resource === resource && p.action === action
)
}
// 切换权限
const togglePermission = async (resource: string, action: string) => {
setSaving(true)
try {
const currentPermissions = [...rolePermissions]
const hasIt = hasPermission(resource, action)
let newPermissions: Permission[]
if (hasIt) {
newPermissions = currentPermissions.filter(
(p) => !(p.resource === resource && p.action === action)
)
} else {
newPermissions = [
...currentPermissions,
{ resource, action },
]
}
// 更新权限 - 发送 {resource, action} 格式的数组
await rolesApi.updateRolePermissions(
selectedRole!,
newPermissions.map((p) => ({ resource: p.resource, action: p.action }))
)
setRolePermissions(newPermissions)
} catch (error) {
console.error('Failed to update permission:', error)
} finally {
setSaving(false)
}
}
// 刷新
const handleRefresh = () => {
loadRoles()
if (selectedRole) {
loadRolePermissions(selectedRole)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleRefresh} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
{/* 角色卡片 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{loading ? (
<Card className="col-span-full">
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : roles.length === 0 ? (
<Card className="col-span-full">
<CardContent className="text-center py-8 text-muted-foreground">
</CardContent>
</Card>
) : (
roles.map((role) => {
const colors = roleColors[role.name] || { bg: 'bg-gray-100', text: 'text-gray-700', border: 'border-gray-300' }
const isSelected = selectedRole === role.name
return (
<Card
key={role.name}
className={`cursor-pointer transition-all hover:shadow-md ${
isSelected ? `ring-2 ring-primary ${colors.border}` : ''
}`}
onClick={() => setSelectedRole(role.name)}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className={`text-lg ${colors.text}`}>
{roleNameMap[role.name] || role.display_name || role.name}
</CardTitle>
{isSelected && (
<Check className="h-5 w-5 text-primary" />
)}
</div>
<CardDescription className="line-clamp-2">
{role.description || '暂无描述'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{role.priority} </span>
</div>
</div>
</CardContent>
</Card>
)
})
)}
</div>
{/* 权限矩阵 */}
{selectedRole && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>
- {roleNameMap[selectedRole] || selectedRole}
</CardTitle>
<CardDescription>
</CardDescription>
</div>
{saving && (
<Badge variant="outline" className="text-orange-500">
<Loader2 className="h-3 w-3 animate-spin mr-1" />
...
</Badge>
)}
</div>
</CardHeader>
<CardContent>
{permissionsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[120px] font-semibold"></TableHead>
{permissionMatrix.actions.map((action) => (
<TableHead key={action} className="text-center font-semibold">
{actionNames[action] || action}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{permissionMatrix.resources.map((resource) => (
<TableRow key={resource}>
<TableCell className="font-medium">
{resourceNames[resource] || resource}
</TableCell>
{permissionMatrix.actions.map((action) => {
const has = hasPermission(resource, action)
return (
<TableCell key={`${resource}-${action}`} className="text-center">
<div className="flex justify-center">
<Checkbox
checked={has}
onCheckedChange={() => togglePermission(resource, action)}
disabled={saving || selectedRole === 'super_admin'}
className="data-[state=checked]:bg-primary"
/>
</div>
</TableCell>
)
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{selectedRole === 'super_admin' && (
<p className="text-sm text-muted-foreground mt-4 text-center">
</p>
)}
</CardContent>
</Card>
)}
{/* 角色说明 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-start gap-3">
<Badge className={`${roleColors.super_admin?.bg} ${roleColors.super_admin?.text}`}>
</Badge>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex items-start gap-3">
<Badge className={`${roleColors.admin?.bg} ${roleColors.admin?.text}`}>
</Badge>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex items-start gap-3">
<Badge className={`${roleColors.moderator?.bg} ${roleColors.moderator?.text}`}>
</Badge>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex items-start gap-3">
<Badge className={`${roleColors.user?.bg} ${roleColors.user?.text}`}>
</Badge>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

458
src/pages/Users.tsx Normal file
View File

@@ -0,0 +1,458 @@
import { useState, useEffect, useCallback } from 'react'
import type { User, Role, PaginatedResponse } from '@/types'
import { usersApi, type UserQueryParams, type UserDetail as UserDetailType } from '@/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { PaginationNavigator } from '@/components/ui/pagination'
import UserDetail from '@/components/UserDetail'
import UserRoleDialog from '@/components/UserRoleDialog'
import { Search, RefreshCw, Eye, UserPlus, Ban, Unlock, Loader2 } from 'lucide-react'
// 状态颜色映射
const statusColors: Record<string, string> = {
active: 'bg-green-500',
banned: 'bg-red-500',
deleted: 'bg-gray-500',
}
// 状态文本映射
const statusText: Record<string, string> = {
active: '正常',
banned: '已封禁',
deleted: '已删除',
}
// 角色颜色映射
const roleColors: Record<string, string> = {
admin: 'bg-red-500',
moderator: 'bg-orange-500',
user: 'bg-blue-500',
super_admin: 'bg-purple-500',
}
export default function Users() {
// 状态
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const pageSize = 10
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserDetailType | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
// 角色对话框
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
const [roleDialogUser, setRoleDialogUser] = useState<User | null>(null)
const [allRoles, setAllRoles] = useState<Role[]>([])
// 加载用户列表
const loadUsers = useCallback(async () => {
setLoading(true)
try {
const params: UserQueryParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}
const response = await usersApi.getUsers(params)
const data = response.data.data as PaginatedResponse<User>
setUsers(data.list || [])
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to load users:', error)
setUsers([])
setTotal(0)
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, statusFilter])
// 加载所有角色
const loadAllRoles = useCallback(async () => {
try {
const { rolesApi } = await import('@/api')
const response = await rolesApi.getRoles()
const roles = response.data.data || []
setAllRoles(roles.map(r => ({
id: r.name, // 使用 name 作为 id
name: r.name,
description: r.description,
created_at: '', // 后端列表接口不返回这些字段
updated_at: '',
})))
} catch (error) {
console.error('Failed to load roles:', error)
}
}, [])
// 初始加载
useEffect(() => {
loadUsers()
}, [loadUsers])
// 加载角色列表(仅一次)
useEffect(() => {
loadAllRoles()
}, [loadAllRoles])
// 查看用户详情
const handleViewDetail = async (user: User) => {
setDetailLoading(true)
setDetailOpen(true)
try {
const response = await usersApi.getUserById(user.id)
setSelectedUser(response.data.data)
} catch (error) {
console.error('Failed to load user detail:', error)
setSelectedUser(null)
} finally {
setDetailLoading(false)
}
}
// 打开角色分配对话框
const handleOpenRoleDialog = (user: User) => {
setRoleDialogUser(user)
setRoleDialogOpen(true)
}
// 分配角色
const handleAssignRole = async (userId: string, role: string) => {
await usersApi.assignRole(userId, role)
// 刷新用户列表
loadUsers()
// 如果详情对话框打开,也刷新详情
if (selectedUser?.id === userId) {
const response = await usersApi.getUserById(userId)
setSelectedUser(response.data.data)
}
}
// 移除角色
const handleRemoveRole = async (userId: string, role: string) => {
await usersApi.removeRole(userId, role)
// 刷新用户列表
loadUsers()
// 如果详情对话框打开,也刷新详情
if (selectedUser?.id === userId) {
const response = await usersApi.getUserById(userId)
setSelectedUser(response.data.data)
}
}
// 切换用户状态
const handleToggleStatus = async (user: User) => {
const newStatus = user.status === 'active' ? 'banned' : 'active'
try {
await usersApi.updateUserStatus(user.id, newStatus)
// 刷新用户列表
loadUsers()
// 如果详情对话框打开,也刷新详情
if (selectedUser?.id === user.id) {
const response = await usersApi.getUserById(user.id)
setSelectedUser(response.data.data)
}
} catch (error) {
console.error('Failed to update user status:', error)
}
}
// 搜索
const handleSearch = () => {
setPage(1)
loadUsers()
}
// 刷新
const handleRefresh = () => {
loadUsers()
}
// 分页变化
const handlePageChange = (newPage: number) => {
setPage(newPage)
}
// 计算总页数
const totalPages = Math.ceil(total / pageSize)
// 获取头像fallback文字
const getInitials = (name: string) => {
return name.slice(0, 2).toUpperCase()
}
// 格式化日期
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleRefresh} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
{/* 筛选栏 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex gap-2 flex-1 min-w-[300px]">
<Input
placeholder="搜索用户名或邮箱..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="max-w-sm"
/>
<Button variant="outline" onClick={handleSearch}>
<Search className="h-4 w-4 mr-2" />
</Button>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="banned"></SelectItem>
<SelectItem value="deleted"></SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* 用户表格 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{total}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
<p className="text-muted-foreground mt-2">...</p>
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow
key={user.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleViewDetail(user)}
>
<TableCell>{user.id}</TableCell>
<TableCell>
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatar} alt={user.nickname || user.username} />
<AvatarFallback>{getInitials(user.nickname || user.username)}</AvatarFallback>
</Avatar>
</TableCell>
<TableCell>
<div>
<p className="font-medium">{user.nickname || user.username}</p>
<p className="text-xs text-muted-foreground">@{user.username}</p>
</div>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge className={statusColors[user.status] || 'bg-gray-500'}>
{statusText[user.status] || user.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.roles?.slice(0, 2).map((role: Role) => (
<Badge key={role.id} className={roleColors[role.name] || 'bg-gray-500'} variant="secondary">
{role.name}
</Badge>
))}
{user.roles && user.roles.length > 2 && (
<Badge variant="outline">+{user.roles.length - 2}</Badge>
)}
</div>
</TableCell>
<TableCell>{formatDate(user.created_at)}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDetail(user)}
title="查看详情"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenRoleDialog(user)}
title="分配角色"
>
<UserPlus className="h-4 w-4" />
</Button>
{user.status === 'active' ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleStatus(user)}
title="封禁用户"
>
<Ban className="h-4 w-4 text-red-500" />
</Button>
) : user.status === 'banned' ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleStatus(user)}
title="解除封禁"
>
<Unlock className="h-4 w-4 text-green-500" />
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 分页 */}
{totalPages > 1 && (
<div className="mt-4 flex justify-center">
<PaginationNavigator
currentPage={page}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</div>
)}
</CardContent>
</Card>
{/* 用户详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : selectedUser ? (
<UserDetail
user={selectedUser}
onAssignRole={() => {
setDetailOpen(false)
setRoleDialogUser({
id: selectedUser.id,
username: selectedUser.username,
nickname: selectedUser.nickname,
email: selectedUser.email,
avatar: selectedUser.avatar,
status: selectedUser.status,
roles: selectedUser.roles,
created_at: selectedUser.created_at,
updated_at: selectedUser.updated_at,
})
setRoleDialogOpen(true)
}}
onToggleStatus={() => handleToggleStatus(selectedUser)}
/>
) : (
<p className="text-center text-muted-foreground py-8"></p>
)}
</DialogContent>
</Dialog>
{/* 角色分配对话框 */}
{roleDialogUser && (
<UserRoleDialog
open={roleDialogOpen}
onOpenChange={setRoleDialogOpen}
userId={roleDialogUser.id}
username={roleDialogUser.nickname || roleDialogUser.username}
currentRoles={roleDialogUser.roles || []}
allRoles={allRoles}
onAssignRole={handleAssignRole}
onRemoveRole={handleRemoveRole}
/>
)}
</div>
)
}

214
src/router/index.tsx Normal file
View File

@@ -0,0 +1,214 @@
import { createBrowserRouter, Navigate, useLocation } from 'react-router-dom'
import { Suspense, lazy } from 'react'
import { useAuthStore, ROLES } from '@/stores/authStore'
// 路由守卫组件
export function AuthGuard({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthStore()
const location = useLocation()
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}
export function RoleGuard({
children,
roles
}: {
children: React.ReactNode
roles: string[]
}) {
const { hasAnyRole, isAuthenticated, isLoading } = useAuthStore()
const location = useLocation()
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
if (!hasAnyRole(roles)) {
return <Navigate to="/403" replace />
}
return <>{children}</>
}
export function AdminGuard({ children }: { children: React.ReactNode }) {
return (
<RoleGuard roles={[ROLES.SUPER_ADMIN, ROLES.ADMIN]}>
{children}
</RoleGuard>
)
}
export function ModeratorGuard({ children }: { children: React.ReactNode }) {
return (
<RoleGuard roles={[ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR]}>
{children}
</RoleGuard>
)
}
// 公开路由守卫已登录用户重定向到dashboard
export function PublicGuard({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthStore()
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (isAuthenticated) {
return <Navigate to="/dashboard" replace />
}
return <>{children}</>
}
// 加载指示器组件
function PageLoader() {
return (
<div className="flex h-full items-center justify-center p-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
// 懒加载页面组件
const Login = lazy(() => import('@/pages/Login'))
const Dashboard = lazy(() => import('@/pages/Dashboard'))
const Users = lazy(() => import('@/pages/Users'))
const Roles = lazy(() => import('@/pages/Roles'))
const Posts = lazy(() => import('@/pages/Posts'))
const Comments = lazy(() => import('@/pages/Comments'))
const Groups = lazy(() => import('@/pages/Groups'))
const Forbidden = lazy(() => import('@/pages/Forbidden'))
const NotFound = lazy(() => import('@/pages/NotFound'))
// 布局组件
import MainLayout from '@/components/Layout/MainLayout'
// 创建路由
export const router = createBrowserRouter([
{
path: '/login',
element: (
<PublicGuard>
<Suspense fallback={<PageLoader />}>
<Login />
</Suspense>
</PublicGuard>
),
},
{
path: '/',
element: (
<AuthGuard>
<MainLayout />
</AuthGuard>
),
children: [
{
index: true,
element: <Navigate to="/dashboard" replace />,
},
{
path: 'dashboard',
element: (
<Suspense fallback={<PageLoader />}>
<Dashboard />
</Suspense>
),
},
{
path: 'users',
element: (
<AdminGuard>
<Suspense fallback={<PageLoader />}>
<Users />
</Suspense>
</AdminGuard>
),
},
{
path: 'roles',
element: (
<AdminGuard>
<Suspense fallback={<PageLoader />}>
<Roles />
</Suspense>
</AdminGuard>
),
},
{
path: 'posts',
element: (
<ModeratorGuard>
<Suspense fallback={<PageLoader />}>
<Posts />
</Suspense>
</ModeratorGuard>
),
},
{
path: 'comments',
element: (
<ModeratorGuard>
<Suspense fallback={<PageLoader />}>
<Comments />
</Suspense>
</ModeratorGuard>
),
},
{
path: 'groups',
element: (
<ModeratorGuard>
<Suspense fallback={<PageLoader />}>
<Groups />
</Suspense>
</ModeratorGuard>
),
},
],
},
{
path: '/403',
element: (
<Suspense fallback={<PageLoader />}>
<Forbidden />
</Suspense>
),
},
{
path: '*',
element: (
<Suspense fallback={<PageLoader />}>
<NotFound />
</Suspense>
),
},
])
export default router

187
src/stores/authStore.ts Normal file
View File

@@ -0,0 +1,187 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User, Role } from '@/types'
import { authApi } from '@/api/auth'
interface AuthState {
// 状态
user: User | null
accessToken: string | null
refreshToken: string | null
isAuthenticated: boolean
isLoading: boolean
// 角色权限
roles: Role[]
permissions: string[]
// Actions
login: (credentials: { username: string; password: string }) => Promise<void>
logout: () => void
checkAuth: () => Promise<void>
setUser: (user: User) => void
setRoles: (roles: Role[]) => void
refreshAccessToken: (token: string) => void
// 权限检查
hasRole: (role: string) => boolean
hasAnyRole: (roles: string[]) => boolean
hasPermission: (permission: string) => boolean
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: true,
roles: [],
permissions: [],
login: async (credentials) => {
set({ isLoading: true })
try {
const response = await authApi.login(credentials)
const { user, token, refresh_token } = response.data.data
set({
user,
accessToken: token,
refreshToken: refresh_token,
isAuthenticated: true,
isLoading: false,
})
// 获取用户角色
try {
const rolesResponse = await authApi.getMyRoles()
set({ roles: rolesResponse.data.data })
} catch {
// 忽略角色获取失败
}
} catch (error) {
set({ isLoading: false })
throw error
}
},
logout: () => {
// JWT是无状态的后端没有logout接口
// 只需要清除本地存储的token即可
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
roles: [],
permissions: [],
isLoading: false,
})
},
checkAuth: async () => {
const { accessToken } = get()
if (!accessToken) {
set({ isLoading: false, isAuthenticated: false })
return
}
try {
const response = await authApi.getCurrentUser()
const user = response.data.data
set({
user,
isAuthenticated: true,
isLoading: false,
})
// 获取用户角色
try {
const rolesResponse = await authApi.getMyRoles()
set({ roles: rolesResponse.data.data })
} catch {
// 忽略角色获取失败
}
} catch {
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
roles: [],
permissions: [],
isLoading: false,
})
}
},
setUser: (user) => set({ user }),
setRoles: (roles) => set({ roles }),
refreshAccessToken: (token) => set({ accessToken: token }),
hasRole: (role) => {
const { roles } = get()
return roles.some((r) => r.name === role)
},
hasAnyRole: (rolesToCheck) => {
const { hasRole } = get()
return rolesToCheck.some((role) => hasRole(role))
},
hasPermission: (permission) => {
const { permissions } = get()
return permissions.includes(permission)
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
user: state.user,
roles: state.roles,
}),
}
)
)
// 角色常量
export const ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
MODERATOR: 'moderator',
USER: 'user',
BANNED: 'banned',
} as const
// 权限常量
export const PERMISSIONS = {
// 用户管理
USERS_VIEW: 'users:view',
USERS_EDIT: 'users:edit',
USERS_DELETE: 'users:delete',
USERS_MANAGE_ROLES: 'users:manage_roles',
// 内容管理
POSTS_VIEW: 'posts:view',
POSTS_REVIEW: 'posts:review',
POSTS_DELETE: 'posts:delete',
COMMENTS_VIEW: 'comments:view',
COMMENTS_DELETE: 'comments:delete',
// 群组管理
GROUPS_VIEW: 'groups:view',
GROUPS_MANAGE: 'groups:manage',
// 系统管理
SENSITIVE_WORDS_MANAGE: 'sensitive_words:manage',
SYSTEM_SETTINGS: 'system:settings',
} as const

132
src/types/index.ts Normal file
View File

@@ -0,0 +1,132 @@
// API通用响应类型
export interface ApiResponse<T> {
code: number
message: string
data: T
}
export interface PaginatedResponse<T> {
list: T[]
total: number
page: number
page_size: number
total_pages: number
}
// 用户状态
export type UserStatus = 'active' | 'banned' | 'deleted'
// 帖子状态
export type PostStatus = 'pending' | 'published' | 'rejected' | 'deleted'
// 评论状态
export type CommentStatus = 'pending' | 'published' | 'rejected' | 'deleted'
// 用户角色
export interface Role {
id: string
name: string
description?: string
permissions?: string[]
created_at: string
updated_at: string
}
// 用户
export interface User {
id: string
username: string
nickname: string
email: string
avatar?: string
status: UserStatus
roles: Role[]
created_at: string
updated_at: string
}
// 帖子
export interface Post {
id: string
title: string
content: string
author: User
status: PostStatus
like_count: number
comment_count: number
view_count: number
created_at: string
updated_at: string
}
// 评论
export interface Comment {
id: string
content: string
author: User
post_id: string
post_title?: string
status: CommentStatus
like_count: number
created_at: string
updated_at: string
}
// 群组
export interface Group {
id: string
name: string
description?: string
avatar?: string
owner_id: string
member_count: number
created_at: string
updated_at: string
}
// 敏感词分类
export type SensitiveWordCategory = 'politics' | 'porn' | 'violence' | 'ad' | 'gambling' | 'fraud' | 'other'
// 敏感词级别
export type SensitiveWordLevel = 'low' | 'medium' | 'high'
// 敏感词
export interface SensitiveWord {
id: string
word: string
category: SensitiveWordCategory
level: SensitiveWordLevel
status: 'active' | 'inactive'
created_at: string
updated_at: string
}
// 认证相关类型
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
user: User
token: string
refresh_token: string
}
// Dashboard统计
export interface DashboardStats {
total_users: number
today_posts: number
pending_review: number
active_users: number
}
export interface UserActivityData {
date: string
count: number
}
export interface ContentDistribution {
name: string
value: number
}

74
tailwind.config.js Normal file
View File

@@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

34
tsconfig.app.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

21
vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})