Initial commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
73
README.md
Normal 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
23
eslint.config.js
Normal 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
13
index.html
Normal 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
6266
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
package.json
Normal file
54
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
public/favicon.svg
Normal file
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
24
public/icons.svg
Normal 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
184
src/App.css
Normal 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
42
src/App.tsx
Normal 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
16
src/api/auth.ts
Normal 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
39
src/api/client.ts
Normal 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
55
src/api/comments.ts
Normal 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
99
src/api/dashboard.ts
Normal 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
73
src/api/groups.ts
Normal 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
14
src/api/index.ts
Normal 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
60
src/api/posts.ts
Normal 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
60
src/api/roles.ts
Normal 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
47
src/api/users.ts
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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
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 |
109
src/components/ActivityChart.tsx
Normal file
109
src/components/ActivityChart.tsx
Normal 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
|
||||
87
src/components/ContentPieChart.tsx
Normal file
87
src/components/ContentPieChart.tsx
Normal 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
|
||||
98
src/components/Layout/Header.tsx
Normal file
98
src/components/Layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/Layout/MainLayout.tsx
Normal file
26
src/components/Layout/MainLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
src/components/Layout/Sidebar.tsx
Normal file
133
src/components/Layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
src/components/RetentionChart.tsx
Normal file
79
src/components/RetentionChart.tsx
Normal 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
|
||||
63
src/components/StatsCard.tsx
Normal file
63
src/components/StatsCard.tsx
Normal 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
|
||||
189
src/components/UserDetail.tsx
Normal file
189
src/components/UserDetail.tsx
Normal 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
|
||||
142
src/components/UserRoleDialog.tsx
Normal file
142
src/components/UserRoleDialog.tsx
Normal 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
|
||||
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal 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 }
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 }
|
||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal 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 }
|
||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal 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 }
|
||||
28
src/components/ui/checkbox.tsx
Normal file
28
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
120
src/components/ui/dialog.tsx
Normal file
120
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal 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 }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||
255
src/components/ui/pagination.tsx
Normal file
255
src/components/ui/pagination.tsx
Normal 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,
|
||||
}
|
||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal 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
117
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal 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 }
|
||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal 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
33
src/index.css
Normal 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
33
src/lib/utils.ts
Normal 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
10
src/main.tsx
Normal 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
525
src/pages/Comments.tsx
Normal 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
251
src/pages/Dashboard.tsx
Normal 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
28
src/pages/Forbidden.tsx
Normal 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
581
src/pages/Groups.tsx
Normal 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
116
src/pages/Login.tsx
Normal 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
28
src/pages/NotFound.tsx
Normal 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
624
src/pages/Posts.tsx
Normal 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
349
src/pages/Roles.tsx
Normal 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
458
src/pages/Users.tsx
Normal 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
214
src/router/index.tsx
Normal 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
187
src/stores/authStore.ts
Normal 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
132
src/types/index.ts
Normal 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
74
tailwind.config.js
Normal 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
34
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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
21
vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user