init: initial commit
This commit is contained in:
+24
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
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: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
+13
@@ -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>sundynix-micro-ui</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5581
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "sundynix-micro-ui",
|
||||
"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-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.15.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
+85
@@ -0,0 +1,85 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import AdminLayout from '@/layouts/AdminLayout'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||
import { Suspense, useMemo, lazy, useEffect } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { SystemMenu } from '@/api/system'
|
||||
|
||||
const pages = import.meta.glob('./pages/**/*.tsx')
|
||||
|
||||
const dynamicComponentMap: Record<string, React.LazyExoticComponent<any>> = {}
|
||||
for (const path in pages) {
|
||||
let routePath = path.replace(/^\.\/pages/, '').replace(/\.tsx$/, '').replace(/\/index$/, '').toLowerCase()
|
||||
if (routePath === '/loginpage') continue
|
||||
dynamicComponentMap[routePath] = lazy(pages[path] as any)
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
|
||||
if (isAuthenticated) return <Navigate to="/dashboard" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const menus = useAuthStore(s => s.menus)
|
||||
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
|
||||
const refreshMenus = useAuthStore(s => s.refreshMenus)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && menus.length === 0) refreshMenus()
|
||||
}, [isAuthenticated, menus.length, refreshMenus])
|
||||
|
||||
const dynamicRoutes = useMemo(() => {
|
||||
const routes: { path: string; Component: React.ComponentType }[] = []
|
||||
const traverse = (items: SystemMenu[]) => {
|
||||
items.forEach(item => {
|
||||
if (item.children?.length) traverse(item.children)
|
||||
const routeKey = item.path || item.code
|
||||
if (routeKey && dynamicComponentMap[routeKey]) {
|
||||
routes.push({ path: routeKey, Component: dynamicComponentMap[routeKey] })
|
||||
}
|
||||
})
|
||||
}
|
||||
if (menus) traverse(menus)
|
||||
return routes
|
||||
}, [menus])
|
||||
|
||||
const Loading = <div className="flex justify-center p-8"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
|
||||
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
{dynamicRoutes.map(({ path, Component }) => (
|
||||
<Route key={path} path={path.startsWith('/') ? path.substring(1) : path}
|
||||
element={<ErrorBoundary><Suspense fallback={Loading}><Component /></Suspense></ErrorBoundary>} />
|
||||
))}
|
||||
{!dynamicRoutes.some(r => r.path === '/dashboard') && dynamicComponentMap['/dashboard'] && (
|
||||
<Route path="dashboard" element={
|
||||
<ErrorBoundary><Suspense fallback={Loading}>
|
||||
{(() => { const D = dynamicComponentMap['/dashboard']; return <D /> })()}
|
||||
</Suspense></ErrorBoundary>
|
||||
} />
|
||||
)}
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { delay } from '@/mock'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface ServiceStatus {
|
||||
id: string
|
||||
name: string
|
||||
endpoint: string
|
||||
status: 'healthy' | 'degraded' | 'down'
|
||||
uptime: number // seconds
|
||||
latency: number // ms
|
||||
version: string
|
||||
lastCheck: string
|
||||
cpu: number // percentage 0-100
|
||||
memory: number // percentage 0-100
|
||||
}
|
||||
|
||||
export interface SystemMetrics {
|
||||
totalRequests24h: number
|
||||
avgResponseTime: number
|
||||
errorRate: number
|
||||
activeConnections: number
|
||||
diskUsage: number // percentage
|
||||
memoryUsage: number // percentage
|
||||
cpuUsage: number // percentage
|
||||
uptimeHours: number
|
||||
}
|
||||
|
||||
export interface TrafficPoint {
|
||||
hour: string
|
||||
requests: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
// ==================== Mock Data ====================
|
||||
|
||||
const mockServices: ServiceStatus[] = [
|
||||
{
|
||||
id: 'svc-plant', name: 'Plant 服务', endpoint: 'plant-rpc:8081',
|
||||
status: 'healthy', uptime: 864000, latency: 12, version: 'v1.3.2',
|
||||
lastCheck: new Date().toISOString(), cpu: 23, memory: 45,
|
||||
},
|
||||
{
|
||||
id: 'svc-radio', name: 'Radio 服务', endpoint: 'radio-rpc:8082',
|
||||
status: 'healthy', uptime: 720000, latency: 18, version: 'v1.1.0',
|
||||
lastCheck: new Date().toISOString(), cpu: 15, memory: 38,
|
||||
},
|
||||
{
|
||||
id: 'svc-gateway', name: 'API 网关', endpoint: 'gateway:8888',
|
||||
status: 'healthy', uptime: 864000, latency: 5, version: 'v2.0.1',
|
||||
lastCheck: new Date().toISOString(), cpu: 31, memory: 52,
|
||||
},
|
||||
{
|
||||
id: 'svc-db', name: 'MySQL 数据库', endpoint: 'mysql:3306',
|
||||
status: 'healthy', uptime: 2592000, latency: 3, version: '8.0.35',
|
||||
lastCheck: new Date().toISOString(), cpu: 18, memory: 62,
|
||||
},
|
||||
{
|
||||
id: 'svc-redis', name: 'Redis 缓存', endpoint: 'redis:6379',
|
||||
status: 'healthy', uptime: 2592000, latency: 1, version: '7.2.4',
|
||||
lastCheck: new Date().toISOString(), cpu: 5, memory: 28,
|
||||
},
|
||||
{
|
||||
id: 'svc-etcd', name: 'Etcd 注册中心', endpoint: 'etcd:2379',
|
||||
status: 'degraded', uptime: 172800, latency: 45, version: '3.5.12',
|
||||
lastCheck: new Date().toISOString(), cpu: 42, memory: 71,
|
||||
},
|
||||
]
|
||||
|
||||
function generateTraffic(): TrafficPoint[] {
|
||||
const now = new Date()
|
||||
const points: TrafficPoint[] = []
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const h = new Date(now.getTime() - i * 3600000)
|
||||
const hour = h.getHours()
|
||||
// Simulate realistic traffic pattern
|
||||
const base = hour >= 9 && hour <= 22 ? 800 : 200
|
||||
const peak = hour >= 10 && hour <= 12 ? 400 : hour >= 19 && hour <= 21 ? 500 : 0
|
||||
const requests = base + peak + Math.floor(Math.random() * 200)
|
||||
const errors = Math.floor(requests * (Math.random() * 0.02))
|
||||
points.push({
|
||||
hour: `${String(hour).padStart(2, '0')}:00`,
|
||||
requests,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
const mockMetrics: SystemMetrics = {
|
||||
totalRequests24h: 18420,
|
||||
avgResponseTime: 142,
|
||||
errorRate: 0.3,
|
||||
activeConnections: 87,
|
||||
diskUsage: 34,
|
||||
memoryUsage: 58,
|
||||
cpuUsage: 27,
|
||||
uptimeHours: 720,
|
||||
}
|
||||
|
||||
// ==================== API ====================
|
||||
|
||||
export async function getServiceList(): Promise<ServiceStatus[]> {
|
||||
await delay(400)
|
||||
// Add some randomness to simulate live data
|
||||
return mockServices.map(s => ({
|
||||
...s,
|
||||
latency: s.latency + Math.floor(Math.random() * 10 - 5),
|
||||
cpu: Math.min(100, Math.max(0, s.cpu + Math.floor(Math.random() * 10 - 5))),
|
||||
memory: Math.min(100, Math.max(0, s.memory + Math.floor(Math.random() * 6 - 3))),
|
||||
lastCheck: new Date().toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getSystemMetrics(): Promise<SystemMetrics> {
|
||||
await delay(300)
|
||||
return {
|
||||
...mockMetrics,
|
||||
totalRequests24h: mockMetrics.totalRequests24h + Math.floor(Math.random() * 200),
|
||||
avgResponseTime: mockMetrics.avgResponseTime + Math.floor(Math.random() * 30 - 15),
|
||||
activeConnections: mockMetrics.activeConnections + Math.floor(Math.random() * 20 - 10),
|
||||
cpuUsage: Math.min(100, Math.max(0, mockMetrics.cpuUsage + Math.floor(Math.random() * 10 - 5))),
|
||||
memoryUsage: Math.min(100, Math.max(0, mockMetrics.memoryUsage + Math.floor(Math.random() * 8 - 4))),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrafficData(): Promise<TrafficPoint[]> {
|
||||
await delay(350)
|
||||
return generateTraffic()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { post, type PageResult, type PageParams } from '@/lib/request'
|
||||
import { USE_MOCK, delay, paginate, mockId } from '@/mock'
|
||||
import { mockWikis, mockWikiClasses, type PlantWiki, type PlantWikiClass } from '@/mock/plant/wiki'
|
||||
import { mockBanners, type Banner } from '@/mock/plant/banner'
|
||||
import { mockTopics, mockPosts, type Topic, type Post } from '@/mock/plant/community'
|
||||
import { mockExchangeItems, mockExchangeOrders, type ExchangeItem, type ExchangeOrder } from '@/mock/plant/exchange'
|
||||
|
||||
export type { PlantWiki, PlantWikiClass, Banner, Topic, Post, ExchangeItem, ExchangeOrder }
|
||||
|
||||
// ==================== Wiki ====================
|
||||
export async function getWikiList(data: PageParams & { classId?: string }) {
|
||||
if (USE_MOCK) { await delay(); let f = [...mockWikis]; if (data.classId) f = f.filter(w => w.classId === data.classId); if (data.keyword) f = f.filter(w => w.title.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
|
||||
return post<{ data: PageResult<PlantWiki> }>('/plant/wiki/list', data)
|
||||
}
|
||||
export async function saveWiki(data: Partial<PlantWiki>) {
|
||||
if (USE_MOCK) { await delay(); mockWikis.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as PlantWiki); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/wiki/save', data)
|
||||
}
|
||||
export async function updateWiki(data: Partial<PlantWiki>) {
|
||||
if (USE_MOCK) { await delay(); const i = mockWikis.findIndex(w => w.id === data.id); if (i >= 0) Object.assign(mockWikis[i], data); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/wiki/update', data)
|
||||
}
|
||||
export async function deleteWiki(id: string) {
|
||||
if (USE_MOCK) { await delay(); const i = mockWikis.findIndex(w => w.id === id); if (i >= 0) mockWikis.splice(i, 1); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/wiki/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== Wiki Class ====================
|
||||
export async function getWikiClassList() {
|
||||
if (USE_MOCK) { await delay(); return { data: mockWikiClasses } }
|
||||
return post<{ data: PlantWikiClass[] }>('/plant/wikiClass/list', {})
|
||||
}
|
||||
export async function saveWikiClass(data: Partial<PlantWikiClass>) {
|
||||
if (USE_MOCK) { await delay(); mockWikiClasses.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as PlantWikiClass); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/wikiClass/save', data)
|
||||
}
|
||||
export async function deleteWikiClass(id: string) {
|
||||
if (USE_MOCK) { await delay(); const i = mockWikiClasses.findIndex(c => c.id === id); if (i >= 0) mockWikiClasses.splice(i, 1); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/wikiClass/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== Banner ====================
|
||||
export async function getBannerList(data: PageParams & { title?: string; isActive?: number }) {
|
||||
if (USE_MOCK) { await delay(); let f = [...mockBanners]; if (data.keyword) f = f.filter(b => b.title.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
|
||||
return post<{ data: PageResult<Banner> }>('/plant/banner/list', data)
|
||||
}
|
||||
export async function saveBanner(data: Partial<Banner>) {
|
||||
if (USE_MOCK) { await delay(); mockBanners.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as Banner); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/banner/save', data)
|
||||
}
|
||||
export async function updateBanner(data: Partial<Banner>) {
|
||||
if (USE_MOCK) { await delay(); const i = mockBanners.findIndex(b => b.id === data.id); if (i >= 0) Object.assign(mockBanners[i], data); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/banner/update', data)
|
||||
}
|
||||
export async function deleteBanner(id: string) {
|
||||
if (USE_MOCK) { await delay(); const i = mockBanners.findIndex(b => b.id === id); if (i >= 0) mockBanners.splice(i, 1); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/banner/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== Topic ====================
|
||||
export async function getTopicList(data: PageParams) {
|
||||
if (USE_MOCK) { await delay(); let f = [...mockTopics]; if (data.keyword) f = f.filter(t => t.title.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
|
||||
return post<{ data: PageResult<Topic> }>('/plant/topic/list', data)
|
||||
}
|
||||
export async function saveTopic(data: Partial<Topic>) {
|
||||
if (USE_MOCK) { await delay(); mockTopics.push({ ...data, id: mockId(), postCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as Topic); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/topic/save', data)
|
||||
}
|
||||
export async function deleteTopic(id: string) {
|
||||
if (USE_MOCK) { await delay(); const i = mockTopics.findIndex(t => t.id === id); if (i >= 0) mockTopics.splice(i, 1); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/topic/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== Post ====================
|
||||
export async function getPostList(data: PageParams & { topicId?: string }) {
|
||||
if (USE_MOCK) { await delay(); let f = [...mockPosts]; if (data.topicId) f = f.filter(p => p.topicId === data.topicId); if (data.keyword) f = f.filter(p => p.title.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
|
||||
return post<{ data: PageResult<Post> }>('/plant/post/list', data)
|
||||
}
|
||||
export async function deletePost(id: string) {
|
||||
if (USE_MOCK) { await delay(); const i = mockPosts.findIndex(p => p.id === id); if (i >= 0) mockPosts.splice(i, 1); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/post/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== Exchange ====================
|
||||
export async function getExchangeItemList(data: PageParams) {
|
||||
if (USE_MOCK) { await delay(); let f = [...mockExchangeItems]; if (data.keyword) f = f.filter(e => e.name.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
|
||||
return post<{ data: PageResult<ExchangeItem> }>('/plant/exchange/itemList', data)
|
||||
}
|
||||
export async function saveExchangeItem(data: Partial<ExchangeItem>) {
|
||||
if (USE_MOCK) { await delay(); mockExchangeItems.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as ExchangeItem); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/exchange/saveItem', data)
|
||||
}
|
||||
export async function deleteExchangeItem(id: string) {
|
||||
if (USE_MOCK) { await delay(); const i = mockExchangeItems.findIndex(e => e.id === id); if (i >= 0) mockExchangeItems.splice(i, 1); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/plant/exchange/deleteItem', { id })
|
||||
}
|
||||
export async function getExchangeOrderList(data: PageParams) {
|
||||
if (USE_MOCK) { await delay(); return { data: paginate([...mockExchangeOrders], data.current, data.pageSize) } }
|
||||
return post<{ data: PageResult<ExchangeOrder> }>('/plant/exchange/orderList', data)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { post, type PageResult, type PageParams } from '@/lib/request'
|
||||
import { USE_MOCK, delay, paginate, mockId } from '@/mock'
|
||||
import { mockRadioCategories, mockRadioChannels, mockRadioPrograms } from '@/mock/radio/channels'
|
||||
import { mockSubscriptions } from '@/mock/radio/subscriptions'
|
||||
import type { RadioCategory, RadioChannel, RadioProgram, RadioSubscription } from '@/mock/radio/channels'
|
||||
|
||||
export type { RadioCategory, RadioChannel, RadioProgram, RadioSubscription }
|
||||
|
||||
// ==================== Category ====================
|
||||
export async function getRadioCategoryList() {
|
||||
if (USE_MOCK) { await delay(); return { data: [...mockRadioCategories] } }
|
||||
return post<{ data: RadioCategory[] }>('/radio/category/list', {})
|
||||
}
|
||||
export async function saveRadioCategory(data: Partial<RadioCategory>) {
|
||||
if (USE_MOCK) { await delay(); mockRadioCategories.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as RadioCategory); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/radio/category/save', data)
|
||||
}
|
||||
export async function deleteRadioCategory(id: string) {
|
||||
if (USE_MOCK) { await delay(); const i = mockRadioCategories.findIndex(c => c.id === id); if (i >= 0) mockRadioCategories.splice(i, 1); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/radio/category/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== Channel ====================
|
||||
export async function getRadioChannelList(data: PageParams & { categoryId?: string }) {
|
||||
if (USE_MOCK) { await delay(); let f = [...mockRadioChannels]; if (data.categoryId) f = f.filter(c => c.categoryId === data.categoryId); if (data.keyword) f = f.filter(c => c.name.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
|
||||
return post<{ data: PageResult<RadioChannel> }>('/radio/channel/list', data)
|
||||
}
|
||||
export async function saveRadioChannel(data: Partial<RadioChannel>) {
|
||||
if (USE_MOCK) { await delay(); mockRadioChannels.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as RadioChannel); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/radio/channel/save', data)
|
||||
}
|
||||
export async function updateRadioChannel(data: Partial<RadioChannel>) {
|
||||
if (USE_MOCK) { await delay(); const i = mockRadioChannels.findIndex(c => c.id === data.id); if (i >= 0) Object.assign(mockRadioChannels[i], data); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/radio/channel/update', data)
|
||||
}
|
||||
export async function deleteRadioChannel(id: string) {
|
||||
if (USE_MOCK) { await delay(); const i = mockRadioChannels.findIndex(c => c.id === id); if (i >= 0) mockRadioChannels.splice(i, 1); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/radio/channel/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== Program ====================
|
||||
export async function getRadioProgramList(data: PageParams & { channelId?: string }) {
|
||||
if (USE_MOCK) { await delay(); let f = [...mockRadioPrograms]; if (data.channelId) f = f.filter(p => p.channelId === data.channelId); if (data.keyword) f = f.filter(p => p.title.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
|
||||
return post<{ data: PageResult<RadioProgram> }>('/radio/program/list', data)
|
||||
}
|
||||
export async function saveRadioProgram(data: Partial<RadioProgram>) {
|
||||
if (USE_MOCK) { await delay(); mockRadioPrograms.push({ ...data, id: mockId(), audioStatus: 0, duration: 0, playCount: 0, likeCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as RadioProgram); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/radio/program/save', data)
|
||||
}
|
||||
export async function updateRadioProgram(data: Partial<RadioProgram>) {
|
||||
if (USE_MOCK) { await delay(); const i = mockRadioPrograms.findIndex(p => p.id === data.id); if (i >= 0) Object.assign(mockRadioPrograms[i], data); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/radio/program/update', data)
|
||||
}
|
||||
export async function deleteRadioProgram(id: string) {
|
||||
if (USE_MOCK) { await delay(); const i = mockRadioPrograms.findIndex(p => p.id === id); if (i >= 0) mockRadioPrograms.splice(i, 1); return { msg: 'ok' } }
|
||||
return post<{ msg: string }>('/radio/program/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== Subscription ====================
|
||||
export async function getSubscriptionList(data: PageParams & { channelId?: string; status?: number }) {
|
||||
if (USE_MOCK) { await delay(); let f = [...mockSubscriptions]; if (data.channelId) f = f.filter(s => s.channelId === data.channelId); if (data.status !== undefined) f = f.filter(s => s.status === data.status); return { data: paginate(f, data.current, data.pageSize) } }
|
||||
return post<{ data: PageResult<RadioSubscription> }>('/radio/subscription/list', data)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { get, post } from '@/lib/request'
|
||||
import { USE_MOCK, delay } from '@/mock'
|
||||
import { mockUsers } from '@/mock/system/users'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface SystemUser {
|
||||
id: string; account: string; name: string; nickName?: string; phone?: string
|
||||
avatar?: SystemOss; avatarId?: string; clientId?: string; tenantId?: string
|
||||
createdAt: string; updatedAt: string; roles?: SystemRole[]
|
||||
}
|
||||
|
||||
export interface SystemRole {
|
||||
id: string; name: string; code: string; sort?: number
|
||||
menus?: SystemMenu[]; createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface SystemMenu {
|
||||
id: string; name: string; title?: string; code?: string; path?: string
|
||||
icon?: string; locale?: string; parentId?: string; permission?: string
|
||||
sort?: number; category?: number; children?: SystemMenu[]
|
||||
createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface SystemOss {
|
||||
id: string; name: string; key: string; url: string; suffix?: string
|
||||
tag?: string; md5?: string; width?: number; height?: number
|
||||
createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface SystemClient {
|
||||
id: string; clientId: string; name: string; grantType?: string
|
||||
activeTimeout?: number; additionalInfo?: string
|
||||
createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface OperationLog {
|
||||
id: string; operatorId: string; operatorName: string
|
||||
clientId: string; clientName: string
|
||||
method: string; path: string; title: string
|
||||
statusCode: number; duration: number // ms
|
||||
ip: string; userAgent?: string
|
||||
requestBody?: string; responseBody?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CaptchaRes { captcha: string; captchaId: string }
|
||||
export interface LoginParams { account: string; password: string; captcha: string; captchaId: string }
|
||||
export interface LoginResponse { token: string; expiresAt: number; user: SystemUser }
|
||||
|
||||
// ==================== Auth ====================
|
||||
|
||||
export async function getCaptcha() {
|
||||
if (USE_MOCK) { await delay(200); return { data: { captcha: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', captchaId: 'mock-captcha-id' } } }
|
||||
return get<{ data: CaptchaRes }>('/auth/captcha')
|
||||
}
|
||||
|
||||
export async function login(data: LoginParams) {
|
||||
if (USE_MOCK) { await delay(500); return { data: { token: 'mock-token-xxx', expiresAt: Date.now() + 7200000, user: mockUsers[0] }, msg: '登录成功' } }
|
||||
return post<{ data: LoginResponse; msg: string }>('/auth/login', data)
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
if (USE_MOCK) { await delay(100); return { msg: '已登出' } }
|
||||
return get<{ msg: string }>('/auth/logout')
|
||||
}
|
||||
|
||||
// ==================== 以下在各自文件中实现 ====================
|
||||
// 这里只导出类型,具体 CRUD 在 api/system/*.ts 子文件中
|
||||
@@ -0,0 +1,27 @@
|
||||
import { get, post, put, del } from '@/lib/request'
|
||||
import { USE_MOCK, delay } from '@/mock'
|
||||
import { mockMenuTree } from '@/mock/system/menus'
|
||||
import type { SystemMenu } from '../system'
|
||||
|
||||
export async function getMenuTree() {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
return { data: mockMenuTree }
|
||||
}
|
||||
return get<{ data: SystemMenu[] }>('/system/menus/tree')
|
||||
}
|
||||
|
||||
export async function createMenu(data: Partial<SystemMenu>) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '创建成功' } }
|
||||
return post<{ msg: string }>('/system/menus', data)
|
||||
}
|
||||
|
||||
export async function updateMenu(id: string, data: Partial<SystemMenu>) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '更新成功' } }
|
||||
return put<{ msg: string }>(`/system/menus/${id}`, data)
|
||||
}
|
||||
|
||||
export async function deleteMenu(id: string) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '删除成功' } }
|
||||
return del<{ msg: string }>(`/system/menus/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { get, post, put, del } from '@/lib/request'
|
||||
import { USE_MOCK, delay, paginate, mockDate } from '@/mock'
|
||||
import type { SystemRole } from '../system'
|
||||
|
||||
const mockRoles: SystemRole[] = [
|
||||
{ id: '1', name: '超级管理员', code: 'super_admin', sort: 1, createdAt: mockDate(120), updatedAt: mockDate(1) },
|
||||
{ id: '2', name: '系统管理员', code: 'admin', sort: 2, createdAt: mockDate(120), updatedAt: mockDate(1) },
|
||||
{ id: '3', name: '运营专员', code: 'operator', sort: 3, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '4', name: '内容审核', code: 'auditor', sort: 4, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '5', name: '客服', code: 'customer_service', sort: 5, createdAt: mockDate(30), updatedAt: mockDate(1) },
|
||||
]
|
||||
|
||||
export async function getRoleList(params: { current: number; pageSize: number; name?: string }) {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
let list = [...mockRoles]
|
||||
if (params.name) list = list.filter(r => r.name.includes(params.name!))
|
||||
return { data: paginate(list, params.current, params.pageSize) }
|
||||
}
|
||||
return get<{ data: { list: SystemRole[]; total: number } }>('/system/roles', params)
|
||||
}
|
||||
|
||||
export async function createRole(data: Partial<SystemRole>) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '创建成功' } }
|
||||
return post<{ msg: string }>('/system/roles', data)
|
||||
}
|
||||
|
||||
export async function updateRole(id: string, data: Partial<SystemRole>) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '更新成功' } }
|
||||
return put<{ msg: string }>(`/system/roles/${id}`, data)
|
||||
}
|
||||
|
||||
export async function deleteRole(id: string) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '删除成功' } }
|
||||
return del<{ msg: string }>(`/system/roles/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { get, post, put, del } from '@/lib/request'
|
||||
import { USE_MOCK, delay, paginate } from '@/mock'
|
||||
import { mockUsers } from '@/mock/system/users'
|
||||
import type { SystemUser } from '../system'
|
||||
|
||||
export async function getUserList(params: { current: number; pageSize: number; account?: string; name?: string }) {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
let list = [...mockUsers]
|
||||
if (params.account) list = list.filter(u => u.account.includes(params.account!))
|
||||
if (params.name) list = list.filter(u => u.name.includes(params.name!))
|
||||
return { data: paginate(list, params.current, params.pageSize) }
|
||||
}
|
||||
return get<{ data: { list: SystemUser[]; total: number } }>('/system/users', params)
|
||||
}
|
||||
|
||||
export async function createUser(data: Partial<SystemUser>) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '创建成功' } }
|
||||
return post<{ msg: string }>('/system/users', data)
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, data: Partial<SystemUser>) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '更新成功' } }
|
||||
return put<{ msg: string }>(`/system/users/${id}`, data)
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '删除成功' } }
|
||||
return del<{ msg: string }>(`/system/users/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { get, post, type PageResult, type PageParams } from '@/lib/request'
|
||||
import { USE_MOCK, delay, paginate, mockId } from '@/mock'
|
||||
import { mockUsers } from '@/mock/system/users'
|
||||
import { mockRoles } from '@/mock/system/roles'
|
||||
import { mockMenuTree } from '@/mock/system/menus'
|
||||
import { mockClients } from '@/mock/system/clients'
|
||||
import { mockFiles } from '@/mock/system/files'
|
||||
import { mockLogs } from '@/mock/system/logs'
|
||||
import type { SystemUser, SystemRole, SystemMenu, SystemClient, SystemOss, OperationLog } from './system'
|
||||
|
||||
// ==================== User ====================
|
||||
|
||||
export async function getUserList(data: PageParams & { account?: string; phone?: string }) {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
let filtered = [...mockUsers]
|
||||
if (data.keyword) filtered = filtered.filter(u => u.name.includes(data.keyword!) || u.account.includes(data.keyword!))
|
||||
return { data: paginate(filtered, data.current, data.pageSize) }
|
||||
}
|
||||
return post<{ data: PageResult<SystemUser> }>('/user/getUserList', data)
|
||||
}
|
||||
|
||||
export async function saveUser(data: Partial<SystemUser>) {
|
||||
if (USE_MOCK) { await delay(); mockUsers.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as SystemUser); return { msg: '创建成功' } }
|
||||
return post<{ msg: string }>('/user/save', data)
|
||||
}
|
||||
|
||||
export async function updateUser(data: Partial<SystemUser>) {
|
||||
if (USE_MOCK) { await delay(); const i = mockUsers.findIndex(u => u.id === data.id); if (i >= 0) Object.assign(mockUsers[i], data); return { msg: '更新成功' } }
|
||||
return post<{ msg: string }>('/user/update', data)
|
||||
}
|
||||
|
||||
export async function deleteUser(ids: string[]) {
|
||||
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockUsers.findIndex(u => u.id === id); if (i >= 0) mockUsers.splice(i, 1) }); return { msg: '删除成功' } }
|
||||
return post<{ msg: string }>('/user/delete', { ids })
|
||||
}
|
||||
|
||||
export async function changePassword(data: { id: string; newPwd: string }) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '密码修改成功' } }
|
||||
return post<{ msg: string }>('/user/changePassword', data)
|
||||
}
|
||||
|
||||
export async function grantRole(data: { userId: string; roleIds: string[] }) {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
const user = mockUsers.find(u => u.id === data.userId)
|
||||
if (user) user.roles = mockRoles.filter(r => data.roleIds.includes(r.id)).map(r => ({ ...r }))
|
||||
return { msg: '角色分配成功' }
|
||||
}
|
||||
return post<{ msg: string }>('/user/grantRole', data)
|
||||
}
|
||||
|
||||
// ==================== Role ====================
|
||||
|
||||
export async function getRoleList(data: PageParams & { name?: string }) {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
let filtered = [...mockRoles]
|
||||
if (data.keyword) filtered = filtered.filter(r => r.name.includes(data.keyword!))
|
||||
return { data: paginate(filtered, data.current, data.pageSize) }
|
||||
}
|
||||
return post<{ data: PageResult<SystemRole> }>('/role/getRoleList', data)
|
||||
}
|
||||
|
||||
export async function getAllRoles() {
|
||||
if (USE_MOCK) { await delay(100); return { data: [...mockRoles] } }
|
||||
return post<{ data: SystemRole[] }>('/role/getAllRoles', {})
|
||||
}
|
||||
|
||||
export async function saveRole(data: Partial<SystemRole>) {
|
||||
if (USE_MOCK) { await delay(); mockRoles.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as SystemRole); return { msg: '创建成功' } }
|
||||
return post<{ msg: string }>('/role/save', data)
|
||||
}
|
||||
|
||||
export async function updateRole(data: Partial<SystemRole>) {
|
||||
if (USE_MOCK) { await delay(); const i = mockRoles.findIndex(r => r.id === data.id); if (i >= 0) Object.assign(mockRoles[i], data); return { msg: '更新成功' } }
|
||||
return post<{ msg: string }>('/role/update', data)
|
||||
}
|
||||
|
||||
export async function deleteRole(ids: string[]) {
|
||||
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockRoles.findIndex(r => r.id === id); if (i >= 0) mockRoles.splice(i, 1) }); return { msg: '删除成功' } }
|
||||
return post<{ msg: string }>('/role/delete', { ids })
|
||||
}
|
||||
|
||||
export async function grantMenu(data: { roleId: string; menuIds: string[] }) {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
const role = mockRoles.find(r => r.id === data.roleId)
|
||||
if (role) {
|
||||
const flatMenus = (items: SystemMenu[]): SystemMenu[] => items.flatMap(m => [m, ...(m.children ? flatMenus(m.children) : [])])
|
||||
role.menus = flatMenus(mockMenuTree).filter(m => data.menuIds.includes(m.id))
|
||||
}
|
||||
return { msg: '菜单授权成功' }
|
||||
}
|
||||
return post<{ msg: string }>('/role/grantMenu', data)
|
||||
}
|
||||
|
||||
// ==================== Menu ====================
|
||||
|
||||
export async function getAllMenuTree() {
|
||||
if (USE_MOCK) { await delay(); return { data: mockMenuTree } }
|
||||
return post<{ data: SystemMenu[] }>('/menu/getAllMenuTree', {})
|
||||
}
|
||||
|
||||
export async function getUserMenuTree() {
|
||||
if (USE_MOCK) { await delay(); return { data: mockMenuTree } }
|
||||
return get<{ data: SystemMenu[] }>('/menu/getUserMenuTree')
|
||||
}
|
||||
|
||||
export async function saveMenu(data: Partial<SystemMenu>) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '创建成功' } }
|
||||
return post<{ msg: string }>('/menu/save', data)
|
||||
}
|
||||
|
||||
export async function updateMenu(data: Partial<SystemMenu>) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '更新成功' } }
|
||||
return post<{ msg: string }>('/menu/update', data)
|
||||
}
|
||||
|
||||
export async function deleteMenu(id: string) {
|
||||
if (USE_MOCK) { await delay(); return { msg: '删除成功' } }
|
||||
return get<{ msg: string }>('/menu/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== Client ====================
|
||||
|
||||
export async function getClientList(data: PageParams & { clientId?: string; name?: string }) {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
let filtered = [...mockClients]
|
||||
if (data.keyword) filtered = filtered.filter(c => c.name.includes(data.keyword!) || c.clientId.includes(data.keyword!))
|
||||
return { data: paginate(filtered, data.current, data.pageSize) }
|
||||
}
|
||||
return post<{ data: PageResult<SystemClient> }>('/client/getClientList', data)
|
||||
}
|
||||
|
||||
export async function saveClient(data: Partial<SystemClient>) {
|
||||
if (USE_MOCK) { await delay(); mockClients.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as SystemClient); return { msg: '创建成功' } }
|
||||
return post<{ msg: string }>('/client/save', data)
|
||||
}
|
||||
|
||||
export async function updateClient(data: Partial<SystemClient>) {
|
||||
if (USE_MOCK) { await delay(); const i = mockClients.findIndex(c => c.id === data.id); if (i >= 0) Object.assign(mockClients[i], data); return { msg: '更新成功' } }
|
||||
return post<{ msg: string }>('/client/update', data)
|
||||
}
|
||||
|
||||
export async function deleteClient(ids: string[]) {
|
||||
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockClients.findIndex(c => c.id === id); if (i >= 0) mockClients.splice(i, 1) }); return { msg: '删除成功' } }
|
||||
return post<{ msg: string }>('/client/delete', { ids })
|
||||
}
|
||||
|
||||
// ==================== File ====================
|
||||
|
||||
export async function getFileList(data: PageParams & { name?: string }) {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
let filtered = [...mockFiles]
|
||||
if (data.keyword) filtered = filtered.filter(f => f.name.includes(data.keyword!))
|
||||
return { data: paginate(filtered, data.current, data.pageSize) }
|
||||
}
|
||||
return post<{ data: PageResult<SystemOss> }>('/oss/getFileList', data)
|
||||
}
|
||||
|
||||
export async function uploadFile(_file: File) {
|
||||
if (USE_MOCK) { await delay(800); const f: SystemOss = { id: mockId(), name: _file.name, key: `uploads/${_file.name}`, url: `https://picsum.photos/seed/${Date.now()}/400/300`, suffix: _file.name.split('.').pop() || '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; mockFiles.unshift(f); return { data: { file: f }, msg: '上传成功' } }
|
||||
const formData = new FormData(); formData.append('file', _file)
|
||||
return post<{ data: { file: SystemOss }; msg: string }>('/oss/upload', formData)
|
||||
}
|
||||
|
||||
export async function deleteFile(ids: string[]) {
|
||||
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockFiles.findIndex(f => f.id === id); if (i >= 0) mockFiles.splice(i, 1) }); return { msg: '删除成功' } }
|
||||
return post<{ msg: string }>('/oss/delete', { ids })
|
||||
}
|
||||
|
||||
// ==================== Operation Log ====================
|
||||
|
||||
export async function getOperationLogList(data: PageParams & { clientId?: string; method?: string; statusCode?: number }) {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
let filtered = [...mockLogs]
|
||||
if (data.clientId) filtered = filtered.filter(l => l.clientId === data.clientId)
|
||||
if (data.method) filtered = filtered.filter(l => l.method === data.method)
|
||||
if (data.statusCode !== undefined) filtered = filtered.filter(l => l.statusCode === data.statusCode)
|
||||
if (data.keyword) filtered = filtered.filter(l => l.path.includes(data.keyword!) || l.title.includes(data.keyword!) || l.operatorName.includes(data.keyword!))
|
||||
return { data: paginate(filtered, data.current, data.pageSize) }
|
||||
}
|
||||
return post<{ data: PageResult<OperationLog> }>('/log/getOperationLogList', data)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,33 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
interface Props { children: ReactNode }
|
||||
interface State { hasError: boolean; error?: Error }
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-12 text-center">
|
||||
<div className="text-4xl mb-4">😵</div>
|
||||
<h2 className="text-lg font-semibold mb-2">页面出错了</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">{this.state.error?.message}</p>
|
||||
<button onClick={() => this.setState({ hasError: false })}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:bg-primary/90">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface PupilProps {
|
||||
size?: number;
|
||||
maxDistance?: number;
|
||||
pupilColor?: string;
|
||||
forceLookX?: number;
|
||||
forceLookY?: number;
|
||||
}
|
||||
|
||||
const Pupil = ({
|
||||
size = 12, maxDistance = 5, pupilColor = 'black',
|
||||
forceLookX, forceLookY,
|
||||
}: PupilProps) => {
|
||||
const [mouseX, setMouseX] = useState(0);
|
||||
const [mouseY, setMouseY] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => { setMouseX(e.clientX); setMouseY(e.clientY); };
|
||||
window.addEventListener('mousemove', h);
|
||||
return () => window.removeEventListener('mousemove', h);
|
||||
}, []);
|
||||
|
||||
const calc = () => {
|
||||
if (!ref.current) return { x: 0, y: 0 };
|
||||
if (forceLookX !== undefined && forceLookY !== undefined) return { x: forceLookX, y: forceLookY };
|
||||
const r = ref.current.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
||||
const dx = mouseX - cx, dy = mouseY - cy;
|
||||
const dist = Math.min(Math.sqrt(dx * dx + dy * dy), maxDistance);
|
||||
const a = Math.atan2(dy, dx);
|
||||
return { x: Math.cos(a) * dist, y: Math.sin(a) * dist };
|
||||
};
|
||||
const p = calc();
|
||||
return (
|
||||
<div ref={ref} className="rounded-full" style={{
|
||||
width: size, height: size, backgroundColor: pupilColor,
|
||||
transform: `translate(${p.x}px, ${p.y}px)`, transition: 'transform 0.1s ease-out',
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
interface EyeBallProps {
|
||||
size?: number; pupilSize?: number; maxDistance?: number;
|
||||
eyeColor?: string; pupilColor?: string; isBlinking?: boolean;
|
||||
forceLookX?: number; forceLookY?: number;
|
||||
}
|
||||
|
||||
const EyeBall = ({
|
||||
size = 48, pupilSize = 16, maxDistance = 10,
|
||||
eyeColor = 'white', pupilColor = 'black', isBlinking = false,
|
||||
forceLookX, forceLookY,
|
||||
}: EyeBallProps) => {
|
||||
const [mouseX, setMouseX] = useState(0);
|
||||
const [mouseY, setMouseY] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => { setMouseX(e.clientX); setMouseY(e.clientY); };
|
||||
window.addEventListener('mousemove', h);
|
||||
return () => window.removeEventListener('mousemove', h);
|
||||
}, []);
|
||||
|
||||
const calc = () => {
|
||||
if (!ref.current) return { x: 0, y: 0 };
|
||||
if (forceLookX !== undefined && forceLookY !== undefined) return { x: forceLookX, y: forceLookY };
|
||||
const r = ref.current.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
||||
const dx = mouseX - cx, dy = mouseY - cy;
|
||||
const dist = Math.min(Math.sqrt(dx * dx + dy * dy), maxDistance);
|
||||
const a = Math.atan2(dy, dx);
|
||||
return { x: Math.cos(a) * dist, y: Math.sin(a) * dist };
|
||||
};
|
||||
const p = calc();
|
||||
return (
|
||||
<div ref={ref} className="rounded-full flex items-center justify-center transition-all duration-150"
|
||||
style={{ width: size, height: isBlinking ? 2 : size, backgroundColor: eyeColor, overflow: 'hidden' }}>
|
||||
{!isBlinking && (
|
||||
<div className="rounded-full" style={{
|
||||
width: pupilSize, height: pupilSize, backgroundColor: pupilColor,
|
||||
transform: `translate(${p.x}px, ${p.y}px)`, transition: 'transform 0.1s ease-out',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props { isTyping?: boolean; showPassword?: boolean; passwordLength?: number; }
|
||||
|
||||
export default function LoginCharacters({ isTyping = false, showPassword = false, passwordLength = 0 }: Props) {
|
||||
const [mouseX, setMouseX] = useState(0);
|
||||
const [mouseY, setMouseY] = useState(0);
|
||||
const [isPrimaryBlink, setIsPrimaryBlink] = useState(false);
|
||||
const [isDarkBlink, setIsDarkBlink] = useState(false);
|
||||
const [isLooking, setIsLooking] = useState(false);
|
||||
const [isPrimaryPeek, setIsPrimaryPeek] = useState(false);
|
||||
const primaryRef = useRef<HTMLDivElement>(null);
|
||||
const darkRef = useRef<HTMLDivElement>(null);
|
||||
const yellowRef = useRef<HTMLDivElement>(null);
|
||||
const orangeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => { setMouseX(e.clientX); setMouseY(e.clientY); };
|
||||
window.addEventListener('mousemove', h);
|
||||
return () => window.removeEventListener('mousemove', h);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const schedule = (): ReturnType<typeof setTimeout> => setTimeout(() => {
|
||||
setIsPrimaryBlink(true);
|
||||
setTimeout(() => { setIsPrimaryBlink(false); schedule(); }, 150);
|
||||
}, Math.random() * 4000 + 3000);
|
||||
const t = schedule(); return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const schedule = (): ReturnType<typeof setTimeout> => setTimeout(() => {
|
||||
setIsDarkBlink(true);
|
||||
setTimeout(() => { setIsDarkBlink(false); schedule(); }, 150);
|
||||
}, Math.random() * 4000 + 3000);
|
||||
const t = schedule(); return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTyping) { setIsLooking(true); const t = setTimeout(() => setIsLooking(false), 800); return () => clearTimeout(t); }
|
||||
else setIsLooking(false);
|
||||
}, [isTyping]);
|
||||
|
||||
useEffect(() => {
|
||||
if (passwordLength > 0 && showPassword) {
|
||||
const t = setTimeout(() => {
|
||||
setIsPrimaryPeek(true);
|
||||
setTimeout(() => setIsPrimaryPeek(false), 800);
|
||||
}, Math.random() * 3000 + 2000);
|
||||
return () => clearTimeout(t);
|
||||
} else setIsPrimaryPeek(false);
|
||||
}, [passwordLength, showPassword, isPrimaryPeek]);
|
||||
|
||||
const calcPos = (ref: React.RefObject<HTMLDivElement | null>) => {
|
||||
if (!ref.current) return { faceX: 0, faceY: 0, bodySkew: 0 };
|
||||
const r = ref.current.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2, cy = r.top + r.height / 3;
|
||||
const dx = mouseX - cx, dy = mouseY - cy;
|
||||
return {
|
||||
faceX: Math.max(-15, Math.min(15, dx / 20)),
|
||||
faceY: Math.max(-10, Math.min(10, dy / 30)),
|
||||
bodySkew: Math.max(-6, Math.min(6, -dx / 120)),
|
||||
};
|
||||
};
|
||||
|
||||
const pp = calcPos(primaryRef), bp = calcPos(darkRef);
|
||||
const yp = calcPos(yellowRef), op = calcPos(orangeRef);
|
||||
const hiding = passwordLength > 0 && !showPassword;
|
||||
const showing = passwordLength > 0 && showPassword;
|
||||
|
||||
return (
|
||||
<div className="relative w-full" style={{ height: 300 }}>
|
||||
{/* Primary tall - back */}
|
||||
<div ref={primaryRef} className="absolute bottom-0 transition-all duration-700 ease-in-out" style={{
|
||||
left: '12%', width: '33%', height: (isTyping || hiding) ? '110%' : '100%',
|
||||
backgroundColor: '#10b981', borderRadius: '10px 10px 0 0', zIndex: 1,
|
||||
transform: showing ? 'skewX(0deg)' : (isTyping || hiding)
|
||||
? `skewX(${(pp.bodySkew || 0) - 12}deg) translateX(40px)` : `skewX(${pp.bodySkew || 0}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}>
|
||||
<div className="absolute flex gap-8 transition-all duration-700 ease-in-out" style={{
|
||||
left: showing ? 20 : isLooking ? 55 : 45 + pp.faceX,
|
||||
top: showing ? 35 : isLooking ? 65 : 40 + pp.faceY,
|
||||
}}>
|
||||
<EyeBall size={18} pupilSize={7} maxDistance={5} eyeColor="white" pupilColor="#064e3b"
|
||||
isBlinking={isPrimaryBlink}
|
||||
forceLookX={showing ? (isPrimaryPeek ? 4 : -4) : isLooking ? 3 : undefined}
|
||||
forceLookY={showing ? (isPrimaryPeek ? 5 : -4) : isLooking ? 4 : undefined} />
|
||||
<EyeBall size={18} pupilSize={7} maxDistance={5} eyeColor="white" pupilColor="#064e3b"
|
||||
isBlinking={isPrimaryBlink}
|
||||
forceLookX={showing ? (isPrimaryPeek ? 4 : -4) : isLooking ? 3 : undefined}
|
||||
forceLookY={showing ? (isPrimaryPeek ? 5 : -4) : isLooking ? 4 : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dark - middle */}
|
||||
<div ref={darkRef} className="absolute bottom-0 transition-all duration-700 ease-in-out" style={{
|
||||
left: '44%', width: '22%', height: '77%',
|
||||
backgroundColor: '#0f766e', borderRadius: '8px 8px 0 0', zIndex: 2,
|
||||
transform: showing ? 'skewX(0deg)' : isLooking
|
||||
? `skewX(${(bp.bodySkew || 0) * 1.5 + 10}deg) translateX(20px)`
|
||||
: (isTyping || hiding) ? `skewX(${(bp.bodySkew || 0) * 1.5}deg)` : `skewX(${bp.bodySkew || 0}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}>
|
||||
<div className="absolute flex gap-6 transition-all duration-700 ease-in-out" style={{
|
||||
left: showing ? 10 : isLooking ? 32 : 26 + bp.faceX,
|
||||
top: showing ? 28 : isLooking ? 12 : 32 + bp.faceY,
|
||||
}}>
|
||||
<EyeBall size={16} pupilSize={6} maxDistance={4} eyeColor="white" pupilColor="#042f2e"
|
||||
isBlinking={isDarkBlink}
|
||||
forceLookX={showing ? -4 : isLooking ? 0 : undefined}
|
||||
forceLookY={showing ? -4 : isLooking ? -4 : undefined} />
|
||||
<EyeBall size={16} pupilSize={6} maxDistance={4} eyeColor="white" pupilColor="#042f2e"
|
||||
isBlinking={isDarkBlink}
|
||||
forceLookX={showing ? -4 : isLooking ? 0 : undefined}
|
||||
forceLookY={showing ? -4 : isLooking ? -4 : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orange semi-circle - front left */}
|
||||
<div ref={orangeRef} className="absolute bottom-0 transition-all duration-700 ease-in-out" style={{
|
||||
left: '0%', width: '44%', height: '50%', backgroundColor: '#f59e0b',
|
||||
borderRadius: '120px 120px 0 0', zIndex: 3,
|
||||
transform: showing ? 'skewX(0deg)' : `skewX(${op.bodySkew || 0}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}>
|
||||
<div className="absolute flex gap-8 transition-all duration-200 ease-out" style={{
|
||||
left: showing ? '20%' : `calc(34% + ${op.faceX || 0}px)`,
|
||||
top: showing ? '42%' : `calc(45% + ${op.faceY || 0}px)`,
|
||||
}}>
|
||||
<Pupil size={12} maxDistance={5} pupilColor="#78350f"
|
||||
forceLookX={showing ? -5 : undefined} forceLookY={showing ? -4 : undefined} />
|
||||
<Pupil size={12} maxDistance={5} pupilColor="#78350f"
|
||||
forceLookX={showing ? -5 : undefined} forceLookY={showing ? -4 : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Yellow rounded - front right */}
|
||||
<div ref={yellowRef} className="absolute bottom-0 transition-all duration-700 ease-in-out" style={{
|
||||
left: '56%', width: '26%', height: '58%', backgroundColor: '#84cc16',
|
||||
borderRadius: '70px 70px 0 0', zIndex: 4,
|
||||
transform: showing ? 'skewX(0deg)' : `skewX(${yp.bodySkew || 0}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}>
|
||||
<div className="absolute flex gap-6 transition-all duration-200 ease-out" style={{
|
||||
left: showing ? '14%' : `calc(37% + ${yp.faceX || 0}px)`,
|
||||
top: showing ? '15%' : `calc(17% + ${yp.faceY || 0}px)`,
|
||||
}}>
|
||||
<Pupil size={12} maxDistance={5} pupilColor="#3f6212"
|
||||
forceLookX={showing ? -5 : undefined} forceLookY={showing ? -4 : undefined} />
|
||||
<Pupil size={12} maxDistance={5} pupilColor="#3f6212"
|
||||
forceLookX={showing ? -5 : undefined} forceLookY={showing ? -4 : undefined} />
|
||||
</div>
|
||||
<div className="absolute w-16 h-[4px] bg-[#3f6212] rounded-full transition-all duration-200 ease-out"
|
||||
style={{
|
||||
left: showing ? '7%' : `calc(28% + ${yp.faceX || 0}px)`,
|
||||
top: showing ? '38%' : `calc(38% + ${yp.faceY || 0}px)`,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.ComponentRef<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.ComponentRef<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.ComponentRef<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 }
|
||||
@@ -0,0 +1,38 @@
|
||||
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-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary/10 text-primary",
|
||||
secondary:
|
||||
"border-transparent bg-muted text-muted-foreground",
|
||||
destructive:
|
||||
"border-transparent bg-destructive/10 text-destructive",
|
||||
outline: "text-foreground border-border/60",
|
||||
success: "border-transparent bg-emerald-500/10 text-emerald-600",
|
||||
warning: "border-transparent bg-amber-500/10 text-amber-600",
|
||||
},
|
||||
},
|
||||
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 }
|
||||
@@ -0,0 +1,41 @@
|
||||
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 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
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 }
|
||||
@@ -0,0 +1,76 @@
|
||||
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-2xl border border-border/40 bg-card text-card-foreground shadow-sm transition-shadow duration-200",
|
||||
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<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
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 }
|
||||
@@ -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.ComponentRef<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 shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring 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 }
|
||||
@@ -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.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm 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.ComponentRef<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 border-border/50 bg-background p-6 shadow-xl 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-2xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1 opacity-60 ring-offset-background transition-all hover:opacity-100 hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<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 gap-2 sm:flex-row sm:justify-end sm:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<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.ComponentRef<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,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center 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.ComponentRef<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.ComponentRef<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.ComponentRef<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]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ComponentRef<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.ComponentRef<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.ComponentRef<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.ComponentRef<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,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors 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-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -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.ComponentRef<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 }
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,157 @@
|
||||
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.ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring 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.ComponentRef<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.ComponentRef<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.ComponentRef<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.ComponentRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ComponentRef<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-2 pr-8 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 right-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.ComponentRef<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,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -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.ComponentRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm 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-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,120 @@
|
||||
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 border-border/40 transition-colors hover:bg-muted/30 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-11 px-4 text-left align-middle text-xs font-medium uppercase tracking-wider text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-4 py-3.5 align-middle text-sm [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
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,
|
||||
}
|
||||
@@ -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.ComponentRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ComponentRef<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-md px-3 py-1 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ComponentRef<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 }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: oklch(0.98 0.01 145);
|
||||
--color-foreground: oklch(0.15 0.02 145);
|
||||
--color-card: oklch(1 0 0);
|
||||
--color-card-foreground: oklch(0.15 0.02 145);
|
||||
--color-popover: oklch(1 0 0);
|
||||
--color-popover-foreground: oklch(0.15 0.02 145);
|
||||
--color-primary: oklch(0.55 0.12 145);
|
||||
--color-primary-foreground: oklch(1 0 0);
|
||||
--color-secondary: oklch(0.96 0.01 145);
|
||||
--color-secondary-foreground: oklch(0.35 0.08 145);
|
||||
--color-muted: oklch(0.96 0.01 145);
|
||||
--color-muted-foreground: oklch(0.55 0.02 145);
|
||||
--color-accent: oklch(0.96 0.01 145);
|
||||
--color-accent-foreground: oklch(0.35 0.08 145);
|
||||
--color-destructive: oklch(0.58 0.18 25);
|
||||
--color-destructive-foreground: oklch(1 0 0);
|
||||
--color-border: oklch(0.92 0.01 145);
|
||||
--color-input: oklch(0.92 0.01 145);
|
||||
--color-ring: oklch(0.55 0.12 145);
|
||||
|
||||
--color-sidebar-background: oklch(0.985 0.005 100 / 0.8);
|
||||
--color-sidebar-foreground: oklch(0.30 0.02 140);
|
||||
--color-sidebar-primary: oklch(0.55 0.12 145);
|
||||
--color-sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--color-sidebar-accent: oklch(0.95 0.02 135);
|
||||
--color-sidebar-accent-foreground: oklch(0.35 0.08 145);
|
||||
--color-sidebar-border: oklch(0.90 0.01 110 / 0.3);
|
||||
--color-sidebar-ring: oklch(0.55 0.12 145);
|
||||
|
||||
--radius-lg: 1.5rem;
|
||||
--radius-md: 1rem;
|
||||
--radius-sm: 0.6rem;
|
||||
|
||||
--shadow-soft: 0 4px 20px -2px oklch(0 0 0 / 0.05), 0 0 3px oklch(0 0 0 / 0.02);
|
||||
--shadow-soft-lg: 0 10px 40px -4px oklch(0 0 0 / 0.08), 0 0 4px oklch(0 0 0 / 0.03);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--color-background: oklch(0.18 0.02 145);
|
||||
--color-foreground: oklch(0.96 0.01 130);
|
||||
--color-card: oklch(0.22 0.02 145);
|
||||
--color-card-foreground: oklch(0.96 0.01 130);
|
||||
--color-popover: oklch(0.22 0.02 145);
|
||||
--color-popover-foreground: oklch(0.96 0.01 130);
|
||||
--color-primary: oklch(0.65 0.15 145);
|
||||
--color-primary-foreground: oklch(0.12 0.02 145);
|
||||
--color-secondary: oklch(0.28 0.03 145);
|
||||
--color-secondary-foreground: oklch(0.92 0.02 145);
|
||||
--color-muted: oklch(0.25 0.02 145);
|
||||
--color-muted-foreground: oklch(0.70 0.02 145);
|
||||
--color-accent: oklch(0.28 0.03 145);
|
||||
--color-accent-foreground: oklch(0.92 0.02 145);
|
||||
--color-destructive: oklch(0.58 0.18 25);
|
||||
--color-destructive-foreground: oklch(0.99 0 0);
|
||||
--color-border: oklch(0.32 0.03 145);
|
||||
--color-input: oklch(0.32 0.03 145);
|
||||
--color-ring: oklch(0.65 0.15 145);
|
||||
|
||||
--color-sidebar-background: oklch(0.16 0.02 145 / 0.8);
|
||||
--color-sidebar-foreground: oklch(0.85 0.02 145);
|
||||
--color-sidebar-primary: oklch(0.65 0.15 145);
|
||||
--color-sidebar-primary-foreground: oklch(0.12 0.02 145);
|
||||
--color-sidebar-accent: oklch(0.28 0.03 145);
|
||||
--color-sidebar-accent-foreground: oklch(0.92 0.02 145);
|
||||
--color-sidebar-border: oklch(0.32 0.03 145 / 0.3);
|
||||
--color-sidebar-ring: oklch(0.65 0.15 145);
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
font-feature-settings: "cv11", "ss01", "ss03";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.015em;
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 50%, oklch(0.55 0.12 145 / 0.05), transparent 25%),
|
||||
radial-gradient(circle at 85% 30%, oklch(0.65 0.15 170 / 0.06), transparent 25%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
:root.dark body {
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 50%, oklch(0.55 0.12 145 / 0.15), transparent 25%),
|
||||
radial-gradient(circle at 85% 30%, oklch(0.65 0.15 170 / 0.15), transparent 25%);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: oklch(0.75 0 0 / 0.3); border-radius: 999px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: oklch(0.6 0 0 / 0.5); }
|
||||
|
||||
/* Focus */
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes slideIn { from { opacity: 0; transform: translateX(-8px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes fadeInUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.animate-fadeIn { animation: fadeIn 0.2s ease-out; }
|
||||
.animate-slideIn { animation: slideIn 0.2s ease-out; }
|
||||
.animate-fade-in-up { animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
||||
|
||||
/* Card & Table enhancements */
|
||||
.card-hover { @apply transition-all duration-200; }
|
||||
.card-hover:hover { @apply shadow-md; transform: translateY(-1px); }
|
||||
tr { @apply transition-colors duration-150; }
|
||||
button { @apply transition-all duration-150; }
|
||||
button:active:not(:disabled) { transform: scale(0.97); }
|
||||
input:focus, textarea:focus, select:focus { @apply transition-shadow duration-150; }
|
||||
|
||||
/* Status colors */
|
||||
.status-success { @apply bg-emerald-500/10 text-emerald-600; }
|
||||
.status-warning { @apply bg-amber-500/10 text-amber-600; }
|
||||
.status-error { @apply bg-red-500/10 text-red-600; }
|
||||
.status-info { @apply bg-blue-500/10 text-blue-600; }
|
||||
|
||||
/* Gradients */
|
||||
.gradient-primary { background: linear-gradient(135deg, oklch(0.52 0.14 150), oklch(0.58 0.12 160)); }
|
||||
|
||||
/* Glassmorphism */
|
||||
.glass-panel { @apply bg-background/60 backdrop-blur-2xl border-white/40 dark:border-white/10 shadow-soft; }
|
||||
.glass-card { @apply bg-card/80 backdrop-blur-xl border border-white/60 dark:border-white/10 shadow-soft transition-all duration-300; }
|
||||
.glass-card:hover { @apply shadow-soft-lg -translate-y-1 bg-card/90 border-white/80 dark:border-white/20; }
|
||||
|
||||
/* Skeleton */
|
||||
.skeleton { @apply bg-muted animate-pulse rounded;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Users, Shield, MessageSquare, FolderTree, Leaf,
|
||||
LogOut, ChevronDown, Menu, FileText, Settings, Book, Home, Monitor,
|
||||
Hash, Folder, ChevronRight, Search, Bell, ChevronLeft, Bot, Radio,
|
||||
Image, Music, ScrollText, Sun, Moon,
|
||||
} from 'lucide-react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useAppStore } from '@/store/app'
|
||||
import type { SystemMenu } from '@/api/system'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Icon mapping
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
dashboard: <LayoutDashboard className="h-4 w-4" />,
|
||||
home: <Home className="h-4 w-4" />,
|
||||
user: <Users className="h-4 w-4" />,
|
||||
users: <Users className="h-4 w-4" />,
|
||||
role: <Shield className="h-4 w-4" />,
|
||||
shield: <Shield className="h-4 w-4" />,
|
||||
system: <Settings className="h-4 w-4" />,
|
||||
settings: <Settings className="h-4 w-4" />,
|
||||
topic: <MessageSquare className="h-4 w-4" />,
|
||||
message: <MessageSquare className="h-4 w-4" />,
|
||||
post: <FileText className="h-4 w-4" />,
|
||||
category: <FolderTree className="h-4 w-4" />,
|
||||
tree: <FolderTree className="h-4 w-4" />,
|
||||
plant: <Leaf className="h-4 w-4" />,
|
||||
leaf: <Leaf className="h-4 w-4" />,
|
||||
wiki: <Book className="h-4 w-4" />,
|
||||
book: <Book className="h-4 w-4" />,
|
||||
menu: <Menu className="h-4 w-4" />,
|
||||
monitor: <Monitor className="h-4 w-4" />,
|
||||
client: <Monitor className="h-4 w-4" />,
|
||||
hash: <Hash className="h-4 w-4" />,
|
||||
'file-text': <FileText className="h-4 w-4" />,
|
||||
folder: <Folder className="h-4 w-4" />,
|
||||
bot: <Bot className="h-4 w-4" />,
|
||||
ai: <Bot className="h-4 w-4" />,
|
||||
radio: <Radio className="h-4 w-4" />,
|
||||
image: <Image className="h-4 w-4" />,
|
||||
music: <Music className="h-4 w-4" />,
|
||||
scroll: <ScrollText className="h-4 w-4" />,
|
||||
log: <ScrollText className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
function getIcon(iconName?: string): React.ReactNode {
|
||||
if (!iconName) return <FileText className="h-4 w-4" />
|
||||
const lower = iconName.toLowerCase()
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (lower.includes(key)) return icon
|
||||
}
|
||||
return <FileText className="h-4 w-4" />
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
title: string; href: string; icon: React.ReactNode
|
||||
permission?: string; children?: NavItem[]
|
||||
}
|
||||
|
||||
function convertMenuToNavItem(menu: SystemMenu): NavItem {
|
||||
return {
|
||||
title: menu.title || menu.name,
|
||||
href: menu.path || menu.code || `/${menu.name.toLowerCase()}`,
|
||||
icon: getIcon(menu.icon),
|
||||
permission: menu.permission,
|
||||
children: menu.children?.map(convertMenuToNavItem),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Nav Item ====================
|
||||
|
||||
function NavItemComponent({ item, collapsed, level = 0 }: { item: NavItem; collapsed: boolean; level?: number }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasPermission = useAuthStore(s => s.hasPermission)
|
||||
const location = useLocation()
|
||||
const hasActiveChild = item.children?.some(c => location.pathname.startsWith(c.href))
|
||||
|
||||
useEffect(() => {
|
||||
if (hasActiveChild && !collapsed) setOpen(true)
|
||||
}, [hasActiveChild, collapsed])
|
||||
|
||||
if (item.permission && !hasPermission(item.permission)) return null
|
||||
|
||||
if (item.children?.length) {
|
||||
const visible = item.children.filter(c => !c.permission || hasPermission(c.permission))
|
||||
if (!visible.length) return null
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<button onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sidebar-foreground transition-all duration-300 hover:bg-sidebar-accent/60 group relative overflow-hidden',
|
||||
(open || hasActiveChild) && 'text-primary font-medium bg-sidebar-accent/30',
|
||||
level > 0 && 'text-[13px] py-2'
|
||||
)}>
|
||||
<span className={cn("transition-colors", (open || hasActiveChild) ? "text-primary" : "text-muted-foreground group-hover:text-primary")}>{item.icon}</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left line-clamp-1 text-sm">{item.title}</span>
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform text-muted-foreground/70', open && 'rotate-180 text-foreground')} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{open && !collapsed && (
|
||||
<div className={cn('mt-1 space-y-0.5 relative', level === 0 ? 'ml-4 pl-3 border-l border-sidebar-border/50' : 'ml-3 pl-3 border-l border-sidebar-border/50')}>
|
||||
{visible.map(child => <NavItemComponent key={child.href} item={child} collapsed={collapsed} level={level + 1} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink to={item.href} end
|
||||
className={({ isActive }) => cn(
|
||||
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sidebar-foreground transition-all duration-300 hover:bg-sidebar-accent/60 mb-1 group relative overflow-hidden',
|
||||
isActive && 'bg-primary/10 text-primary font-semibold',
|
||||
level > 0 && 'text-[13px] py-2'
|
||||
)}>
|
||||
<span className={cn("absolute left-0 top-1/2 -translate-y-1/2 w-1 h-0 bg-primary rounded-r-full transition-all duration-300", location.pathname === item.href && "h-3/5")} />
|
||||
{level === 0 && <span className="text-muted-foreground group-hover:text-primary transition-colors">{item.icon}</span>}
|
||||
{level > 0 && <span className={cn("w-1.5 h-1.5 rounded-full transition-all bg-current opacity-30 group-hover:opacity-70", location.pathname === item.href && "opacity-100 scale-110 bg-primary")} />}
|
||||
{!collapsed && <span className="text-sm line-clamp-1">{item.title}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Breadcrumb ====================
|
||||
|
||||
function Breadcrumb() {
|
||||
const location = useLocation()
|
||||
const menus = useAuthStore(s => s.menus)
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
const segments = location.pathname.split('/').filter(Boolean)
|
||||
const crumbs: { title: string; href: string }[] = []
|
||||
let currentPath = ''
|
||||
const findMenuItem = (items: SystemMenu[], target: string): SystemMenu | undefined => {
|
||||
for (const item of items) {
|
||||
if (item.path === target || item.code === target) return item
|
||||
if (item.children) { const found = findMenuItem(item.children, target); if (found) return found }
|
||||
}
|
||||
}
|
||||
segments.forEach(seg => {
|
||||
currentPath += `/${seg}`
|
||||
const menuItem = menus ? findMenuItem(menus, currentPath) : null
|
||||
crumbs.push({ title: menuItem ? (menuItem.title || menuItem.name) : seg.charAt(0).toUpperCase() + seg.slice(1), href: currentPath })
|
||||
})
|
||||
return crumbs
|
||||
}, [location.pathname, menus])
|
||||
|
||||
if (!breadcrumbs.length) return null
|
||||
return (
|
||||
<div className="flex items-center text-sm">
|
||||
<NavLink to="/dashboard" className="flex items-center text-muted-foreground hover:text-foreground transition-colors"><Home className="h-4 w-4" /></NavLink>
|
||||
{breadcrumbs.length > 0 && <ChevronRight className="h-4 w-4 mx-2 text-muted-foreground/40" />}
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<div key={crumb.href} className="flex items-center">
|
||||
{i > 0 && <ChevronRight className="h-4 w-4 mx-2 text-muted-foreground/40" />}
|
||||
<span className={cn("transition-colors", i === breadcrumbs.length - 1 ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground")}>
|
||||
{i === breadcrumbs.length - 1 ? crumb.title : <NavLink to={crumb.href}>{crumb.title}</NavLink>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Layout ====================
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { sidebarOpen, mobileMenuOpen, toggleSidebar, setMobileMenu } = useAppStore()
|
||||
const { user, logout, menus } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
if (menus?.length) return menus.map(convertMenuToNavItem)
|
||||
return [{ title: '仪表盘', href: '/dashboard', icon: <LayoutDashboard className="h-4 w-4" /> }]
|
||||
}, [menus])
|
||||
|
||||
const handleLogout = async () => { await logout(); navigate('/login') }
|
||||
|
||||
return <LayoutShell sidebarOpen={sidebarOpen} mobileMenuOpen={mobileMenuOpen} toggleSidebar={toggleSidebar} setMobileMenu={setMobileMenu} navItems={navItems} user={user} onLogout={handleLogout} />
|
||||
}
|
||||
|
||||
// ==================== Shell Render ====================
|
||||
|
||||
function LayoutShell({ sidebarOpen, mobileMenuOpen, toggleSidebar, setMobileMenu, navItems, user, onLogout }: {
|
||||
sidebarOpen: boolean; mobileMenuOpen: boolean; toggleSidebar: () => void; setMobileMenu: (v: boolean) => void
|
||||
navItems: NavItem[]; user: import('@/api/system').SystemUser | null; onLogout: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background p-2 lg:p-3 gap-2 lg:gap-3">
|
||||
{/* Sidebar */}
|
||||
<aside className={cn(
|
||||
'fixed inset-y-2 lg:inset-y-3 left-2 lg:left-3 z-40 flex flex-col rounded-2xl border border-white/40 dark:border-white/5 glass-panel transition-all duration-300 overflow-hidden',
|
||||
sidebarOpen ? 'w-[260px]' : 'w-20',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-[120%] lg:translate-x-0'
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between px-5 shrink-0">
|
||||
<div className={cn("flex items-center gap-3 overflow-hidden transition-all", !sidebarOpen && "justify-center w-full")}>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-500 text-white shadow-lg shadow-emerald-500/20">
|
||||
<Leaf className="h-4 w-4" />
|
||||
</div>
|
||||
{sidebarOpen && <span className="font-bold text-lg tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-emerald-600 to-teal-600 truncate">Sundynix Console</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Nav */}
|
||||
<ScrollArea className="flex-1 px-3 py-4">
|
||||
<nav className="space-y-0.5">
|
||||
{navItems.map(item => <NavItemComponent key={item.href} item={item} collapsed={!sidebarOpen} />)}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
{/* User */}
|
||||
<div className="p-3 shrink-0 bg-sidebar-background/50 border-t border-white/10 dark:border-white/5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className={cn("flex w-full items-center gap-3 rounded-xl p-2 transition-all duration-200 hover:bg-white/50 dark:hover:bg-white/5 outline-none", !sidebarOpen && "justify-center")}>
|
||||
<Avatar className="h-9 w-9 ring-2 ring-white/80 dark:ring-white/10 shadow-sm">
|
||||
<AvatarImage src={user?.avatar?.url} alt={user?.name || user?.account} />
|
||||
<AvatarFallback className="bg-emerald-100 text-emerald-700 text-xs font-bold">{(user?.name || user?.account)?.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
{sidebarOpen && (
|
||||
<div className="flex-1 text-left overflow-hidden">
|
||||
<p className="text-sm font-semibold text-sidebar-foreground truncate leading-none mb-1.5">{user?.name || user?.account}</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate leading-none">管理员</p>
|
||||
</div>
|
||||
)}
|
||||
{sidebarOpen && <ChevronDown className="h-4 w-4 text-muted-foreground/50" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56" side="right" sideOffset={12}>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<p className="text-sm font-medium leading-none">{user?.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground mt-1">{user?.account}</p>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onLogout} className="text-destructive cursor-pointer hover:bg-destructive/10 focus:bg-destructive/10">
|
||||
<LogOut className="mr-2 h-4 w-4" /> 退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileMenuOpen && <div className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden animate-in fade-in duration-200" onClick={() => setMobileMenu(false)} />}
|
||||
|
||||
{/* Main */}
|
||||
<div className={cn(
|
||||
'flex-1 flex flex-col min-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1.5rem)] transition-all duration-300 bg-white/40 dark:bg-slate-900/40 rounded-2xl border border-white/50 dark:border-white/5 shadow-soft overflow-hidden relative backdrop-blur-xl',
|
||||
sidebarOpen ? 'lg:ml-[268px]' : 'lg:ml-[88px]'
|
||||
)}>
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-white/30 dark:border-white/5 bg-white/30 dark:bg-black/10 backdrop-blur-md px-4 lg:px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" className="hidden lg:flex h-8 w-8 rounded-full hover:bg-white/50 dark:hover:bg-white/10" onClick={toggleSidebar}>
|
||||
{sidebarOpen ? <ChevronLeft className="h-4 w-4 text-muted-foreground" /> : <Menu className="h-4 w-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="lg:hidden h-8 w-8 rounded-full" onClick={() => setMobileMenu(!mobileMenuOpen)}>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<div className="w-px h-4 bg-border/60 mx-1" />
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden md:flex relative group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground group-focus-within:text-emerald-500 transition-colors" />
|
||||
<Input placeholder="全局搜索..." className="w-56 focus:w-72 pl-9 bg-white/50 dark:bg-black/20 border-white/40 dark:border-white/10 focus:bg-white dark:focus:bg-slate-800 focus:border-emerald-500/30 transition-all h-9 text-sm rounded-full shadow-sm" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="relative rounded-full h-9 w-9 hover:bg-white/50 dark:hover:bg-white/10"
|
||||
onClick={() => document.documentElement.classList.toggle('dark')}>
|
||||
<Sun className="h-4 w-4 text-muted-foreground block dark:hidden" />
|
||||
<Moon className="h-4 w-4 text-muted-foreground hidden dark:block" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="relative rounded-full h-9 w-9 hover:bg-white/50 dark:hover:bg-white/10">
|
||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="absolute top-2.5 right-2.5 w-1.5 h-1.5 bg-red-500 rounded-full ring-2 ring-white dark:ring-slate-900" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<main className="p-4 lg:p-6 relative z-0 min-h-full">
|
||||
<div className="mx-auto w-full max-w-[1600px] animate-fade-in-up space-y-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
const AUTH_WHITELIST = ['/auth/captcha', '/auth/login']
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const isWhitelisted = AUTH_WHITELIST.some(path => config.url?.startsWith(path))
|
||||
if (!isWhitelisted) {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
if (config.data instanceof FormData) {
|
||||
config.headers.delete('Content-Type')
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => Promise.reject(error)
|
||||
)
|
||||
|
||||
request.interceptors.response.use(
|
||||
response => {
|
||||
const res = response.data
|
||||
if (res.code !== undefined && res.code !== 200) {
|
||||
if (res.code === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(new Error(res.msg || '请求失败'))
|
||||
}
|
||||
return res
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export function get<T = unknown>(url: string, params?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<T> {
|
||||
return request.get(url, { params, ...config })
|
||||
}
|
||||
|
||||
export function post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
return request.post(url, data, config)
|
||||
}
|
||||
|
||||
export function put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
return request.put(url, data, config)
|
||||
}
|
||||
|
||||
export function del<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return request.delete(url, config)
|
||||
}
|
||||
|
||||
export default request
|
||||
|
||||
export interface ApiResponse<T = unknown> { code: number; data: T; msg: string }
|
||||
export interface PageResult<T = unknown> { list: T[]; page: number; pageSize: number; total: number }
|
||||
export interface PageParams { current: number; pageSize: number; keyword?: string }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
// Mock data adapter — switch between mock and real API
|
||||
// Set VITE_USE_MOCK=true in .env to enable mock mode
|
||||
export const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||
|
||||
export function delay(ms = 300): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function paginate<T>(list: T[], current: number, pageSize: number): { list: T[]; total: number; page: number; pageSize: number } {
|
||||
const start = (current - 1) * pageSize
|
||||
return {
|
||||
list: list.slice(start, start + pageSize),
|
||||
total: list.length,
|
||||
page: current,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
let _idCounter = 1000
|
||||
export function mockId(): string {
|
||||
return String(++_idCounter)
|
||||
}
|
||||
|
||||
export function mockDate(daysAgo = 0): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - daysAgo)
|
||||
return d.toISOString()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { mockDate } from '../index'
|
||||
|
||||
export interface Banner {
|
||||
id: string; title: string; imageId: string; image?: { id: string; url: string }
|
||||
sort: number; isActive: number; targetUrl?: string
|
||||
createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export const mockBanners: Banner[] = [
|
||||
{ id: '1', title: '春日花园', imageId: '1', image: { id: '1', url: 'https://picsum.photos/seed/spring/800/400' }, sort: 0, isActive: 1, targetUrl: '/pages/wiki/index', createdAt: mockDate(30), updatedAt: mockDate(1) },
|
||||
{ id: '2', title: '夏日养护', imageId: '2', image: { id: '2', url: 'https://picsum.photos/seed/summer/800/400' }, sort: 1, isActive: 1, createdAt: mockDate(20), updatedAt: mockDate(2) },
|
||||
{ id: '3', title: '秋季收获', imageId: '3', image: { id: '3', url: 'https://picsum.photos/seed/autumn/800/400' }, sort: 2, isActive: 0, createdAt: mockDate(10), updatedAt: mockDate(1) },
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
import { mockDate } from '../index'
|
||||
|
||||
export interface Topic {
|
||||
id: string; title: string; description?: string; sort: number; status: number
|
||||
postCount: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string; topicId: string; topicTitle?: string; userId: string; userName?: string
|
||||
title: string; content: string; likeCount: number; commentCount: number
|
||||
status: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export const mockTopics: Topic[] = [
|
||||
{ id: '1', title: '养花心得', description: '分享你的养花经验', sort: 0, status: 1, postCount: 28, createdAt: mockDate(60), updatedAt: mockDate(1) },
|
||||
{ id: '2', title: '植物鉴定', description: '帮你识别未知植物', sort: 1, status: 1, postCount: 15, createdAt: mockDate(50), updatedAt: mockDate(2) },
|
||||
{ id: '3', title: '病虫害防治', description: '植物健康问题讨论', sort: 2, status: 1, postCount: 22, createdAt: mockDate(40), updatedAt: mockDate(1) },
|
||||
]
|
||||
|
||||
export const mockPosts: Post[] = [
|
||||
{ id: '1', topicId: '1', topicTitle: '养花心得', userId: '2', userName: '张三', title: '我的阳台花园改造', content: '经过三个月的改造...', likeCount: 42, commentCount: 12, status: 1, createdAt: mockDate(10), updatedAt: mockDate(1) },
|
||||
{ id: '2', topicId: '2', topicTitle: '植物鉴定', userId: '3', userName: '李四', title: '这是什么花?', content: '路边看到的一种花...', likeCount: 8, commentCount: 5, status: 1, createdAt: mockDate(7), updatedAt: mockDate(1) },
|
||||
{ id: '3', topicId: '3', topicTitle: '病虫害防治', userId: '4', userName: '王五', title: '绿萝叶子发黄怎么办', content: '最近发现绿萝叶子...', likeCount: 35, commentCount: 18, status: 1, createdAt: mockDate(5), updatedAt: mockDate(1) },
|
||||
{ id: '4', topicId: '1', topicTitle: '养花心得', userId: '5', userName: '赵六', title: '新手第一次养多肉', content: '刚入坑的多肉小白...', likeCount: 20, commentCount: 8, status: 0, createdAt: mockDate(3), updatedAt: mockDate(1) },
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
import { mockDate } from '../index'
|
||||
|
||||
export interface ExchangeItem {
|
||||
id: string; name: string; description?: string; points: number
|
||||
stock: number; cover?: string; status: number; sort: number
|
||||
createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface ExchangeOrder {
|
||||
id: string; itemId: string; itemName?: string; userId: string; userName?: string
|
||||
points: number; status: number; address?: string
|
||||
createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export const mockExchangeItems: ExchangeItem[] = [
|
||||
{ id: '1', name: '精美花盆', description: '陶瓷手绘花盆', points: 200, stock: 50, cover: 'https://picsum.photos/seed/pot/300/200', status: 1, sort: 0, createdAt: mockDate(30), updatedAt: mockDate(1) },
|
||||
{ id: '2', name: '园艺工具套装', description: '5件套园艺工具', points: 500, stock: 20, cover: 'https://picsum.photos/seed/tools/300/200', status: 1, sort: 1, createdAt: mockDate(25), updatedAt: mockDate(1) },
|
||||
{ id: '3', name: '有机肥料', description: '通用型有机肥 1kg', points: 100, stock: 100, cover: 'https://picsum.photos/seed/fertilizer/300/200', status: 1, sort: 2, createdAt: mockDate(20), updatedAt: mockDate(1) },
|
||||
]
|
||||
|
||||
export const mockExchangeOrders: ExchangeOrder[] = [
|
||||
{ id: '1', itemId: '1', itemName: '精美花盆', userId: '2', userName: '张三', points: 200, status: 1, address: '北京市朝阳区xxx', createdAt: mockDate(5), updatedAt: mockDate(1) },
|
||||
{ id: '2', itemId: '2', itemName: '园艺工具套装', userId: '3', userName: '李四', points: 500, status: 2, address: '上海市浦东新区xxx', createdAt: mockDate(3), updatedAt: mockDate(1) },
|
||||
{ id: '3', itemId: '3', itemName: '有机肥料', userId: '4', userName: '王五', points: 100, status: 0, createdAt: mockDate(1), updatedAt: mockDate(0) },
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
import { mockDate } from '../index'
|
||||
|
||||
export interface PlantWikiClass {
|
||||
id: string; name: string; sort: number; parentId?: string
|
||||
children?: PlantWikiClass[]; createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface PlantWiki {
|
||||
id: string; title: string; classId: string; className?: string
|
||||
content: string; cover?: string; status: number
|
||||
createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export const mockWikiClasses: PlantWikiClass[] = [
|
||||
{ id: '1', name: '观叶植物', sort: 0, createdAt: mockDate(60), updatedAt: mockDate(1) },
|
||||
{ id: '2', name: '多肉植物', sort: 1, createdAt: mockDate(60), updatedAt: mockDate(1) },
|
||||
{ id: '3', name: '花卉植物', sort: 2, createdAt: mockDate(60), updatedAt: mockDate(1) },
|
||||
{ id: '4', name: '果蔬类', sort: 3, createdAt: mockDate(30), updatedAt: mockDate(1) },
|
||||
]
|
||||
|
||||
export const mockWikis: PlantWiki[] = [
|
||||
{ id: '1', title: '绿萝养护指南', classId: '1', className: '观叶植物', content: '绿萝是最常见的室内观叶植物...', cover: 'https://picsum.photos/seed/greenplant/300/200', status: 1, createdAt: mockDate(30), updatedAt: mockDate(2) },
|
||||
{ id: '2', title: '多肉浇水技巧', classId: '2', className: '多肉植物', content: '多肉植物浇水需要遵循...', cover: 'https://picsum.photos/seed/succulent/300/200', status: 1, createdAt: mockDate(25), updatedAt: mockDate(1) },
|
||||
{ id: '3', title: '月季修剪方法', classId: '3', className: '花卉植物', content: '月季修剪的最佳时期...', cover: 'https://picsum.photos/seed/rose2/300/200', status: 1, createdAt: mockDate(20), updatedAt: mockDate(3) },
|
||||
{ id: '4', title: '番茄种植攻略', classId: '4', className: '果蔬类', content: '家庭番茄种植从播种到收获...', cover: 'https://picsum.photos/seed/tomato/300/200', status: 0, createdAt: mockDate(10), updatedAt: mockDate(1) },
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
import { mockDate } from '../index'
|
||||
|
||||
export interface RadioCategory {
|
||||
id: string; name: string; sort: number; status: number
|
||||
createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface RadioChannel {
|
||||
id: string; categoryId: string; categoryName?: string
|
||||
name: string; description?: string; cover?: string
|
||||
isFree: boolean; isVipOnly: boolean
|
||||
monthlyPrice?: number; quarterlyPrice?: number; annualPrice?: number
|
||||
tags?: string; sort: number; status: number
|
||||
createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface RadioProgram {
|
||||
id: string; channelId: string; channelName?: string
|
||||
title: string; description?: string; content?: string
|
||||
cover?: string; audioId?: string; audioStatus: number
|
||||
duration: number; playCount: number; likeCount: number
|
||||
status: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface RadioSubscription {
|
||||
id: string; userId: string; userName?: string
|
||||
channelId: string; channelName?: string
|
||||
planType: string; startAt: string; expireAt: string
|
||||
amount: number; status: number
|
||||
createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export const mockRadioCategories: RadioCategory[] = [
|
||||
{ id: '1', name: '音乐', sort: 0, status: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '2', name: '故事', sort: 1, status: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '3', name: '知识', sort: 2, status: 1, createdAt: mockDate(60), updatedAt: mockDate(1) },
|
||||
{ id: '4', name: '冥想', sort: 3, status: 1, createdAt: mockDate(30), updatedAt: mockDate(1) },
|
||||
]
|
||||
|
||||
export const mockRadioChannels: RadioChannel[] = [
|
||||
{ id: '1', categoryId: '1', categoryName: '音乐', name: '晨间音乐', description: '每日精选晨间音乐', cover: 'https://picsum.photos/seed/music1/300/300', isFree: true, isVipOnly: false, sort: 0, status: 1, createdAt: mockDate(60), updatedAt: mockDate(1) },
|
||||
{ id: '2', categoryId: '2', categoryName: '故事', name: '睡前故事', description: '温馨的睡前故事', cover: 'https://picsum.photos/seed/story1/300/300', isFree: false, isVipOnly: false, monthlyPrice: 9.9, quarterlyPrice: 25, annualPrice: 88, sort: 1, status: 1, createdAt: mockDate(50), updatedAt: mockDate(2) },
|
||||
{ id: '3', categoryId: '3', categoryName: '知识', name: '科学探索', description: '有趣的科学知识', cover: 'https://picsum.photos/seed/science/300/300', isFree: false, isVipOnly: true, monthlyPrice: 19.9, sort: 2, status: 1, createdAt: mockDate(30), updatedAt: mockDate(1) },
|
||||
]
|
||||
|
||||
export const mockRadioPrograms: RadioProgram[] = [
|
||||
{ id: '1', channelId: '1', channelName: '晨间音乐', title: '春日晨曲', description: '轻快的春日旋律', audioStatus: 2, duration: 180, playCount: 1200, likeCount: 89, status: 1, createdAt: mockDate(20), updatedAt: mockDate(1) },
|
||||
{ id: '2', channelId: '1', channelName: '晨间音乐', title: '森林协奏曲', description: '大自然的声音', audioStatus: 2, duration: 240, playCount: 890, likeCount: 56, status: 1, createdAt: mockDate(15), updatedAt: mockDate(1) },
|
||||
{ id: '3', channelId: '2', channelName: '睡前故事', title: '小王子的旅途', description: '经典故事改编', audioStatus: 2, duration: 600, playCount: 2300, likeCount: 178, status: 1, createdAt: mockDate(10), updatedAt: mockDate(2) },
|
||||
{ id: '4', channelId: '3', channelName: '科学探索', title: '黑洞的秘密', description: '宇宙中的神秘天体', audioStatus: 1, duration: 0, playCount: 0, likeCount: 0, status: 0, createdAt: mockDate(2), updatedAt: mockDate(0) },
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { mockDate } from '../index'
|
||||
import type { RadioSubscription } from './channels'
|
||||
|
||||
export const mockSubscriptions: RadioSubscription[] = [
|
||||
{ id: '1', userId: '2', userName: '张三', channelId: '2', channelName: '睡前故事', planType: 'monthly', startAt: mockDate(25), expireAt: mockDate(-5), amount: 9.9, status: 1, createdAt: mockDate(25), updatedAt: mockDate(1) },
|
||||
{ id: '2', userId: '3', userName: '李四', channelId: '3', channelName: '科学探索', planType: 'annual', startAt: mockDate(60), expireAt: mockDate(-305), amount: 88, status: 1, createdAt: mockDate(60), updatedAt: mockDate(1) },
|
||||
{ id: '3', userId: '4', userName: '王五', channelId: '2', channelName: '睡前故事', planType: 'quarterly', startAt: mockDate(90), expireAt: mockDate(0), amount: 25, status: 2, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '4', userId: '5', userName: '赵六', channelId: '3', channelName: '科学探索', planType: 'monthly', startAt: mockDate(10), expireAt: mockDate(-20), amount: 19.9, status: 1, createdAt: mockDate(10), updatedAt: mockDate(1) },
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
import { mockDate } from '../index'
|
||||
import type { SystemClient } from '@/api/system'
|
||||
|
||||
export const mockClients: SystemClient[] = [
|
||||
{ id: '1', clientId: 'plant', name: 'Plant 花园服务', grantType: 'password,refresh_token', activeTimeout: 7200, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '2', clientId: 'radio', name: 'Radio 电台服务', grantType: 'password,refresh_token', activeTimeout: 7200, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '3', clientId: 'admin', name: '管理后台', grantType: 'password', activeTimeout: 3600, createdAt: mockDate(120), updatedAt: mockDate(1) },
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
import { mockDate } from '../index'
|
||||
import type { SystemOss } from '@/api/system'
|
||||
|
||||
export const mockFiles: SystemOss[] = [
|
||||
{ id: '1', name: 'banner-spring.jpg', key: 'uploads/banner-spring.jpg', url: 'https://picsum.photos/seed/spring/800/400', suffix: 'jpg', tag: 'banner', createdAt: mockDate(30), updatedAt: mockDate(1) },
|
||||
{ id: '2', name: 'banner-summer.jpg', key: 'uploads/banner-summer.jpg', url: 'https://picsum.photos/seed/summer/800/400', suffix: 'jpg', tag: 'banner', createdAt: mockDate(25), updatedAt: mockDate(1) },
|
||||
{ id: '3', name: 'plant-rose.png', key: 'uploads/plant-rose.png', url: 'https://picsum.photos/seed/rose/400/400', suffix: 'png', tag: 'wiki', createdAt: mockDate(20), updatedAt: mockDate(1) },
|
||||
{ id: '4', name: 'avatar-admin.jpg', key: 'uploads/avatar-admin.jpg', url: 'https://picsum.photos/seed/admin/200/200', suffix: 'jpg', tag: 'avatar', createdAt: mockDate(60), updatedAt: mockDate(1) },
|
||||
{ id: '5', name: 'channel-cover.jpg', key: 'uploads/channel-cover.jpg', url: 'https://picsum.photos/seed/radio/400/400', suffix: 'jpg', tag: 'radio', createdAt: mockDate(15), updatedAt: mockDate(1) },
|
||||
{ id: '6', name: 'audio-ep01.mp3', key: 'uploads/audio-ep01.mp3', url: '#', suffix: 'mp3', tag: 'audio', createdAt: mockDate(10), updatedAt: mockDate(1) },
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mockDate, mockId } from '../index'
|
||||
import type { OperationLog } from '@/api/system'
|
||||
|
||||
const methods = ['GET', 'POST', 'PUT', 'DELETE']
|
||||
const clients = [
|
||||
{ id: 'gateway', name: 'API 网关' },
|
||||
{ id: 'plant', name: 'Plant 服务' },
|
||||
{ id: 'radio', name: 'Radio 服务' },
|
||||
]
|
||||
const operators = [
|
||||
{ id: 'u1', name: '超级管理员' },
|
||||
{ id: 'u2', name: '张三' },
|
||||
{ id: 'u3', name: '李四' },
|
||||
]
|
||||
const apis = [
|
||||
{ method: 'POST', path: '/api/auth/login', title: '用户登录', status: 200 },
|
||||
{ method: 'GET', path: '/api/auth/captcha', title: '获取验证码', status: 200 },
|
||||
{ method: 'POST', path: '/api/user/getUserList', title: '查询用户列表', status: 200 },
|
||||
{ method: 'POST', path: '/api/user/save', title: '创建用户', status: 200 },
|
||||
{ method: 'POST', path: '/api/user/update', title: '更新用户', status: 200 },
|
||||
{ method: 'POST', path: '/api/user/delete', title: '删除用户', status: 200 },
|
||||
{ method: 'POST', path: '/api/user/grantRole', title: '分配角色', status: 200 },
|
||||
{ method: 'POST', path: '/api/role/getRoleList', title: '查询角色列表', status: 200 },
|
||||
{ method: 'POST', path: '/api/role/grantMenu', title: '角色授权菜单', status: 200 },
|
||||
{ method: 'POST', path: '/api/menu/getAllMenuTree', title: '查询菜单树', status: 200 },
|
||||
{ method: 'POST', path: '/api/oss/upload', title: '上传文件', status: 200 },
|
||||
{ method: 'POST', path: '/api/plant/wiki/list', title: '百科列表', status: 200 },
|
||||
{ method: 'POST', path: '/api/plant/wiki/save', title: '新增百科', status: 200 },
|
||||
{ method: 'POST', path: '/api/plant/banner/list', title: '轮播图列表', status: 200 },
|
||||
{ method: 'POST', path: '/api/radio/channel/list', title: '频道列表', status: 200 },
|
||||
{ method: 'POST', path: '/api/radio/program/save', title: '新增节目', status: 200 },
|
||||
{ method: 'GET', path: '/api/radio/subscription/list', title: '订阅列表', status: 200 },
|
||||
{ method: 'POST', path: '/api/user/changePassword', title: '修改密码', status: 200 },
|
||||
{ method: 'GET', path: '/api/client/getClientList', title: '客户端列表', status: 200 },
|
||||
{ method: 'POST', path: '/api/auth/logout', title: '用户登出', status: 200 },
|
||||
{ method: 'POST', path: '/api/user/save', title: '创建用户', status: 400 },
|
||||
{ method: 'POST', path: '/api/oss/upload', title: '上传文件', status: 500 },
|
||||
]
|
||||
|
||||
const ips = ['192.168.1.100', '10.0.0.15', '172.16.0.42', '192.168.2.88']
|
||||
const uas = [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
'PostmanRuntime/7.37.3',
|
||||
]
|
||||
|
||||
function rand<T>(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)] }
|
||||
|
||||
export const mockLogs: OperationLog[] = Array.from({ length: 120 }, (_, i) => {
|
||||
const api = rand(apis)
|
||||
const client = rand(clients)
|
||||
const op = rand(operators)
|
||||
return {
|
||||
id: mockId(),
|
||||
operatorId: op.id,
|
||||
operatorName: op.name,
|
||||
clientId: client.id,
|
||||
clientName: client.name,
|
||||
method: api.method,
|
||||
path: api.path,
|
||||
title: api.title,
|
||||
statusCode: api.status,
|
||||
duration: Math.floor(Math.random() * 500) + 5,
|
||||
ip: rand(ips),
|
||||
userAgent: rand(uas),
|
||||
requestBody: api.method === 'GET' ? undefined : '{"page":1}',
|
||||
responseBody: api.status === 200 ? '{"code":0,"msg":"ok"}' : '{"code":1,"msg":"error"}',
|
||||
createdAt: mockDate(i * 0.3),
|
||||
}
|
||||
}).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mockDate } from '../index'
|
||||
import type { SystemMenu } from '@/api/system'
|
||||
|
||||
export const mockMenuTree: SystemMenu[] = [
|
||||
{
|
||||
id: '1', name: 'dashboard', title: '仪表盘', path: '/dashboard', icon: 'dashboard',
|
||||
sort: 0, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1),
|
||||
},
|
||||
{
|
||||
id: '10', name: 'system', title: '系统管理', path: '/system', icon: 'settings',
|
||||
sort: 1, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1),
|
||||
children: [
|
||||
{ id: '11', name: 'users', title: '用户管理', path: '/system/users', icon: 'users', parentId: '10', sort: 0, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '12', name: 'roles', title: '角色管理', path: '/system/roles', icon: 'shield', parentId: '10', sort: 1, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '13', name: 'menus', title: '菜单管理', path: '/system/menus', icon: 'menu', parentId: '10', sort: 2, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '14', name: 'clients', title: '客户端管理', path: '/system/clients', icon: 'monitor', parentId: '10', sort: 3, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '15', name: 'files', title: '文件管理', path: '/system/files', icon: 'folder', parentId: '10', sort: 4, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '16', name: 'logs', title: '操作日志', path: '/system/logs', icon: 'scroll', parentId: '10', sort: 5, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '20', name: 'plant', title: 'Plant 服务', path: '/plant', icon: 'leaf',
|
||||
sort: 2, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1),
|
||||
children: [
|
||||
{ id: '21', name: 'wiki', title: '植物百科', path: '/plant/wiki/wiki', icon: 'book', parentId: '20', sort: 0, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '22', name: 'wikiClass', title: '百科分类', path: '/plant/wiki/class', icon: 'tree', parentId: '20', sort: 1, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '23', name: 'banner', title: '轮播图', path: '/plant/banner', icon: 'image', parentId: '20', sort: 2, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '24', name: 'topic', title: '话题管理', path: '/plant/community/topic', icon: 'message', parentId: '20', sort: 3, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '25', name: 'post', title: '帖子管理', path: '/plant/community/post', icon: 'file-text', parentId: '20', sort: 4, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '26', name: 'exchangeItem', title: '兑换商品', path: '/plant/exchange', icon: 'hash', parentId: '20', sort: 5, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '27', name: 'aiConfig', title: 'AI 配置', path: '/plant/conf/aiconfig', icon: 'bot', parentId: '20', sort: 7, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '30', name: 'radio', title: 'Radio 服务', path: '/radio', icon: 'radio',
|
||||
sort: 3, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1),
|
||||
children: [
|
||||
{ id: '31', name: 'channel', title: '频道管理', path: '/radio/channel', icon: 'radio', parentId: '30', sort: 0, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '32', name: 'program', title: '节目管理', path: '/radio/program', icon: 'music', parentId: '30', sort: 1, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '33', name: 'radioCategory', title: '频道分类', path: '/radio/category', icon: 'category', parentId: '30', sort: 2, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '34', name: 'subscription', title: '订阅管理', path: '/radio/subscription', icon: 'user', parentId: '30', sort: 3, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { mockDate } from '../index'
|
||||
import type { SystemRole } from '@/api/system'
|
||||
|
||||
export const mockRoles: SystemRole[] = [
|
||||
{ id: '1', name: '超级管理员', code: 'super_admin', sort: 0, createdAt: mockDate(120), updatedAt: mockDate(1) },
|
||||
{ id: '2', name: '运营', code: 'operator', sort: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '3', name: '编辑', code: 'editor', sort: 2, createdAt: mockDate(90), updatedAt: mockDate(1) },
|
||||
{ id: '4', name: '访客', code: 'guest', sort: 3, createdAt: mockDate(60), updatedAt: mockDate(1) },
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mockDate } from '../index'
|
||||
import type { SystemUser } from '@/api/system'
|
||||
|
||||
export const mockUsers: SystemUser[] = [
|
||||
{ id: '1', account: 'admin', name: '超级管理员', phone: '13800138000', createdAt: mockDate(120), updatedAt: mockDate(1), roles: [{ id: '1', name: '超级管理员', code: 'super_admin', createdAt: mockDate(120), updatedAt: mockDate(1) }] },
|
||||
{ id: '2', account: 'zhangsan', name: '张三', phone: '13900139000', clientId: 'plant', createdAt: mockDate(60), updatedAt: mockDate(5), roles: [{ id: '2', name: '运营', code: 'operator', createdAt: mockDate(90), updatedAt: mockDate(1) }] },
|
||||
{ id: '3', account: 'lisi', name: '李四', phone: '13700137000', clientId: 'radio', createdAt: mockDate(30), updatedAt: mockDate(2), roles: [{ id: '3', name: '编辑', code: 'editor', createdAt: mockDate(90), updatedAt: mockDate(1) }] },
|
||||
{ id: '4', account: 'wangwu', name: '王五', phone: '13600136000', clientId: 'plant', createdAt: mockDate(15), updatedAt: mockDate(3) },
|
||||
{ id: '5', account: 'zhaoliu', name: '赵六', phone: '13500135000', createdAt: mockDate(7), updatedAt: mockDate(1) },
|
||||
]
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Users, Leaf, Radio, Folder, Activity, BarChart3,
|
||||
RefreshCw, Wifi, WifiOff, Zap, Clock, HardDrive,
|
||||
Cpu, MemoryStick, ArrowUpRight, TrendingUp, Server,
|
||||
CheckCircle2, AlertTriangle, XCircle, Globe, Database,
|
||||
Gauge,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getServiceList, getSystemMetrics, getTrafficData,
|
||||
type ServiceStatus, type SystemMetrics, type TrafficPoint,
|
||||
} from '@/api/dashboard'
|
||||
|
||||
// ==================== Metric Ring ====================
|
||||
|
||||
function MetricRing({ value, max = 100, size = 80, strokeWidth = 7, color, label, suffix = '%' }: {
|
||||
value: number; max?: number; size?: number; strokeWidth?: number; color: string; label: string; suffix?: string
|
||||
}) {
|
||||
const r = (size - strokeWidth) / 2
|
||||
const c = 2 * Math.PI * r
|
||||
const pct = Math.min(value / max, 1)
|
||||
const offset = c * (1 - pct)
|
||||
const getColor = () => {
|
||||
if (color === 'auto') return pct > 0.85 ? '#ef4444' : pct > 0.65 ? '#f59e0b' : '#10b981'
|
||||
return color
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none"
|
||||
stroke="currentColor" strokeWidth={strokeWidth}
|
||||
className="text-muted/30 dark:text-muted/20" />
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none"
|
||||
stroke={getColor()} strokeWidth={strokeWidth}
|
||||
strokeDasharray={c} strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-1000 ease-out" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-bold tabular-nums">{Math.round(value)}{suffix}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground font-medium">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Traffic Bar Chart ====================
|
||||
|
||||
function TrafficChart({ data }: { data: TrafficPoint[] }) {
|
||||
const maxReq = Math.max(...data.map(d => d.requests), 1)
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-[3px] h-32 w-full px-1">
|
||||
{data.map((d, i) => {
|
||||
const h = (d.requests / maxReq) * 100
|
||||
const errH = d.errors > 0 ? Math.max((d.errors / maxReq) * 100, 2) : 0
|
||||
return (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-0.5 group relative">
|
||||
{/* Tooltip */}
|
||||
<div className="absolute -top-16 left-1/2 -translate-x-1/2 bg-popover text-popover-foreground border border-border shadow-lg rounded-lg px-2.5 py-1.5 text-[10px] whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-10">
|
||||
<p className="font-semibold">{d.hour}</p>
|
||||
<p className="text-emerald-500">{d.requests} 请求</p>
|
||||
{d.errors > 0 && <p className="text-red-500">{d.errors} 错误</p>}
|
||||
</div>
|
||||
{/* Bar */}
|
||||
<div className="w-full rounded-t-sm bg-emerald-500/70 dark:bg-emerald-400/60 group-hover:bg-emerald-500 transition-all duration-300"
|
||||
style={{ height: `${h}%`, minHeight: 2, animationDelay: `${i * 30}ms` }} />
|
||||
{errH > 0 && (
|
||||
<div className="w-full rounded-t-sm bg-red-400/70"
|
||||
style={{ height: `${errH}%`, minHeight: 1 }} />
|
||||
)}
|
||||
{/* X label (every 4h) */}
|
||||
{i % 4 === 0 && (
|
||||
<span className="text-[9px] text-muted-foreground mt-1 tabular-nums">{d.hour.slice(0, 2)}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Components ====================
|
||||
|
||||
function MetricStatCard({ icon: Icon, title, value, sub, color, dot }: {
|
||||
icon: any; title: string; value: string | React.ReactNode; sub: string; color: string; dot?: string
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-0 shadow-soft glass-card group cursor-default overflow-hidden relative">
|
||||
<div className={`absolute top-0 left-0 w-1 h-full bg-${color}-500/50 group-hover:bg-${color}-500 transition-colors`} />
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
{dot && <span className={`w-2 h-2 rounded-full ${dot} animate-pulse`} />}
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className={`p-2 rounded-xl bg-${color}-500/10 text-${color}-500 group-hover:bg-${color}-500 group-hover:text-white transition-colors shadow-sm`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{sub}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Status Helpers ====================
|
||||
|
||||
const statusConfig = {
|
||||
healthy: { label: '正常', icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10', dot: 'bg-emerald-500' },
|
||||
degraded: { label: '降级', icon: AlertTriangle, color: 'text-amber-500', bg: 'bg-amber-500/10', dot: 'bg-amber-500' },
|
||||
down: { label: '离线', icon: XCircle, color: 'text-red-500', bg: 'bg-red-500/10', dot: 'bg-red-500' },
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
if (d > 0) return `${d}天${h}时`
|
||||
return `${h}小时`
|
||||
}
|
||||
|
||||
const svcIcons: Record<string, React.ReactNode> = {
|
||||
'svc-plant': <Leaf className="h-4 w-4" />,
|
||||
'svc-radio': <Radio className="h-4 w-4" />,
|
||||
'svc-gateway': <Globe className="h-4 w-4" />,
|
||||
'svc-db': <Database className="h-4 w-4" />,
|
||||
'svc-redis': <Zap className="h-4 w-4" />,
|
||||
'svc-etcd': <Server className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
// ==================== Static Data ====================
|
||||
|
||||
const activities = [
|
||||
{ user: '张三', action: '发布了新帖子', target: '我的阳台花园改造', time: '5 分钟前', avatar: '张' },
|
||||
{ user: '李四', action: '上传了音频', target: '春日晨曲', time: '12 分钟前', avatar: '李' },
|
||||
{ user: '王五', action: '兑换了商品', target: '精美花盆', time: '30 分钟前', avatar: '王' },
|
||||
{ user: '赵六', action: '新增了百科', target: '绿萝养护指南', time: '1 小时前', avatar: '赵' },
|
||||
{ user: 'admin', action: '更新了菜单配置', target: '系统管理', time: '2 小时前', avatar: 'A' },
|
||||
]
|
||||
|
||||
const quickLinks = [
|
||||
{ title: '用户管理', path: '/system/users', icon: Users, desc: '管理系统用户' },
|
||||
{ title: '植物百科', path: '/plant/wiki/wiki', icon: Leaf, desc: '管理植物百科' },
|
||||
{ title: '频道管理', path: '/radio/channel', icon: Radio, desc: '管理电台频道' },
|
||||
{ title: '文件管理', path: '/system/files', icon: Folder, desc: '管理上传文件' },
|
||||
]
|
||||
|
||||
// ==================== Dashboard ====================
|
||||
|
||||
export default function Dashboard() {
|
||||
const [services, setServices] = useState<ServiceStatus[]>([])
|
||||
const [metrics, setMetrics] = useState<SystemMetrics | null>(null)
|
||||
const [traffic, setTraffic] = useState<TrafficPoint[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date())
|
||||
|
||||
const fetchAll = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true)
|
||||
else setRefreshing(true)
|
||||
try {
|
||||
const [s, m, t] = await Promise.all([getServiceList(), getSystemMetrics(), getTrafficData()])
|
||||
setServices(s); setMetrics(m); setTraffic(t); setLastRefresh(new Date())
|
||||
} finally { setLoading(false); setRefreshing(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchAll() }, [fetchAll])
|
||||
// Auto-refresh every 30s
|
||||
useEffect(() => { const id = setInterval(() => fetchAll(true), 30000); return () => clearInterval(id) }, [fetchAll])
|
||||
|
||||
const healthyCount = services.filter(s => s.status === 'healthy').length
|
||||
const totalCount = services.length
|
||||
const allHealthy = healthyCount === totalCount && totalCount > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn pb-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">仪表盘</h1>
|
||||
<p className="text-muted-foreground mt-1">Sundynix 微服务管理控制台 · 系统监控</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
||||
上次刷新 {lastRefresh.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => fetchAll(true)} disabled={refreshing}
|
||||
className="gap-1.5 rounded-full h-8 px-3 text-xs">
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Metric Stats Row */}
|
||||
{metrics && (
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<MetricStatCard icon={Gauge} title="全局健康" value={allHealthy ? '健康' : '异常'}
|
||||
sub={`${healthyCount}/${totalCount} 服务在线`}
|
||||
color={allHealthy ? 'emerald' : 'amber'}
|
||||
dot={allHealthy ? 'bg-emerald-500' : 'bg-amber-500'} />
|
||||
<MetricStatCard icon={Activity} title="24h 请求量"
|
||||
value={metrics.totalRequests24h.toLocaleString()} sub={`平均响应 ${metrics.avgResponseTime}ms`}
|
||||
color="blue" />
|
||||
<MetricStatCard icon={Wifi} title="活跃连接" value={String(metrics.activeConnections)}
|
||||
sub={`错误率 ${metrics.errorRate}%`} color="purple" />
|
||||
<MetricStatCard icon={Clock} title="连续运行" value={`${metrics.uptimeHours}h`}
|
||||
sub={`${(metrics.uptimeHours / 24).toFixed(0)} 天`} color="amber" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-3 mt-6">
|
||||
{/* Services Grid */}
|
||||
<Card className="lg:col-span-2 border-border/60 shadow-soft">
|
||||
<CardHeader className="pb-3 border-b border-border/40">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-primary" /> 核心服务状态
|
||||
</div>
|
||||
<span className="text-xs font-normal text-muted-foreground bg-muted/50 px-2 py-0.5 rounded-full">
|
||||
{healthyCount} / {totalCount} 正常
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{services.map(svc => {
|
||||
const status = statusConfig[svc.status]
|
||||
const Icon = status.icon
|
||||
const SvcIcon = svcIcons[svc.id] || <Server className="h-4 w-4" />
|
||||
return (
|
||||
<div key={svc.id} className="p-3 rounded-xl border border-border/50 hover:border-border/80 transition-colors bg-card flex flex-col gap-3 group">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-primary/10 text-primary">
|
||||
{SvcIcon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{svc.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono">{svc.endpoint}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${status.bg} ${status.color}`}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{status.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-border/40">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-muted-foreground">延迟</span>
|
||||
<span className="text-xs font-medium tabular-nums">{svc.latency}ms</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-muted-foreground">CPU</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-1000 ${svc.cpu > 80 ? 'bg-red-500' : svc.cpu > 50 ? 'bg-amber-500' : 'bg-emerald-500'}`} style={{ width: `${svc.cpu}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] tabular-nums font-medium min-w-[2ch]">{svc.cpu}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-muted-foreground">RAM</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-1000 ${svc.memory > 85 ? 'bg-red-500' : svc.memory > 60 ? 'bg-amber-500' : 'bg-blue-500'}`} style={{ width: `${svc.memory}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] tabular-nums font-medium min-w-[2ch]">{svc.memory}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Resources & Traffic */}
|
||||
<div className="space-y-6">
|
||||
<Card className="border-border/60 shadow-soft">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-primary" /> 资源使用率
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<div className="flex justify-between items-center px-2">
|
||||
<MetricRing value={metrics?.cpuUsage || 0} color="auto" label="CPU使用率" />
|
||||
<MetricRing value={metrics?.memoryUsage || 0} color="auto" label="内存使用率" />
|
||||
<MetricRing value={metrics?.diskUsage || 0} color="#3b82f6" label="磁盘空间" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 shadow-soft flex-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-primary" /> 请求趋势
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">过去24小时</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 pb-2">
|
||||
<TrafficChart data={traffic} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links + Activity */}
|
||||
<div className="grid gap-6 lg:grid-cols-5 mt-6">
|
||||
{/* Quick Links */}
|
||||
<Card className="lg:col-span-2 border-border/60 shadow-soft">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<ArrowUpRight className="h-5 w-5 text-primary" /> 快捷入口
|
||||
</CardTitle>
|
||||
<CardDescription>常用管理页面快速访问</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3">
|
||||
{quickLinks.map(l => (
|
||||
<Link key={l.path} to={l.path}
|
||||
className="flex flex-col gap-2 p-4 rounded-xl border border-border/50 hover:border-primary/30 hover:bg-primary/5 transition-all group">
|
||||
<l.icon className="h-5 w-5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{l.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{l.desc}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card className="lg:col-span-3 border-border/60 shadow-soft">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-primary" /> 最近动态
|
||||
</CardTitle>
|
||||
<CardDescription>系统最新操作记录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activities.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-4 group">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-sm font-bold">
|
||||
{a.avatar}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{a.user}</span>
|
||||
<span className="text-muted-foreground"> {a.action} </span>
|
||||
<span className="font-medium text-primary">{a.target}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{a.time}</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer">
|
||||
查看详情
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Leaf, Loader2, Eye, EyeOff, Disc3 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { login as apiLogin, getCaptcha } from '@/api/system'
|
||||
import LoginCharacters from '@/components/LoginCharacters'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [account, setAccount] = useState('admin')
|
||||
const [password, setPassword] = useState('123456')
|
||||
const [captcha, setCaptcha] = useState('')
|
||||
const [captchaId, setCaptchaId] = useState('')
|
||||
const [captchaImg, setCaptchaImg] = useState('')
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [pwdFocused, setPwdFocused] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const loginStore = useAuthStore(s => s.login)
|
||||
|
||||
const fetchCaptcha = async () => {
|
||||
try {
|
||||
const res = await getCaptcha()
|
||||
const d = (res as any).data
|
||||
setCaptchaId(d.captchaId)
|
||||
setCaptchaImg(d.captcha)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCaptcha() }, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!account || !password) { setError('请输入账号和密码'); return }
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const res = await apiLogin({ account, password, captcha, captchaId })
|
||||
const d = (res as any).data
|
||||
loginStore(d.user, d.token)
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '登录失败')
|
||||
fetchCaptcha()
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f3f4f6] p-6 relative">
|
||||
{/* Background dot pattern */}
|
||||
<div className="absolute inset-0 z-0 opacity-[0.03] pointer-events-none" style={{
|
||||
backgroundImage: 'radial-gradient(circle, #000 1px, transparent 1px)',
|
||||
backgroundSize: '24px 24px'
|
||||
}} />
|
||||
|
||||
<div className="relative z-10 w-full max-w-[1000px] grid grid-cols-1 lg:grid-cols-2 bg-white rounded-[2rem] shadow-2xl shadow-emerald-900/10 overflow-hidden ring-1 ring-black/5">
|
||||
|
||||
{/* Left — Characters Banner */}
|
||||
<div className="relative hidden lg:flex flex-col justify-between bg-slate-800 p-10 text-white overflow-hidden">
|
||||
<div className="relative z-20 flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Leaf className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold tracking-tight">Sundynix Console</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-20 flex items-end justify-center mt-auto" style={{ height: 320 }}>
|
||||
<LoginCharacters
|
||||
isTyping={pwdFocused}
|
||||
showPassword={showPwd}
|
||||
passwordLength={password.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-20 mt-10 text-xs text-white/40 font-medium tracking-wide">
|
||||
© {new Date().getFullYear()} 光衍矩阵 · SUNDYNIX TECH
|
||||
</div>
|
||||
|
||||
{/* Decorative blurs */}
|
||||
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-emerald-500/10 rounded-full blur-[80px] -translate-y-1/2 translate-x-1/4 pointer-events-none" />
|
||||
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] bg-teal-500/10 rounded-full blur-[60px] translate-y-1/4 -translate-x-1/4 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Right — Login Form */}
|
||||
<div className="flex flex-col items-center justify-center px-8 py-16 sm:px-12 bg-white relative">
|
||||
<div className="w-full max-w-[360px]">
|
||||
{/* Mobile logo */}
|
||||
<div className="lg:hidden flex flex-col items-center justify-center gap-3 mb-10">
|
||||
<div className="w-12 h-12 bg-emerald-500 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Leaf className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold tracking-tight text-slate-900">Sundynix Console</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2 text-slate-900">欢迎回来!</h1>
|
||||
<p className="text-slate-500 text-sm">请输入管理员账号与密码</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-100 px-4 py-3 text-sm text-red-600 flex items-center gap-2 animate-fadeIn">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-red-500" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold text-slate-700">账号</Label>
|
||||
<Input placeholder="请输入管理员账号" value={account}
|
||||
onChange={e => setAccount(e.target.value)} disabled={loading}
|
||||
className="h-12 bg-slate-50/50 border-slate-200 focus:bg-white focus:border-emerald-500 focus:ring-emerald-500/20 text-slate-900 placeholder:text-slate-400 rounded-xl transition-all" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold text-slate-700">密码</Label>
|
||||
<div className="relative">
|
||||
<Input type={showPwd ? 'text' : 'password'} placeholder="••••••••"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
onFocus={() => setPwdFocused(true)} onBlur={() => setPwdFocused(false)}
|
||||
disabled={loading}
|
||||
className="h-12 pr-10 bg-slate-50/50 border-slate-200 focus:bg-white focus:border-emerald-500 focus:ring-emerald-500/20 text-slate-900 placeholder:text-slate-400 rounded-xl transition-all" />
|
||||
<button type="button" onClick={() => setShowPwd(!showPwd)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors" tabIndex={-1}>
|
||||
{showPwd ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold text-slate-700">验证码</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input placeholder="验证码" value={captcha}
|
||||
onChange={e => setCaptcha(e.target.value)} disabled={loading}
|
||||
className="h-12 flex-1 bg-slate-50/50 border-slate-200 focus:bg-white focus:border-emerald-500 focus:ring-emerald-500/20 text-slate-900 placeholder:text-slate-400 rounded-xl transition-all" />
|
||||
<div className="h-12 w-32 border border-slate-200 rounded-xl overflow-hidden cursor-pointer hover:border-slate-300 transition-colors flex items-center justify-center bg-white group relative shrink-0"
|
||||
onClick={fetchCaptcha}>
|
||||
{captchaImg
|
||||
? <img src={captchaImg} alt="captcha" className="h-full w-full object-fill" />
|
||||
: <Disc3 className="w-5 h-5 text-slate-400 animate-spin" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading}
|
||||
className="w-full h-12 mt-4 text-base font-bold bg-emerald-600 hover:bg-emerald-700 text-white border-0 shadow-lg shadow-emerald-600/20 rounded-xl transition-all duration-300">
|
||||
{loading
|
||||
? <span className="flex items-center gap-2"><Loader2 className="w-5 h-5 animate-spin" />登录中...</span>
|
||||
: '登 录'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-slate-400 mt-6">Mock 模式 · 任意账号密码均可登录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Plus, Search, Pencil, Trash2, Loader2, MoreHorizontal, GalleryHorizontalEnd, Eye, Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getBannerList, saveBanner, updateBanner, deleteBanner } from '@/api/plant'
|
||||
import type { Banner } from '@/api/plant'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function BannerPage() {
|
||||
const [banners, setBanners] = useState<Banner[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [form, setForm] = useState({ id: '', title: '', imageId: '', imageUrl: '', sort: 0, isActive: 1, targetUrl: '' })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<Banner | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getBannerList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setBanners(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
const handleAdd = () => { setIsEdit(false); setForm({ id: '', title: '', imageId: '', imageUrl: '', sort: 0, isActive: 1, targetUrl: '' }); setDialogOpen(true) }
|
||||
const handleEdit = (b: Banner) => { setIsEdit(true); setForm({ id: b.id, title: b.title, imageId: b.imageId, imageUrl: b.image?.url || '', sort: b.sort, isActive: b.isActive, targetUrl: b.targetUrl || '' }); setDialogOpen(true) }
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.title) return; setSubmitting(true)
|
||||
try { if (isEdit) await updateBanner(form as any); else await saveBanner(form as any); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!toDelete) return
|
||||
try { await deleteBanner(toDelete.id); setDeleteOpen(false); fetchList() } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><GalleryHorizontalEnd className="h-5 w-5 text-primary" />轮播图管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理花园首页的轮播图</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-[180px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
<Button onClick={handleAdd} className="h-10 gap-2"><Plus className="h-4 w-4" />新增</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">预览</TableHead><TableHead className="font-semibold">标题</TableHead>
|
||||
<TableHead className="font-semibold">状态</TableHead><TableHead className="font-semibold">排序</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{banners.map(b => (
|
||||
<TableRow key={b.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6">{b.image ? <img src={b.image.url} alt={b.title} className="h-12 w-24 rounded-lg object-cover border" /> : <div className="h-12 w-24 bg-muted/50 rounded-lg" />}</TableCell>
|
||||
<TableCell className="font-medium">{b.title}</TableCell>
|
||||
<TableCell>{b.isActive === 1 ? <Badge className="bg-green-500/10 text-green-700 border-green-500/20">启用</Badge> : <Badge variant="secondary">禁用</Badge>}</TableCell>
|
||||
<TableCell className="font-mono text-muted-foreground">{b.sort}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(b)}><Pencil className="mr-2 h-4 w-4" />编辑</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => { setToDelete(b); setDeleteOpen(true) }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]"><DialogHeader><DialogTitle>{isEdit ? '编辑轮播图' : '新增轮播图'}</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>标题 *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2"><Label>状态</Label><Select value={String(form.isActive)} onValueChange={v => setForm(f => ({ ...f, isActive: parseInt(v) }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="1">启用</SelectItem><SelectItem value="0">禁用</SelectItem></SelectContent></Select></div>
|
||||
<div className="grid gap-2"><Label>排序</Label><Input type="number" value={form.sort} onChange={e => setForm(f => ({ ...f, sort: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
<div className="grid gap-2"><Label>跳转链接</Label><Input value={form.targetUrl} onChange={e => setForm(f => ({ ...f, targetUrl: e.target.value }))} placeholder="/pages/wiki/index" /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.title}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除「{toDelete?.title}」?</DialogTitle></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={handleDelete}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, Trash2, Loader2, MoreHorizontal, FileText, ThumbsUp, MessageCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getPostList, deletePost } from '@/api/plant'
|
||||
import type { Post } from '@/api/plant'
|
||||
|
||||
export default function PostPage() {
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<Post | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getPostList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setPosts(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><FileText className="h-5 w-5 text-primary" />帖子管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理社区用户发布的帖子</CardDescription>
|
||||
</div>
|
||||
<div className="relative"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-[200px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">标题</TableHead><TableHead className="font-semibold">话题</TableHead>
|
||||
<TableHead className="font-semibold">作者</TableHead><TableHead className="font-semibold">互动</TableHead>
|
||||
<TableHead className="font-semibold">状态</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{posts.map(p => (
|
||||
<TableRow key={p.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6 font-medium max-w-[200px] truncate">{p.title}</TableCell>
|
||||
<TableCell><Badge variant="secondary" className="text-xs">{p.topicTitle}</Badge></TableCell>
|
||||
<TableCell className="text-sm">{p.userName}</TableCell>
|
||||
<TableCell><div className="flex items-center gap-3 text-xs text-muted-foreground"><span className="flex items-center gap-1"><ThumbsUp className="h-3 w-3" />{p.likeCount}</span><span className="flex items-center gap-1"><MessageCircle className="h-3 w-3" />{p.commentCount}</span></div></TableCell>
|
||||
<TableCell>{p.status === 1 ? <Badge className="bg-green-500/10 text-green-700 border-green-500/20">正常</Badge> : <Badge variant="secondary">隐藏</Badge>}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end"><DropdownMenuItem className="text-destructive" onClick={() => { setToDelete(p); setDeleteOpen(true) }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem></DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
{Math.ceil(total/10) > 1 && <div className="flex items-center justify-between pt-4"><p className="text-sm text-muted-foreground">共 {total} 条</p><div className="flex gap-2"><Button variant="outline" size="sm" disabled={page<=1} onClick={()=>setPage(p=>p-1)}>上一页</Button><Button variant="outline" size="sm" disabled={page>=Math.ceil(total/10)} onClick={()=>setPage(p=>p+1)}>下一页</Button></div></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除帖子「{toDelete?.title}」?</DialogTitle></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={async () => { if (toDelete) { await deletePost(toDelete.id); setDeleteOpen(false); fetchList() } }}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Search, Trash2, Loader2, MoreHorizontal, MessageSquare } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getTopicList, saveTopic, deleteTopic } from '@/api/plant'
|
||||
import type { Topic } from '@/api/plant'
|
||||
|
||||
export default function TopicPage() {
|
||||
const [topics, setTopics] = useState<Topic[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [form, setForm] = useState({ title: '', description: '', sort: 0 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<Topic | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getTopicList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setTopics(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.title) return; setSubmitting(true)
|
||||
try { await saveTopic({ ...form, status: 1 }); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><MessageSquare className="h-5 w-5 text-primary" />话题管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理社区话题分类</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-[180px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
<Button onClick={() => { setForm({ title: '', description: '', sort: 0 }); setDialogOpen(true) }} className="h-10 gap-2"><Plus className="h-4 w-4" />新增</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">话题</TableHead><TableHead className="font-semibold">描述</TableHead>
|
||||
<TableHead className="font-semibold">帖子数</TableHead><TableHead className="font-semibold">状态</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{topics.map(t => (
|
||||
<TableRow key={t.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6 font-medium">{t.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{t.description || '-'}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{t.postCount}</Badge></TableCell>
|
||||
<TableCell>{t.status === 1 ? <Badge className="bg-green-500/10 text-green-700 border-green-500/20">启用</Badge> : <Badge variant="secondary">禁用</Badge>}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end"><DropdownMenuItem className="text-destructive" onClick={() => { setToDelete(t); setDeleteOpen(true) }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem></DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[420px]"><DialogHeader><DialogTitle>新增话题</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>标题 *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
|
||||
<div className="grid gap-2"><Label>描述</Label><Input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.title}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除话题「{toDelete?.title}」?</DialogTitle></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={async () => { if (toDelete) { await deleteTopic(toDelete.id); setDeleteOpen(false); fetchList() } }}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
import { Bot, Save, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
export default function AiConfigPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [config, setConfig] = useState({
|
||||
model: 'gpt-4o', temperature: 0.7, maxTokens: 2000, enabled: true,
|
||||
systemPrompt: '你是一个植物养护专家,帮助用户解答关于植物养护的各种问题。请用专业但友好的语气回答。',
|
||||
welcomeMsg: '你好!我是植物养护AI助手,有什么关于植物的问题都可以问我~ 🌿',
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2"><Bot className="h-6 w-6 text-primary" />AI 配置</h1>
|
||||
<p className="text-muted-foreground mt-1">配置 Plant 服务的 AI 助手参数</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-lg">模型参数</CardTitle><CardDescription>设置 AI 模型和推理参数</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><Label className="text-sm font-medium">启用 AI 助手</Label><p className="text-xs text-muted-foreground mt-0.5">开启后用户可以在小程序中使用 AI 问答</p></div>
|
||||
<Switch checked={config.enabled} onCheckedChange={v => setConfig(c => ({ ...c, enabled: v }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-2"><Label>模型</Label><Select value={config.model} onValueChange={v => setConfig(c => ({ ...c, model: v }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="gpt-4o">GPT-4o</SelectItem><SelectItem value="gpt-4o-mini">GPT-4o Mini</SelectItem><SelectItem value="deepseek-v3">DeepSeek V3</SelectItem></SelectContent></Select></div>
|
||||
<div className="grid gap-2"><Label>Temperature</Label><Input type="number" step={0.1} min={0} max={2} value={config.temperature} onChange={e => setConfig(c => ({ ...c, temperature: parseFloat(e.target.value) || 0 }))} /></div>
|
||||
<div className="grid gap-2"><Label>Max Tokens</Label><Input type="number" value={config.maxTokens} onChange={e => setConfig(c => ({ ...c, maxTokens: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-lg">Prompt 模板</CardTitle><CardDescription>配置 AI 对话的系统提示词和欢迎语</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2"><Label>System Prompt</Label><Textarea value={config.systemPrompt} onChange={e => setConfig(c => ({ ...c, systemPrompt: e.target.value }))} rows={5} className="font-mono text-sm" /></div>
|
||||
<div className="grid gap-2"><Label>欢迎语</Label><Input value={config.welcomeMsg} onChange={e => setConfig(c => ({ ...c, welcomeMsg: e.target.value }))} /></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2 px-6 shadow-sm">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Search, Trash2, Loader2, MoreHorizontal, Hash, Pencil } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getExchangeItemList, saveExchangeItem, deleteExchangeItem, getExchangeOrderList } from '@/api/plant'
|
||||
import type { ExchangeItem, ExchangeOrder } from '@/api/plant'
|
||||
|
||||
export default function ExchangePage() {
|
||||
const [items, setItems] = useState<ExchangeItem[]>([])
|
||||
const [orders, setOrders] = useState<ExchangeOrder[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [tab, setTab] = useState('items')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', description: '', points: 100, stock: 10, sort: 0 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getExchangeItemList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setItems(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getExchangeOrderList({ current: page, pageSize: 10 }); if (res?.data) { setOrders(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page])
|
||||
|
||||
useEffect(() => { if (tab === 'items') fetchItems(); else fetchOrders() }, [tab, fetchItems, fetchOrders])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name) return; setSubmitting(true)
|
||||
try { await saveExchangeItem({ ...form, status: 1 }); setDialogOpen(false); fetchItems() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const statusMap: Record<number, string> = { 0: '待处理', 1: '已完成', 2: '已过期' }
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><Hash className="h-5 w-5 text-primary" />积分兑换</CardTitle>
|
||||
<CardDescription>管理兑换商品和兑换订单</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={tab} onValueChange={v => { setTab(v); setPage(1) }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<TabsList><TabsTrigger value="items">商品管理</TabsTrigger><TabsTrigger value="orders">兑换订单</TabsTrigger></TabsList>
|
||||
{tab === 'items' && <Button onClick={() => { setForm({ name: '', description: '', points: 100, stock: 10, sort: 0 }); setDialogOpen(true) }} className="h-9 gap-2"><Plus className="h-4 w-4" />新增商品</Button>}
|
||||
</div>
|
||||
<TabsContent value="items">
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">商品</TableHead><TableHead className="font-semibold">积分</TableHead>
|
||||
<TableHead className="font-semibold">库存</TableHead><TableHead className="font-semibold">状态</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{items.map(item => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/30">
|
||||
<TableCell className="pl-6"><div className="flex items-center gap-3">{item.cover && <img src={item.cover} alt="" className="h-10 w-10 rounded-lg object-cover border" />}<div><p className="font-medium text-sm">{item.name}</p><p className="text-xs text-muted-foreground">{item.description}</p></div></div></TableCell>
|
||||
<TableCell className="font-mono font-semibold text-amber-600">{item.points}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{item.stock}</Badge></TableCell>
|
||||
<TableCell>{item.status === 1 ? <Badge className="bg-green-500/10 text-green-700">上架</Badge> : <Badge variant="secondary">下架</Badge>}</TableCell>
|
||||
<TableCell><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end"><DropdownMenuItem className="text-destructive" onClick={async () => { await deleteExchangeItem(item.id); fetchItems() }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem></DropdownMenuContent>
|
||||
</DropdownMenu></TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="orders">
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">商品</TableHead><TableHead className="font-semibold">用户</TableHead>
|
||||
<TableHead className="font-semibold">积分</TableHead><TableHead className="font-semibold">状态</TableHead><TableHead className="font-semibold">时间</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{orders.map(o => (
|
||||
<TableRow key={o.id} className="hover:bg-muted/30">
|
||||
<TableCell className="pl-6 font-medium">{o.itemName}</TableCell>
|
||||
<TableCell>{o.userName}</TableCell>
|
||||
<TableCell className="font-mono text-amber-600">{o.points}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{statusMap[o.status] || '未知'}</Badge></TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{new Date(o.createdAt).toLocaleDateString()}</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[460px]"><DialogHeader><DialogTitle>新增兑换商品</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>名称 *</Label><Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||
<div className="grid gap-2"><Label>描述</Label><Input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2"><Label>积分</Label><Input type="number" value={form.points} onChange={e => setForm(f => ({ ...f, points: parseInt(e.target.value) || 0 }))} /></div>
|
||||
<div className="grid gap-2"><Label>库存</Label><Input type="number" value={form.stock} onChange={e => setForm(f => ({ ...f, stock: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.name}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Pencil, Trash2, Loader2, FolderTree } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getWikiClassList, saveWikiClass, deleteWikiClass } from '@/api/plant'
|
||||
import type { PlantWikiClass } from '@/api/plant'
|
||||
|
||||
export default function WikiClassPage() {
|
||||
const [classes, setClasses] = useState<PlantWikiClass[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', sort: 0 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<PlantWikiClass | null>(null)
|
||||
|
||||
const fetchList = async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getWikiClassList(); if (res?.data) setClasses(res.data) }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetchList() }, [])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name) return; setSubmitting(true)
|
||||
try { await saveWikiClass(form); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!toDelete) return
|
||||
try { await deleteWikiClass(toDelete.id); setDeleteOpen(false); fetchList() } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><FolderTree className="h-5 w-5 text-primary" />百科分类 <Badge variant="secondary" className="text-xs">{classes.length}</Badge></CardTitle>
|
||||
<CardDescription>管理植物百科的分类体系</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => { setForm({ name: '', sort: 0 }); setDialogOpen(true) }} className="h-10 gap-2"><Plus className="h-4 w-4" />新增</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{classes.map(c => (
|
||||
<div key={c.id} className="flex items-center justify-between p-4 rounded-xl border border-border/50 hover:border-primary/30 hover:bg-primary/5 transition-all group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center"><FolderTree className="h-5 w-5 text-primary" /></div>
|
||||
<div><p className="font-medium text-sm">{c.name}</p><p className="text-xs text-muted-foreground">排序: {c.sort}</p></div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => { setToDelete(c); setDeleteOpen(true) }}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]"><DialogHeader><DialogTitle>新增分类</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>名称 *</Label><Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||
<div className="grid gap-2"><Label>排序</Label><Input type="number" value={form.sort} onChange={e => setForm(f => ({ ...f, sort: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.name}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除分类「{toDelete?.name}」?</DialogTitle></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={handleDelete}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Search, Pencil, Trash2, Loader2, MoreHorizontal, Book, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getWikiList, saveWiki, updateWiki, deleteWiki, getWikiClassList } from '@/api/plant'
|
||||
import type { PlantWiki, PlantWikiClass } from '@/api/plant'
|
||||
|
||||
export default function WikiPage() {
|
||||
const [wikis, setWikis] = useState<PlantWiki[]>([])
|
||||
const [classes, setClasses] = useState<PlantWikiClass[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [classFilter, setClassFilter] = useState('all')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [form, setForm] = useState({ id: '', title: '', classId: '', content: '', cover: '', status: 1 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<PlantWiki | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = { current: page, pageSize: 10, keyword: search || undefined }
|
||||
if (classFilter !== 'all') params.classId = classFilter
|
||||
const res = await getWikiList(params)
|
||||
if (res?.data) { setWikis(res.data.list || []); setTotal(res.data.total || 0) }
|
||||
} catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search, classFilter])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
useEffect(() => { getWikiClassList().then(res => { if (res?.data) setClasses(res.data) }) }, [])
|
||||
|
||||
const handleAdd = () => { setIsEdit(false); setForm({ id: '', title: '', classId: classes[0]?.id || '', content: '', cover: '', status: 1 }); setDialogOpen(true) }
|
||||
const handleEdit = (w: PlantWiki) => { setIsEdit(true); setForm({ id: w.id, title: w.title, classId: w.classId, content: w.content, cover: w.cover || '', status: w.status }); setDialogOpen(true) }
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.title || !form.classId) return; setSubmitting(true)
|
||||
try { if (isEdit) await updateWiki(form); else await saveWiki(form); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!toDelete) return
|
||||
try { await deleteWiki(toDelete.id); setDeleteOpen(false); fetchList() } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / 10)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><Book className="h-5 w-5 text-primary" />植物百科 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理植物百科词条</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto flex-wrap">
|
||||
<div className="relative flex-1 sm:flex-none"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-full sm:w-[180px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
<Select value={classFilter} onValueChange={v => { setClassFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[130px] h-10"><SelectValue placeholder="分类" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">全部分类</SelectItem>{classes.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleAdd} className="h-10 gap-2 shadow-sm"><Plus className="h-4 w-4" />新增</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">封面</TableHead><TableHead className="font-semibold">标题</TableHead>
|
||||
<TableHead className="font-semibold">分类</TableHead><TableHead className="font-semibold">状态</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{wikis.length === 0 ? <TableRow><TableCell colSpan={5} className="h-32 text-center text-muted-foreground">暂无数据</TableCell></TableRow>
|
||||
: wikis.map(w => (
|
||||
<TableRow key={w.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6">{w.cover ? <img src={w.cover} alt="" className="h-10 w-16 rounded-lg object-cover border" /> : <div className="h-10 w-16 rounded-lg bg-muted/50 flex items-center justify-center"><Book className="h-4 w-4 text-muted-foreground/40" /></div>}</TableCell>
|
||||
<TableCell className="font-medium">{w.title}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{w.className}</Badge></TableCell>
|
||||
<TableCell>{w.status === 1 ? <Badge className="bg-green-500/10 text-green-700 border-green-500/20"><Eye className="h-3 w-3 mr-1" />已发布</Badge> : <Badge variant="secondary"><EyeOff className="h-3 w-3 mr-1" />草稿</Badge>}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(w)}><Pencil className="mr-2 h-4 w-4" />编辑</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => { setToDelete(w); setDeleteOpen(true) }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && <div className="flex items-center justify-between pt-4"><p className="text-sm text-muted-foreground">共 {total} 条</p><div className="flex gap-2"><Button variant="outline" size="sm" disabled={page<=1} onClick={()=>setPage(p=>p-1)}>上一页</Button><span className="text-sm text-muted-foreground px-2">{page}/{totalPages}</span><Button variant="outline" size="sm" disabled={page>=totalPages} onClick={()=>setPage(p=>p+1)}>下一页</Button></div></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[560px]"><DialogHeader><DialogTitle>{isEdit ? '编辑百科' : '新增百科'}</DialogTitle><DialogDescription>编辑植物百科词条内容</DialogDescription></DialogHeader>
|
||||
<div className="grid gap-4 py-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="grid gap-2"><Label>标题 *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2"><Label>分类 *</Label><Select value={form.classId} onValueChange={v => setForm(f => ({ ...f, classId: v }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{classes.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="grid gap-2"><Label>状态</Label><Select value={String(form.status)} onValueChange={v => setForm(f => ({ ...f, status: parseInt(v) }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="1">已发布</SelectItem><SelectItem value="0">草稿</SelectItem></SelectContent></Select></div>
|
||||
</div>
|
||||
<div className="grid gap-2"><Label>封面URL</Label><Input value={form.cover} onChange={e => setForm(f => ({ ...f, cover: e.target.value }))} placeholder="图片地址" /></div>
|
||||
<div className="grid gap-2"><Label>内容</Label><Textarea value={form.content} onChange={e => setForm(f => ({ ...f, content: e.target.value }))} rows={6} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.title}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除</DialogTitle><DialogDescription>确定要删除「{toDelete?.title}」吗?</DialogDescription></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={()=>setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={handleDelete}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Trash2, Loader2, FolderTree } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getRadioCategoryList, saveRadioCategory, deleteRadioCategory } from '@/api/radio'
|
||||
import type { RadioCategory } from '@/api/radio'
|
||||
|
||||
export default function RadioCategoryPage() {
|
||||
const [categories, setCategories] = useState<RadioCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', sort: 0 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<RadioCategory | null>(null)
|
||||
|
||||
const fetchList = async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getRadioCategoryList(); if (res?.data) setCategories(res.data) }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetchList() }, [])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name) return; setSubmitting(true)
|
||||
try { await saveRadioCategory({ ...form, status: 1 }); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><FolderTree className="h-5 w-5 text-primary" />频道分类 <Badge variant="secondary" className="text-xs">{categories.length}</Badge></CardTitle>
|
||||
<CardDescription>管理电台频道分类</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => { setForm({ name: '', sort: 0 }); setDialogOpen(true) }} className="h-10 gap-2"><Plus className="h-4 w-4" />新增</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
{categories.map(c => (
|
||||
<div key={c.id} className="flex items-center justify-between p-4 rounded-xl border border-border/50 hover:border-primary/30 hover:bg-primary/5 transition-all group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-purple-500/10 flex items-center justify-center"><FolderTree className="h-5 w-5 text-purple-500" /></div>
|
||||
<div><p className="font-medium text-sm">{c.name}</p><p className="text-xs text-muted-foreground">排序: {c.sort}</p></div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100 text-destructive" onClick={() => { setToDelete(c); setDeleteOpen(true) }}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]"><DialogHeader><DialogTitle>新增分类</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>名称 *</Label><Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||
<div className="grid gap-2"><Label>排序</Label><Input type="number" value={form.sort} onChange={e => setForm(f => ({ ...f, sort: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.name}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除分类「{toDelete?.name}」?</DialogTitle></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={async () => { if (toDelete) { await deleteRadioCategory(toDelete.id); setDeleteOpen(false); fetchList() } }}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Search, Pencil, Trash2, Loader2, MoreHorizontal, Radio } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getRadioChannelList, saveRadioChannel, updateRadioChannel, deleteRadioChannel, getRadioCategoryList } from '@/api/radio'
|
||||
import type { RadioChannel, RadioCategory } from '@/api/radio'
|
||||
|
||||
export default function ChannelPage() {
|
||||
const [channels, setChannels] = useState<RadioChannel[]>([])
|
||||
const [categories, setCategories] = useState<RadioCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [form, setForm] = useState({ id: '', categoryId: '', name: '', description: '', isFree: true, isVipOnly: false, monthlyPrice: 0, sort: 0 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<RadioChannel | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getRadioChannelList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setChannels(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
useEffect(() => { getRadioCategoryList().then(res => { if (res?.data) setCategories(res.data) }) }, [])
|
||||
|
||||
const handleAdd = () => { setIsEdit(false); setForm({ id: '', categoryId: categories[0]?.id || '', name: '', description: '', isFree: true, isVipOnly: false, monthlyPrice: 0, sort: 0 }); setDialogOpen(true) }
|
||||
const handleEdit = (c: RadioChannel) => { setIsEdit(true); setForm({ id: c.id, categoryId: c.categoryId, name: c.name, description: c.description || '', isFree: c.isFree, isVipOnly: c.isVipOnly, monthlyPrice: c.monthlyPrice || 0, sort: c.sort }); setDialogOpen(true) }
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name || !form.categoryId) return; setSubmitting(true)
|
||||
try { if (isEdit) await updateRadioChannel({ ...form, status: 1 } as any); else await saveRadioChannel({ ...form, status: 1 } as any); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><Radio className="h-5 w-5 text-primary" />频道管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理电台频道及定价</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-[180px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
<Button onClick={handleAdd} className="h-10 gap-2"><Plus className="h-4 w-4" />新增</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">封面</TableHead><TableHead className="font-semibold">频道</TableHead>
|
||||
<TableHead className="font-semibold">分类</TableHead><TableHead className="font-semibold">定价</TableHead>
|
||||
<TableHead className="font-semibold">状态</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{channels.map(c => (
|
||||
<TableRow key={c.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6">{c.cover ? <img src={c.cover} alt="" className="h-10 w-10 rounded-lg object-cover border" /> : <div className="h-10 w-10 rounded-lg bg-muted/50" />}</TableCell>
|
||||
<TableCell><div><p className="font-medium text-sm">{c.name}</p><p className="text-xs text-muted-foreground">{c.description}</p></div></TableCell>
|
||||
<TableCell><Badge variant="secondary">{c.categoryName}</Badge></TableCell>
|
||||
<TableCell>{c.isFree ? <Badge className="bg-green-500/10 text-green-700">免费</Badge> : <span className="text-sm font-mono text-amber-600">¥{c.monthlyPrice}/月</span>}</TableCell>
|
||||
<TableCell>{c.status === 1 ? <Badge className="bg-green-500/10 text-green-700">启用</Badge> : <Badge variant="secondary">禁用</Badge>}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(c)}><Pencil className="mr-2 h-4 w-4" />编辑</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => { setToDelete(c); setDeleteOpen(true) }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]"><DialogHeader><DialogTitle>{isEdit ? '编辑频道' : '新增频道'}</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>名称 *</Label><Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||
<div className="grid gap-2"><Label>描述</Label><Input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2"><Label>分类 *</Label><Select value={form.categoryId} onValueChange={v => setForm(f => ({ ...f, categoryId: v }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{categories.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="grid gap-2"><Label>排序</Label><Input type="number" value={form.sort} onChange={e => setForm(f => ({ ...f, sort: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2"><Switch checked={form.isFree} onCheckedChange={v => setForm(f => ({ ...f, isFree: v }))} /><Label>免费</Label></div>
|
||||
{!form.isFree && <div className="grid gap-2 flex-1"><Label>月价(¥)</Label><Input type="number" value={form.monthlyPrice} onChange={e => setForm(f => ({ ...f, monthlyPrice: parseFloat(e.target.value) || 0 }))} /></div>}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.name}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除频道「{toDelete?.name}」?</DialogTitle></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={async () => { if (toDelete) { await deleteRadioChannel(toDelete.id); setDeleteOpen(false); fetchList() } }}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Search, Pencil, Trash2, Loader2, MoreHorizontal, Music, Play, Clock } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getRadioProgramList, saveRadioProgram, deleteRadioProgram } from '@/api/radio'
|
||||
import type { RadioProgram } from '@/api/radio'
|
||||
|
||||
const audioStatusMap: Record<number, { label: string; cls: string }> = {
|
||||
0: { label: '无音频', cls: 'bg-gray-100 text-gray-600' },
|
||||
1: { label: '生成中', cls: 'bg-amber-100 text-amber-700' },
|
||||
2: { label: '已就绪', cls: 'bg-green-100 text-green-700' },
|
||||
}
|
||||
|
||||
const formatDuration = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||||
|
||||
export default function ProgramPage() {
|
||||
const [programs, setPrograms] = useState<RadioProgram[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [form, setForm] = useState({ title: '', channelId: '1', description: '' })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getRadioProgramList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setPrograms(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.title) return; setSubmitting(true)
|
||||
try { await saveRadioProgram({ ...form, status: 0 } as any); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><Music className="h-5 w-5 text-primary" />节目管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理电台节目内容</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-[180px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
<Button onClick={() => { setForm({ title: '', channelId: '1', description: '' }); setDialogOpen(true) }} className="h-10 gap-2"><Plus className="h-4 w-4" />新增</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">标题</TableHead><TableHead className="font-semibold">频道</TableHead>
|
||||
<TableHead className="font-semibold">音频</TableHead><TableHead className="font-semibold">时长</TableHead>
|
||||
<TableHead className="font-semibold">播放/点赞</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{programs.map(p => {
|
||||
const as = audioStatusMap[p.audioStatus] || audioStatusMap[0]
|
||||
return (
|
||||
<TableRow key={p.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6 font-medium">{p.title}</TableCell>
|
||||
<TableCell><Badge variant="secondary" className="text-xs">{p.channelName}</Badge></TableCell>
|
||||
<TableCell><Badge className={as.cls}>{as.label}</Badge></TableCell>
|
||||
<TableCell className="text-muted-foreground flex items-center gap-1"><Clock className="h-3 w-3" />{p.duration ? formatDuration(p.duration) : '-'}</TableCell>
|
||||
<TableCell><div className="flex items-center gap-2 text-xs text-muted-foreground"><span className="flex items-center gap-1"><Play className="h-3 w-3" />{p.playCount}</span><span>❤ {p.likeCount}</span></div></TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end"><DropdownMenuItem className="text-destructive" onClick={async () => { await deleteRadioProgram(p.id); fetchList() }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem></DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)})}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[460px]"><DialogHeader><DialogTitle>新增节目</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>标题 *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
|
||||
<div className="grid gap-2"><Label>描述</Label><Input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.title}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, Loader2, Users, Calendar, CreditCard } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getSubscriptionList } from '@/api/radio'
|
||||
import type { RadioSubscription } from '@/api/radio'
|
||||
|
||||
const statusMap: Record<number, { label: string; cls: string }> = {
|
||||
1: { label: '生效中', cls: 'bg-green-500/10 text-green-700 border-green-500/20' },
|
||||
2: { label: '已过期', cls: 'bg-gray-100 text-gray-600' },
|
||||
0: { label: '待支付', cls: 'bg-amber-100 text-amber-700' },
|
||||
}
|
||||
|
||||
const planMap: Record<string, string> = { monthly: '月订阅', quarterly: '季订阅', annual: '年订阅' }
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const [subs, setSubs] = useState<RadioSubscription[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = { current: page, pageSize: 10, keyword: search || undefined }
|
||||
if (statusFilter !== 'all') params.status = parseInt(statusFilter)
|
||||
const res = await getSubscriptionList(params)
|
||||
if (res?.data) { setSubs(res.data.list || []); setTotal(res.data.total || 0) }
|
||||
} catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search, statusFilter])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
const totalPages = Math.ceil(total / 10)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-l-4 border-l-green-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2"><CardTitle className="text-sm font-medium text-muted-foreground">活跃订阅</CardTitle><Users className="h-4 w-4 text-green-500" /></CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{subs.filter(s => s.status === 1).length}</div></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-amber-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2"><CardTitle className="text-sm font-medium text-muted-foreground">总收入</CardTitle><CreditCard className="h-4 w-4 text-amber-500" /></CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">¥{subs.reduce((s, sub) => s + sub.amount, 0).toFixed(1)}</div></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-blue-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2"><CardTitle className="text-sm font-medium text-muted-foreground">总订阅数</CardTitle><Calendar className="h-4 w-4 text-blue-500" /></CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{total}</div></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><CreditCard className="h-5 w-5 text-primary" />订阅管理</CardTitle>
|
||||
<CardDescription>查看频道订阅记录</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={statusFilter} onValueChange={v => { setStatusFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[120px] h-10"><SelectValue placeholder="状态" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">全部</SelectItem><SelectItem value="1">生效中</SelectItem><SelectItem value="2">已过期</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">用户</TableHead><TableHead className="font-semibold">频道</TableHead>
|
||||
<TableHead className="font-semibold">套餐</TableHead><TableHead className="font-semibold">金额</TableHead>
|
||||
<TableHead className="font-semibold">有效期</TableHead><TableHead className="font-semibold">状态</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{subs.map(s => {
|
||||
const st = statusMap[s.status] || statusMap[0]
|
||||
return (
|
||||
<TableRow key={s.id} className="hover:bg-muted/30">
|
||||
<TableCell className="pl-6 font-medium">{s.userName}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{s.channelName}</Badge></TableCell>
|
||||
<TableCell className="text-sm">{planMap[s.planType] || s.planType}</TableCell>
|
||||
<TableCell className="font-mono text-amber-600">¥{s.amount}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{new Date(s.startAt).toLocaleDateString()} ~ {new Date(s.expireAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell><Badge className={st.cls}>{st.label}</Badge></TableCell>
|
||||
</TableRow>
|
||||
)})}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && <div className="flex items-center justify-between pt-4"><p className="text-sm text-muted-foreground">共 {total} 条</p><div className="flex gap-2"><Button variant="outline" size="sm" disabled={page<=1} onClick={()=>setPage(p=>p-1)}>上一页</Button><Button variant="outline" size="sm" disabled={page>=totalPages} onClick={()=>setPage(p=>p+1)}>下一页</Button></div></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Search, Pencil, Trash2, Loader2, MoreHorizontal, Monitor } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getClientList, saveClient, updateClient, deleteClient } from '@/api/systemCrud'
|
||||
import type { SystemClient } from '@/api/system'
|
||||
|
||||
export default function ClientsPage() {
|
||||
const [clients, setClients] = useState<SystemClient[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [form, setForm] = useState({ id: '', clientId: '', name: '', grantType: 'password', activeTimeout: 3600 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [toDelete, setToDelete] = useState<SystemClient | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getClientList({ current: page, pageSize: 10, keyword: search || undefined }); if (res?.data) { setClients(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
const handleAdd = () => { setIsEdit(false); setForm({ id: '', clientId: '', name: '', grantType: 'password', activeTimeout: 3600 }); setDialogOpen(true) }
|
||||
const handleEdit = (c: SystemClient) => { setIsEdit(true); setForm({ id: c.id, clientId: c.clientId, name: c.name, grantType: c.grantType || '', activeTimeout: c.activeTimeout || 3600 }); setDialogOpen(true) }
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.clientId || !form.name) return; setSubmitting(true)
|
||||
try { if (isEdit) await updateClient(form); else await saveClient(form); setDialogOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!toDelete) return
|
||||
try { await deleteClient([toDelete.id]); setDeleteOpen(false); fetchList() } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2"><Monitor className="h-5 w-5 text-primary" />客户端管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理微服务客户端应用</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="relative flex-1 sm:flex-none"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索..." className="pl-9 w-full sm:w-[200px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
<Button onClick={handleAdd} className="h-10 gap-2 shadow-sm"><Plus className="h-4 w-4" />新增</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">客户端ID</TableHead><TableHead className="font-semibold">名称</TableHead>
|
||||
<TableHead className="font-semibold">授权方式</TableHead><TableHead className="font-semibold">超时(s)</TableHead><TableHead className="w-[60px]" />
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{clients.length === 0 ? <TableRow><TableCell colSpan={5} className="h-32 text-center text-muted-foreground">暂无数据</TableCell></TableRow>
|
||||
: clients.map(c => (
|
||||
<TableRow key={c.id} className="group hover:bg-muted/30">
|
||||
<TableCell className="pl-6"><Badge className="bg-primary/10 text-primary border-primary/20">{c.clientId}</Badge></TableCell>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{c.grantType}</TableCell>
|
||||
<TableCell className="font-mono text-muted-foreground">{c.activeTimeout}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(c)}><Pencil className="mr-2 h-4 w-4" />编辑</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => { setToDelete(c); setDeleteOpen(true) }}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[460px]"><DialogHeader><DialogTitle>{isEdit ? '编辑客户端' : '新增客户端'}</DialogTitle><DialogDescription>配置微服务客户端信息</DialogDescription></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2"><Label>客户端ID *</Label><Input value={form.clientId} onChange={e => setForm(f => ({ ...f, clientId: e.target.value }))} placeholder="如: plant" /></div>
|
||||
<div className="grid gap-2"><Label>名称 *</Label><Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="如: Plant 花园服务" /></div>
|
||||
<div className="grid gap-2"><Label>授权方式</Label><Input value={form.grantType} onChange={e => setForm(f => ({ ...f, grantType: e.target.value }))} placeholder="password,refresh_token" /></div>
|
||||
<div className="grid gap-2"><Label>超时时间(秒)</Label><Input type="number" value={form.activeTimeout} onChange={e => setForm(f => ({ ...f, activeTimeout: parseInt(e.target.value) || 0 }))} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button><Button onClick={handleSubmit} disabled={submitting || !form.clientId || !form.name}>{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除</DialogTitle><DialogDescription>确定要删除客户端「{toDelete?.name}」吗?</DialogDescription></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={handleDelete}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Search, Trash2, Loader2, Upload, Grid, List, FileText, Image, Music, File } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { getFileList, uploadFile, deleteFile } from '@/api/systemCrud'
|
||||
import type { SystemOss } from '@/api/system'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const getFileIcon = (suffix?: string) => {
|
||||
if (!suffix) return <File className="h-5 w-5" />
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(suffix)) return <Image className="h-5 w-5 text-blue-500" />
|
||||
if (['mp3', 'wav', 'ogg', 'flac'].includes(suffix)) return <Music className="h-5 w-5 text-purple-500" />
|
||||
return <FileText className="h-5 w-5 text-amber-500" />
|
||||
}
|
||||
|
||||
const isImage = (suffix?: string) => suffix && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(suffix)
|
||||
|
||||
export default function FilesPage() {
|
||||
const [files, setFiles] = useState<SystemOss[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [view, setView] = useState<'grid' | 'list'>('grid')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [previewUrl, setPreviewUrl] = useState('')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const res = await getFileList({ current: page, pageSize: 20, keyword: search || undefined }); if (res?.data) { setFiles(res.data.list || []); setTotal(res.data.total || 0) } }
|
||||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]; if (!file) return
|
||||
setUploading(true)
|
||||
try { await uploadFile(file); fetchList() } catch (err) { console.error(err) }
|
||||
finally { setUploading(false); if (fileRef.current) fileRef.current.value = '' }
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next })
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try { await deleteFile(Array.from(selected)); setSelected(new Set()); setDeleteOpen(false); fetchList() }
|
||||
catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / 20)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">文件管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||||
<CardDescription>管理上传的文件和图片资源</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto flex-wrap">
|
||||
<div className="relative flex-1 sm:flex-none"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索文件..." className="pl-9 w-full sm:w-[200px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||||
<div className="flex gap-1 border rounded-lg p-0.5">
|
||||
<Button variant={view === 'grid' ? 'secondary' : 'ghost'} size="icon" className="h-8 w-8" onClick={() => setView('grid')}><Grid className="h-4 w-4" /></Button>
|
||||
<Button variant={view === 'list' ? 'secondary' : 'ghost'} size="icon" className="h-8 w-8" onClick={() => setView('list')}><List className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
{selected.size > 0 && <Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}><Trash2 className="h-4 w-4 mr-1" />删除({selected.size})</Button>}
|
||||
<input type="file" ref={fileRef} className="hidden" onChange={handleUpload} />
|
||||
<Button onClick={() => fileRef.current?.click()} disabled={uploading} className="h-10 gap-2 shadow-sm">
|
||||
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}上传
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
|
||||
: view === 'grid' ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{files.map(f => (
|
||||
<div key={f.id} className={cn("group relative rounded-xl border overflow-hidden cursor-pointer transition-all hover:shadow-md", selected.has(f.id) && "ring-2 ring-primary border-primary")}>
|
||||
<div className="absolute top-2 left-2 z-10"><Checkbox checked={selected.has(f.id)} onCheckedChange={() => toggleSelect(f.id)} /></div>
|
||||
<div className="aspect-square bg-muted/30 flex items-center justify-center" onClick={() => isImage(f.suffix) && setPreviewUrl(f.url)}>
|
||||
{isImage(f.suffix) ? <img src={f.url} alt={f.name} className="w-full h-full object-cover" /> : getFileIcon(f.suffix)}
|
||||
</div>
|
||||
<div className="p-2"><p className="text-xs font-medium truncate">{f.name}</p><p className="text-[10px] text-muted-foreground">{f.suffix?.toUpperCase()}</p></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table><TableHeader className="bg-muted/50"><TableRow><TableHead className="w-8" /><TableHead className="font-semibold pl-2">文件名</TableHead><TableHead>类型</TableHead><TableHead>创建时间</TableHead></TableRow></TableHeader>
|
||||
<TableBody>{files.map(f => (
|
||||
<TableRow key={f.id} className="hover:bg-muted/30">
|
||||
<TableCell><Checkbox checked={selected.has(f.id)} onCheckedChange={() => toggleSelect(f.id)} /></TableCell>
|
||||
<TableCell className="pl-2 flex items-center gap-2">{getFileIcon(f.suffix)}<span className="text-sm">{f.name}</span></TableCell>
|
||||
<TableCell><Badge variant="secondary" className="text-xs">{f.suffix?.toUpperCase()}</Badge></TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{new Date(f.createdAt).toLocaleDateString()}</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-muted-foreground">共 {total} 条</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</Button>
|
||||
<span className="text-sm text-muted-foreground px-2">{page}/{totalPages}</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent><DialogHeader><DialogTitle>确认删除</DialogTitle><DialogDescription>确定要删除选中的 {selected.size} 个文件吗?</DialogDescription></DialogHeader>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={handleDelete}>删除</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!previewUrl} onOpenChange={() => setPreviewUrl('')}>
|
||||
<DialogContent className="sm:max-w-[700px] p-2"><img src={previewUrl} alt="preview" className="w-full rounded-lg" /></DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, Loader2, ScrollText, Eye, Clock, ArrowUpDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getOperationLogList } from '@/api/systemCrud'
|
||||
import type { OperationLog } from '@/api/system'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const methodColor: Record<string, string> = {
|
||||
GET: 'bg-blue-500/10 text-blue-700 border-blue-500/20',
|
||||
POST: 'bg-green-500/10 text-green-700 border-green-500/20',
|
||||
PUT: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
|
||||
DELETE: 'bg-red-500/10 text-red-700 border-red-500/20',
|
||||
}
|
||||
|
||||
const statusColor = (code: number) => {
|
||||
if (code >= 200 && code < 300) return 'bg-green-500/10 text-green-700'
|
||||
if (code >= 400 && code < 500) return 'bg-amber-500/10 text-amber-700'
|
||||
return 'bg-red-500/10 text-red-700'
|
||||
}
|
||||
|
||||
const durationColor = (ms: number) => {
|
||||
if (ms < 100) return 'text-green-600'
|
||||
if (ms < 300) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [logs, setLogs] = useState<OperationLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [clientFilter, setClientFilter] = useState('all')
|
||||
const [methodFilter, setMethodFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<OperationLog | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = { current: page, pageSize: 15, keyword: search || undefined }
|
||||
if (clientFilter !== 'all') params.clientId = clientFilter
|
||||
if (methodFilter !== 'all') params.method = methodFilter
|
||||
if (statusFilter !== 'all') params.statusCode = parseInt(statusFilter)
|
||||
const res = await getOperationLogList(params)
|
||||
if (res?.data) { setLogs(res.data.list || []); setTotal(res.data.total || 0) }
|
||||
} catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search, clientFilter, methodFilter, statusFilter])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
const totalPages = Math.ceil(total / 15)
|
||||
|
||||
// Stats from current filtered results
|
||||
const avgDuration = logs.length ? Math.round(logs.reduce((s, l) => s + l.duration, 0) / logs.length) : 0
|
||||
const errorCount = logs.filter(l => l.statusCode >= 400).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="border-l-4 border-l-blue-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">总记录</CardTitle>
|
||||
<ScrollText className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{total}</div></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-green-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">平均耗时</CardTitle>
|
||||
<Clock className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{avgDuration}<span className="text-sm font-normal text-muted-foreground ml-1">ms</span></div></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-red-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">异常请求</CardTitle>
|
||||
<ArrowUpDown className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold text-red-600">{errorCount}</div></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-purple-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">成功率</CardTitle>
|
||||
<Badge className="bg-purple-500/10 text-purple-700 text-xs">%</Badge>
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{logs.length ? ((1 - errorCount / logs.length) * 100).toFixed(1) : '—'}</div></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main table card */}
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<ScrollText className="h-5 w-5 text-primary" />操作日志
|
||||
</CardTitle>
|
||||
<CardDescription>查看系统 API 调用记录,支持按客户端、方法、状态筛选</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full lg:w-auto flex-wrap">
|
||||
<div className="relative flex-1 lg:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="搜索路径/操作/人..." className="pl-9 w-full lg:w-[180px] h-9 bg-muted/30 text-sm" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} />
|
||||
</div>
|
||||
<Select value={clientFilter} onValueChange={v => { setClientFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[120px] h-9 text-sm"><SelectValue placeholder="客户端" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部客户端</SelectItem>
|
||||
<SelectItem value="gateway">API 网关</SelectItem>
|
||||
<SelectItem value="plant">Plant</SelectItem>
|
||||
<SelectItem value="radio">Radio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={methodFilter} onValueChange={v => { setMethodFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[100px] h-9 text-sm"><SelectValue placeholder="方法" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部方法</SelectItem>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={v => { setStatusFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[100px] h-9 text-sm"><SelectValue placeholder="状态" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="400">400</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-4 w-[130px]">时间</TableHead>
|
||||
<TableHead className="font-semibold w-[60px]">方法</TableHead>
|
||||
<TableHead className="font-semibold">接口</TableHead>
|
||||
<TableHead className="font-semibold w-[90px]">客户端</TableHead>
|
||||
<TableHead className="font-semibold w-[80px]">操作者</TableHead>
|
||||
<TableHead className="font-semibold w-[60px]">状态</TableHead>
|
||||
<TableHead className="font-semibold w-[70px]">耗时</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} className="h-32 text-center text-muted-foreground">暂无日志</TableCell></TableRow>
|
||||
) : logs.map(l => (
|
||||
<TableRow key={l.id} className="group hover:bg-muted/30 text-sm">
|
||||
<TableCell className="pl-4 text-xs text-muted-foreground font-mono whitespace-nowrap">
|
||||
{new Date(l.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</TableCell>
|
||||
<TableCell><Badge className={cn('text-[10px] font-mono font-bold px-1.5', methodColor[l.method])}>{l.method}</Badge></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono text-xs truncate max-w-[200px]">{l.path}</span>
|
||||
<span className="text-xs text-muted-foreground hidden lg:inline">({l.title})</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell><Badge variant="outline" className="text-[10px]">{l.clientName}</Badge></TableCell>
|
||||
<TableCell className="text-xs">{l.operatorName}</TableCell>
|
||||
<TableCell><Badge className={cn('text-[10px] font-mono', statusColor(l.statusCode))}>{l.statusCode}</Badge></TableCell>
|
||||
<TableCell><span className={cn('text-xs font-mono font-semibold', durationColor(l.duration))}>{l.duration}ms</span></TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100" onClick={() => { setDetail(l); setDetailOpen(true) }}>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-muted-foreground">共 {total} 条</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</Button>
|
||||
<span className="text-sm text-muted-foreground px-2">{page}/{totalPages}</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader><DialogTitle className="flex items-center gap-2"><Eye className="h-5 w-5 text-primary" />日志详情</DialogTitle></DialogHeader>
|
||||
{detail && (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoRow label="操作" value={detail.title} />
|
||||
<InfoRow label="操作者" value={detail.operatorName} />
|
||||
<InfoRow label="客户端" value={detail.clientName} />
|
||||
<InfoRow label="IP" value={detail.ip} />
|
||||
<InfoRow label="方法" value={<Badge className={cn('text-[10px] font-mono', methodColor[detail.method])}>{detail.method}</Badge>} />
|
||||
<InfoRow label="状态" value={<Badge className={cn('text-[10px] font-mono', statusColor(detail.statusCode))}>{detail.statusCode}</Badge>} />
|
||||
<InfoRow label="耗时" value={<span className={cn('font-mono font-semibold', durationColor(detail.duration))}>{detail.duration}ms</span>} />
|
||||
<InfoRow label="时间" value={new Date(detail.createdAt).toLocaleString()} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground">接口路径</p>
|
||||
<code className="block bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono break-all">{detail.path}</code>
|
||||
</div>
|
||||
{detail.userAgent && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground">User-Agent</p>
|
||||
<code className="block bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono break-all">{detail.userAgent}</code>
|
||||
</div>
|
||||
)}
|
||||
{detail.requestBody && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground">请求体</p>
|
||||
<pre className="bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono overflow-x-auto">{detail.requestBody}</pre>
|
||||
</div>
|
||||
)}
|
||||
{detail.responseBody && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground">响应体</p>
|
||||
<pre className="bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono overflow-x-auto">{detail.responseBody}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-12 shrink-0">{label}</span>
|
||||
<span className="text-xs font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, RefreshCw, Edit, Trash2, MenuSquare, ChevronRight, ChevronDown } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
import { getMenuTree, createMenu, updateMenu, deleteMenu } from '@/api/system/menu'
|
||||
import type { SystemMenu } from '@/api/system'
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
// Tree Row Component
|
||||
function MenuRow({
|
||||
item, depth = 0, onEdit, onDelete, onAddChild
|
||||
}: {
|
||||
item: SystemMenu; depth?: number; onEdit: (m: SystemMenu) => void; onDelete: (id: string) => void; onAddChild: (id: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const IconCmp = (Icons as any)[item.icon ? item.icon.charAt(0).toUpperCase() + item.icon.slice(1) : 'Circle'] || Icons.Circle
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow className="hover:bg-muted/20">
|
||||
<TableCell className="font-medium" style={{ paddingLeft: `${depth * 24 + 24}px` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChildren ? (
|
||||
<button onClick={() => setExpanded(!expanded)} className="p-0.5 hover:bg-muted rounded-md text-muted-foreground">
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : <span className="w-5" />}
|
||||
<span className="flex items-center gap-2">
|
||||
<IconCmp className="h-4 w-4 text-muted-foreground" />
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{item.path || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={item.category === 1 ? 'border-emerald-200 text-emerald-600 bg-emerald-50' : 'border-blue-200 text-blue-600 bg-blue-50'}>
|
||||
{item.category === 1 ? '菜单' : '按钮/权限'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{item.sort}</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-emerald-600" title="添加子节点" onClick={() => onAddChild(item.id)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-blue-500" title="编辑" onClick={() => onEdit(item)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-red-500 hover:text-red-600" title="删除" onClick={() => onDelete(item.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && hasChildren && item.children!.map(child => (
|
||||
<MenuRow key={child.id} item={child} depth={depth + 1} onEdit={onEdit} onDelete={onDelete} onAddChild={onAddChild} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Menus() {
|
||||
const [menus, setMenus] = useState<SystemMenu[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Dialog State
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingMenu, setEditingMenu] = useState<SystemMenu | null>(null)
|
||||
const [formData, setFormData] = useState({ title: '', name: '', path: '', icon: '', sort: 0, category: 1, parentId: '' })
|
||||
|
||||
const fetchMenus = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getMenuTree()
|
||||
if (res.data) setMenus(res.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetchMenus() }, [])
|
||||
|
||||
const openCreateDialog = (parentId: string = '') => {
|
||||
setEditingMenu(null)
|
||||
setFormData({ title: '', name: '', path: '', icon: '', sort: 1, category: 1, parentId })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (menu: SystemMenu) => {
|
||||
setEditingMenu(menu)
|
||||
setFormData({ title: menu.title || '', name: menu.name || '', path: menu.path || '', icon: menu.icon || '', sort: menu.sort || 1, category: menu.category || 1, parentId: menu.parentId || '' })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (editingMenu) await updateMenu(editingMenu.id, formData)
|
||||
else await createMenu(formData)
|
||||
setDialogOpen(false)
|
||||
fetchMenus()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('确定要删除这个菜单节点吗?其子节点也会受到影响。')) {
|
||||
await deleteMenu(id)
|
||||
fetchMenus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn pb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">菜单管理</h1>
|
||||
<p className="text-muted-foreground mt-1">配置系统左侧导航菜单、路由及页面操作权限。</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/60 shadow-soft">
|
||||
<CardHeader className="pb-3 border-b border-border/40">
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MenuSquare className="h-5 w-5 text-primary" /> 菜单树
|
||||
</CardTitle>
|
||||
<Button size="sm" onClick={() => openCreateDialog('')} className="h-9 gap-1.5">
|
||||
<Plus className="h-4 w-4" /> 新增根菜单
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="pl-6">菜单名称</TableHead>
|
||||
<TableHead>路由路径 / 权限标识</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>排序</TableHead>
|
||||
<TableHead className="text-right pr-6">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-32 text-center text-muted-foreground">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-primary/50" /> 加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : menus.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-32 text-center text-muted-foreground">暂无数据</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
menus.map(menu => (
|
||||
<MenuRow key={menu.id} item={menu} onEdit={openEditDialog} onDelete={handleDelete} onAddChild={openCreateDialog} />
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingMenu ? '编辑菜单' : '新增菜单'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">菜单名称</Label>
|
||||
<Input id="title" value={formData.title} onChange={e => setFormData({ ...formData, title: e.target.value })} className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">路由 Name</Label>
|
||||
<Input id="name" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} className="col-span-3" placeholder="如: system_users" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="path" className="text-right">路由路径</Label>
|
||||
<Input id="path" value={formData.path} onChange={e => setFormData({ ...formData, path: e.target.value })} className="col-span-3" placeholder="/system/users" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="icon" className="text-right">图标</Label>
|
||||
<Input id="icon" value={formData.icon} onChange={e => setFormData({ ...formData, icon: e.target.value })} className="col-span-3" placeholder="lucide-react 图标名" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="sort" className="text-right">排序</Label>
|
||||
<Input id="sort" type="number" value={formData.sort} onChange={e => setFormData({ ...formData, sort: parseInt(e.target.value) || 0 })} className="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Search, RefreshCw, Edit, Trash2, Shield, Settings2 } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
import { getRoleList, createRole, updateRole, deleteRole } from '@/api/system/role'
|
||||
import type { SystemRole } from '@/api/system'
|
||||
|
||||
export default function Roles() {
|
||||
const [roles, setRoles] = useState<SystemRole[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState({ name: '' })
|
||||
|
||||
// Dialog State
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingRole, setEditingRole] = useState<SystemRole | null>(null)
|
||||
const [formData, setFormData] = useState({ name: '', code: '', sort: 0 })
|
||||
|
||||
const fetchRoles = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getRoleList({ current: 1, pageSize: 10, ...search })
|
||||
if (res.data) {
|
||||
setRoles(res.data.list)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchRoles() }, [])
|
||||
|
||||
const handleSearch = () => fetchRoles()
|
||||
const handleReset = () => { setSearch({ name: '' }); setTimeout(() => fetchRoles(), 0) }
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingRole(null)
|
||||
setFormData({ name: '', code: '', sort: 1 })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (role: SystemRole) => {
|
||||
setEditingRole(role)
|
||||
setFormData({ name: role.name, code: role.code, sort: role.sort || 1 })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (editingRole) await updateRole(editingRole.id, formData)
|
||||
else await createRole(formData)
|
||||
setDialogOpen(false)
|
||||
fetchRoles()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('确定要删除这个角色吗?')) {
|
||||
await deleteRole(id)
|
||||
fetchRoles()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">角色管理</h1>
|
||||
<p className="text-muted-foreground mt-1">配置系统角色,用于 RBAC 权限分配。</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/60 shadow-soft">
|
||||
<CardHeader className="pb-3 border-b border-border/40">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-primary" /> 角色列表
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索角色名..."
|
||||
className="pl-9 w-48 h-9"
|
||||
value={search.name}
|
||||
onChange={e => setSearch({ ...search, name: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} className="h-9 px-3">重置</Button>
|
||||
<Button size="sm" onClick={openCreateDialog} className="h-9 gap-1.5">
|
||||
<Plus className="h-4 w-4" /> 新增角色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px] pl-6">角色名称</TableHead>
|
||||
<TableHead>角色代码</TableHead>
|
||||
<TableHead>排序</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right pr-6">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-32 text-center text-muted-foreground">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-primary/50" /> 加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-32 text-center text-muted-foreground">暂无数据</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map(role => (
|
||||
<TableRow key={role.id} className="hover:bg-muted/20">
|
||||
<TableCell className="font-medium pl-6">{role.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{role.code}</TableCell>
|
||||
<TableCell>{role.sort}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{new Date(role.createdAt).toLocaleDateString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1" onClick={() => openEditDialog(role)}>
|
||||
<Edit className="h-3.5 w-3.5 text-blue-500" /> 编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1" onClick={() => {}}>
|
||||
<Settings2 className="h-3.5 w-3.5 text-emerald-500" /> 菜单权限
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 hover:text-red-500" onClick={() => handleDelete(role.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-red-500" /> 删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border/40 text-sm text-muted-foreground">
|
||||
<div>共 {total} 条记录</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled>上一页</Button>
|
||||
<Button variant="outline" size="sm" disabled>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRole ? '编辑角色' : '新增角色'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingRole ? '修改角色的基本信息。' : '在系统中创建一个新角色。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">角色名称</Label>
|
||||
<Input id="name" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="code" className="text-right">角色代码</Label>
|
||||
<Input id="code" value={formData.code} onChange={e => setFormData({ ...formData, code: e.target.value })} className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="sort" className="text-right">排序</Label>
|
||||
<Input id="sort" type="number" value={formData.sort} onChange={e => setFormData({ ...formData, sort: parseInt(e.target.value) || 0 })} className="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave}>保存更改</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Search, RefreshCw, Edit, Trash2, ShieldCheck, MoreHorizontal } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
import { getUserList, createUser, updateUser, deleteUser } from '@/api/system/user'
|
||||
import type { SystemUser } from '@/api/system'
|
||||
|
||||
export default function UserManage() {
|
||||
const [users, setUsers] = useState<SystemUser[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState({ account: '', name: '' })
|
||||
|
||||
// Dialog State
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<SystemUser | null>(null)
|
||||
const [formData, setFormData] = useState({ account: '', name: '', phone: '', clientId: '' })
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getUserList({ current: 1, pageSize: 10, ...search })
|
||||
if (res.data) {
|
||||
setUsers(res.data.list)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchUsers() }, [])
|
||||
|
||||
const handleSearch = () => fetchUsers()
|
||||
|
||||
const handleReset = () => {
|
||||
setSearch({ account: '', name: '' })
|
||||
setTimeout(() => fetchUsers(), 0)
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingUser(null)
|
||||
setFormData({ account: '', name: '', phone: '', clientId: '' })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (user: SystemUser) => {
|
||||
setEditingUser(user)
|
||||
setFormData({ account: user.account, name: user.name, phone: user.phone || '', clientId: user.clientId || '' })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (editingUser) {
|
||||
await updateUser(editingUser.id, formData)
|
||||
} else {
|
||||
await createUser(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('确定要删除这个用户吗?')) {
|
||||
await deleteUser(id)
|
||||
fetchUsers()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">用户管理</h1>
|
||||
<p className="text-muted-foreground mt-1">管理系统和各个客户端(Plant, Radio等)的用户访问权限。</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/60 shadow-soft">
|
||||
<CardHeader className="pb-3 border-b border-border/40">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" /> 用户列表
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索账号..."
|
||||
className="pl-9 w-40 h-9"
|
||||
value={search.account}
|
||||
onChange={e => setSearch({ ...search, account: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索姓名..."
|
||||
className="pl-9 w-40 h-9"
|
||||
value={search.name}
|
||||
onChange={e => setSearch({ ...search, name: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} className="h-9 px-3">重置</Button>
|
||||
<Button size="sm" onClick={openCreateDialog} className="h-9 gap-1.5">
|
||||
<Plus className="h-4 w-4" /> 新增用户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px] pl-6">账号</TableHead>
|
||||
<TableHead>姓名</TableHead>
|
||||
<TableHead>手机号</TableHead>
|
||||
<TableHead>客户端来源</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right pr-6">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-primary/50" />
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">暂无数据</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map(user => (
|
||||
<TableRow key={user.id} className="hover:bg-muted/20">
|
||||
<TableCell className="font-medium pl-6">{user.account}</TableCell>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.phone || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{user.clientId ? (
|
||||
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
|
||||
{user.clientId}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-muted text-muted-foreground">System</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{user.roles?.map(r => (
|
||||
<Badge key={r.id} variant="default" className="text-[10px] h-5 font-normal">
|
||||
{r.name}
|
||||
</Badge>
|
||||
)) || <span className="text-muted-foreground text-xs">无角色</span>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{new Date(user.createdAt).toLocaleDateString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">打开菜单</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => openEditDialog(user)}>
|
||||
<Edit className="mr-2 h-4 w-4 text-blue-500" /> 编辑信息
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleDelete(user.id)} className="text-red-500 focus:text-red-500 focus:bg-red-50 dark:focus:bg-red-950">
|
||||
<Trash2 className="mr-2 h-4 w-4" /> 删除用户
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Simple Pagination Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border/40 text-sm text-muted-foreground">
|
||||
<div>共 {total} 条记录</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled>上一页</Button>
|
||||
<Button variant="outline" size="sm" disabled>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialog for Create/Edit */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingUser ? '编辑用户' : '新增用户'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingUser ? '修改用户信息和客户端绑定。' : '在系统中创建一个新用户账号。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="account" className="text-right">账号</Label>
|
||||
<Input id="account" value={formData.account} onChange={e => setFormData({ ...formData, account: e.target.value })} className="col-span-3" disabled={!!editingUser} />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">姓名</Label>
|
||||
<Input id="name" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="phone" className="text-right">手机号</Label>
|
||||
<Input id="phone" value={formData.phone} onChange={e => setFormData({ ...formData, phone: e.target.value })} className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="clientId" className="text-right">客户端</Label>
|
||||
<Input id="clientId" placeholder="如: plant, radio 或留空" value={formData.clientId} onChange={e => setFormData({ ...formData, clientId: e.target.value })} className="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave}>保存更改</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface AppState {
|
||||
sidebarOpen: boolean
|
||||
mobileMenuOpen: boolean
|
||||
toggleSidebar: () => void
|
||||
setMobileMenu: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
sidebarOpen: true,
|
||||
mobileMenuOpen: false,
|
||||
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
setMobileMenu: (open) => set({ mobileMenuOpen: open }),
|
||||
}))
|
||||
@@ -0,0 +1,78 @@
|
||||
import { create } from 'zustand'
|
||||
import type { SystemUser, SystemMenu } from '@/api/system'
|
||||
import { getUserMenuTree } from '@/api/systemCrud'
|
||||
import { logout as apiLogout } from '@/api/system'
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
const USER_KEY = 'user'
|
||||
|
||||
interface AuthState {
|
||||
user: SystemUser | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
menus: SystemMenu[]
|
||||
permissions: string[]
|
||||
login: (user: SystemUser, token: string) => void
|
||||
logout: () => Promise<void>
|
||||
refreshMenus: () => Promise<void>
|
||||
hasPermission: (permission: string) => boolean
|
||||
}
|
||||
|
||||
function extractPermissions(menus: SystemMenu[]): string[] {
|
||||
const perms: string[] = []
|
||||
const traverse = (m: SystemMenu) => {
|
||||
if (m.permission) perms.push(m.permission)
|
||||
m.children?.forEach(traverse)
|
||||
}
|
||||
menus.forEach(traverse)
|
||||
return perms
|
||||
}
|
||||
|
||||
function getStoredAuth() {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const userStr = localStorage.getItem(USER_KEY)
|
||||
if (token && userStr) {
|
||||
return { user: JSON.parse(userStr) as SystemUser, token, isAuthenticated: true }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { user: null, token: null, isAuthenticated: false }
|
||||
}
|
||||
|
||||
const initial = getStoredAuth()
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: initial.user,
|
||||
token: initial.token,
|
||||
isAuthenticated: initial.isAuthenticated,
|
||||
menus: [],
|
||||
permissions: [],
|
||||
|
||||
login: (user, token) => {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
set({ user, token, isAuthenticated: true, menus: [], permissions: [] })
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try { await apiLogout() } catch { /* ignore */ }
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
set({ user: null, token: null, isAuthenticated: false, menus: [], permissions: [] })
|
||||
},
|
||||
|
||||
refreshMenus: async () => {
|
||||
if (!get().isAuthenticated) return
|
||||
try {
|
||||
const res = await getUserMenuTree()
|
||||
const menus = res.data || []
|
||||
set({ menus, permissions: extractPermissions(menus) })
|
||||
} catch (e) { console.error('获取菜单失败:', e) }
|
||||
},
|
||||
|
||||
hasPermission: (permission) => {
|
||||
const { user, permissions } = get()
|
||||
if (user?.account === 'admin') return true
|
||||
return permissions.includes(permission)
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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 */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8888',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user