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 [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## 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,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>植趣ZeeQ - 后台管理系统</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Generated
+6427
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "plant-care-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-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",
|
||||
"axios": "^1.13.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.12",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,231 @@
|
||||
-- ============================================
|
||||
-- 植物养护后台管理系统 - 初始化数据
|
||||
-- 表前缀: sundynix_ 使用单数形式
|
||||
-- 执行前请确保数据库表已存在
|
||||
-- ============================================
|
||||
|
||||
-- 清空现有数据(可选,谨慎使用)
|
||||
-- DELETE FROM sundynix_role_menu;
|
||||
-- DELETE FROM sundynix_user_role;
|
||||
-- DELETE FROM sundynix_menu;
|
||||
-- DELETE FROM sundynix_role;
|
||||
-- DELETE FROM sundynix_user;
|
||||
-- DELETE FROM sundynix_client;
|
||||
|
||||
-- ============================================
|
||||
-- 客户端数据
|
||||
-- ============================================
|
||||
INSERT INTO sundynix_client (id, client_id, name, grant_type, active_timeout, created_at, updated_at) VALUES
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567801', 'pc', 'PC管理端', 'password', 86400, NOW(), NOW()),
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567802', 'mini', '小程序端', 'wechat', 604800, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 一级菜单
|
||||
-- ============================================
|
||||
|
||||
-- 仪表盘
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('11111111-1111-1111-1111-111111111001', '0', 1, 'dashboard', '仪表盘', '/dashboard', '', 'menu.dashboard', 'dashboard', 1, NOW(), NOW());
|
||||
|
||||
-- 系统管理
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('11111111-1111-1111-1111-111111111002', '0', 1, 'system', '系统管理', '/system', '', 'menu.system', 'settings', 2, NOW(), NOW());
|
||||
|
||||
-- 植趣(新增一级菜单,包含社区和百科)
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('11111111-1111-1111-1111-111111111003', '0', 1, 'plantFun', '植趣', '/plant-fun', '', 'menu.plantFun', 'leaf', 3, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 系统管理 - 子菜单
|
||||
-- ============================================
|
||||
|
||||
-- 用户管理
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('22222222-2222-2222-2222-222222222001', '11111111-1111-1111-1111-111111111002', 1, 'user', '用户管理', '/system/users', 'user:list', 'menu.system.user', 'users', 1, NOW(), NOW());
|
||||
|
||||
-- 角色管理
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('22222222-2222-2222-2222-222222222002', '11111111-1111-1111-1111-111111111002', 1, 'role', '角色管理', '/system/roles', 'role:list', 'menu.system.role', 'shield', 2, NOW(), NOW());
|
||||
|
||||
-- 菜单管理
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('22222222-2222-2222-2222-222222222003', '11111111-1111-1111-1111-111111111002', 1, 'menu', '菜单管理', '/system/menus', 'menu:list', 'menu.system.menu', 'menu', 3, NOW(), NOW());
|
||||
|
||||
-- 客户端管理
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('22222222-2222-2222-2222-222222222004', '11111111-1111-1111-1111-111111111002', 1, 'client', '客户端管理', '/system/clients', 'client:list', 'menu.system.client', 'monitor', 4, NOW(), NOW());
|
||||
|
||||
-- 文件管理
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('22222222-2222-2222-2222-222222222005', '11111111-1111-1111-1111-111111111002', 1, 'file', '文件管理', '/system/files', 'file:list', 'menu.system.file', 'folder', 5, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 植趣 - 子菜单(社区管理 + 百科管理)
|
||||
-- ============================================
|
||||
|
||||
-- 社区管理(二级菜单)
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('22222222-2222-2222-2222-222222222101', '11111111-1111-1111-1111-111111111003', 1, 'community', '社区管理', '/community', '', 'menu.community', 'message', 1, NOW(), NOW());
|
||||
|
||||
-- 百科管理(二级菜单)
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('22222222-2222-2222-2222-222222222102', '11111111-1111-1111-1111-111111111003', 1, 'wiki', '百科管理', '/wiki', '', 'menu.wiki', 'book', 2, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 社区管理 - 子菜单(三级)
|
||||
-- ============================================
|
||||
|
||||
-- 话题管理
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('33333333-3333-3333-3333-333333333001', '22222222-2222-2222-2222-222222222101', 1, 'topic', '话题管理', '/topics', 'topic:list', 'menu.community.topic', 'hash', 1, NOW(), NOW());
|
||||
|
||||
-- 帖子管理
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('33333333-3333-3333-3333-333333333002', '22222222-2222-2222-2222-222222222101', 1, 'post', '帖子管理', '/posts', 'post:list', 'menu.community.post', 'file-text', 2, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 百科管理 - 子菜单(三级)
|
||||
-- ============================================
|
||||
|
||||
-- 分类管理
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('33333333-3333-3333-3333-333333333003', '22222222-2222-2222-2222-222222222102', 1, 'wikiClass', '分类管理', '/categories', 'wikiClass:list', 'menu.wiki.class', 'folder-tree', 1, NOW(), NOW());
|
||||
|
||||
-- 植物百科
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('33333333-3333-3333-3333-333333333004', '22222222-2222-2222-2222-222222222102', 1, 'plant', '植物百科', '/plants', 'wiki:list', 'menu.wiki.plant', 'leaf', 2, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 按钮权限(category = 2)
|
||||
-- ============================================
|
||||
|
||||
-- 用户管理按钮
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('44444444-4444-4444-4444-444444444001', '22222222-2222-2222-2222-222222222001', 2, 'userAdd', '新增用户', '', 'user:add', '', '', 1, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444002', '22222222-2222-2222-2222-222222222001', 2, 'userEdit', '编辑用户', '', 'user:edit', '', '', 2, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444003', '22222222-2222-2222-2222-222222222001', 2, 'userDelete', '删除用户', '', 'user:delete', '', '', 3, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444004', '22222222-2222-2222-2222-222222222001', 2, 'userGrant', '分配角色', '', 'user:grant', '', '', 4, NOW(), NOW());
|
||||
|
||||
-- 角色管理按钮
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('44444444-4444-4444-4444-444444444005', '22222222-2222-2222-2222-222222222002', 2, 'roleAdd', '新增角色', '', 'role:add', '', '', 1, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444006', '22222222-2222-2222-2222-222222222002', 2, 'roleEdit', '编辑角色', '', 'role:edit', '', '', 2, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444007', '22222222-2222-2222-2222-222222222002', 2, 'roleDelete', '删除角色', '', 'role:delete', '', '', 3, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444008', '22222222-2222-2222-2222-222222222002', 2, 'roleGrant', '授权菜单', '', 'role:grant', '', '', 4, NOW(), NOW());
|
||||
|
||||
-- 菜单管理按钮
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('44444444-4444-4444-4444-444444444009', '22222222-2222-2222-2222-222222222003', 2, 'menuAdd', '新增菜单', '', 'menu:add', '', '', 1, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444010', '22222222-2222-2222-2222-222222222003', 2, 'menuEdit', '编辑菜单', '', 'menu:edit', '', '', 2, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444011', '22222222-2222-2222-2222-222222222003', 2, 'menuDelete', '删除菜单', '', 'menu:delete', '', '', 3, NOW(), NOW());
|
||||
|
||||
-- 话题管理按钮
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('44444444-4444-4444-4444-444444444012', '33333333-3333-3333-3333-333333333001', 2, 'topicAdd', '新增话题', '', 'topic:add', '', '', 1, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444013', '33333333-3333-3333-3333-333333333001', 2, 'topicEdit', '编辑话题', '', 'topic:edit', '', '', 2, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444014', '33333333-3333-3333-3333-333333333001', 2, 'topicDelete', '删除话题', '', 'topic:delete', '', '', 3, NOW(), NOW());
|
||||
|
||||
-- 帖子管理按钮
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('44444444-4444-4444-4444-444444444015', '33333333-3333-3333-3333-333333333002', 2, 'postReview', '审核帖子', '', 'post:review', '', '', 1, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444016', '33333333-3333-3333-3333-333333333002', 2, 'postDelete', '删除帖子', '', 'post:delete', '', '', 2, NOW(), NOW());
|
||||
|
||||
-- 分类管理按钮
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('44444444-4444-4444-4444-444444444017', '33333333-3333-3333-3333-333333333003', 2, 'classAdd', '新增分类', '', 'wikiClass:add', '', '', 1, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444018', '33333333-3333-3333-3333-333333333003', 2, 'classEdit', '编辑分类', '', 'wikiClass:edit', '', '', 2, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444019', '33333333-3333-3333-3333-333333333003', 2, 'classDelete', '删除分类', '', 'wikiClass:delete', '', '', 3, NOW(), NOW());
|
||||
|
||||
-- 植物百科按钮
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('44444444-4444-4444-4444-444444444020', '33333333-3333-3333-3333-333333333004', 2, 'wikiAdd', '新增百科', '', 'wiki:add', '', '', 1, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444021', '33333333-3333-3333-3333-333333333004', 2, 'wikiEdit', '编辑百科', '', 'wiki:edit', '', '', 2, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444022', '33333333-3333-3333-3333-333333333004', 2, 'wikiDelete', '删除百科', '', 'wiki:delete', '', '', 3, NOW(), NOW());
|
||||
|
||||
-- 文件管理按钮
|
||||
INSERT INTO sundynix_menu (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('44444444-4444-4444-4444-444444444023', '22222222-2222-2222-2222-222222222005', 2, 'fileUpload', '上传文件', '', 'file:upload', '', '', 1, NOW(), NOW()),
|
||||
('44444444-4444-4444-4444-444444444024', '22222222-2222-2222-2222-222222222005', 2, 'fileDelete', '删除文件', '', 'file:delete', '', '', 2, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 角色数据
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO sundynix_role (id, name, code, sort, created_at, updated_at) VALUES
|
||||
('55555555-5555-5555-5555-555555555001', '超级管理员', 'admin', 1, NOW(), NOW()),
|
||||
('55555555-5555-5555-5555-555555555002', '运营管理员', 'operator', 2, NOW(), NOW()),
|
||||
('55555555-5555-5555-5555-555555555003', '内容编辑', 'editor', 3, NOW(), NOW()),
|
||||
('55555555-5555-5555-5555-555555555004', '普通用户', 'user', 4, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 角色菜单关联
|
||||
-- ============================================
|
||||
|
||||
-- 超级管理员 - 拥有所有菜单权限
|
||||
INSERT INTO sundynix_role_menu (role_id, menu_id)
|
||||
SELECT '55555555-5555-5555-5555-555555555001', id FROM sundynix_menu;
|
||||
|
||||
-- 运营管理员 - 仪表盘 + 植趣(社区+百科)
|
||||
INSERT INTO sundynix_role_menu (role_id, menu_id) VALUES
|
||||
('55555555-5555-5555-5555-555555555002', '11111111-1111-1111-1111-111111111001'),
|
||||
('55555555-5555-5555-5555-555555555002', '11111111-1111-1111-1111-111111111003'),
|
||||
('55555555-5555-5555-5555-555555555002', '22222222-2222-2222-2222-222222222101'),
|
||||
('55555555-5555-5555-5555-555555555002', '22222222-2222-2222-2222-222222222102'),
|
||||
('55555555-5555-5555-5555-555555555002', '33333333-3333-3333-3333-333333333001'),
|
||||
('55555555-5555-5555-5555-555555555002', '33333333-3333-3333-3333-333333333002'),
|
||||
('55555555-5555-5555-5555-555555555002', '33333333-3333-3333-3333-333333333003'),
|
||||
('55555555-5555-5555-5555-555555555002', '33333333-3333-3333-3333-333333333004'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444012'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444013'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444014'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444015'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444016'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444017'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444018'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444019'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444020'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444021'),
|
||||
('55555555-5555-5555-5555-555555555002', '44444444-4444-4444-4444-444444444022');
|
||||
|
||||
-- 内容编辑 - 仪表盘 + 百科管理(无删除权限)
|
||||
INSERT INTO sundynix_role_menu (role_id, menu_id) VALUES
|
||||
('55555555-5555-5555-5555-555555555003', '11111111-1111-1111-1111-111111111001'),
|
||||
('55555555-5555-5555-5555-555555555003', '11111111-1111-1111-1111-111111111003'),
|
||||
('55555555-5555-5555-5555-555555555003', '22222222-2222-2222-2222-222222222102'),
|
||||
('55555555-5555-5555-5555-555555555003', '33333333-3333-3333-3333-333333333003'),
|
||||
('55555555-5555-5555-5555-555555555003', '33333333-3333-3333-3333-333333333004'),
|
||||
('55555555-5555-5555-5555-555555555003', '44444444-4444-4444-4444-444444444017'),
|
||||
('55555555-5555-5555-5555-555555555003', '44444444-4444-4444-4444-444444444018'),
|
||||
('55555555-5555-5555-5555-555555555003', '44444444-4444-4444-4444-444444444020'),
|
||||
('55555555-5555-5555-5555-555555555003', '44444444-4444-4444-4444-444444444021');
|
||||
|
||||
-- 普通用户 - 只有仪表盘
|
||||
INSERT INTO sundynix_role_menu (role_id, menu_id) VALUES
|
||||
('55555555-5555-5555-5555-555555555004', '11111111-1111-1111-1111-111111111001');
|
||||
|
||||
-- ============================================
|
||||
-- 用户数据
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO sundynix_user (id, account, name, nick_name, phone, client_id, created_at, updated_at) VALUES
|
||||
('66666666-6666-6666-6666-666666666001', 'admin', '超级管理员', '管理员', '13800138000', 'pc', NOW(), NOW()),
|
||||
('66666666-6666-6666-6666-666666666002', 'operator', '运营管理员', '小运营', '13800138001', 'pc', NOW(), NOW()),
|
||||
('66666666-6666-6666-6666-666666666003', 'editor', '内容编辑', '小编辑', '13800138002', 'pc', NOW(), NOW()),
|
||||
('66666666-6666-6666-6666-666666666004', 'test', '测试用户', '测试', '13800138003', 'pc', NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 用户角色关联
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO sundynix_user_role (user_id, role_id) VALUES
|
||||
('66666666-6666-6666-6666-666666666001', '55555555-5555-5555-5555-555555555001'),
|
||||
('66666666-6666-6666-6666-666666666002', '55555555-5555-5555-5555-555555555002'),
|
||||
('66666666-6666-6666-6666-666666666003', '55555555-5555-5555-5555-555555555003'),
|
||||
('66666666-6666-6666-6666-666666666004', '55555555-5555-5555-5555-555555555004');
|
||||
|
||||
-- ============================================
|
||||
-- 验证数据
|
||||
-- ============================================
|
||||
-- SELECT id, parent_id, title, code FROM sundynix_menu WHERE category = 1 ORDER BY sort;
|
||||
-- SELECT * FROM sundynix_role;
|
||||
-- SELECT u.account, r.name FROM sundynix_user u JOIN sundynix_user_role ur ON u.id = ur.user_id JOIN sundynix_role r ON ur.role_id = r.id;
|
||||
@@ -0,0 +1,134 @@
|
||||
-- 植物养护后台管理系统 - 菜单初始化数据
|
||||
-- 执行前请确保 menus 表已存在
|
||||
|
||||
-- 清空现有菜单数据(可选,谨慎使用)
|
||||
-- DELETE FROM menus;
|
||||
|
||||
-- 生成UUID函数(如果数据库不支持,可以手动指定ID)
|
||||
-- MySQL可以使用 UUID() 函数
|
||||
|
||||
-- ============================================
|
||||
-- 一级菜单
|
||||
-- ============================================
|
||||
|
||||
-- 仪表盘
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_dashboard', '0', 1, 'dashboard', '仪表盘', '/dashboard', '', 'menu.dashboard', 'dashboard', 1, NOW(), NOW());
|
||||
|
||||
-- 系统管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_system', '0', 1, 'system', '系统管理', '/system', '', 'menu.system', 'settings', 2, NOW(), NOW());
|
||||
|
||||
-- 社区管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_community', '0', 1, 'community', '社区管理', '/community', '', 'menu.community', 'message', 3, NOW(), NOW());
|
||||
|
||||
-- 百科管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_wiki', '0', 1, 'wiki', '百科管理', '/wiki', '', 'menu.wiki', 'book', 4, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 系统管理 - 子菜单
|
||||
-- ============================================
|
||||
|
||||
-- 用户管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_system_user', 'menu_system', 1, 'user', '用户管理', '/system/users', 'user:list', 'menu.system.user', 'users', 1, NOW(), NOW());
|
||||
|
||||
-- 角色管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_system_role', 'menu_system', 1, 'role', '角色管理', '/system/roles', 'role:list', 'menu.system.role', 'shield', 2, NOW(), NOW());
|
||||
|
||||
-- 菜单管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_system_menu', 'menu_system', 1, 'menu', '菜单管理', '/system/menus', 'menu:list', 'menu.system.menu', 'menu', 3, NOW(), NOW());
|
||||
|
||||
-- 客户端管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_system_client', 'menu_system', 1, 'client', '客户端管理', '/system/clients', 'client:list', 'menu.system.client', 'monitor', 4, NOW(), NOW());
|
||||
|
||||
-- 文件管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_system_file', 'menu_system', 1, 'file', '文件管理', '/system/files', 'file:list', 'menu.system.file', 'folder', 5, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 社区管理 - 子菜单
|
||||
-- ============================================
|
||||
|
||||
-- 话题管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_community_topic', 'menu_community', 1, 'topic', '话题管理', '/topics', 'topic:list', 'menu.community.topic', 'hash', 1, NOW(), NOW());
|
||||
|
||||
-- 帖子管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_community_post', 'menu_community', 1, 'post', '帖子管理', '/posts', 'post:list', 'menu.community.post', 'file-text', 2, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 百科管理 - 子菜单
|
||||
-- ============================================
|
||||
|
||||
-- 分类管理
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_wiki_class', 'menu_wiki', 1, 'wikiClass', '分类管理', '/categories', 'wikiClass:list', 'menu.wiki.class', 'folder-tree', 1, NOW(), NOW());
|
||||
|
||||
-- 植物百科
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('menu_wiki_plant', 'menu_wiki', 1, 'plant', '植物百科', '/plants', 'wiki:list', 'menu.wiki.plant', 'leaf', 2, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 按钮权限(category = 2 表示按钮)
|
||||
-- ============================================
|
||||
|
||||
-- 用户管理按钮
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('btn_user_add', 'menu_system_user', 2, 'userAdd', '新增用户', '', 'user:add', '', '', 1, NOW(), NOW()),
|
||||
('btn_user_edit', 'menu_system_user', 2, 'userEdit', '编辑用户', '', 'user:edit', '', '', 2, NOW(), NOW()),
|
||||
('btn_user_delete', 'menu_system_user', 2, 'userDelete', '删除用户', '', 'user:delete', '', '', 3, NOW(), NOW()),
|
||||
('btn_user_grant', 'menu_system_user', 2, 'userGrant', '分配角色', '', 'user:grant', '', '', 4, NOW(), NOW());
|
||||
|
||||
-- 角色管理按钮
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('btn_role_add', 'menu_system_role', 2, 'roleAdd', '新增角色', '', 'role:add', '', '', 1, NOW(), NOW()),
|
||||
('btn_role_edit', 'menu_system_role', 2, 'roleEdit', '编辑角色', '', 'role:edit', '', '', 2, NOW(), NOW()),
|
||||
('btn_role_delete', 'menu_system_role', 2, 'roleDelete', '删除角色', '', 'role:delete', '', '', 3, NOW(), NOW()),
|
||||
('btn_role_grant', 'menu_system_role', 2, 'roleGrant', '授权菜单', '', 'role:grant', '', '', 4, NOW(), NOW());
|
||||
|
||||
-- 菜单管理按钮
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('btn_menu_add', 'menu_system_menu', 2, 'menuAdd', '新增菜单', '', 'menu:add', '', '', 1, NOW(), NOW()),
|
||||
('btn_menu_edit', 'menu_system_menu', 2, 'menuEdit', '编辑菜单', '', 'menu:edit', '', '', 2, NOW(), NOW()),
|
||||
('btn_menu_delete', 'menu_system_menu', 2, 'menuDelete', '删除菜单', '', 'menu:delete', '', '', 3, NOW(), NOW());
|
||||
|
||||
-- 话题管理按钮
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('btn_topic_add', 'menu_community_topic', 2, 'topicAdd', '新增话题', '', 'topic:add', '', '', 1, NOW(), NOW()),
|
||||
('btn_topic_edit', 'menu_community_topic', 2, 'topicEdit', '编辑话题', '', 'topic:edit', '', '', 2, NOW(), NOW()),
|
||||
('btn_topic_delete', 'menu_community_topic', 2, 'topicDelete', '删除话题', '', 'topic:delete', '', '', 3, NOW(), NOW());
|
||||
|
||||
-- 帖子管理按钮
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('btn_post_review', 'menu_community_post', 2, 'postReview', '审核帖子', '', 'post:review', '', '', 1, NOW(), NOW()),
|
||||
('btn_post_delete', 'menu_community_post', 2, 'postDelete', '删除帖子', '', 'post:delete', '', '', 2, NOW(), NOW());
|
||||
|
||||
-- 分类管理按钮
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('btn_class_add', 'menu_wiki_class', 2, 'classAdd', '新增分类', '', 'wikiClass:add', '', '', 1, NOW(), NOW()),
|
||||
('btn_class_edit', 'menu_wiki_class', 2, 'classEdit', '编辑分类', '', 'wikiClass:edit', '', '', 2, NOW(), NOW()),
|
||||
('btn_class_delete', 'menu_wiki_class', 2, 'classDelete', '删除分类', '', 'wikiClass:delete', '', '', 3, NOW(), NOW());
|
||||
|
||||
-- 植物百科按钮
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('btn_wiki_add', 'menu_wiki_plant', 2, 'wikiAdd', '新增百科', '', 'wiki:add', '', '', 1, NOW(), NOW()),
|
||||
('btn_wiki_edit', 'menu_wiki_plant', 2, 'wikiEdit', '编辑百科', '', 'wiki:edit', '', '', 2, NOW(), NOW()),
|
||||
('btn_wiki_delete', 'menu_wiki_plant', 2, 'wikiDelete', '删除百科', '', 'wiki:delete', '', '', 3, NOW(), NOW());
|
||||
|
||||
-- 文件管理按钮
|
||||
INSERT INTO menus (id, parent_id, category, name, title, code, permission, locale, icon, sort, created_at, updated_at) VALUES
|
||||
('btn_file_upload', 'menu_system_file', 2, 'fileUpload', '上传文件', '', 'file:upload', '', '', 1, NOW(), NOW()),
|
||||
('btn_file_delete', 'menu_system_file', 2, 'fileDelete', '删除文件', '', 'file:delete', '', '', 2, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 查询验证
|
||||
-- ============================================
|
||||
-- SELECT * FROM menus WHERE parent_id = '0' ORDER BY sort;
|
||||
-- SELECT * FROM menus WHERE parent_id = 'menu_system' ORDER BY sort;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- 植物养护后台管理系统 - 角色初始化数据
|
||||
-- 执行前请确保 roles 表已存在
|
||||
|
||||
-- ============================================
|
||||
-- 角色数据
|
||||
-- ============================================
|
||||
|
||||
-- 超级管理员
|
||||
INSERT INTO roles (id, name, code, sort, created_at, updated_at) VALUES
|
||||
('role_admin', '超级管理员', 'admin', 1, NOW(), NOW());
|
||||
|
||||
-- 运营管理员
|
||||
INSERT INTO roles (id, name, code, sort, created_at, updated_at) VALUES
|
||||
('role_operator', '运营管理员', 'operator', 2, NOW(), NOW());
|
||||
|
||||
-- 内容编辑
|
||||
INSERT INTO roles (id, name, code, sort, created_at, updated_at) VALUES
|
||||
('role_editor', '内容编辑', 'editor', 3, NOW(), NOW());
|
||||
|
||||
-- 普通用户
|
||||
INSERT INTO roles (id, name, code, sort, created_at, updated_at) VALUES
|
||||
('role_user', '普通用户', 'user', 4, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 角色菜单关联(根据实际表结构调整)
|
||||
-- 假设关联表是 role_menus,字段是 role_id 和 menu_id
|
||||
-- ============================================
|
||||
|
||||
-- 超级管理员 - 拥有所有菜单权限
|
||||
INSERT INTO role_menus (role_id, menu_id)
|
||||
SELECT 'role_admin', id FROM menus;
|
||||
|
||||
-- 运营管理员 - 拥有社区和百科管理权限
|
||||
INSERT INTO role_menus (role_id, menu_id) VALUES
|
||||
('role_operator', 'menu_dashboard'),
|
||||
('role_operator', 'menu_community'),
|
||||
('role_operator', 'menu_community_topic'),
|
||||
('role_operator', 'menu_community_post'),
|
||||
('role_operator', 'btn_topic_add'),
|
||||
('role_operator', 'btn_topic_edit'),
|
||||
('role_operator', 'btn_topic_delete'),
|
||||
('role_operator', 'btn_post_review'),
|
||||
('role_operator', 'btn_post_delete'),
|
||||
('role_operator', 'menu_wiki'),
|
||||
('role_operator', 'menu_wiki_class'),
|
||||
('role_operator', 'menu_wiki_plant'),
|
||||
('role_operator', 'btn_class_add'),
|
||||
('role_operator', 'btn_class_edit'),
|
||||
('role_operator', 'btn_class_delete'),
|
||||
('role_operator', 'btn_wiki_add'),
|
||||
('role_operator', 'btn_wiki_edit'),
|
||||
('role_operator', 'btn_wiki_delete');
|
||||
|
||||
-- 内容编辑 - 只有百科管理权限
|
||||
INSERT INTO role_menus (role_id, menu_id) VALUES
|
||||
('role_editor', 'menu_dashboard'),
|
||||
('role_editor', 'menu_wiki'),
|
||||
('role_editor', 'menu_wiki_class'),
|
||||
('role_editor', 'menu_wiki_plant'),
|
||||
('role_editor', 'btn_class_add'),
|
||||
('role_editor', 'btn_class_edit'),
|
||||
('role_editor', 'btn_wiki_add'),
|
||||
('role_editor', 'btn_wiki_edit');
|
||||
|
||||
-- 普通用户 - 只有仪表盘查看权限
|
||||
INSERT INTO role_menus (role_id, menu_id) VALUES
|
||||
('role_user', 'menu_dashboard');
|
||||
|
||||
-- ============================================
|
||||
-- 查询验证
|
||||
-- ============================================
|
||||
-- SELECT r.name, m.title FROM role_menus rm
|
||||
-- JOIN roles r ON rm.role_id = r.id
|
||||
-- JOIN menus m ON rm.menu_id = m.id
|
||||
-- ORDER BY r.sort, m.sort;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- 植物养护后台管理系统 - 用户初始化数据
|
||||
-- 执行前请确保 users 表已存在
|
||||
-- 注意:密码需要根据实际加密方式进行处理
|
||||
|
||||
-- ============================================
|
||||
-- 用户数据
|
||||
-- 密码:123456 (请根据实际加密算法替换)
|
||||
-- ============================================
|
||||
|
||||
-- 超级管理员
|
||||
INSERT INTO users (id, account, name, nick_name, phone, client_id, created_at, updated_at) VALUES
|
||||
('user_admin', 'admin', '超级管理员', '管理员', '13800138000', 'pc', NOW(), NOW());
|
||||
|
||||
-- 运营管理员
|
||||
INSERT INTO users (id, account, name, nick_name, phone, client_id, created_at, updated_at) VALUES
|
||||
('user_operator', 'operator', '运营管理员', '小运营', '13800138001', 'pc', NOW(), NOW());
|
||||
|
||||
-- 内容编辑
|
||||
INSERT INTO users (id, account, name, nick_name, phone, client_id, created_at, updated_at) VALUES
|
||||
('user_editor', 'editor', '内容编辑', '小编辑', '13800138002', 'pc', NOW(), NOW());
|
||||
|
||||
-- 测试用户
|
||||
INSERT INTO users (id, account, name, nick_name, phone, client_id, created_at, updated_at) VALUES
|
||||
('user_test', 'test', '测试用户', '测试', '13800138003', 'pc', NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 用户角色关联(根据实际表结构调整)
|
||||
-- 假设关联表是 user_roles,字段是 user_id 和 role_id
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO user_roles (user_id, role_id) VALUES
|
||||
('user_admin', 'role_admin'),
|
||||
('user_operator', 'role_operator'),
|
||||
('user_editor', 'role_editor'),
|
||||
('user_test', 'role_user');
|
||||
|
||||
-- ============================================
|
||||
-- 客户端数据(如果需要)
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO clients (id, client_id, name, grant_type, active_timeout, created_at, updated_at) VALUES
|
||||
('client_pc', 'pc', 'PC管理端', 'password', 86400, NOW(), NOW()),
|
||||
('client_mini', 'mini', '小程序端', 'wechat', 604800, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 查询验证
|
||||
-- ============================================
|
||||
-- SELECT u.account, u.name, r.name as role_name FROM user_roles ur
|
||||
-- JOIN users u ON ur.user_id = u.id
|
||||
-- JOIN roles r ON ur.role_id = r.id;
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from '@/contexts/AuthContext'
|
||||
import AdminLayout from '@/layouts/AdminLayout'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||
import { Suspense, useMemo, lazy } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { SystemMenu } from '@/api/system'
|
||||
|
||||
// 自动导入 pages 目录下的所有组件
|
||||
const pages = import.meta.glob('./pages/**/*.tsx')
|
||||
|
||||
const dynamicComponentMap: Record<string, React.LazyExoticComponent<any>> = {}
|
||||
|
||||
for (const path in pages) {
|
||||
// path 格式如: ./pages/Dashboard.tsx
|
||||
// 1. 去掉 ./pages 前缀
|
||||
let routePath = path.replace(/^\.\/pages/, '')
|
||||
// 2. 去掉文件扩展名
|
||||
routePath = routePath.replace(/\.tsx$/, '')
|
||||
// 3. 处理 index 文件
|
||||
routePath = routePath.replace(/\/index$/, '')
|
||||
// 4. 转小写以匹配后端路径
|
||||
routePath = routePath.toLowerCase()
|
||||
|
||||
// 排除 LoginPage (通常它是独立路由,不通过菜单加载)
|
||||
if (routePath === '/loginpage') continue
|
||||
|
||||
dynamicComponentMap[routePath] = lazy(pages[path] as any)
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { menus } = useAuth()
|
||||
|
||||
const dynamicRoutes = useMemo(() => {
|
||||
const routes: { path: string; Component: React.ComponentType }[] = []
|
||||
|
||||
const traverse = (items: SystemMenu[]) => {
|
||||
items.forEach((item) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
traverse(item.children)
|
||||
}
|
||||
|
||||
// 优先使用 path,如果没有则降级到 code
|
||||
// 后端 path 示例: /plant/community/topic
|
||||
const routeKey = item.path || item.code
|
||||
|
||||
if (routeKey && dynamicComponentMap[routeKey]) {
|
||||
// React Router v6 嵌套路由 path prop 可以是相对路径或绝对路径
|
||||
// 如果是绝对路径 (/plant/community/topic),它会匹配 URL
|
||||
// 我们直接使用 routeKey (带 / 前缀) 也没问题,或者去掉前缀
|
||||
// 这里为了保险,如果是绝对路径且我们是在 AdminLayout(/) 下,带不带 / 都行
|
||||
// 但是为了 key 匹配,我们必须用 routeKey 从 map 取组件
|
||||
|
||||
// 如果 routeKey 是 /dashboard,path="/dashboard"
|
||||
// 如果 routeKey 是 /system/users,path="/system/users"
|
||||
// const path = routeKey.startsWith('/') ? routeKey.substring(1) : routeKey
|
||||
|
||||
routes.push({
|
||||
path: routeKey, // 使用绝对路径更安全,避免相对路径层级问题
|
||||
Component: dynamicComponentMap[routeKey]
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (menus) {
|
||||
traverse(menus)
|
||||
}
|
||||
return routes
|
||||
}, [menus])
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* Dynamic Routes */}
|
||||
{dynamicRoutes.map(({ path, Component }) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path.startsWith('/') ? path.substring(1) : path} // Resize path for Route? Layout is at /. Sub-routes should be relative or absolute.
|
||||
// Note: If using absolute path in nested route, it builds upon parent path?
|
||||
// V6: <Route path="dashboard"> relative to parent. <Route path="/dashboard"> absolute path.
|
||||
// Since User can access /dashboard directly, absolute path is fine.
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div className="flex justify-center p-8"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Fallback for Dashboard if not in menu */}
|
||||
{!dynamicRoutes.some(r => r.path === '/dashboard' || r.path === 'dashboard') && dynamicComponentMap['/dashboard'] && (
|
||||
<Route path="dashboard" element={
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div className="flex justify-center p-8"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>}>
|
||||
{(() => {
|
||||
const Dashboard = dynamicComponentMap['/dashboard']
|
||||
return <Dashboard />
|
||||
})()}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
} />
|
||||
)}
|
||||
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,290 @@
|
||||
import { get, post, type PageParams } from '@/lib/request'
|
||||
import type { SystemOss } from './system'
|
||||
|
||||
// ==================== 帖子话题 ====================
|
||||
|
||||
export interface Topic {
|
||||
id: string
|
||||
title: string
|
||||
remark?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface CreateTopicParams {
|
||||
title: string
|
||||
remark?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
export interface UpdateTopicParams {
|
||||
id: number
|
||||
title?: string
|
||||
remark?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
// 话题列表
|
||||
export function getTopicList() {
|
||||
return get<{ data: Topic[] }>('/topic/list')
|
||||
}
|
||||
|
||||
// 话题分页
|
||||
export function getTopicPage(data: PageParams) {
|
||||
return post<{ data: { list: Topic[]; total: number } }>('/topic/page', data)
|
||||
}
|
||||
|
||||
// 话题详情
|
||||
export function getTopicDetail(id: string) {
|
||||
return get<{ data: Topic }>('/topic/detail', { id })
|
||||
}
|
||||
|
||||
// 添加话题
|
||||
export function addTopic(data: CreateTopicParams) {
|
||||
return post<{ msg: string }>('/topic/add', data)
|
||||
}
|
||||
|
||||
// 修改话题
|
||||
export function updateTopic(data: UpdateTopicParams) {
|
||||
return post<{ msg: string }>('/topic/add', data) // API uses /topic/add for update as well
|
||||
}
|
||||
|
||||
// 删除话题
|
||||
export function deleteTopic(ids: string[]) {
|
||||
return post<{ msg: string }>('/topic/delete', { ids })
|
||||
}
|
||||
|
||||
// ==================== 帖子/社区 ====================
|
||||
|
||||
export interface Post {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
location?: string
|
||||
imgList?: SystemOss[]
|
||||
ossIds?: string[]
|
||||
publisher?: {
|
||||
id: string
|
||||
nickName: string
|
||||
avatar?: SystemOss
|
||||
}
|
||||
author?: { // Keep for compatibility if needed, or remove
|
||||
id: string
|
||||
nickName: string
|
||||
avatar?: SystemOss
|
||||
}
|
||||
viewCount?: number
|
||||
likeCount?: number
|
||||
commentCount?: number
|
||||
hasLiked?: number // 0 or 1
|
||||
hasReviewed?: number // 0 or 1
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
createdAtStr?: string
|
||||
commentList?: any[]
|
||||
likeList?: any[]
|
||||
}
|
||||
|
||||
export interface PostPageParams extends PageParams {
|
||||
title?: string
|
||||
hasReviewed?: number // 是否审核通过
|
||||
}
|
||||
|
||||
export interface CreatePostParams {
|
||||
title: string
|
||||
content: string
|
||||
location?: string
|
||||
ossIds?: string[]
|
||||
}
|
||||
|
||||
export interface CreateCommentParams {
|
||||
postId: string
|
||||
content: string
|
||||
}
|
||||
|
||||
// 帖子列表
|
||||
export function getPostPage(data: PostPageParams) {
|
||||
return post<{ data: { list: Post[]; total: number } }>('/post/page', data)
|
||||
}
|
||||
|
||||
// 发布帖子
|
||||
export function publishPost(data: CreatePostParams) {
|
||||
return post<{ msg: string }>('/post/publish', data)
|
||||
}
|
||||
|
||||
// 点赞帖子
|
||||
export function likePost(id: string, type: 1 | 2) {
|
||||
return get<{ msg: string }>('/post/like', { id, type })
|
||||
}
|
||||
|
||||
// 评论帖子
|
||||
export function commentPost(data: CreateCommentParams) {
|
||||
return post<{ msg: string }>('/post/comment', data)
|
||||
}
|
||||
|
||||
// ==================== 百科分类 ====================
|
||||
|
||||
export interface WikiClass {
|
||||
id: string
|
||||
name: string
|
||||
ossId?: string
|
||||
image?: SystemOss
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface CreateWikiClassParams {
|
||||
name: string
|
||||
ossId?: string
|
||||
}
|
||||
|
||||
export interface UpdateWikiClassParams {
|
||||
id: string
|
||||
name?: string
|
||||
ossId?: string
|
||||
}
|
||||
|
||||
// 分类列表
|
||||
export function getWikiClassList() {
|
||||
return get<{ data: WikiClass[] }>('/wiki-class/list')
|
||||
}
|
||||
|
||||
// 分类分页
|
||||
export function getWikiClassPage(data: PageParams) {
|
||||
return post<{ data: { list: WikiClass[]; total: number } }>('/wiki-class/page', data)
|
||||
}
|
||||
|
||||
// 分类详情
|
||||
export function getWikiClassDetail(id: string) {
|
||||
return get<{ data: WikiClass }>('/wiki-class/detail', { id })
|
||||
}
|
||||
|
||||
// 添加分类
|
||||
export function addWikiClass(data: CreateWikiClassParams) {
|
||||
return post<{ msg: string }>('/wiki-class/add', data)
|
||||
}
|
||||
|
||||
// 修改分类
|
||||
export function updateWikiClass(data: UpdateWikiClassParams) {
|
||||
return post<{ msg: string }>('/wiki-class/update', data)
|
||||
}
|
||||
|
||||
// 删除分类
|
||||
export function deleteWikiClass(ids: string[]) {
|
||||
return post<{ msg: string }>('/wiki-class/delete', { ids })
|
||||
}
|
||||
|
||||
// ==================== 百科/植物 ====================
|
||||
|
||||
export interface Wiki {
|
||||
id: string
|
||||
name: string
|
||||
latinName?: string
|
||||
aliases?: string
|
||||
classIds?: string[]
|
||||
classes?: WikiClass[]
|
||||
imgList?: SystemOss[] // 后端返回的图片列表
|
||||
ossIds?: string[]
|
||||
|
||||
// 基本信息
|
||||
genus?: string
|
||||
distributionArea?: string
|
||||
lifeCycle?: string
|
||||
height?: number
|
||||
|
||||
// 叶子信息
|
||||
foliageType?: string
|
||||
foliageColor?: string
|
||||
foliageShape?: string
|
||||
|
||||
// 花信息
|
||||
floweringPeriod?: string
|
||||
floweringColor?: string
|
||||
floweringShape?: string
|
||||
flowerDiameter?: number
|
||||
|
||||
// 果信息
|
||||
fruit?: string
|
||||
stem?: string
|
||||
|
||||
// 养护信息
|
||||
growthHabit?: string
|
||||
lightType?: string
|
||||
lightIntensity?: string
|
||||
optimalTempPeriod?: string
|
||||
reproductionMethod?: string
|
||||
pestsDiseases?: string
|
||||
|
||||
// 其他
|
||||
difficulty?: number // 1-5级
|
||||
isHot?: number // 0否 1是
|
||||
relatedWikiIds?: string[]
|
||||
relatedWikis?: Wiki[]
|
||||
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface WikiPageParams extends PageParams {
|
||||
name?: string
|
||||
classId?: string[]
|
||||
isHot?: number
|
||||
}
|
||||
|
||||
export interface CreateWikiParams {
|
||||
name: string
|
||||
latinName?: string
|
||||
aliases?: string
|
||||
classIds?: string[]
|
||||
ossIds?: string[]
|
||||
genus?: string
|
||||
distributionArea?: string
|
||||
lifeCycle?: string
|
||||
height?: number
|
||||
foliageType?: string
|
||||
foliageColor?: string
|
||||
foliageShape?: string
|
||||
floweringPeriod?: string
|
||||
floweringColor?: string
|
||||
floweringShape?: string
|
||||
flowerDiameter?: number
|
||||
fruit?: string
|
||||
stem?: string
|
||||
growthHabit?: string
|
||||
lightType?: string
|
||||
lightIntensity?: string
|
||||
optimalTempPeriod?: string
|
||||
reproductionMethod?: string
|
||||
pestsDiseases?: string
|
||||
difficulty?: number
|
||||
isHot?: number
|
||||
relatedWikiIds?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateWikiParams extends CreateWikiParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
// 百科分页
|
||||
export function getWikiPage(data: WikiPageParams) {
|
||||
return post<{ data: { list: Wiki[]; total: number } }>('/wiki/page', data)
|
||||
}
|
||||
|
||||
// 百科详情
|
||||
export function getWikiDetail(id: string) {
|
||||
return get<{ data: Wiki }>('/wiki/detail', { id })
|
||||
}
|
||||
|
||||
// 添加百科
|
||||
export function addWiki(data: CreateWikiParams) {
|
||||
return post<{ msg: string }>('/wiki/add', data)
|
||||
}
|
||||
|
||||
// 修改百科
|
||||
export function updateWiki(data: UpdateWikiParams) {
|
||||
return post<{ msg: string }>('/wiki/update', data)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { get, post } from '@/lib/request'
|
||||
|
||||
// 等级配置接口
|
||||
export interface LevelConf {
|
||||
id: string
|
||||
level: number // 等级数值
|
||||
title: string // 等级称号
|
||||
minSunlight: number // 所需阳光值
|
||||
perks: string // 权益描述
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface CreateLevelParams {
|
||||
level: number
|
||||
title: string
|
||||
minSunlight: number
|
||||
perks: string
|
||||
}
|
||||
|
||||
export interface UpdateLevelParams extends Partial<CreateLevelParams> {
|
||||
id: string
|
||||
}
|
||||
|
||||
// 获取等级配置列表 (虽然是 list 接口,但根据以往经验,可能返回带分页的结构或者直接是数组,先假设是 PageResult 或者是 List 结构,Swagger 上没细说。通常 /list 可能是分页的。如果 swagger 没写分页参数,那可能是全量列表)
|
||||
// 根据 swagger "/config/level/list" 没有 parameters,大概率是全量列表。
|
||||
// 假设返回结构 { data: LevelConf[], msg: string, code: number }
|
||||
|
||||
// 创建等级配置
|
||||
export function addLevelConf(data: CreateLevelParams) {
|
||||
return post<string>('/config/level/add', data)
|
||||
}
|
||||
|
||||
// 更新等级配置
|
||||
export function updateLevelConf(data: UpdateLevelParams) {
|
||||
return post<string>('/config/level/update', data)
|
||||
}
|
||||
|
||||
// 获取等级配置详情
|
||||
export function getLevelConfDetail(id: string) {
|
||||
return get<{ data: LevelConf }>('/config/level/detail', { id })
|
||||
}
|
||||
|
||||
// 获取等级配置列表
|
||||
export function getLevelConfList() {
|
||||
return get<{ data: LevelConf[] }>('/config/level/list')
|
||||
}
|
||||
|
||||
// 删除等级配置 (Swagger 里没看到 delete 接口,先不加)
|
||||
@@ -0,0 +1,5 @@
|
||||
// 系统相关 API
|
||||
export * from './system'
|
||||
|
||||
// 业务相关 API
|
||||
export * from './business'
|
||||
@@ -0,0 +1,290 @@
|
||||
import { get, post, type PageResult, type PageParams } from '@/lib/request'
|
||||
|
||||
// ==================== 认证相关 ====================
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
export function getCaptcha() {
|
||||
return get<{ data: CaptchaRes }>('/auth/captcha')
|
||||
}
|
||||
|
||||
// PC登录
|
||||
export function login(data: LoginParams) {
|
||||
return post<{ data: LoginResponse; msg: string }>('/auth/login', data)
|
||||
}
|
||||
|
||||
// PC登出
|
||||
export function logout() {
|
||||
return get<{ msg: string }>('/auth/logout')
|
||||
}
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
export interface SystemUser {
|
||||
id: string
|
||||
account: string
|
||||
name: string
|
||||
nickName?: string
|
||||
phone?: string
|
||||
avatar?: SystemOss
|
||||
avatarId?: string
|
||||
clientId?: string
|
||||
tenantId?: string
|
||||
createdAt: string
|
||||
createdAtStr?: string
|
||||
updatedAt: string
|
||||
roles?: SystemRole[]
|
||||
}
|
||||
|
||||
export interface GetUserListParams extends PageParams {
|
||||
account?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
export function getUserList(data: GetUserListParams) {
|
||||
return post<{ data: PageResult<SystemUser> }>('/user/getUserList', data)
|
||||
}
|
||||
|
||||
// 获取用户详情
|
||||
export function getUserDetail(id: string) {
|
||||
return get<{ data: SystemUser }>('/user/detail', { id })
|
||||
}
|
||||
|
||||
// 新增用户
|
||||
export function saveUser(data: Partial<SystemUser>) {
|
||||
return post<{ msg: string }>('/user/save', data)
|
||||
}
|
||||
|
||||
// 更新用户
|
||||
export function updateUser(data: Partial<SystemUser>) {
|
||||
return post<{ msg: string }>('/user/update', data)
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
export function deleteUser(ids: string[]) {
|
||||
return post<{ msg: string }>('/user/delete', { ids })
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
export function changePassword(data: { id: string; newPwd: string }) {
|
||||
return post<{ data: SystemUser }>('/user/changePassword', data)
|
||||
}
|
||||
|
||||
// 给用户分配角色
|
||||
export function grantRole(data: { userId: string; roleIds: string[] }) {
|
||||
return post<{ msg: string }>('/user/grantRole', data)
|
||||
}
|
||||
|
||||
// ==================== 角色管理 ====================
|
||||
|
||||
export interface SystemRole {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
sort?: number
|
||||
menus?: SystemMenu[]
|
||||
createdAt: string
|
||||
createdAtStr?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GetRoleListParams extends PageParams {
|
||||
name?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
// 获取角色列表
|
||||
export function getRoleList(data: GetRoleListParams) {
|
||||
return post<{ data: PageResult<SystemRole> }>('/role/getRoleList', data)
|
||||
}
|
||||
|
||||
// 获取角色详情
|
||||
export function getRoleDetail(id: string) {
|
||||
return get<{ data: SystemRole }>('/role/detail', { id })
|
||||
}
|
||||
|
||||
// 创建角色
|
||||
export function saveRole(data: Partial<SystemRole>) {
|
||||
return post<{ msg: string }>('/role/save', data)
|
||||
}
|
||||
|
||||
// 修改角色
|
||||
export function updateRole(data: Partial<SystemRole>) {
|
||||
return post<{ msg: string }>('/role/update', data)
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
export function deleteRole(ids: string[]) {
|
||||
return post<{ msg: string }>('/role/delete', { ids })
|
||||
}
|
||||
|
||||
// 授权菜单给角色
|
||||
export function grantMenu(data: { roleId: string; menuIds: string[] }) {
|
||||
return post<{ msg: string }>('/role/grantMenu', data)
|
||||
}
|
||||
|
||||
// ==================== 菜单管理 ====================
|
||||
|
||||
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
|
||||
createdAtStr?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GetMenuTreeParams {
|
||||
category?: number
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
// 获取所有菜单树
|
||||
export function getAllMenuTree(data?: GetMenuTreeParams) {
|
||||
return post<{ data: SystemMenu[] }>('/menu/getAllMenuTree', data || {})
|
||||
}
|
||||
|
||||
// 获取用户菜单数据
|
||||
export function getUserMenuTree() {
|
||||
return get<{ data: SystemMenu[] }>('/menu/getUserMenuTree')
|
||||
}
|
||||
|
||||
// 用户路由
|
||||
export function getMenuRoute() {
|
||||
return get<{ data: SystemMenu[] }>('/menu/route')
|
||||
}
|
||||
|
||||
// 获取菜单详情
|
||||
export function getMenuDetail(id: string) {
|
||||
return get<{ data: SystemMenu }>('/menu/detail', { id })
|
||||
}
|
||||
|
||||
// 新增菜单
|
||||
export function saveMenu(data: Partial<SystemMenu>) {
|
||||
return post<{ msg: string }>('/menu/save', data)
|
||||
}
|
||||
|
||||
// 更新菜单
|
||||
export function updateMenu(data: Partial<SystemMenu>) {
|
||||
return post<{ msg: string }>('/menu/update', data)
|
||||
}
|
||||
|
||||
// 删除菜单
|
||||
export function deleteMenu(id: string) {
|
||||
return get<{ msg: string }>('/menu/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== 文件管理 ====================
|
||||
|
||||
export interface SystemOss {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
url: string
|
||||
suffix?: string
|
||||
tag?: string
|
||||
md5?: string
|
||||
width?: number
|
||||
height?: number
|
||||
createdAt: string
|
||||
createdAtStr?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GetOssFileListParams extends PageParams {
|
||||
name?: string
|
||||
}
|
||||
|
||||
// 文件列表
|
||||
export function getFileList(data: GetOssFileListParams) {
|
||||
return post<{ data: PageResult<SystemOss> }>('/oss/getFileList', data)
|
||||
}
|
||||
|
||||
// 文件详情
|
||||
export function getFileDetail(id: string) {
|
||||
return get<{ data: string }>('/oss/detail', { id })
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
export function uploadFile(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return post<{ data: { file: SystemOss }; msg: string }>('/oss/upload', formData)
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
export function deleteFile(ids: string[]) {
|
||||
return post<{ msg: string }>('/oss/delete', { ids })
|
||||
}
|
||||
|
||||
// ==================== 客户端管理 ====================
|
||||
|
||||
export interface SystemClient {
|
||||
id: string
|
||||
clientId: string
|
||||
name: string
|
||||
grantType?: string
|
||||
activeTimeout?: number
|
||||
additionalInfo?: string
|
||||
createdAt: string
|
||||
createdAtStr?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GetClientListParams extends PageParams {
|
||||
clientId?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
// 获取客户端列表
|
||||
export function getClientList(data: GetClientListParams) {
|
||||
return post<{ data: PageResult<SystemClient> }>('/client/getClientList', data)
|
||||
}
|
||||
|
||||
// 获取客户端详情
|
||||
export function getClientDetail(id: string) {
|
||||
return get<{ data: SystemClient }>('/client/detail', { id })
|
||||
}
|
||||
|
||||
// 创建客户端
|
||||
export function saveClient(data: Partial<SystemClient>) {
|
||||
return post<{ msg: string }>('/client/save', data)
|
||||
}
|
||||
|
||||
// 更新客户端
|
||||
export function updateClient(data: Partial<SystemClient>) {
|
||||
return post<{ msg: string }>('/client/update', data)
|
||||
}
|
||||
|
||||
// 删除客户端
|
||||
export function deleteClient(ids: string[]) {
|
||||
return post<{ msg: string }>('/client/delete', { ids })
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,53 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-destructive mb-2">页面加载出错</h2>
|
||||
<p className="text-muted-foreground mb-4">{this.state.error?.message || '发生了未知错误'}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: undefined })
|
||||
window.location.reload()
|
||||
}}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
||||
@@ -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,57 @@
|
||||
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-xl text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm shadow-primary/20 hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25 active:scale-[0.98]",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm shadow-destructive/20 hover:bg-destructive/90 hover:shadow-md active:scale-[0.98]",
|
||||
outline:
|
||||
"border border-border/60 bg-background hover:bg-accent hover:text-accent-foreground hover:border-border active:scale-[0.98]",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]",
|
||||
ghost: "hover:bg-accent/60 hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-5 py-2",
|
||||
sm: "h-8 rounded-lg px-3.5 text-xs",
|
||||
lg: "h-11 rounded-xl px-8",
|
||||
icon: "h-9 w-9 rounded-lg",
|
||||
},
|
||||
},
|
||||
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,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-xl border border-border/60 bg-background px-4 py-2 text-sm transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50 hover:border-border",
|
||||
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 }
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
||||
import type { SystemUser, SystemMenu } from '@/api/system'
|
||||
import { logout as apiLogout, getUserMenuTree } from '@/api/system'
|
||||
|
||||
interface AuthState {
|
||||
user: SystemUser | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
menus: SystemMenu[]
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (user: SystemUser, token: string) => void
|
||||
logout: () => void
|
||||
hasPermission: (permission: string) => boolean
|
||||
refreshMenus: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
const USER_KEY = 'user'
|
||||
|
||||
function getStoredAuth(): AuthState {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const userStr = localStorage.getItem(USER_KEY)
|
||||
if (token && userStr) {
|
||||
const user = JSON.parse(userStr) as SystemUser
|
||||
return { user, token, isAuthenticated: true, menus: [], permissions: [] }
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { user: null, token: null, isAuthenticated: false, menus: [], permissions: [] }
|
||||
}
|
||||
|
||||
// 从菜单中提取权限
|
||||
function extractPermissions(menus: SystemMenu[]): string[] {
|
||||
const permissions: string[] = []
|
||||
|
||||
function traverse(menu: SystemMenu) {
|
||||
if (menu.permission) {
|
||||
permissions.push(menu.permission)
|
||||
}
|
||||
if (menu.children) {
|
||||
menu.children.forEach(traverse)
|
||||
}
|
||||
}
|
||||
|
||||
menus.forEach(traverse)
|
||||
return permissions
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>(getStoredAuth)
|
||||
|
||||
// 获取用户菜单
|
||||
const refreshMenus = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return
|
||||
|
||||
try {
|
||||
const res = await getUserMenuTree()
|
||||
const menus = res.data || []
|
||||
const permissions = extractPermissions(menus)
|
||||
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
menus,
|
||||
permissions,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('获取菜单失败:', error)
|
||||
}
|
||||
}, [authState.isAuthenticated])
|
||||
|
||||
// 初次登录后获取菜单
|
||||
useEffect(() => {
|
||||
if (authState.isAuthenticated && authState.menus.length === 0) {
|
||||
refreshMenus()
|
||||
}
|
||||
}, [authState.isAuthenticated, authState.menus.length, refreshMenus])
|
||||
|
||||
const login = useCallback((user: SystemUser, token: string) => {
|
||||
// 存储到 localStorage
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
|
||||
setAuthState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
menus: [],
|
||||
permissions: [],
|
||||
})
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await apiLogout()
|
||||
} catch {
|
||||
// 忽略登出 API 错误
|
||||
}
|
||||
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
|
||||
setAuthState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
menus: [],
|
||||
permissions: [],
|
||||
})
|
||||
}, [])
|
||||
|
||||
const hasPermission = useCallback((permission: string): boolean => {
|
||||
// 管理员拥有所有权限
|
||||
if (authState.user?.account === 'admin') {
|
||||
return true
|
||||
}
|
||||
return authState.permissions.includes(permission)
|
||||
}, [authState.user, authState.permissions])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...authState, login, logout, hasPermission, refreshMenus }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { User, Role, Permission, Topic, Category, Plant } from '@/types'
|
||||
|
||||
// Mock Users
|
||||
export const mockUsers: User[] = [
|
||||
{
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'admin@plantcare.com',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin',
|
||||
roleId: '1',
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
username: 'editor',
|
||||
email: 'editor@plantcare.com',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=editor',
|
||||
roleId: '2',
|
||||
status: 'active',
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
username: 'viewer',
|
||||
email: 'viewer@plantcare.com',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=viewer',
|
||||
roleId: '3',
|
||||
status: 'inactive',
|
||||
createdAt: '2024-01-03T00:00:00Z',
|
||||
updatedAt: '2024-01-03T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
// Mock Roles
|
||||
export const mockRoles: Role[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '超级管理员',
|
||||
description: '系统最高权限,可管理所有功能',
|
||||
permissions: ['user:read', 'user:write', 'user:delete', 'role:read', 'role:write', 'role:delete', 'topic:read', 'topic:write', 'topic:delete', 'category:read', 'category:write', 'category:delete', 'plant:read', 'plant:write', 'plant:delete'],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '内容编辑',
|
||||
description: '可管理话题、分类和植物百科内容',
|
||||
permissions: ['topic:read', 'topic:write', 'category:read', 'category:write', 'plant:read', 'plant:write'],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '访客',
|
||||
description: '只读权限',
|
||||
permissions: ['topic:read', 'category:read', 'plant:read'],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
// Mock Permissions
|
||||
export const mockPermissions: Permission[] = [
|
||||
// User Management
|
||||
{ id: '1', name: '查看用户', code: 'user:read', description: '查看用户列表和详情', module: '用户管理' },
|
||||
{ id: '2', name: '编辑用户', code: 'user:write', description: '创建和编辑用户', module: '用户管理' },
|
||||
{ id: '3', name: '删除用户', code: 'user:delete', description: '删除用户', module: '用户管理' },
|
||||
// Role Management
|
||||
{ id: '4', name: '查看角色', code: 'role:read', description: '查看角色列表和详情', module: '角色管理' },
|
||||
{ id: '5', name: '编辑角色', code: 'role:write', description: '创建和编辑角色', module: '角色管理' },
|
||||
{ id: '6', name: '删除角色', code: 'role:delete', description: '删除角色', module: '角色管理' },
|
||||
// Topic Management
|
||||
{ id: '7', name: '查看话题', code: 'topic:read', description: '查看话题列表和详情', module: '话题管理' },
|
||||
{ id: '8', name: '编辑话题', code: 'topic:write', description: '创建和编辑话题', module: '话题管理' },
|
||||
{ id: '9', name: '删除话题', code: 'topic:delete', description: '删除话题', module: '话题管理' },
|
||||
// Category Management
|
||||
{ id: '10', name: '查看分类', code: 'category:read', description: '查看分类列表', module: '分类管理' },
|
||||
{ id: '11', name: '编辑分类', code: 'category:write', description: '创建和编辑分类', module: '分类管理' },
|
||||
{ id: '12', name: '删除分类', code: 'category:delete', description: '删除分类', module: '分类管理' },
|
||||
// Plant Management
|
||||
{ id: '13', name: '查看植物', code: 'plant:read', description: '查看植物百科', module: '植物管理' },
|
||||
{ id: '14', name: '编辑植物', code: 'plant:write', description: '创建和编辑植物', module: '植物管理' },
|
||||
{ id: '15', name: '删除植物', code: 'plant:delete', description: '删除植物', module: '植物管理' },
|
||||
]
|
||||
|
||||
// Mock Topics
|
||||
export const mockTopics: Topic[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '春季植物养护小技巧',
|
||||
content: '春天是植物生长的黄金季节,本文分享一些实用的养护技巧...',
|
||||
authorId: '1',
|
||||
authorName: 'admin',
|
||||
status: 'published',
|
||||
viewCount: 1250,
|
||||
likeCount: 89,
|
||||
commentCount: 23,
|
||||
tags: ['春季养护', '技巧'],
|
||||
coverImage: 'https://images.unsplash.com/photo-1416879595882-3373a0480b5b?w=400',
|
||||
createdAt: '2024-02-01T10:00:00Z',
|
||||
updatedAt: '2024-02-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '如何判断多肉植物是否需要浇水',
|
||||
content: '多肉植物浇水是新手最容易出错的环节,这里教大家几个判断方法...',
|
||||
authorId: '2',
|
||||
authorName: 'editor',
|
||||
status: 'published',
|
||||
viewCount: 890,
|
||||
likeCount: 56,
|
||||
commentCount: 15,
|
||||
tags: ['多肉', '浇水'],
|
||||
coverImage: 'https://images.unsplash.com/photo-1459411552884-841db9b3cc2a?w=400',
|
||||
createdAt: '2024-02-02T11:00:00Z',
|
||||
updatedAt: '2024-02-02T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '室内绿植搭配指南',
|
||||
content: '如何选择适合室内的绿植,以及如何进行美观的搭配...',
|
||||
authorId: '1',
|
||||
authorName: 'admin',
|
||||
status: 'draft',
|
||||
viewCount: 0,
|
||||
likeCount: 0,
|
||||
commentCount: 0,
|
||||
tags: ['室内', '搭配'],
|
||||
createdAt: '2024-02-03T09:00:00Z',
|
||||
updatedAt: '2024-02-03T09:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
// Mock Categories
|
||||
export const mockCategories: Category[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '观叶植物',
|
||||
description: '以观赏叶片为主的植物',
|
||||
parentId: null,
|
||||
order: 1,
|
||||
icon: '🌿',
|
||||
children: [
|
||||
{
|
||||
id: '1-1',
|
||||
name: '蕨类植物',
|
||||
description: '蕨类观叶植物',
|
||||
parentId: '1',
|
||||
order: 1,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '1-2',
|
||||
name: '龟背竹类',
|
||||
description: '龟背竹及相关品种',
|
||||
parentId: '1',
|
||||
order: 2,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '多肉植物',
|
||||
description: '肉质茎叶储水的植物',
|
||||
parentId: null,
|
||||
order: 2,
|
||||
icon: '🪴',
|
||||
children: [
|
||||
{
|
||||
id: '2-1',
|
||||
name: '景天科',
|
||||
description: '景天科多肉植物',
|
||||
parentId: '2',
|
||||
order: 1,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2-2',
|
||||
name: '仙人掌科',
|
||||
description: '仙人掌类植物',
|
||||
parentId: '2',
|
||||
order: 2,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '开花植物',
|
||||
description: '以观赏花朵为主的植物',
|
||||
parentId: null,
|
||||
order: 3,
|
||||
icon: '🌸',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
// Mock Plants
|
||||
export const mockPlants: Plant[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '龟背竹',
|
||||
scientificName: 'Monstera deliciosa',
|
||||
categoryId: '1-2',
|
||||
categoryName: '龟背竹类',
|
||||
description: '龟背竹是一种热带观叶植物,叶片大而有特殊的裂纹,非常适合室内装饰。',
|
||||
careGuide: '1. 光照:喜散射光,避免阳光直射\n2. 浇水:保持土壤微湿,避免积水\n3. 温度:适宜温度18-28°C\n4. 施肥:生长季每月施肥一次',
|
||||
wateringFrequency: '每周1-2次',
|
||||
sunlightRequirement: 'medium',
|
||||
difficultyLevel: 'easy',
|
||||
images: ['https://images.unsplash.com/photo-1614594975525-e45190c55d0b?w=400'],
|
||||
status: 'published',
|
||||
createdAt: '2024-01-15T00:00:00Z',
|
||||
updatedAt: '2024-01-15T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '波士顿蕨',
|
||||
scientificName: 'Nephrolepis exaltata',
|
||||
categoryId: '1-1',
|
||||
categoryName: '蕨类植物',
|
||||
description: '波士顿蕨是最受欢迎的室内蕨类植物之一,叶片优雅下垂,具有很好的空气净化能力。',
|
||||
careGuide: '1. 光照:喜阴,避免阳光直射\n2. 浇水:保持土壤湿润\n3. 湿度:需要较高湿度\n4. 温度:适宜温度16-24°C',
|
||||
wateringFrequency: '每2-3天一次',
|
||||
sunlightRequirement: 'low',
|
||||
difficultyLevel: 'medium',
|
||||
images: ['https://images.unsplash.com/photo-1597055181300-e3633a917e8e?w=400'],
|
||||
status: 'published',
|
||||
createdAt: '2024-01-16T00:00:00Z',
|
||||
updatedAt: '2024-01-16T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '石莲花',
|
||||
scientificName: 'Echeveria elegans',
|
||||
categoryId: '2-1',
|
||||
categoryName: '景天科',
|
||||
description: '石莲花是一种小巧可爱的多肉植物,叶片排列成莲座状,非常适合新手养护。',
|
||||
careGuide: '1. 光照:喜充足阳光\n2. 浇水:干透再浇,避免积水\n3. 温度:适宜温度10-30°C\n4. 土壤:使用排水良好的多肉专用土',
|
||||
wateringFrequency: '每1-2周一次',
|
||||
sunlightRequirement: 'high',
|
||||
difficultyLevel: 'easy',
|
||||
images: ['https://images.unsplash.com/photo-1509423350716-97f9360b4e09?w=400'],
|
||||
status: 'published',
|
||||
createdAt: '2024-01-17T00:00:00Z',
|
||||
updatedAt: '2024-01-17T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
// Helper function to get flat categories list
|
||||
export function getFlatCategories(): { id: string; name: string; parentName?: string }[] {
|
||||
const result: { id: string; name: string; parentName?: string }[] = []
|
||||
|
||||
mockCategories.forEach(cat => {
|
||||
result.push({ id: cat.id, name: cat.name })
|
||||
if (cat.children) {
|
||||
cat.children.forEach(child => {
|
||||
result.push({ id: child.id, name: child.name, parentName: cat.name })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Modern green-based color palette */
|
||||
--color-background: oklch(0.985 0.002 120);
|
||||
--color-foreground: oklch(0.18 0.01 120);
|
||||
--color-card: oklch(1 0 0);
|
||||
--color-card-foreground: oklch(0.18 0.01 120);
|
||||
--color-popover: oklch(1 0 0);
|
||||
--color-popover-foreground: oklch(0.18 0.01 120);
|
||||
--color-primary: oklch(0.52 0.14 150);
|
||||
--color-primary-foreground: oklch(0.99 0 0);
|
||||
--color-secondary: oklch(0.965 0.015 150);
|
||||
--color-secondary-foreground: oklch(0.30 0.06 150);
|
||||
--color-muted: oklch(0.965 0.005 120);
|
||||
--color-muted-foreground: oklch(0.48 0.01 120);
|
||||
--color-accent: oklch(0.96 0.02 150);
|
||||
--color-accent-foreground: oklch(0.30 0.06 150);
|
||||
--color-destructive: oklch(0.58 0.18 25);
|
||||
--color-destructive-foreground: oklch(0.99 0 0);
|
||||
--color-border: oklch(0.92 0.005 120);
|
||||
--color-input: oklch(0.92 0.005 120);
|
||||
--color-ring: oklch(0.52 0.14 150);
|
||||
|
||||
/* Sidebar colors */
|
||||
--color-sidebar-background: oklch(0.99 0.002 120);
|
||||
--color-sidebar-foreground: oklch(0.40 0.01 120);
|
||||
--color-sidebar-primary: oklch(0.52 0.14 150);
|
||||
--color-sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--color-sidebar-accent: oklch(0.965 0.02 150);
|
||||
--color-sidebar-accent-foreground: oklch(0.25 0.06 150);
|
||||
--color-sidebar-border: oklch(0.94 0.005 120);
|
||||
--color-sidebar-ring: oklch(0.52 0.14 150);
|
||||
|
||||
/* Refined radius for smoother look */
|
||||
--radius-lg: 0.875rem;
|
||||
--radius-md: 0.625rem;
|
||||
--radius-sm: 0.375rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: oklch(0.11 0.01 120);
|
||||
--color-foreground: oklch(0.96 0.005 120);
|
||||
--color-card: oklch(0.14 0.01 120);
|
||||
--color-card-foreground: oklch(0.96 0.005 120);
|
||||
--color-popover: oklch(0.14 0.01 120);
|
||||
--color-popover-foreground: oklch(0.96 0.005 120);
|
||||
--color-primary: oklch(0.62 0.14 150);
|
||||
--color-primary-foreground: oklch(0.10 0 0);
|
||||
--color-secondary: oklch(0.20 0.02 150);
|
||||
--color-secondary-foreground: oklch(0.88 0.02 150);
|
||||
--color-muted: oklch(0.20 0.01 120);
|
||||
--color-muted-foreground: oklch(0.62 0.01 120);
|
||||
--color-accent: oklch(0.20 0.02 150);
|
||||
--color-accent-foreground: oklch(0.88 0.02 150);
|
||||
--color-destructive: oklch(0.58 0.18 25);
|
||||
--color-destructive-foreground: oklch(0.99 0 0);
|
||||
--color-border: oklch(0.26 0.01 120);
|
||||
--color-input: oklch(0.26 0.01 120);
|
||||
--color-ring: oklch(0.62 0.14 150);
|
||||
--color-sidebar-background: oklch(0.10 0.01 120);
|
||||
--color-sidebar-foreground: oklch(0.68 0.01 120);
|
||||
--color-sidebar-primary: oklch(0.62 0.14 150);
|
||||
--color-sidebar-primary-foreground: oklch(0.10 0 0);
|
||||
--color-sidebar-accent: oklch(0.20 0.02 150);
|
||||
--color-sidebar-accent-foreground: oklch(0.88 0.02 150);
|
||||
--color-sidebar-border: oklch(0.26 0.01 120);
|
||||
--color-sidebar-ring: oklch(0.62 0.14 150);
|
||||
}
|
||||
|
||||
* {
|
||||
@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: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Smooth 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);
|
||||
}
|
||||
|
||||
/* Better focus states */
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
/* Subtle 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);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slideIn {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Card enhancements */
|
||||
.card-hover {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
@apply shadow-md;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Table row hover effect */
|
||||
tr {
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
/* Button transitions */
|
||||
button {
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
/* Input focus enhancements */
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
@apply transition-shadow duration-150;
|
||||
}
|
||||
|
||||
/* Modern shadows */
|
||||
.shadow-soft {
|
||||
box-shadow: 0 2px 8px -2px oklch(0 0 0 / 0.08), 0 4px 16px -4px oklch(0 0 0 / 0.06);
|
||||
}
|
||||
|
||||
.shadow-soft-lg {
|
||||
box-shadow: 0 4px 12px -2px oklch(0 0 0 / 0.1), 0 8px 24px -4px oklch(0 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Gradient backgrounds */
|
||||
.gradient-primary {
|
||||
background: linear-gradient(135deg, oklch(0.52 0.14 150), oklch(0.58 0.12 160));
|
||||
}
|
||||
|
||||
.gradient-glass {
|
||||
background: linear-gradient(135deg, oklch(1 0 0 / 0.8), oklch(0.98 0.01 150 / 0.6));
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Badge variants */
|
||||
.badge-primary {
|
||||
@apply bg-primary/10 text-primary;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
@apply bg-secondary text-secondary-foreground;
|
||||
}
|
||||
|
||||
/* Page container animation */
|
||||
.page-container {
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.skeleton {
|
||||
@apply bg-muted animate-pulse rounded;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Shield,
|
||||
MessageSquare,
|
||||
FolderTree,
|
||||
Leaf,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Menu,
|
||||
X,
|
||||
FileText,
|
||||
Settings,
|
||||
Book,
|
||||
Home,
|
||||
Monitor,
|
||||
Hash,
|
||||
Folder,
|
||||
ChevronRight,
|
||||
Search,
|
||||
Bell,
|
||||
ChevronLeft,
|
||||
} from 'lucide-react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
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'
|
||||
|
||||
// 图标映射
|
||||
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-tree': <FolderTree className="h-4 w-4" />,
|
||||
'folder': <Folder className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
function getIcon(iconName?: string): React.ReactNode {
|
||||
if (!iconName) return <FileText className="h-4 w-4" />
|
||||
const lowerIcon = iconName.toLowerCase()
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (lowerIcon.includes(key)) return icon
|
||||
}
|
||||
return <FileText className="h-4 w-4" />
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
permission?: string
|
||||
children?: NavItem[]
|
||||
}
|
||||
|
||||
// 默认导航项(当API菜单为空时使用)
|
||||
const defaultNavItems: NavItem[] = [
|
||||
{
|
||||
title: '仪表盘',
|
||||
href: '/dashboard',
|
||||
icon: <LayoutDashboard className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
href: '/system',
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
children: [
|
||||
{ title: '用户管理', href: '/system/users', icon: <Users className="h-4 w-4" /> },
|
||||
{ title: '角色管理', href: '/system/roles', icon: <Shield className="h-4 w-4" /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 将API菜单转换为导航项
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
function NavItemComponent({ item, collapsed, level = 0 }: { item: NavItem; collapsed: boolean; level?: number }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { hasPermission } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
// Check if active or child is active
|
||||
const isActiveLink = location.pathname === item.href
|
||||
const hasActiveChild = item.children?.some(child => location.pathname.startsWith(child.href))
|
||||
|
||||
useEffect(() => {
|
||||
if (hasActiveChild && !collapsed) {
|
||||
setOpen(true)
|
||||
}
|
||||
}, [hasActiveChild, collapsed])
|
||||
|
||||
if (item.permission && !hasPermission(item.permission)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
const visibleChildren = item.children.filter(
|
||||
child => !child.permission || hasPermission(child.permission)
|
||||
)
|
||||
if (visibleChildren.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sidebar-foreground transition-all duration-200 hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground group',
|
||||
(open || hasActiveChild) && 'text-sidebar-accent-foreground font-medium',
|
||||
level > 0 && 'text-[13px]'
|
||||
)}
|
||||
>
|
||||
<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'
|
||||
)}>
|
||||
{visibleChildren.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-lg px-3 py-2 text-sidebar-foreground transition-all duration-200 hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground mb-0.5 group',
|
||||
isActive && 'bg-primary/10 text-primary font-medium shadow-none',
|
||||
level > 0 && 'text-[13px] py-1.5'
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className={cn(
|
||||
"transition-colors",
|
||||
({ isActive }: { isActive: boolean }) => isActive ? "text-primary" : "text-muted-foreground group-hover:text-primary"
|
||||
)}>
|
||||
{level === 0 && item.icon}
|
||||
</span>
|
||||
|
||||
{level > 0 && (
|
||||
<span className={cn(
|
||||
"w-1.5 h-1.5 rounded-full transition-all bg-current opacity-40 group-hover:opacity-100",
|
||||
location.pathname === item.href && "opacity-100 scale-110 bg-primary"
|
||||
)} />
|
||||
)}
|
||||
|
||||
{!collapsed && <span className="text-sm line-clamp-1">{item.title}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function Breadcrumb() {
|
||||
const location = useLocation()
|
||||
const { menus } = useAuth()
|
||||
|
||||
// Simple breadcrumb logic: find path in menus
|
||||
const breadcrumbs = useMemo(() => {
|
||||
const path = location.pathname
|
||||
const segments = path.split('/').filter(Boolean)
|
||||
const crumbs: { title: string, href: string }[] = []
|
||||
|
||||
let currentPath = ''
|
||||
|
||||
// Find menu item by path
|
||||
const findMenuItem = (items: SystemMenu[], targetPath: string): SystemMenu | undefined => {
|
||||
for (const item of items) {
|
||||
if (item.path === targetPath || item.code === targetPath) return item
|
||||
if (item.children) {
|
||||
const found = findMenuItem(item.children, targetPath)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`
|
||||
const menuItem = menus ? findMenuItem(menus, currentPath) : null
|
||||
|
||||
if (menuItem) {
|
||||
crumbs.push({ title: menuItem.title || menuItem.name, href: menuItem.path || menuItem.code || currentPath })
|
||||
} else {
|
||||
crumbs.push({ title: segment.charAt(0).toUpperCase() + segment.slice(1), href: currentPath })
|
||||
}
|
||||
})
|
||||
|
||||
return crumbs
|
||||
}, [location.pathname, menus])
|
||||
|
||||
if (breadcrumbs.length === 0) 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, index) => (
|
||||
<div key={crumb.href} className="flex items-center">
|
||||
{index > 0 && <ChevronRight className="h-4 w-4 mx-2 text-muted-foreground/40" />}
|
||||
<span className={cn(
|
||||
"transition-colors",
|
||||
index === breadcrumbs.length - 1 ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}>
|
||||
{index === breadcrumbs.length - 1 ? (
|
||||
crumb.title
|
||||
) : (
|
||||
<NavLink to={crumb.href}>{crumb.title}</NavLink>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const { user, logout, menus } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
if (menus && menus.length > 0) {
|
||||
return menus.map(convertMenuToNavItem)
|
||||
}
|
||||
return defaultNavItems
|
||||
}, [menus])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-sidebar-border/50 bg-sidebar-background/80 backdrop-blur-md transition-all duration-300',
|
||||
sidebarOpen ? 'w-64' : 'w-16',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between border-b border-sidebar-border/40 px-4 shrink-0 bg-sidebar-background/50">
|
||||
<div className={cn("flex items-center gap-3 overflow-hidden transition-all", !sidebarOpen && "justify-center w-full")}>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm shadow-primary/20">
|
||||
<Leaf className="h-4 w-4" />
|
||||
</div>
|
||||
{sidebarOpen && <span className="font-semibold text-base tracking-tight text-sidebar-foreground truncate">植趣 Admin</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<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="border-t border-sidebar-border/40 p-2 shrink-0 bg-sidebar-background/50">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg p-2 transition-all duration-200 hover:bg-sidebar-accent/50 outline-none",
|
||||
!sidebarOpen && "justify-center"
|
||||
)}>
|
||||
<Avatar className="h-8 w-8 ring-1 ring-sidebar-border/50 transition-transform group-hover:scale-105">
|
||||
<AvatarImage src={user?.avatar?.url} alt={user?.name || user?.account} />
|
||||
<AvatarFallback className="bg-primary/5 text-primary text-xs font-medium">{(user?.name || user?.account)?.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
{sidebarOpen && (
|
||||
<div className="flex-1 text-left overflow-hidden">
|
||||
<p className="text-sm font-medium text-sidebar-foreground truncate leading-none mb-1">{user?.name || user?.account}</p>
|
||||
<p className="text-xs text-muted-foreground truncate leading-none opacity-80">{user?.role || '管理员'}</p>
|
||||
</div>
|
||||
)}
|
||||
{sidebarOpen && <ChevronDown className="h-3 w-3 text-muted-foreground/70" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56" side="right" sideOffset={12}>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user?.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">{user?.account}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
个人中心
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
设置
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} 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={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content wrapper */}
|
||||
<div className={cn(
|
||||
'flex-1 flex flex-col min-h-screen transition-all duration-300 ease-in-out',
|
||||
sidebarOpen ? 'lg:pl-64' : 'lg:pl-16'
|
||||
)}>
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-border/40 bg-background/80 px-4 backdrop-blur-md lg:px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden lg:flex h-8 w-8"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
{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"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<div className="w-px h-4 bg-border/60 mx-1"></div>
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex relative group">
|
||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground group-focus-within:text-primary transition-colors" />
|
||||
<Input
|
||||
placeholder="搜索..."
|
||||
className="w-48 focus:w-64 pl-8 bg-muted/40 border-transparent focus:bg-background focus:border-primary/20 transition-all h-8 text-sm rounded-full shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="relative rounded-full h-8 w-8 hover:bg-muted">
|
||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="absolute top-2 right-2 w-1.5 h-1.5 bg-red-500 rounded-full ring-2 ring-background"></span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 p-4 lg:p-6 bg-muted/10">
|
||||
<div className="mx-auto max-w-7xl animate-fadeIn space-y-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
// API 基础配置 - 使用 Vite 代理
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
// 不需要认证的接口白名单
|
||||
const AUTH_WHITELIST = [
|
||||
'/auth/captcha',
|
||||
'/auth/login',
|
||||
'/auth/miniLogin',
|
||||
'/auth/getLocation',
|
||||
'/auth/getPhone',
|
||||
'/auth/getWeather',
|
||||
]
|
||||
|
||||
// 创建 axios 实例
|
||||
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}`
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是 FormData,删除默认的 Content-Type,让浏览器自动设置正确的 boundary
|
||||
if (config.data instanceof FormData) {
|
||||
// 使用 delete 方法删除 AxiosHeaders 中的 Content-Type
|
||||
config.headers.delete('Content-Type')
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
response => {
|
||||
const res = response.data
|
||||
|
||||
// 如果返回的 code 不是 200,则认为是错误
|
||||
if (res.code !== undefined && res.code !== 200) {
|
||||
// 可以在这里处理特定的错误码
|
||||
if (res.code === 401) {
|
||||
// Token 过期或无效,清除本地存储并跳转到登录页
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(res.msg || '请求失败'))
|
||||
}
|
||||
|
||||
return res
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
// 处理网络错误
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
// 未授权,清除本地存储并跳转到登录页
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
break
|
||||
case 403:
|
||||
console.error('没有权限访问该资源')
|
||||
break
|
||||
case 404:
|
||||
console.error('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
console.error('服务器内部错误')
|
||||
break
|
||||
default:
|
||||
console.error(`请求失败: ${status}`)
|
||||
}
|
||||
} else if (error.request) {
|
||||
console.error('网络错误,请检查网络连接')
|
||||
} else {
|
||||
console.error('请求配置错误:', error.message)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 封装 GET 请求
|
||||
export function get<T = unknown>(url: string, params?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<T> {
|
||||
return request.get(url, { params, ...config })
|
||||
}
|
||||
|
||||
// 封装 POST 请求
|
||||
export function post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
return request.post(url, data, config)
|
||||
}
|
||||
|
||||
// 导出 axios 实例
|
||||
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,20 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(date))
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 11)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,401 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, ChevronRight, ChevronDown, Pencil, Trash2, FolderTree } 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
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 { mockCategories } from '@/data/mockData'
|
||||
import type { Category } from '@/types'
|
||||
import { generateId, cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
interface CategoryItemProps {
|
||||
category: Category
|
||||
level: number
|
||||
onEdit: (category: Category) => void
|
||||
onDelete: (category: Category) => void
|
||||
onAddChild: (parentId: string) => void
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
function CategoryItem({
|
||||
category,
|
||||
level,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: CategoryItemProps) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const hasChildren = category.children && category.children.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded-lg border p-3 transition-colors hover:bg-muted/50',
|
||||
level > 0 && 'ml-6 border-l-2 border-l-primary/20'
|
||||
)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="rounded p-1 hover:bg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6" />
|
||||
)}
|
||||
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-lg">
|
||||
{category.icon || '📁'}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{category.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{category.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{canWrite && level === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAddChild(category.id)}
|
||||
title="添加子分类"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(category)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(category)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && hasChildren && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{category.children!.map(child => (
|
||||
<CategoryItem
|
||||
key={child.id}
|
||||
category={child}
|
||||
level={level + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const [categories, setCategories] = useState<Category[]>(mockCategories)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null)
|
||||
const [parentIdForNew, setParentIdForNew] = useState<string | null>(null)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
parentId: '',
|
||||
})
|
||||
|
||||
const canWrite = hasPermission('category:write')
|
||||
const canDelete = hasPermission('category:delete')
|
||||
|
||||
const openCreateDialog = (parentId: string | null = null) => {
|
||||
setEditingCategory(null)
|
||||
setParentIdForNew(parentId)
|
||||
setFormData({ name: '', description: '', icon: '', parentId: parentId || '' })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (category: Category) => {
|
||||
setEditingCategory(category)
|
||||
setFormData({
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
icon: category.icon || '',
|
||||
parentId: category.parentId || '',
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (editingCategory) {
|
||||
// Update existing category
|
||||
setCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id === editingCategory.id) {
|
||||
return {
|
||||
...cat,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
icon: formData.icon || undefined,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
if (cat.children) {
|
||||
return {
|
||||
...cat,
|
||||
children: cat.children.map(child =>
|
||||
child.id === editingCategory.id
|
||||
? {
|
||||
...child,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
icon: formData.icon || undefined,
|
||||
updatedAt: now,
|
||||
}
|
||||
: child
|
||||
),
|
||||
}
|
||||
}
|
||||
return cat
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// Create new category
|
||||
const newCategory: Category = {
|
||||
id: generateId(),
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
parentId: parentIdForNew,
|
||||
order: 999,
|
||||
icon: formData.icon || undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
if (parentIdForNew) {
|
||||
// Add as child
|
||||
setCategories(prev =>
|
||||
prev.map(cat =>
|
||||
cat.id === parentIdForNew
|
||||
? { ...cat, children: [...(cat.children || []), newCategory] }
|
||||
: cat
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Add as root
|
||||
setCategories(prev => [...prev, newCategory])
|
||||
}
|
||||
}
|
||||
|
||||
setDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (categoryToDelete) {
|
||||
setCategories(prev => {
|
||||
// Remove from root level
|
||||
const filtered = prev.filter(cat => cat.id !== categoryToDelete.id)
|
||||
// Remove from children
|
||||
return filtered.map(cat => ({
|
||||
...cat,
|
||||
children: cat.children?.filter(child => child.id !== categoryToDelete.id),
|
||||
}))
|
||||
})
|
||||
setDeleteDialogOpen(false)
|
||||
setCategoryToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
const totalCategories = categories.reduce(
|
||||
(acc, cat) => acc + 1 + (cat.children?.length || 0),
|
||||
0
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">百科分类管理</h1>
|
||||
<p className="text-muted-foreground">管理植物百科的分类结构</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button onClick={() => openCreateDialog(null)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加分类
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderTree className="h-5 w-5 text-primary" />
|
||||
分类树
|
||||
</CardTitle>
|
||||
<CardDescription>共 {totalCategories} 个分类</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{categories.map(category => (
|
||||
<CategoryItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
level={0}
|
||||
onEdit={openEditDialog}
|
||||
onDelete={cat => {
|
||||
setCategoryToDelete(cat)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
onAddChild={parentId => openCreateDialog(parentId)}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCategory
|
||||
? '编辑分类'
|
||||
: parentIdForNew
|
||||
? '添加子分类'
|
||||
: '添加分类'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingCategory ? '修改分类信息' : '创建新的百科分类'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">分类名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon">图标(Emoji)</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
value={formData.icon}
|
||||
onChange={e => setFormData(prev => ({ ...prev, icon: e.target.value }))}
|
||||
placeholder="如:🌿"
|
||||
/>
|
||||
</div>
|
||||
{!editingCategory && !parentIdForNew && (
|
||||
<div className="space-y-2">
|
||||
<Label>父分类(可选)</Label>
|
||||
<Select
|
||||
value={formData.parentId}
|
||||
onValueChange={value =>
|
||||
setFormData(prev => ({ ...prev, parentId: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择父分类(可选)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">无(作为顶级分类)</SelectItem>
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.icon} {cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit">{editingCategory ? '保存' : '创建'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除分类 "{categoryToDelete?.name}" 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { mockUsers, mockTopics, mockCategories, mockPlants } from '@/data/mockData'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: '用户总数',
|
||||
value: mockUsers.length,
|
||||
icon: Users,
|
||||
change: '+12%',
|
||||
changeType: 'positive' as const,
|
||||
color: 'from-blue-500/20 to-blue-500/5',
|
||||
iconBg: 'bg-blue-500/10 text-blue-600',
|
||||
},
|
||||
{
|
||||
title: '话题数量',
|
||||
value: mockTopics.length,
|
||||
icon: MessageSquare,
|
||||
change: '+8%',
|
||||
changeType: 'positive' as const,
|
||||
color: 'from-violet-500/20 to-violet-500/5',
|
||||
iconBg: 'bg-violet-500/10 text-violet-600',
|
||||
},
|
||||
{
|
||||
title: '百科分类',
|
||||
value: mockCategories.length,
|
||||
icon: FolderTree,
|
||||
change: '0%',
|
||||
changeType: 'neutral' as const,
|
||||
color: 'from-amber-500/20 to-amber-500/5',
|
||||
iconBg: 'bg-amber-500/10 text-amber-600',
|
||||
},
|
||||
{
|
||||
title: '植物百科',
|
||||
value: mockPlants.length,
|
||||
icon: Leaf,
|
||||
change: '+25%',
|
||||
changeType: 'positive' as const,
|
||||
color: 'from-emerald-500/20 to-emerald-500/5',
|
||||
iconBg: 'bg-emerald-500/10 text-emerald-600',
|
||||
},
|
||||
]
|
||||
|
||||
const recentActivities = [
|
||||
{ action: '添加了新植物', target: '龟背竹', time: '2分钟前', user: 'admin' },
|
||||
{ action: '发布了新话题', target: '春季植物养护小技巧', time: '1小时前', user: 'editor' },
|
||||
{ action: '更新了分类', target: '观叶植物', time: '3小时前', user: 'admin' },
|
||||
{ action: '添加了新用户', target: 'viewer', time: '昨天', user: 'admin' },
|
||||
]
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">概览</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">欢迎回来 👋</h1>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date().toLocaleDateString('zh-CN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="relative overflow-hidden border-0 shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${stat.color} opacity-60`} />
|
||||
<CardHeader className="relative flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
<div className={`rounded-xl p-2.5 ${stat.iconBg}`}>
|
||||
<stat.icon className="h-4 w-4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative">
|
||||
<div className="text-3xl font-bold tracking-tight">{stat.value}</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs">
|
||||
<span
|
||||
className={`flex items-center gap-0.5 rounded-full px-1.5 py-0.5 font-medium ${stat.changeType === 'positive'
|
||||
? 'bg-emerald-500/10 text-emerald-600'
|
||||
: stat.changeType === 'negative'
|
||||
? 'bg-red-500/10 text-red-600'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{stat.changeType === 'positive' && <ArrowUpRight className="h-3 w-3" />}
|
||||
{stat.change}
|
||||
</span>
|
||||
<span className="text-muted-foreground">较上月</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Grid */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Recent Topics */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5">
|
||||
<MessageSquare className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
最新话题
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">最近发布的社区话题</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{mockTopics.slice(0, 3).map(topic => (
|
||||
<div
|
||||
key={topic.id}
|
||||
className="group flex items-start gap-4 rounded-xl border border-border/50 p-4 transition-all duration-200 hover:bg-muted/30 hover:border-border cursor-pointer"
|
||||
>
|
||||
{topic.coverImage && (
|
||||
<img
|
||||
src={topic.coverImage}
|
||||
alt={topic.title}
|
||||
className="h-14 w-14 rounded-lg object-cover ring-1 ring-border/50"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<h4 className="font-medium text-sm leading-tight group-hover:text-primary transition-colors">{topic.title}</h4>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||
{topic.content}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
{topic.viewCount}
|
||||
</span>
|
||||
<span className="font-medium">{topic.authorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activities */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
最近活动
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">系统最近的操作记录</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-1">
|
||||
{recentActivities.map((activity, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-4 rounded-lg px-3 py-3 transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 ring-1 ring-primary/10">
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
{activity.user.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">
|
||||
<span className="font-medium">{activity.user}</span>{' '}
|
||||
<span className="text-muted-foreground">{activity.action}</span>{' '}
|
||||
<span className="font-medium text-primary">{activity.target}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{activity.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5">
|
||||
<Leaf className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
植物百科概览
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">按分类统计的植物数量</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{mockCategories.map(category => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="group flex items-center gap-4 rounded-xl border border-border/50 p-4 transition-all duration-200 hover:bg-muted/30 hover:border-border hover:shadow-sm cursor-pointer"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 text-2xl">
|
||||
{category.icon || '🌱'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm group-hover:text-primary transition-colors">{category.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{category.children?.length || 0} 个子分类
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Leaf, Eye, EyeOff, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { getCaptcha, login as apiLogin } from '@/api/system'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
|
||||
const [account, setAccount] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [captcha, setCaptcha] = useState('')
|
||||
const [captchaId, setCaptchaId] = useState('')
|
||||
const [captchaImg, setCaptchaImg] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 获取验证码
|
||||
const fetchCaptcha = async () => {
|
||||
try {
|
||||
const res = await getCaptcha()
|
||||
setCaptchaId(res.data.captchaId)
|
||||
setCaptchaImg(res.data.captcha)
|
||||
} catch (err) {
|
||||
console.error('获取验证码失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCaptcha()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!account || !password || !captcha) {
|
||||
setError('请填写完整的登录信息')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await apiLogin({
|
||||
account,
|
||||
password,
|
||||
captcha,
|
||||
captchaId,
|
||||
})
|
||||
|
||||
// 登录成功
|
||||
login(res.data.user, res.data.token)
|
||||
navigate('/dashboard')
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '登录失败'
|
||||
setError(errorMessage)
|
||||
// 刷新验证码
|
||||
fetchCaptcha()
|
||||
setCaptcha('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-green-50/50 to-teal-50 p-4">
|
||||
{/* Background decoration */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-gradient-to-br from-primary/10 to-emerald-100/20 rounded-full blur-3xl -translate-y-1/2 translate-x-1/4" />
|
||||
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-gradient-to-tr from-teal-100/30 to-cyan-100/20 rounded-full blur-3xl translate-y-1/3 -translate-x-1/4" />
|
||||
<div className="absolute top-1/2 left-1/2 w-[300px] h-[300px] bg-gradient-to-r from-green-100/20 to-emerald-100/10 rounded-full blur-2xl -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
|
||||
<Card className="relative w-full max-w-[400px] shadow-xl shadow-black/5 border-0 bg-white/70 backdrop-blur-xl">
|
||||
<CardHeader className="space-y-4 text-center pb-2 pt-8">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-emerald-600 shadow-lg shadow-primary/30 transition-transform hover:scale-105">
|
||||
<Leaf className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-xl font-semibold tracking-tight text-foreground">
|
||||
植趣ZeeQ
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground text-sm">
|
||||
请登录您的管理员账号
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6 pb-8">
|
||||
<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">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-red-500" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account" className="text-sm font-medium text-foreground">用户名</Label>
|
||||
<Input
|
||||
id="account"
|
||||
placeholder="请输入用户名"
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
disabled={loading}
|
||||
className="h-11 rounded-xl border-border/50 bg-white/60 placeholder:text-muted-foreground/60 focus:bg-white transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium text-foreground">密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
className="h-11 rounded-xl border-border/50 bg-white/60 placeholder:text-muted-foreground/60 focus:bg-white transition-colors pr-11"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="captcha" className="text-sm font-medium text-foreground">验证码</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
id="captcha"
|
||||
placeholder="请输入验证码"
|
||||
value={captcha}
|
||||
onChange={e => setCaptcha(e.target.value)}
|
||||
disabled={loading}
|
||||
className="flex-1 h-11 rounded-xl border-border/50 bg-white/60 placeholder:text-muted-foreground/60 focus:bg-white transition-colors"
|
||||
/>
|
||||
<div
|
||||
className="flex-shrink-0 h-11 w-36 rounded-xl border border-border/50 bg-white/80 overflow-hidden cursor-pointer relative group transition-all hover:border-border hover:shadow-sm"
|
||||
onClick={fetchCaptcha}
|
||||
>
|
||||
{captchaImg ? (
|
||||
<img
|
||||
src={captchaImg}
|
||||
alt="验证码"
|
||||
className="h-full w-full object-fill"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-[1px]">
|
||||
<RefreshCw className="h-4 w-4 text-foreground/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 rounded-xl bg-gradient-to-r from-primary to-emerald-600 hover:from-primary/90 hover:to-emerald-600/90 shadow-md shadow-primary/20 font-medium transition-all hover:shadow-lg hover:shadow-primary/25"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 pt-5 border-t border-border/30">
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
© {new Date().getFullYear() > 2026 ? `2026-${new Date().getFullYear()}` : '2026'} sundynix · 安全登录
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Search, MoreHorizontal, Pencil, Trash2, Droplets, Sun, Gauge } 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 { Textarea } from '@/components/ui/textarea'
|
||||
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 { mockPlants, getFlatCategories } from '@/data/mockData'
|
||||
import type { Plant } from '@/types'
|
||||
import { formatDate, generateId } from '@/lib/utils'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
export default function PlantsPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const [plants, setPlants] = useState<Plant[]>(mockPlants)
|
||||
const [search, setSearch] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingPlant, setEditingPlant] = useState<Plant | null>(null)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [plantToDelete, setPlantToDelete] = useState<Plant | null>(null)
|
||||
|
||||
const flatCategories = getFlatCategories()
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
scientificName: '',
|
||||
categoryId: '',
|
||||
description: '',
|
||||
careGuide: '',
|
||||
wateringFrequency: '',
|
||||
sunlightRequirement: 'medium' as 'low' | 'medium' | 'high',
|
||||
difficultyLevel: 'easy' as 'easy' | 'medium' | 'hard',
|
||||
images: '',
|
||||
status: 'draft' as 'draft' | 'published',
|
||||
})
|
||||
|
||||
const canWrite = hasPermission('plant:write')
|
||||
const canDelete = hasPermission('plant:delete')
|
||||
|
||||
const filteredPlants = plants.filter(plant => {
|
||||
const matchesSearch =
|
||||
plant.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
plant.scientificName.toLowerCase().includes(search.toLowerCase())
|
||||
const matchesCategory =
|
||||
categoryFilter === 'all' || plant.categoryId === categoryFilter
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
|
||||
const getSunlightBadge = (level: Plant['sunlightRequirement']) => {
|
||||
const config = {
|
||||
low: { label: '弱光', className: 'bg-blue-100 text-blue-800' },
|
||||
medium: { label: '中等', className: 'bg-yellow-100 text-yellow-800' },
|
||||
high: { label: '强光', className: 'bg-orange-100 text-orange-800' },
|
||||
}
|
||||
const { label, className } = config[level]
|
||||
return <Badge className={className}>{label}</Badge>
|
||||
}
|
||||
|
||||
const getDifficultyBadge = (level: Plant['difficultyLevel']) => {
|
||||
const config = {
|
||||
easy: { label: '简单', className: 'bg-green-100 text-green-800' },
|
||||
medium: { label: '中等', className: 'bg-yellow-100 text-yellow-800' },
|
||||
hard: { label: '困难', className: 'bg-red-100 text-red-800' },
|
||||
}
|
||||
const { label, className } = config[level]
|
||||
return <Badge className={className}>{label}</Badge>
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingPlant(null)
|
||||
setFormData({
|
||||
name: '',
|
||||
scientificName: '',
|
||||
categoryId: '',
|
||||
description: '',
|
||||
careGuide: '',
|
||||
wateringFrequency: '',
|
||||
sunlightRequirement: 'medium',
|
||||
difficultyLevel: 'easy',
|
||||
images: '',
|
||||
status: 'draft',
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (plant: Plant) => {
|
||||
setEditingPlant(plant)
|
||||
setFormData({
|
||||
name: plant.name,
|
||||
scientificName: plant.scientificName,
|
||||
categoryId: plant.categoryId,
|
||||
description: plant.description,
|
||||
careGuide: plant.careGuide,
|
||||
wateringFrequency: plant.wateringFrequency,
|
||||
sunlightRequirement: plant.sunlightRequirement,
|
||||
difficultyLevel: plant.difficultyLevel,
|
||||
images: plant.images.join('\n'),
|
||||
status: plant.status,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const now = new Date().toISOString()
|
||||
const category = flatCategories.find(c => c.id === formData.categoryId)
|
||||
|
||||
if (editingPlant) {
|
||||
setPlants(prev =>
|
||||
prev.map(p =>
|
||||
p.id === editingPlant.id
|
||||
? {
|
||||
...p,
|
||||
...formData,
|
||||
categoryName: category?.name || '',
|
||||
images: formData.images.split('\n').filter(Boolean),
|
||||
updatedAt: now,
|
||||
}
|
||||
: p
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const newPlant: Plant = {
|
||||
id: generateId(),
|
||||
...formData,
|
||||
categoryName: category?.name || '',
|
||||
images: formData.images.split('\n').filter(Boolean),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
setPlants(prev => [newPlant, ...prev])
|
||||
}
|
||||
|
||||
setDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (plantToDelete) {
|
||||
setPlants(prev => prev.filter(p => p.id !== plantToDelete.id))
|
||||
setDeleteDialogOpen(false)
|
||||
setPlantToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">植物百科管理</h1>
|
||||
<p className="text-muted-foreground">管理植物百科内容</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加植物
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>植物列表</CardTitle>
|
||||
<CardDescription>共 {filteredPlants.length} 个植物</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex flex-col gap-4 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索植物名称或学名..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="分类筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
{flatCategories.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.parentName ? `${cat.parentName} / ` : ''}
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>植物</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>养护信息</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="w-[70px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPlants.map(plant => (
|
||||
<TableRow key={plant.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-start gap-3">
|
||||
{plant.images[0] && (
|
||||
<img
|
||||
src={plant.images[0]}
|
||||
alt={plant.name}
|
||||
className="h-12 w-12 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{plant.name}</p>
|
||||
<p className="text-xs italic text-muted-foreground">
|
||||
{plant.scientificName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{plant.categoryName}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Droplets className="h-3 w-3 text-blue-500" />
|
||||
<span className="text-muted-foreground">{plant.wateringFrequency}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="h-3 w-3 text-yellow-500" />
|
||||
{getSunlightBadge(plant.sunlightRequirement)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-3 w-3 text-primary" />
|
||||
{getDifficultyBadge(plant.difficultyLevel)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={plant.status === 'published' ? 'default' : 'secondary'}>
|
||||
{plant.status === 'published' ? '已发布' : '草稿'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(plant.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(canWrite || canDelete) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canWrite && (
|
||||
<DropdownMenuItem onClick={() => openEditDialog(plant)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setPlantToDelete(plant)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingPlant ? '编辑植物' : '添加植物'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingPlant ? '修改植物百科信息' : '创建新的植物百科条目'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">植物名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scientificName">学名</Label>
|
||||
<Input
|
||||
id="scientificName"
|
||||
value={formData.scientificName}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, scientificName: e.target.value }))
|
||||
}
|
||||
placeholder="如:Monstera deliciosa"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>分类</Label>
|
||||
<Select
|
||||
value={formData.categoryId}
|
||||
onValueChange={value =>
|
||||
setFormData(prev => ({ ...prev, categoryId: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{flatCategories.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.parentName ? `${cat.parentName} / ` : ''}
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>状态</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={value =>
|
||||
setFormData(prev => ({ ...prev, status: value as 'draft' | 'published' }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="published">已发布</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="careGuide">养护指南</Label>
|
||||
<Textarea
|
||||
id="careGuide"
|
||||
value={formData.careGuide}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, careGuide: e.target.value }))
|
||||
}
|
||||
rows={4}
|
||||
placeholder="请输入详细的养护说明..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wateringFrequency">浇水频率</Label>
|
||||
<Input
|
||||
id="wateringFrequency"
|
||||
value={formData.wateringFrequency}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, wateringFrequency: e.target.value }))
|
||||
}
|
||||
placeholder="如:每周1-2次"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>光照需求</Label>
|
||||
<Select
|
||||
value={formData.sunlightRequirement}
|
||||
onValueChange={value =>
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
sunlightRequirement: value as 'low' | 'medium' | 'high',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">弱光</SelectItem>
|
||||
<SelectItem value="medium">中等</SelectItem>
|
||||
<SelectItem value="high">强光</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>难度等级</Label>
|
||||
<Select
|
||||
value={formData.difficultyLevel}
|
||||
onValueChange={value =>
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
difficultyLevel: value as 'easy' | 'medium' | 'hard',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">简单</SelectItem>
|
||||
<SelectItem value="medium">中等</SelectItem>
|
||||
<SelectItem value="hard">困难</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="images">图片 URL(每行一个)</Label>
|
||||
<Textarea
|
||||
id="images"
|
||||
value={formData.images}
|
||||
onChange={e => setFormData(prev => ({ ...prev, images: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit">{editingPlant ? '保存' : '创建'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除植物 "{plantToDelete?.name}" 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Search, MoreHorizontal, Pencil, Trash2, Eye, Heart, MessageCircle } 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 { Textarea } from '@/components/ui/textarea'
|
||||
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 { mockTopics } from '@/data/mockData'
|
||||
import type { Topic } from '@/types'
|
||||
import { formatDate, generateId } from '@/lib/utils'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
export default function TopicsPage() {
|
||||
const { hasPermission, user } = useAuth()
|
||||
const [topics, setTopics] = useState<Topic[]>(mockTopics)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingTopic, setEditingTopic] = useState<Topic | null>(null)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [topicToDelete, setTopicToDelete] = useState<Topic | null>(null)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
content: '',
|
||||
tags: '',
|
||||
coverImage: '',
|
||||
status: 'draft' as 'draft' | 'published' | 'archived',
|
||||
})
|
||||
|
||||
const canWrite = hasPermission('topic:write')
|
||||
const canDelete = hasPermission('topic:delete')
|
||||
|
||||
const filteredTopics = topics.filter(topic => {
|
||||
const matchesSearch =
|
||||
topic.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
topic.content.toLowerCase().includes(search.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all' || topic.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const getStatusBadge = (status: Topic['status']) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return <Badge>已发布</Badge>
|
||||
case 'draft':
|
||||
return <Badge variant="secondary">草稿</Badge>
|
||||
case 'archived':
|
||||
return <Badge variant="outline">已归档</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingTopic(null)
|
||||
setFormData({ title: '', content: '', tags: '', coverImage: '', status: 'draft' })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (topic: Topic) => {
|
||||
setEditingTopic(topic)
|
||||
setFormData({
|
||||
title: topic.title,
|
||||
content: topic.content,
|
||||
tags: topic.tags.join(', '),
|
||||
coverImage: topic.coverImage || '',
|
||||
status: topic.status,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (editingTopic) {
|
||||
setTopics(prev =>
|
||||
prev.map(t =>
|
||||
t.id === editingTopic.id
|
||||
? {
|
||||
...t,
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
tags: formData.tags.split(',').map(tag => tag.trim()).filter(Boolean),
|
||||
coverImage: formData.coverImage || undefined,
|
||||
status: formData.status,
|
||||
updatedAt: now,
|
||||
}
|
||||
: t
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const newTopic: Topic = {
|
||||
id: generateId(),
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
authorId: user?.id || '',
|
||||
authorName: user?.username || '',
|
||||
status: formData.status,
|
||||
viewCount: 0,
|
||||
likeCount: 0,
|
||||
commentCount: 0,
|
||||
tags: formData.tags.split(',').map(tag => tag.trim()).filter(Boolean),
|
||||
coverImage: formData.coverImage || undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
setTopics(prev => [newTopic, ...prev])
|
||||
}
|
||||
|
||||
setDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (topicToDelete) {
|
||||
setTopics(prev => prev.filter(t => t.id !== topicToDelete.id))
|
||||
setDeleteDialogOpen(false)
|
||||
setTopicToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">社区话题管理</h1>
|
||||
<p className="text-muted-foreground">管理社区发布的话题内容</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加话题
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>话题列表</CardTitle>
|
||||
<CardDescription>共 {filteredTopics.length} 个话题</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex flex-col gap-4 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索话题..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="published">已发布</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>话题</TableHead>
|
||||
<TableHead>作者</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>数据统计</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="w-[70px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTopics.map(topic => (
|
||||
<TableRow key={topic.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-start gap-3">
|
||||
{topic.coverImage && (
|
||||
<img
|
||||
src={topic.coverImage}
|
||||
alt={topic.title}
|
||||
className="h-12 w-12 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium leading-tight">{topic.title}</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{topic.content}
|
||||
</p>
|
||||
{topic.tags.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{topic.tags.slice(0, 2).map(tag => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{topic.authorName}</TableCell>
|
||||
<TableCell>{getStatusBadge(topic.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
{topic.viewCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-3 w-3" />
|
||||
{topic.likeCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
{topic.commentCount}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(topic.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(canWrite || canDelete) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canWrite && (
|
||||
<DropdownMenuItem onClick={() => openEditDialog(topic)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setTopicToDelete(topic)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTopic ? '编辑话题' : '添加话题'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingTopic ? '修改话题内容' : '创建新的社区话题'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">标题</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={e => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">内容</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={formData.content}
|
||||
onChange={e => setFormData(prev => ({ ...prev, content: e.target.value }))}
|
||||
rows={5}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tags">标签(逗号分隔)</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={formData.tags}
|
||||
onChange={e => setFormData(prev => ({ ...prev, tags: e.target.value }))}
|
||||
placeholder="如:养护技巧, 春季"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">状态</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={value =>
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
status: value as 'draft' | 'published' | 'archived',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="published">已发布</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coverImage">封面图片 URL</Label>
|
||||
<Input
|
||||
id="coverImage"
|
||||
value={formData.coverImage}
|
||||
onChange={e => setFormData(prev => ({ ...prev, coverImage: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit">{editingTopic ? '保存' : '创建'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除话题 "{topicToDelete?.title}" 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, Eye, Heart, MessageCircle, Trash2, ShieldCheck, User } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
// import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
// import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
getPostPage,
|
||||
type Post,
|
||||
type PostPageParams,
|
||||
} from '@/api/business'
|
||||
|
||||
|
||||
export default function PostsPage() {
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchParams, setSearchParams] = useState<PostPageParams>({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
title: '',
|
||||
hasReviewed: undefined,
|
||||
})
|
||||
const [previewPost, setPreviewPost] = useState<Post | null>(null)
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedPost, setSelectedPost] = useState<Post | null>(null)
|
||||
|
||||
// 获取帖子列表
|
||||
const fetchPosts = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getPostPage(searchParams)
|
||||
const list = res.data?.list || []
|
||||
setPosts(Array.isArray(list) ? list : [])
|
||||
setTotal(res.data?.total || 0)
|
||||
} catch (error) {
|
||||
console.error('获取帖子列表失败:', error)
|
||||
setPosts([])
|
||||
setTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts()
|
||||
}, [searchParams.current, searchParams.pageSize, searchParams.hasReviewed, searchParams.title])
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
setSearchParams(prev => ({ ...prev, current: 1 }))
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handlePreview = (post: Post) => {
|
||||
setPreviewPost(post)
|
||||
setPreviewDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (post: Post) => {
|
||||
setSelectedPost(post)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除 (Mock)
|
||||
const handleDelete = () => {
|
||||
alert('删除功能开发中')
|
||||
setDeleteDialogOpen(false)
|
||||
}
|
||||
|
||||
// 审核 (Mock)
|
||||
const handleReview = (post: Post, status: number) => {
|
||||
alert(`审核操作开发中: "${post.title}" -> ${status === 1 ? '通过' : '拒绝'}`)
|
||||
}
|
||||
|
||||
// 获取审核状态
|
||||
const getReviewStatus = (status?: number) => {
|
||||
// 0: 待审核/未处理, 1: 已审核/已通过 (假设)
|
||||
switch (status) {
|
||||
case 1:
|
||||
return { label: '已审核', variant: 'default' as const, className: 'bg-green-600 hover:bg-green-700' }
|
||||
case 0:
|
||||
default:
|
||||
return { label: '待审核', variant: 'secondary' as const, className: 'text-orange-600 bg-orange-100 hover:bg-orange-200' }
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / searchParams.pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">帖子管理</h1>
|
||||
<p className="text-muted-foreground mt-1">管理社区用户发布的帖子内容、评论及审核状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div className="flex-1 w-full sm:max-w-sm space-y-2">
|
||||
<span className="text-sm font-medium">关键词搜索</span>
|
||||
<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 bg-muted/30"
|
||||
value={searchParams.title}
|
||||
onChange={e => setSearchParams({ ...searchParams, title: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">状态筛选</span>
|
||||
<Tabs
|
||||
value={searchParams.hasReviewed === undefined ? 'all' : String(searchParams.hasReviewed)}
|
||||
onValueChange={v => setSearchParams({
|
||||
...searchParams,
|
||||
hasReviewed: v === 'all' ? undefined : Number(v),
|
||||
current: 1
|
||||
})}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="0">待审核</TabsTrigger>
|
||||
<TabsTrigger value="1">已审核</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Button onClick={handleSearch} className="mb-0.5">
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 帖子列表 */}
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="border-b border-border/40 bg-muted/20">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<MessageCircle className="h-5 w-5 text-primary" />
|
||||
帖子列表
|
||||
<Badge variant="secondary" className="ml-2 font-normal">{total}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 space-y-4">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary"></div>
|
||||
<p className="text-sm text-muted-foreground">加载数据中...</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="pl-6 w-[350px]">帖子内容</TableHead>
|
||||
<TableHead>发布者</TableHead>
|
||||
<TableHead>互动数据</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>发布时间</TableHead>
|
||||
<TableHead className="text-right pr-6">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{posts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-64 text-center">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-3">
|
||||
<div className="bg-muted p-4 rounded-full">
|
||||
<Search className="h-8 w-8 opacity-40" />
|
||||
</div>
|
||||
<p>暂无相关帖子数据</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
posts.map((post) => {
|
||||
const status = getReviewStatus(post.hasReviewed)
|
||||
const publisher = post.publisher || post.author // Fallback
|
||||
const images = post.imgList || []
|
||||
const timeDisplay = post.createdAtStr || post.createdAt?.replace('T', ' ').slice(0, 16) || '-'
|
||||
|
||||
return (
|
||||
<TableRow key={post.id} className="hover:bg-muted/10">
|
||||
<TableCell className="pl-6 align-top py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium line-clamp-1 text-base">{post.title}</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">
|
||||
{post.content}
|
||||
</div>
|
||||
{images.length > 0 && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{images.slice(0, 3).map((img, i) => (
|
||||
<div key={i} className="relative h-14 w-14 rounded-md overflow-hidden border border-border/50">
|
||||
<img
|
||||
src={img.url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{images.length > 3 && (
|
||||
<div className="h-14 w-14 rounded-md bg-muted flex items-center justify-center text-xs text-muted-foreground border border-border/50">
|
||||
+{images.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9 border border-border/50">
|
||||
<AvatarImage src={publisher?.avatar?.url} />
|
||||
<AvatarFallback>
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{publisher?.nickName || '未知用户'}</span>
|
||||
<span className="text-xs text-muted-foreground">ID: {publisher?.id?.slice(0, 6)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top py-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span>{post.viewCount || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Heart className="h-3.5 w-3.5" />
|
||||
<span>{post.likeCount || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MessageCircle className="h-3.5 w-3.5" />
|
||||
<span>{post.commentCount || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top py-4">
|
||||
<Badge variant={status.variant} className={status.className}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="align-top py-4 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{timeDisplay}
|
||||
</TableCell>
|
||||
<TableCell className="align-top py-4 text-right pr-6">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => handlePreview(post)} title="查看详情">
|
||||
<Eye className="h-4 w-4 text-muted-foreground hover:text-primary" />
|
||||
</Button>
|
||||
{post.hasReviewed === 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-green-600 hover:bg-green-50"
|
||||
onClick={() => handleReview(post, 1)}
|
||||
title="通过审核"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteConfirm(post)}
|
||||
title="删除帖子"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="p-4 border-t flex items-center justify-between bg-muted/10">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
显示 {posts.length > 0 ? (searchParams.current - 1) * searchParams.pageSize + 1 : 0} 到 {Math.min(searchParams.current * searchParams.pageSize, total)} 条,共 {total} 条
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current === 1}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current - 1 })}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{'<'}
|
||||
</Button>
|
||||
<span className="text-sm font-medium mx-2">{searchParams.current} / {Math.max(1, totalPages)}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current >= totalPages || totalPages === 0}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current + 1 })}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{'>'}
|
||||
</Button>
|
||||
<Select
|
||||
value={String(searchParams.pageSize)}
|
||||
onValueChange={v => setSearchParams({ ...searchParams, pageSize: Number(v), current: 1 })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 / 页</SelectItem>
|
||||
<SelectItem value="20">20 / 页</SelectItem>
|
||||
<SelectItem value="50">50 / 页</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 帖子详情弹窗 */}
|
||||
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<div className="p-6 border-b bg-muted/10">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
帖子详情
|
||||
{previewPost && (
|
||||
<Badge variant={getReviewStatus(previewPost.hasReviewed).variant} className={getReviewStatus(previewPost.hasReviewed).className}>
|
||||
{getReviewStatus(previewPost.hasReviewed).label}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{previewPost && (
|
||||
<div className="grid md:grid-cols-2 h-full">
|
||||
{/* 左侧:帖子内容 */}
|
||||
<div className="p-6 space-y-6 border-r">
|
||||
{/* 作者 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 border">
|
||||
<AvatarImage src={(previewPost.publisher || previewPost.author)?.avatar?.url} />
|
||||
<AvatarFallback>
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-semibold text-base">{(previewPost.publisher || previewPost.author)?.nickName || '未知用户'}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<span>{previewPost.createdAtStr || previewPost.createdAt?.replace('T', ' ').slice(0, 16)}</span>
|
||||
{previewPost.location && <span>· {previewPost.location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-bold text-lg">{previewPost.title}</h3>
|
||||
<p className="text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{previewPost.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 图片 */}
|
||||
{(previewPost.imgList || []).length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(previewPost.imgList || []).map((img, i) => (
|
||||
<div key={i} className="rounded-lg overflow-hidden border bg-muted/20">
|
||||
<img
|
||||
src={img.url}
|
||||
alt=""
|
||||
className="w-full h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-6 pt-4 text-muted-foreground text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Eye className="h-4 w-4" />
|
||||
{previewPost.viewCount || 0} 阅读
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Heart className="h-4 w-4" />
|
||||
{previewPost.likeCount || 0} 点赞
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{previewPost.commentCount || 0} 评论
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:评论和点赞 */}
|
||||
<div className="flex flex-col h-full bg-muted/5">
|
||||
<Tabs defaultValue="comments" className="flex-1 flex flex-col">
|
||||
<div className="px-6 pt-6">
|
||||
<TabsList className="w-full grid grid-cols-2">
|
||||
<TabsTrigger value="comments">评论列表 ({previewPost.commentCount || 0})</TabsTrigger>
|
||||
<TabsTrigger value="likes">点赞用户 ({previewPost.likeCount || 0})</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<TabsContent value="comments" className="absolute inset-0 overflow-y-auto p-6 space-y-4 h-full">
|
||||
{(previewPost.commentList && previewPost.commentList.length > 0) ? (
|
||||
previewPost.commentList.map((comment: any) => (
|
||||
<div key={comment.id} className="flex gap-3 text-sm">
|
||||
<Avatar className="h-8 w-8 mt-1">
|
||||
<AvatarImage src={comment.commentator?.avatar?.url} />
|
||||
<AvatarFallback>{comment.commentator?.nickName?.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-foreground">{comment.commentator?.nickName}</span>
|
||||
<span className="text-xs text-muted-foreground">{comment.createdAtStr || comment.createdAt?.slice(0, 10)}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground opacity-50">
|
||||
<MessageCircle className="h-10 w-10 mb-2" />
|
||||
<p>暂无评论</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="likes" className="absolute inset-0 overflow-y-auto p-6 h-full">
|
||||
{(previewPost.likeList && previewPost.likeList.length > 0) ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{previewPost.likeList.map((like: any) => (
|
||||
<div key={like.id} className="flex items-center gap-2 bg-background border p-2 rounded-full pr-4">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={like.liker?.avatar?.url} />
|
||||
<AvatarFallback>{like.liker?.nickName?.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{like.liker?.nickName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground opacity-50">
|
||||
<Heart className="h-10 w-10 mb-2" />
|
||||
<p>暂无点赞</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认操作</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除帖子"{selectedPost?.title}"吗?删除后无法恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>确认删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Search, Pencil, Trash2, MessageCircle, Calendar, X, Clock, AlertCircle } 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 { Textarea } from '@/components/ui/textarea'
|
||||
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, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import {
|
||||
getTopicPage,
|
||||
getTopicDetail,
|
||||
addTopic,
|
||||
updateTopic,
|
||||
deleteTopic,
|
||||
type Topic,
|
||||
} from '@/api/business'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TopicFormData {
|
||||
id?: string
|
||||
title: string
|
||||
remark: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
const defaultFormData: TopicFormData = {
|
||||
title: '',
|
||||
remark: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
}
|
||||
|
||||
export default function TopicsPage() {
|
||||
const [topics, setTopics] = useState<Topic[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
})
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedTopic, setSelectedTopic] = useState<Topic | null>(null)
|
||||
const [formData, setFormData] = useState<TopicFormData>(defaultFormData)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 获取话题列表
|
||||
const fetchTopics = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getTopicPage(searchParams)
|
||||
const list = res.data?.list || []
|
||||
|
||||
// Map backend snake_case to camelCase if necessary (for robustness)
|
||||
const mappedList = list.map((item: any) => ({
|
||||
...item,
|
||||
startTime: (item.startTime || item.start_time) ? String(item.startTime || item.start_time) : undefined,
|
||||
endTime: (item.endTime || item.end_time) ? String(item.endTime || item.end_time) : undefined
|
||||
})) as Topic[]
|
||||
|
||||
setTopics(Array.isArray(mappedList) ? mappedList : [])
|
||||
setTotal(res.data?.total || 0)
|
||||
} catch (error) {
|
||||
console.error('获取话题列表失败:', error)
|
||||
setTopics([])
|
||||
setTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopics()
|
||||
}, [searchParams.current, searchParams.pageSize, searchParams.keyword])
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
setSearchParams(prev => ({ ...prev, current: 1 }))
|
||||
}
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
setFormData(defaultFormData)
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = async (topic: Topic) => {
|
||||
try {
|
||||
const res = await getTopicDetail(topic.id)
|
||||
const detail = res.data as any // Cast to any to handle potential snake_case from backend
|
||||
|
||||
setFormData({
|
||||
id: detail.id,
|
||||
title: detail.title || '',
|
||||
remark: detail.remark || '',
|
||||
// backend might return start_time, map to startTime
|
||||
startTime: detail.startTime || detail.start_time || '',
|
||||
endTime: detail.endTime || detail.end_time || '',
|
||||
})
|
||||
setIsEdit(true)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取话题详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (topic: Topic) => {
|
||||
setSelectedTopic(topic)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedTopic) return
|
||||
|
||||
try {
|
||||
await deleteTopic([selectedTopic.id])
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedTopic(null)
|
||||
fetchTopics()
|
||||
} catch (error) {
|
||||
console.error('删除话题失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.title) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// Convert to API params (interface uses camelCase now)
|
||||
const payload = {
|
||||
...formData,
|
||||
startTime: formData.startTime || undefined,
|
||||
endTime: formData.endTime || undefined
|
||||
}
|
||||
|
||||
if (isEdit && formData.id) {
|
||||
await updateTopic({ ...payload, id: Number(formData.id) })
|
||||
} else {
|
||||
await addTopic(payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchTopics()
|
||||
} catch (error) {
|
||||
console.error('保存话题失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取话题状态
|
||||
const getTopicStatus = (topic: Topic) => {
|
||||
const now = new Date()
|
||||
const start = topic.startTime ? new Date(topic.startTime) : null
|
||||
const end = topic.endTime ? new Date(topic.endTime) : null
|
||||
|
||||
if (start && start > now) {
|
||||
return { label: '未开始', variant: 'secondary' as const, color: 'text-blue-500 bg-blue-500/10 border-blue-200' }
|
||||
}
|
||||
if (end && end < now) {
|
||||
return { label: '已结束', variant: 'outline' as const, color: 'text-muted-foreground bg-muted border-muted' }
|
||||
}
|
||||
return { label: '进行中', variant: 'default' as const, color: 'text-green-600 bg-green-500/10 border-green-200' }
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / searchParams.pageSize)
|
||||
|
||||
// Stats calculation
|
||||
const activeTopics = topics.filter(t => getTopicStatus(t).label === '进行中').length
|
||||
const upcomingTopics = topics.filter(t => getTopicStatus(t).label === '未开始').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
{/* Header & Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-l-4 border-l-green-500 shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 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">{activeTopics}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">当前正在进行的话题数量</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-blue-500 shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 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">{upcomingTopics}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">预定即将上线的话题</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-orange-500 shadow-sm hover:shadow-md transition-all group cursor-pointer bg-gradient-to-br from-background to-orange-50/50 hover:to-orange-100/50" onClick={handleAdd}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-orange-600">发布新话题</CardTitle>
|
||||
<div className="bg-orange-100 p-1 rounded-full group-hover:scale-110 transition-transform">
|
||||
<Plus className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-700">New</div>
|
||||
<p className="text-xs text-orange-600/80 mt-1">点击创建新的讨论主题</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<Card className="border-border/60 shadow-sm hover:shadow transition-shadow duration-300">
|
||||
<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="font-normal text-xs">{total}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
管理社区讨论话题,设置起止时间和描述信息。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<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-[240px] h-10 bg-muted/30 focus:bg-background transition-colors"
|
||||
value={searchParams.keyword}
|
||||
onChange={e => setSearchParams({ ...searchParams, keyword: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAdd} className="h-10 gap-2 shadow-sm bg-primary hover:bg-primary/90 transition-all active:scale-95">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">新增话题</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 space-y-4 text-muted-foreground bg-muted/10 rounded-lg border border-dashed border-border/60">
|
||||
<div className="relative">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<MessageCircle className="h-4 w-4 text-primary opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium animate-pulse">正在加载话题数据...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden bg-background">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50 backdrop-blur-sm sticky top-0">
|
||||
<TableRow className="hover:bg-transparent border-b-border/60">
|
||||
<TableHead className="pl-6">话题标题</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>时间安排</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="w-[100px] text-right pr-6">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topics.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-64 text-center">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-3">
|
||||
<div className="bg-muted/30 p-4 rounded-full">
|
||||
<Search className="h-8 w-8 opacity-40" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">未找到相关话题</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">尝试调整搜索关键词或发布新话题</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
topics.map((topic, index) => {
|
||||
const status = getTopicStatus(topic)
|
||||
return (
|
||||
<TableRow key={topic.id} className={cn(
|
||||
"group transition-all duration-200 hover:bg-muted/30 border-b-border/40",
|
||||
index % 2 === 1 && "bg-muted/5"
|
||||
)}>
|
||||
<TableCell className="pl-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary font-bold shadow-sm ring-1 ring-primary/20">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{topic.title}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
<p className="truncate text-muted-foreground text-sm" title={topic.remark}>
|
||||
{topic.remark || '-'}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
<span>{topic.startTime ? topic.startTime.replace('T', ' ').slice(0, 16) : '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400"></span>
|
||||
<span>{topic.endTime ? topic.endTime.replace('T', ' ').slice(0, 16) : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={cn("font-normal border", status.color)}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10" onClick={() => handleEdit(topic)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteConfirm(topic)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border/40">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current === 1}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current - 1 })}
|
||||
className="h-8"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground px-2">
|
||||
{searchParams.current} / {Math.max(1, totalPages)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current >= totalPages || totalPages === 0}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current + 1 })}
|
||||
className="h-8"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑话题' : '新增话题'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? '修改话题信息' : '创建新的讨论话题,设置起止时间。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-5 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">话题标题 *</Label>
|
||||
<div className="relative group">
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={e => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="输入话题标题"
|
||||
className="pr-8 bg-muted/30 focus:bg-background transition-all"
|
||||
/>
|
||||
{formData.title && (
|
||||
<button
|
||||
className="absolute right-2 top-2.5 text-muted-foreground/30 hover:text-muted-foreground transition-colors"
|
||||
onClick={() => setFormData({ ...formData, title: '' })}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="remark">话题描述</Label>
|
||||
<div className="relative group">
|
||||
<Textarea
|
||||
id="remark"
|
||||
value={formData.remark}
|
||||
onChange={e => setFormData({ ...formData, remark: e.target.value })}
|
||||
placeholder="输入话题描述"
|
||||
rows={3}
|
||||
className="pr-8 resize-none bg-muted/30 focus:bg-background transition-all"
|
||||
/>
|
||||
{formData.remark && (
|
||||
<button
|
||||
className="absolute right-2 top-2.5 text-muted-foreground/30 hover:text-muted-foreground transition-colors"
|
||||
onClick={() => setFormData({ ...formData, remark: '' })}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startTime">开始时间</Label>
|
||||
<Input
|
||||
id="startTime"
|
||||
type="date"
|
||||
value={formData.startTime ? (formData.startTime.includes('T') ? formData.startTime.split('T')[0] : formData.startTime.split(' ')[0]) : ''}
|
||||
onChange={e => {
|
||||
const date = e.target.value
|
||||
setFormData({
|
||||
...formData,
|
||||
startTime: date ? `${date} 00:00:00` : ''
|
||||
})
|
||||
}}
|
||||
className="font-mono text-sm bg-muted/30 focus:bg-background transition-all"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">默认为 00:00:00</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endTime">结束时间</Label>
|
||||
<Input
|
||||
id="endTime"
|
||||
type="date"
|
||||
value={formData.endTime ? (formData.endTime.includes('T') ? formData.endTime.split('T')[0] : formData.endTime.split(' ')[0]) : ''}
|
||||
onChange={e => {
|
||||
const date = e.target.value
|
||||
setFormData({
|
||||
...formData,
|
||||
endTime: date ? `${date} 23:59:59` : ''
|
||||
})
|
||||
}}
|
||||
className="font-mono text-sm bg-muted/30 focus:bg-background transition-all"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">默认为 23:59:59</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
确认删除
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除话题 <span className="font-medium text-foreground">"{selectedTopic?.title}"</span> 吗?
|
||||
<br />此操作不可撤销,且可能会影响关联的帖子。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Medal, Award, Lock } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function BadgePage() {
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Medal className="h-6 w-6 text-primary" />
|
||||
徽章管理
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">配置用户可获得的徽章及其获取条件。</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Placeholder Cards to look nice */}
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="border-border/60 shadow-sm opacity-60">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">徽章示例 {i}</CardTitle>
|
||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<div className="h-16 w-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<Award className="h-8 w-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div className="h-4 w-24 bg-muted rounded animate-pulse mb-2"></div>
|
||||
<div className="h-3 w-32 bg-muted/50 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Card className="col-span-full border-dashed border-2 shadow-none bg-muted/5">
|
||||
<CardContent className="h-64 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="bg-primary/10 p-4 rounded-full mb-4">
|
||||
<Medal className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">功能开发中</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
|
||||
徽章配置模块即将上线。届时您将能够自定义徽章图标、达成条件以及显示特效。
|
||||
</p>
|
||||
<Button disabled variant="outline">敬请期待</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Edit, Crown, Sprout, X, Search, Trophy, Star, Sparkles } from 'lucide-react'
|
||||
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 { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { getLevelConfList, addLevelConf, updateLevelConf, type LevelConf } from '@/api/config'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LevelFormData {
|
||||
id?: string
|
||||
level: number
|
||||
title: string
|
||||
minSunlight: number
|
||||
perks: string
|
||||
}
|
||||
|
||||
const defaultFormData: LevelFormData = {
|
||||
level: 1,
|
||||
title: '',
|
||||
minSunlight: 0,
|
||||
perks: '',
|
||||
}
|
||||
|
||||
export default function LevelConfigPage() {
|
||||
const [levels, setLevels] = useState<LevelConf[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [formData, setFormData] = useState<LevelFormData>(defaultFormData)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
// 获取列表
|
||||
const fetchLevels = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getLevelConfList()
|
||||
console.log('等级配置接口返回:', res)
|
||||
|
||||
let list: LevelConf[] = []
|
||||
|
||||
if (res && Array.isArray(res.data)) {
|
||||
list = res.data
|
||||
} else if (res && res.data && Array.isArray((res.data as any).list)) {
|
||||
list = (res.data as any).list
|
||||
} else if (Array.isArray(res)) {
|
||||
list = res as unknown as LevelConf[]
|
||||
}
|
||||
|
||||
// Sort by level
|
||||
list.sort((a, b) => a.level - b.level)
|
||||
|
||||
setLevels(list)
|
||||
} catch (error) {
|
||||
console.error('获取等级配置失败:', error)
|
||||
setLevels([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchLevels()
|
||||
}, [])
|
||||
|
||||
// 打开新增对话框
|
||||
const handleAdd = () => {
|
||||
setIsEdit(false)
|
||||
// Auto increment level suggestion
|
||||
const maxLevel = levels.length > 0 ? Math.max(...levels.map(l => l.level)) : 0
|
||||
setFormData({ ...defaultFormData, level: maxLevel + 1 })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const handleEdit = (level: LevelConf) => {
|
||||
setIsEdit(true)
|
||||
setFormData({
|
||||
id: level.id,
|
||||
level: level.level,
|
||||
title: level.title,
|
||||
minSunlight: level.minSunlight,
|
||||
perks: level.perks,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.title) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit && formData.id) {
|
||||
await updateLevelConf({ ...formData, id: formData.id })
|
||||
} else {
|
||||
await addLevelConf(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchLevels()
|
||||
} catch (error) {
|
||||
console.error('保存等级配置失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLevels = levels.filter(l =>
|
||||
l.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
l.perks?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
// Calculate stats
|
||||
const maxSunlight = Math.max(...levels.map(l => l.minSunlight), 0)
|
||||
const totalPerks = levels.reduce((acc, curr) => acc + (curr.perks ? 1 : 0), 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
{/* Header & Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="border-l-4 border-l-primary shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">总等级数</CardTitle>
|
||||
<Trophy className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{levels.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">当前配置的等级总数</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-green-500 shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">最高阳光门槛</CardTitle>
|
||||
<Sprout className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{maxSunlight.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">解锁最高等级所需阳光</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-yellow-500 shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">权益覆盖率</CardTitle>
|
||||
<Star className="h-4 w-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{levels.length > 0 ? Math.round((totalPerks / levels.length) * 100) : 0}%</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">已配置权益的等级占比</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-purple-500 shadow-sm hover:shadow-md transition-all group cursor-pointer bg-gradient-to-br from-background to-purple-50/50 hover:to-purple-100/50" onClick={handleAdd}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-purple-600">快速添加</CardTitle>
|
||||
<div className="bg-purple-100 p-1 rounded-full group-hover:scale-110 transition-transform">
|
||||
<Plus className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-purple-700">New</div>
|
||||
<p className="text-xs text-purple-600/80 mt-1">点击创建新等级配置</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<Card className="border-border/60 shadow-sm hover:shadow transition-shadow duration-300">
|
||||
<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="font-normal text-xs">{levels.length}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
管理用户成长的等级体系,定义每个阶段的阳光值要求与专属特权。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<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-[240px] h-10 bg-muted/30 focus:bg-background transition-colors"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAdd} className="h-10 gap-2 shadow-sm bg-primary hover:bg-primary/90 transition-all active:scale-95">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">新增等级</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 space-y-4 text-muted-foreground bg-muted/10 rounded-lg border border-dashed border-border/60">
|
||||
<div className="relative">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Crown className="h-4 w-4 text-primary opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium animate-pulse">正在加载配置数据...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden bg-background">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50 backdrop-blur-sm sticky top-0">
|
||||
<TableRow className="hover:bg-transparent border-b-border/60">
|
||||
<TableHead className="w-[100px] font-semibold pl-6">等级</TableHead>
|
||||
<TableHead className="font-semibold">称号 & 标识</TableHead>
|
||||
<TableHead className="font-semibold">阳光值门槛</TableHead>
|
||||
<TableHead className="font-semibold">权益说明</TableHead>
|
||||
<TableHead className="w-[100px] text-right font-semibold pr-6">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLevels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-64 text-center">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-3">
|
||||
<div className="bg-muted/30 p-4 rounded-full">
|
||||
<Search className="h-8 w-8 opacity-40" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">未找到匹配数据</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">尝试调整搜索关键词或添加新等级</p>
|
||||
</div>
|
||||
{searchTerm && (
|
||||
<Button variant="outline" size="sm" onClick={() => setSearchTerm('')} className="mt-2">
|
||||
清除搜索
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredLevels.map((item, index) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"group transition-all duration-200 hover:bg-muted/30 border-b-border/40",
|
||||
index % 2 === 1 && "bg-muted/5"
|
||||
)}
|
||||
>
|
||||
<TableCell className="font-medium pl-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg font-bold shadow-sm transition-transform group-hover:scale-110",
|
||||
item.level <= 3 ? "bg-primary/10 text-primary border border-primary/20" :
|
||||
item.level <= 6 ? "bg-purple-500/10 text-purple-600 border border-purple-500/20" :
|
||||
"bg-orange-500/10 text-orange-600 border border-orange-500/20"
|
||||
)}>
|
||||
{item.level}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-background p-1.5 rounded-full shadow-sm border border-border/50">
|
||||
<Crown className={cn("h-4 w-4",
|
||||
item.level <= 3 ? "text-primary/70" :
|
||||
item.level <= 6 ? "text-purple-500" :
|
||||
"text-orange-500"
|
||||
)} />
|
||||
</div>
|
||||
<span className="font-medium text-foreground/90">{item.title}</span>
|
||||
{item.level === levels.length && levels.length > 5 && (
|
||||
<Badge variant="secondary" className="bg-gradient-to-r from-orange-500/10 to-red-500/10 text-orange-600 border-none text-[10px] px-1.5 py-0 h-5">MAX</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<Sprout className="h-4 w-4 text-green-500/70" />
|
||||
<span className="font-mono text-sm tracking-wide">{item.minSunlight.toLocaleString()}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[300px]">
|
||||
{item.perks ? (
|
||||
<div className="flex items-start gap-2 group/perk">
|
||||
<Sparkles className="h-3.5 w-3.5 mt-0.5 text-purple-500/70 shrink-0 group-hover/perk:text-purple-500 transition-colors" />
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 group-hover:text-foreground/80 transition-colors cursor-help" title={item.perks}>
|
||||
{item.perks}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/30 text-sm italic flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-muted-foreground/20 rounded-full"></span>
|
||||
暂无权益
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground/60 hover:text-primary hover:bg-primary/10 transition-all duration-200"
|
||||
onClick={() => handleEdit(item)}
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<div className={cn("p-2 rounded-lg", isEdit ? "bg-primary/10 text-primary" : "bg-purple-500/10 text-purple-600")}>
|
||||
{isEdit ? <Edit className="h-5 w-5" /> : <Plus className="h-5 w-5" />}
|
||||
</div>
|
||||
{isEdit ? '编辑等级' : '新增等级'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="pt-2">
|
||||
配置等级数值、称号及对应的权益。这些设置将直接影响用户成长体验。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-6">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="level" className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">
|
||||
等级数值
|
||||
</Label>
|
||||
<div className="relative group">
|
||||
<Input
|
||||
id="level"
|
||||
type="number"
|
||||
min={1}
|
||||
value={formData.level}
|
||||
onChange={(e) => setFormData({ ...formData, level: parseInt(e.target.value) || 0 })}
|
||||
className="pr-8 font-mono bg-muted/30 focus:bg-background h-10 transition-all"
|
||||
/>
|
||||
{formData.level > 0 && (
|
||||
<button
|
||||
className="absolute right-2 top-3 text-muted-foreground/30 hover:text-destructive transition-colors"
|
||||
onClick={() => setFormData({ ...formData, level: 0 })}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="minSunlight" className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">
|
||||
所需阳光值
|
||||
</Label>
|
||||
<div className="relative group">
|
||||
<Input
|
||||
id="minSunlight"
|
||||
type="number"
|
||||
min={0}
|
||||
value={formData.minSunlight}
|
||||
onChange={(e) => setFormData({ ...formData, minSunlight: parseInt(e.target.value) || 0 })}
|
||||
className="pr-8 font-mono bg-muted/30 focus:bg-background h-10 transition-all"
|
||||
/>
|
||||
{formData.minSunlight > 0 && (
|
||||
<button
|
||||
className="absolute right-2 top-3 text-muted-foreground/30 hover:text-destructive transition-colors"
|
||||
onClick={() => setFormData({ ...formData, minSunlight: 0 })}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">
|
||||
等级称号
|
||||
</Label>
|
||||
<div className="relative group">
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="例如:萌芽园丁"
|
||||
className="pr-8 bg-muted/30 focus:bg-background h-10 transition-all"
|
||||
/>
|
||||
{formData.title && (
|
||||
<button
|
||||
className="absolute right-2 top-3 text-muted-foreground/30 hover:text-destructive transition-colors"
|
||||
onClick={() => setFormData({ ...formData, title: '' })}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="perks" className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">
|
||||
权益描述
|
||||
</Label>
|
||||
<span className="text-[10px] text-muted-foreground">支持换行</span>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<Textarea
|
||||
id="perks"
|
||||
value={formData.perks}
|
||||
onChange={(e) => setFormData({ ...formData, perks: e.target.value })}
|
||||
placeholder="例如:解锁每日签到双倍奖励、专属头像框"
|
||||
className="h-28 pr-8 resize-none bg-muted/30 focus:bg-background transition-all leading-relaxed"
|
||||
/>
|
||||
{formData.perks && (
|
||||
<button
|
||||
className="absolute right-2 top-3 text-muted-foreground/30 hover:text-destructive transition-colors"
|
||||
onClick={() => setFormData({ ...formData, perks: '' })}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting} className="h-10 px-6">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting} className="h-10 px-6 min-w-[80px]">
|
||||
{submitting ? <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Plus, Search, Pencil, Trash2, FolderTree, Image as ImageIcon } 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
getWikiClassList,
|
||||
getWikiClassDetail,
|
||||
addWikiClass,
|
||||
updateWikiClass,
|
||||
deleteWikiClass,
|
||||
type WikiClass,
|
||||
} from '@/api/business'
|
||||
import { uploadFile, type SystemOss } from '@/api/system'
|
||||
|
||||
interface ClassFormData {
|
||||
id?: string
|
||||
name: string
|
||||
ossId?: string
|
||||
image?: SystemOss
|
||||
}
|
||||
|
||||
const defaultFormData: ClassFormData = {
|
||||
name: '',
|
||||
ossId: '',
|
||||
}
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<WikiClass[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedCategory, setSelectedCategory] = useState<WikiClass | null>(null)
|
||||
const [formData, setFormData] = useState<ClassFormData>(defaultFormData)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
// 获取分类列表(无分页)
|
||||
const fetchCategories = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getWikiClassList()
|
||||
const data = res?.data
|
||||
if (Array.isArray(data)) {
|
||||
setCategories(data)
|
||||
} else if (data && typeof data === 'object' && 'list' in data) {
|
||||
setCategories(Array.isArray((data as { list: WikiClass[] }).list) ? (data as { list: WikiClass[] }).list : [])
|
||||
} else {
|
||||
setCategories([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分类列表失败:', error)
|
||||
setCategories([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
}, [])
|
||||
|
||||
// 前端过滤搜索
|
||||
const filteredCategories = useMemo(() => {
|
||||
if (!searchKeyword.trim()) {
|
||||
return categories
|
||||
}
|
||||
return categories.filter(cat =>
|
||||
cat.name.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
)
|
||||
}, [categories, searchKeyword])
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
setFormData(defaultFormData)
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = async (category: WikiClass) => {
|
||||
try {
|
||||
const res = await getWikiClassDetail(category.id)
|
||||
const detail = res.data
|
||||
setFormData({
|
||||
id: detail.id,
|
||||
name: detail.name || '',
|
||||
ossId: detail.ossId,
|
||||
image: detail.image,
|
||||
})
|
||||
setIsEdit(true)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取分类详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (category: WikiClass) => {
|
||||
setSelectedCategory(category)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedCategory) return
|
||||
|
||||
try {
|
||||
await deleteWikiClass([selectedCategory.id])
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedCategory(null)
|
||||
fetchCategories()
|
||||
} catch (error) {
|
||||
console.error('删除分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await uploadFile(file)
|
||||
setFormData({
|
||||
...formData,
|
||||
ossId: res.data.file.id,
|
||||
image: res.data.file,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('上传图片失败:', error)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit && formData.id) {
|
||||
await updateWikiClass({
|
||||
id: formData.id,
|
||||
name: formData.name,
|
||||
ossId: formData.ossId,
|
||||
})
|
||||
} else {
|
||||
await addWikiClass({
|
||||
name: formData.name,
|
||||
ossId: formData.ossId,
|
||||
})
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchCategories()
|
||||
} catch (error) {
|
||||
console.error('保存分类失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">百科分类</h1>
|
||||
<p className="text-muted-foreground">管理植物百科的分类体系</p>
|
||||
</div>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增分类
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 max-w-sm">
|
||||
<Input
|
||||
placeholder="搜索分类名称..."
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 分类列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderTree className="h-5 w-5" />
|
||||
分类列表
|
||||
<Badge variant="secondary" className="ml-2">{filteredCategories.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>分类图片</TableHead>
|
||||
<TableHead>分类名称</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCategories.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredCategories.map(category => (
|
||||
<TableRow key={category.id}>
|
||||
<TableCell>
|
||||
{category.image?.url ? (
|
||||
<img
|
||||
src={category.image.url}
|
||||
alt={category.name}
|
||||
className="h-12 w-12 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
|
||||
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{category.name}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{category.createdAt?.split('T')[0]}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleEdit(category)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteConfirm(category)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑分类' : '新增分类'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? '修改分类信息' : '创建新的百科分类'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>分类名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="输入分类名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>分类图片</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
{formData.image?.url ? (
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={formData.image.url}
|
||||
alt="分类图片"
|
||||
className="h-20 w-20 rounded-lg object-cover"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute -top-2 -right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => setFormData({ ...formData, ossId: undefined, image: undefined })}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="h-20 w-20 rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 cursor-pointer flex items-center justify-center transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
) : (
|
||||
<Plus className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除分类 "{selectedCategory?.name}" 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Search, MoreHorizontal, Pencil, Trash2, Monitor, Clock } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
getClientList,
|
||||
getClientDetail,
|
||||
saveClient,
|
||||
updateClient,
|
||||
deleteClient,
|
||||
type SystemClient,
|
||||
type GetClientListParams,
|
||||
} from '@/api/system'
|
||||
|
||||
interface ClientFormData {
|
||||
id?: string
|
||||
clientId: string
|
||||
name: string
|
||||
grantType: string
|
||||
activeTimeout: number
|
||||
additionalInfo: string
|
||||
}
|
||||
|
||||
const defaultFormData: ClientFormData = {
|
||||
clientId: '',
|
||||
name: '',
|
||||
grantType: 'password',
|
||||
activeTimeout: 86400,
|
||||
additionalInfo: '',
|
||||
}
|
||||
|
||||
const grantTypeOptions = [
|
||||
{ value: 'password', label: '密码模式' },
|
||||
{ value: 'wechat', label: '微信授权' },
|
||||
{ value: 'sms', label: '短信验证' },
|
||||
{ value: 'token', label: 'Token模式' },
|
||||
]
|
||||
|
||||
export default function ClientsPage() {
|
||||
const [clients, setClients] = useState<SystemClient[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchParams, setSearchParams] = useState<GetClientListParams>({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
})
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedClient, setSelectedClient] = useState<SystemClient | null>(null)
|
||||
const [formData, setFormData] = useState<ClientFormData>(defaultFormData)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 获取客户端列表
|
||||
const fetchClients = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getClientList(searchParams)
|
||||
setClients(res.data?.list || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
} catch (error) {
|
||||
console.error('获取客户端列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchClients()
|
||||
}, [searchParams.current, searchParams.pageSize, searchParams.keyword])
|
||||
|
||||
// 搜索 - 只更新 searchParams,让 useEffect 触发请求
|
||||
const handleSearch = () => {
|
||||
setSearchParams(prev => ({ ...prev, current: 1 }))
|
||||
}
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
setFormData(defaultFormData)
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = async (client: SystemClient) => {
|
||||
try {
|
||||
const res = await getClientDetail(client.id)
|
||||
const detail = res.data
|
||||
setFormData({
|
||||
id: detail.id,
|
||||
clientId: detail.clientId || '',
|
||||
name: detail.name || '',
|
||||
grantType: detail.grantType || 'password',
|
||||
activeTimeout: detail.activeTimeout || 86400,
|
||||
additionalInfo: detail.additionalInfo || '',
|
||||
})
|
||||
setIsEdit(true)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取客户端详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (client: SystemClient) => {
|
||||
setSelectedClient(client)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedClient) return
|
||||
|
||||
try {
|
||||
await deleteClient([selectedClient.id])
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedClient(null)
|
||||
fetchClients()
|
||||
} catch (error) {
|
||||
console.error('删除客户端失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.clientId || !formData.name) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit && formData.id) {
|
||||
await updateClient(formData)
|
||||
} else {
|
||||
await saveClient(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchClients()
|
||||
} catch (error) {
|
||||
console.error('保存客户端失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化超时时间
|
||||
const formatTimeout = (seconds: number) => {
|
||||
if (seconds >= 86400) {
|
||||
return `${Math.floor(seconds / 86400)} 天`
|
||||
} else if (seconds >= 3600) {
|
||||
return `${Math.floor(seconds / 3600)} 小时`
|
||||
} else if (seconds >= 60) {
|
||||
return `${Math.floor(seconds / 60)} 分钟`
|
||||
}
|
||||
return `${seconds} 秒`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">客户端管理</h1>
|
||||
<p className="text-muted-foreground">管理系统接入的客户端应用</p>
|
||||
</div>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增客户端
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="搜索客户端ID或名称..."
|
||||
value={searchParams.keyword}
|
||||
onChange={e => setSearchParams({ ...searchParams, keyword: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 客户端列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
客户端列表
|
||||
<Badge variant="secondary" className="ml-2">{total}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>客户端ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>授权模式</TableHead>
|
||||
<TableHead>Token有效期</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="w-[80px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clients.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
clients.map(client => (
|
||||
<TableRow key={client.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Monitor className="h-4 w-4" />
|
||||
</div>
|
||||
<code className="text-sm bg-muted px-2 py-0.5 rounded">
|
||||
{client.clientId}
|
||||
</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{client.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{grantTypeOptions.find(o => o.value === client.grantType)?.label || client.grantType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{formatTimeout(client.activeTimeout || 0)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{client.createdAtStr || client.createdAt}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(client)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteConfirm(client)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current === 1}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{(() => {
|
||||
const totalPages = Math.ceil(total / searchParams.pageSize)
|
||||
if (totalPages === 0) {
|
||||
return <Button variant="default" size="sm" className="w-8 h-8 p-0" disabled>1</Button>
|
||||
}
|
||||
return Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page = i + 1
|
||||
if (totalPages > 5) {
|
||||
if (searchParams.current <= 3) {
|
||||
page = i + 1
|
||||
} else if (searchParams.current >= totalPages - 2) {
|
||||
page = totalPages - 4 + i
|
||||
} else {
|
||||
page = searchParams.current - 2 + i
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={searchParams.current === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0"
|
||||
onClick={() => setSearchParams({ ...searchParams, current: page })}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current >= Math.ceil(total / searchParams.pageSize) || total === 0}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Select
|
||||
value={String(searchParams.pageSize)}
|
||||
onValueChange={v => setSearchParams({ ...searchParams, pageSize: Number(v), current: 1 })}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 条/页</SelectItem>
|
||||
<SelectItem value="20">20 条/页</SelectItem>
|
||||
<SelectItem value="50">50 条/页</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑客户端' : '新增客户端'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? '修改客户端配置信息' : '添加新的客户端应用'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>客户端ID *</Label>
|
||||
<Input
|
||||
value={formData.clientId}
|
||||
onChange={e => setFormData({ ...formData, clientId: e.target.value })}
|
||||
placeholder="唯一标识,如:pc、app、mini"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="客户端名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>授权模式</Label>
|
||||
<Select
|
||||
value={formData.grantType}
|
||||
onValueChange={v => setFormData({ ...formData, grantType: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{grantTypeOptions.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Token有效期(秒)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.activeTimeout}
|
||||
onChange={e => setFormData({ ...formData, activeTimeout: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当前设置:{formatTimeout(formData.activeTimeout)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>附加信息</Label>
|
||||
<Textarea
|
||||
value={formData.additionalInfo}
|
||||
onChange={e => setFormData({ ...formData, additionalInfo: e.target.value })}
|
||||
placeholder="JSON格式的附加配置信息"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除客户端 "{selectedClient?.name}" 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Upload, Search, MoreHorizontal, Trash2, Copy, Download, Image, FileText, Film, Music, Archive, File } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
getFileList,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
type SystemOss,
|
||||
type GetOssFileListParams,
|
||||
} from '@/api/system'
|
||||
|
||||
// 根据文件后缀获取图标
|
||||
function getFileIcon(suffix?: string) {
|
||||
const ext = suffix?.toLowerCase()
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext || '')) {
|
||||
return <Image className="h-6 w-6" />
|
||||
}
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext || '')) {
|
||||
return <Film className="h-6 w-6" />
|
||||
}
|
||||
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(ext || '')) {
|
||||
return <Music className="h-6 w-6" />
|
||||
}
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) {
|
||||
return <Archive className="h-6 w-6" />
|
||||
}
|
||||
if (['doc', 'docx', 'pdf', 'txt', 'md', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext || '')) {
|
||||
return <FileText className="h-6 w-6" />
|
||||
}
|
||||
return <File className="h-6 w-6" />
|
||||
}
|
||||
|
||||
// 判断是否是图片
|
||||
function isImage(suffix?: string) {
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(suffix?.toLowerCase() || '')
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const [files, setFiles] = useState<SystemOss[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [searchParams, setSearchParams] = useState<GetOssFileListParams>({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
keyword: '',
|
||||
})
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<SystemOss | null>(null)
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 获取文件列表
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getFileList(searchParams)
|
||||
setFiles(res.data?.list || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
} catch (error) {
|
||||
console.error('获取文件列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles()
|
||||
}, [searchParams.current, searchParams.pageSize, searchParams.keyword])
|
||||
|
||||
// 搜索 - 只更新 searchParams,让 useEffect 触发请求
|
||||
const handleSearch = () => {
|
||||
setSearchParams(prev => ({ ...prev, current: 1 }))
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
await uploadFile(file)
|
||||
fetchFiles()
|
||||
} catch (error) {
|
||||
console.error('上传文件失败:', error)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 复制链接
|
||||
const handleCopyUrl = (url: string) => {
|
||||
navigator.clipboard.writeText(url)
|
||||
// 可以添加 toast 提示
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const handleDownload = (file: SystemOss) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = file.url
|
||||
link.download = file.name
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 预览文件
|
||||
const handlePreview = (file: SystemOss) => {
|
||||
setSelectedFile(file)
|
||||
setPreviewDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (file: SystemOss) => {
|
||||
setSelectedFile(file)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedFile) return
|
||||
|
||||
try {
|
||||
await deleteFile([selectedFile.id])
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedFile(null)
|
||||
fetchFiles()
|
||||
} catch (error) {
|
||||
console.error('删除文件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">文件管理</h1>
|
||||
<p className="text-muted-foreground">管理系统上传的所有文件资源</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<Button onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{uploading ? '上传中...' : '上传文件'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="搜索文件名..."
|
||||
value={searchParams.keyword}
|
||||
onChange={e => setSearchParams({ ...searchParams, keyword: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
<div className="flex border rounded-lg overflow-hidden">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-none"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
网格
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-none"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
列表
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<File className="h-5 w-5" />
|
||||
文件列表
|
||||
<Badge variant="secondary" className="ml-2">{total}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<File className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暂无文件,点击上传添加</p>
|
||||
</div>
|
||||
) : viewMode === '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(file => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative rounded-lg border bg-card overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* 预览区域 */}
|
||||
<div
|
||||
className="aspect-square flex items-center justify-center bg-muted cursor-pointer"
|
||||
onClick={() => handlePreview(file)}
|
||||
>
|
||||
{isImage(file.suffix) && file.url ? (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{getFileIcon(file.suffix)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="p-2">
|
||||
<p className="text-sm font-medium truncate" title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{file.suffix?.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleCopyUrl(file.url)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制链接
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteConfirm(file)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 列表视图
|
||||
<div className="space-y-2">
|
||||
{files.map(file => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center gap-4 p-3 rounded-lg border hover:bg-muted/50 group"
|
||||
>
|
||||
{/* 图标/缩略图 */}
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted overflow-hidden flex-shrink-0">
|
||||
{isImage(file.suffix) && file.url ? (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
className="h-full w-full object-cover cursor-pointer"
|
||||
onClick={() => handlePreview(file)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{getFileIcon(file.suffix)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{file.name}</p>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>{file.suffix?.toUpperCase()}</span>
|
||||
{file.width && file.height && (
|
||||
<span>{file.width} × {file.height}</span>
|
||||
)}
|
||||
<span>{file.createdAtStr || file.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="hidden md:block max-w-xs truncate text-sm text-muted-foreground">
|
||||
{file.url}
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleCopyUrl(file.url)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDownload(file)}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteConfirm(file)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current === 1}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{(() => {
|
||||
const totalPages = Math.ceil(total / searchParams.pageSize)
|
||||
if (totalPages === 0) {
|
||||
return <Button variant="default" size="sm" className="w-8 h-8 p-0" disabled>1</Button>
|
||||
}
|
||||
return Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page = i + 1
|
||||
if (totalPages > 5) {
|
||||
if (searchParams.current <= 3) {
|
||||
page = i + 1
|
||||
} else if (searchParams.current >= totalPages - 2) {
|
||||
page = totalPages - 4 + i
|
||||
} else {
|
||||
page = searchParams.current - 2 + i
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={searchParams.current === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0"
|
||||
onClick={() => setSearchParams({ ...searchParams, current: page })}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current >= Math.ceil(total / searchParams.pageSize) || total === 0}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<select
|
||||
className="h-8 px-2 border rounded-md text-sm bg-background"
|
||||
value={searchParams.pageSize}
|
||||
onChange={e => setSearchParams({ ...searchParams, pageSize: Number(e.target.value), current: 1 })}
|
||||
>
|
||||
<option value={10}>10 条/页</option>
|
||||
<option value={20}>20 条/页</option>
|
||||
<option value={50}>50 条/页</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 图片预览对话框 */}
|
||||
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedFile?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center min-h-[300px] bg-muted rounded-lg overflow-hidden">
|
||||
{selectedFile && isImage(selectedFile.suffix) ? (
|
||||
<img
|
||||
src={selectedFile.url}
|
||||
alt={selectedFile.name}
|
||||
className="max-h-[70vh] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
<div className="mx-auto mb-4">{getFileIcon(selectedFile?.suffix)}</div>
|
||||
<p>无法预览此文件类型</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>{selectedFile?.suffix?.toUpperCase()}</span>
|
||||
{selectedFile?.width && selectedFile?.height && (
|
||||
<span>{selectedFile.width} × {selectedFile.height}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => selectedFile && handleCopyUrl(selectedFile.url)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制链接
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => selectedFile && handleDownload(selectedFile)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除文件 "{selectedFile?.name}" 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, ChevronRight, ChevronDown, Pencil, Trash2, FolderTree } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
getAllMenuTree,
|
||||
getMenuDetail,
|
||||
saveMenu,
|
||||
updateMenu,
|
||||
deleteMenu,
|
||||
type SystemMenu
|
||||
} from '@/api/system'
|
||||
|
||||
// 图标选项
|
||||
const iconOptions = [
|
||||
{ value: 'dashboard', label: '仪表盘' },
|
||||
{ value: 'settings', label: '设置' },
|
||||
{ value: 'users', label: '用户' },
|
||||
{ value: 'shield', label: '盾牌' },
|
||||
{ value: 'menu', label: '菜单' },
|
||||
{ value: 'folder', label: '文件夹' },
|
||||
{ value: 'folder-tree', label: '目录树' },
|
||||
{ value: 'leaf', label: '叶子' },
|
||||
{ value: 'message', label: '消息' },
|
||||
{ value: 'book', label: '书籍' },
|
||||
{ value: 'file-text', label: '文档' },
|
||||
{ value: 'hash', label: '标签' },
|
||||
{ value: 'monitor', label: '监控' },
|
||||
{ value: 'home', label: '首页' },
|
||||
]
|
||||
|
||||
interface MenuFormData {
|
||||
id?: string
|
||||
parentId: string
|
||||
category: number
|
||||
name: string
|
||||
title: string
|
||||
code: string
|
||||
permission: string
|
||||
locale: string
|
||||
icon: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
const defaultFormData: MenuFormData = {
|
||||
parentId: '0',
|
||||
category: 1,
|
||||
name: '',
|
||||
title: '',
|
||||
code: '',
|
||||
permission: '',
|
||||
locale: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
}
|
||||
|
||||
// 菜单树节点组件
|
||||
function MenuTreeNode({
|
||||
menu,
|
||||
level = 0,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
}: {
|
||||
menu: SystemMenu
|
||||
level?: number
|
||||
onEdit: (menu: SystemMenu) => void
|
||||
onDelete: (id: string) => void
|
||||
onAddChild: (parentId: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(level < 2)
|
||||
const hasChildren = menu.children && menu.children.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-muted/50 group',
|
||||
level > 0 && 'ml-6'
|
||||
)}
|
||||
>
|
||||
{/* 展开/折叠按钮 */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted',
|
||||
!hasChildren && 'invisible'
|
||||
)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 图标 */}
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* 名称和信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{menu.title || menu.name}</span>
|
||||
<Badge variant={menu.category === 1 ? 'default' : 'secondary'} className="text-xs">
|
||||
{menu.category === 1 ? '菜单' : '按钮'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{menu.code && <span>路由: {menu.code}</span>}
|
||||
{menu.permission && <span>权限: {menu.permission}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 排序 */}
|
||||
<span className="text-sm text-muted-foreground">排序: {menu.sort}</span>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{menu.category === 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onAddChild(menu.id)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onEdit(menu)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(menu.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 子菜单 */}
|
||||
{expanded && hasChildren && (
|
||||
<div className="border-l border-border ml-6">
|
||||
{menu.children!.map(child => (
|
||||
<MenuTreeNode
|
||||
key={child.id}
|
||||
menu={child}
|
||||
level={level + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MenusPage() {
|
||||
const [menus, setMenus] = useState<SystemMenu[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedMenuId, setSelectedMenuId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<MenuFormData>(defaultFormData)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 获取所有菜单树
|
||||
const fetchMenus = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getAllMenuTree({ category: 1, parentId: '0' })
|
||||
setMenus(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('获取菜单失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMenus()
|
||||
}, [])
|
||||
|
||||
// 展平菜单树用于父级选择
|
||||
const flattenMenus = (menuList: SystemMenu[], level = 0): { menu: SystemMenu; level: number }[] => {
|
||||
const result: { menu: SystemMenu; level: number }[] = []
|
||||
for (const menu of menuList) {
|
||||
if (menu.category === 1) { // 只有菜单类型才能作为父级
|
||||
result.push({ menu, level })
|
||||
if (menu.children) {
|
||||
result.push(...flattenMenus(menu.children, level + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const flatMenus = flattenMenus(menus)
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
setFormData(defaultFormData)
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理添加子菜单
|
||||
const handleAddChild = (parentId: string) => {
|
||||
setFormData({ ...defaultFormData, parentId })
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = async (menu: SystemMenu) => {
|
||||
try {
|
||||
const res = await getMenuDetail(menu.id)
|
||||
const detail = res.data
|
||||
setFormData({
|
||||
id: detail.id,
|
||||
parentId: detail.parentId || '0',
|
||||
category: detail.category || 1,
|
||||
name: detail.name || '',
|
||||
title: detail.title || '',
|
||||
code: detail.code || '',
|
||||
permission: detail.permission || '',
|
||||
locale: detail.locale || '',
|
||||
icon: detail.icon || '',
|
||||
sort: detail.sort || 0,
|
||||
})
|
||||
setIsEdit(true)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取菜单详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (id: string) => {
|
||||
setSelectedMenuId(id)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedMenuId) return
|
||||
|
||||
try {
|
||||
await deleteMenu(selectedMenuId)
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedMenuId(null)
|
||||
fetchMenus()
|
||||
} catch (error) {
|
||||
console.error('删除菜单失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || !formData.title) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit && formData.id) {
|
||||
await updateMenu(formData)
|
||||
} else {
|
||||
await saveMenu(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchMenus()
|
||||
} catch (error) {
|
||||
console.error('保存菜单失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">菜单管理</h1>
|
||||
<p className="text-muted-foreground">管理系统菜单和权限配置</p>
|
||||
</div>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增菜单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 菜单树 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderTree className="h-5 w-5" />
|
||||
菜单树
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : menus.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
暂无菜单数据,请添加
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{menus.map(menu => (
|
||||
<MenuTreeNode
|
||||
key={menu.id}
|
||||
menu={menu}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDeleteConfirm}
|
||||
onAddChild={handleAddChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑菜单' : '新增菜单'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? '修改菜单信息' : '添加新的菜单或按钮权限'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>父级菜单</Label>
|
||||
<Select
|
||||
value={formData.parentId}
|
||||
onValueChange={v => setFormData({ ...formData, parentId: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择父级" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">顶级菜单</SelectItem>
|
||||
{flatMenus.map(({ menu, level }) => (
|
||||
<SelectItem key={menu.id} value={menu.id}>
|
||||
{' '.repeat(level)}{menu.title || menu.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>类型</Label>
|
||||
<Select
|
||||
value={String(formData.category)}
|
||||
onValueChange={v => setFormData({ ...formData, category: Number(v) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">菜单</SelectItem>
|
||||
<SelectItem value="2">按钮</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="英文标识"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>标题 *</Label>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={e => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="显示名称"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.category === 1 && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>路由路径</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={e => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="/path"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>图标</Label>
|
||||
<Select
|
||||
value={formData.icon}
|
||||
onValueChange={v => setFormData({ ...formData, icon: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择图标" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{iconOptions.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>权限标识</Label>
|
||||
<Input
|
||||
value={formData.permission}
|
||||
onChange={e => setFormData({ ...formData, permission: e.target.value })}
|
||||
placeholder="module:action"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>排序</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.sort}
|
||||
onChange={e => setFormData({ ...formData, sort: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>国际化键名</Label>
|
||||
<Input
|
||||
value={formData.locale}
|
||||
onChange={e => setFormData({ ...formData, locale: e.target.value })}
|
||||
placeholder="menu.xxx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
删除菜单将同时删除其所有子菜单和权限配置,此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Search, MoreHorizontal, Pencil, Trash2, Shield, Key } 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 { Checkbox } from '@/components/ui/checkbox'
|
||||
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, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
getRoleList,
|
||||
getRoleDetail,
|
||||
saveRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
grantMenu,
|
||||
getAllMenuTree,
|
||||
type SystemRole,
|
||||
type SystemMenu,
|
||||
type GetRoleListParams,
|
||||
} from '@/api/system'
|
||||
|
||||
interface RoleFormData {
|
||||
id?: string
|
||||
name: string
|
||||
code: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
const defaultFormData: RoleFormData = {
|
||||
name: '',
|
||||
code: '',
|
||||
sort: 0,
|
||||
}
|
||||
|
||||
// 递归获取所有菜单ID
|
||||
function getAllMenuIds(menus: SystemMenu[]): string[] {
|
||||
const ids: string[] = []
|
||||
for (const menu of menus) {
|
||||
ids.push(menu.id)
|
||||
if (menu.children) {
|
||||
ids.push(...getAllMenuIds(menu.children))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// 菜单树选择组件
|
||||
function MenuTreeSelect({
|
||||
menus,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
level = 0,
|
||||
}: {
|
||||
menus: SystemMenu[]
|
||||
selectedIds: string[]
|
||||
onToggle: (id: string, children?: SystemMenu[]) => void
|
||||
level?: number
|
||||
}) {
|
||||
return (
|
||||
<div className={level > 0 ? 'ml-6 border-l pl-4' : ''}>
|
||||
{menus.map(menu => {
|
||||
const hasChildren = menu.children && menu.children.length > 0
|
||||
const isChecked = selectedIds.includes(menu.id)
|
||||
const childIds = hasChildren ? getAllMenuIds(menu.children!) : []
|
||||
const allChildrenSelected = childIds.length > 0 && childIds.every(id => selectedIds.includes(id))
|
||||
const someChildrenSelected = childIds.some(id => selectedIds.includes(id))
|
||||
|
||||
return (
|
||||
<div key={menu.id} className="py-1">
|
||||
<div className="flex items-center gap-2 py-1 hover:bg-muted/50 rounded px-2">
|
||||
<Checkbox
|
||||
id={`menu-${menu.id}`}
|
||||
checked={isChecked}
|
||||
ref={(el) => {
|
||||
if (el && hasChildren) {
|
||||
(el as HTMLButtonElement & { indeterminate?: boolean }).indeterminate =
|
||||
someChildrenSelected && !allChildrenSelected
|
||||
}
|
||||
}}
|
||||
onCheckedChange={() => onToggle(menu.id, menu.children)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`menu-${menu.id}`}
|
||||
className="flex-1 cursor-pointer text-sm"
|
||||
>
|
||||
<span className="font-medium">{menu.title || menu.name}</span>
|
||||
{menu.permission && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({menu.permission})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Badge variant={menu.category === 1 ? 'outline' : 'secondary'} className="text-xs">
|
||||
{menu.category === 1 ? '菜单' : '按钮'}
|
||||
</Badge>
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<MenuTreeSelect
|
||||
menus={menu.children!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={onToggle}
|
||||
level={level + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const [roles, setRoles] = useState<SystemRole[]>([])
|
||||
const [menus, setMenus] = useState<SystemMenu[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchParams, setSearchParams] = useState<GetRoleListParams>({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
})
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [menuDialogOpen, setMenuDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRole | null>(null)
|
||||
const [selectedMenuIds, setSelectedMenuIds] = useState<string[]>([])
|
||||
const [formData, setFormData] = useState<RoleFormData>(defaultFormData)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const canWrite = hasPermission('role:add') || hasPermission('role:edit')
|
||||
const canDelete = hasPermission('role:delete')
|
||||
const canGrant = hasPermission('role:grant')
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getRoleList(searchParams)
|
||||
setRoles(res.data?.list || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
} catch (error) {
|
||||
console.error('获取角色列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取菜单树
|
||||
const fetchMenus = async () => {
|
||||
try {
|
||||
const res = await getAllMenuTree({ category: 1, parentId: '0' })
|
||||
setMenus(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('获取菜单树失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles()
|
||||
}, [searchParams.current, searchParams.pageSize, searchParams.keyword])
|
||||
|
||||
// 只在组件挂载时获取菜单树
|
||||
useEffect(() => {
|
||||
fetchMenus()
|
||||
}, [])
|
||||
|
||||
// 搜索 - 只更新 searchParams,让 useEffect 触发请求
|
||||
const handleSearch = () => {
|
||||
setSearchParams(prev => ({ ...prev, current: 1 }))
|
||||
}
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
setFormData(defaultFormData)
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = async (role: SystemRole) => {
|
||||
try {
|
||||
const res = await getRoleDetail(role.id)
|
||||
const detail = res.data
|
||||
setFormData({
|
||||
id: detail.id,
|
||||
name: detail.name || '',
|
||||
code: detail.code || '',
|
||||
sort: detail.sort || 0,
|
||||
})
|
||||
setIsEdit(true)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取角色详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理授权菜单
|
||||
const handleGrantMenu = async (role: SystemRole) => {
|
||||
setSelectedRole(role)
|
||||
// 获取角色已有的菜单权限
|
||||
try {
|
||||
const res = await getRoleDetail(role.id)
|
||||
const menuIds = res.data.menus?.map((m: SystemMenu) => m.id) || []
|
||||
setSelectedMenuIds(menuIds)
|
||||
setMenuDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取角色菜单失败:', error)
|
||||
setSelectedMenuIds([])
|
||||
setMenuDialogOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换菜单选择
|
||||
const handleMenuToggle = (menuId: string, children?: SystemMenu[]) => {
|
||||
setSelectedMenuIds(prev => {
|
||||
const isSelected = prev.includes(menuId)
|
||||
let newIds = isSelected
|
||||
? prev.filter(id => id !== menuId)
|
||||
: [...prev, menuId]
|
||||
|
||||
// 如果有子菜单,同时选中/取消子菜单
|
||||
if (children && children.length > 0) {
|
||||
const childIds = getAllMenuIds(children)
|
||||
if (isSelected) {
|
||||
newIds = newIds.filter(id => !childIds.includes(id))
|
||||
} else {
|
||||
newIds = [...new Set([...newIds, ...childIds])]
|
||||
}
|
||||
}
|
||||
|
||||
return newIds
|
||||
})
|
||||
}
|
||||
|
||||
// 提交授权菜单
|
||||
const handleSubmitGrant = async () => {
|
||||
if (!selectedRole) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await grantMenu({ roleId: selectedRole.id, menuIds: selectedMenuIds })
|
||||
setMenuDialogOpen(false)
|
||||
fetchRoles()
|
||||
} catch (error) {
|
||||
console.error('授权菜单失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (role: SystemRole) => {
|
||||
setSelectedRole(role)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedRole) return
|
||||
|
||||
try {
|
||||
await deleteRole([selectedRole.id])
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedRole(null)
|
||||
fetchRoles()
|
||||
} catch (error) {
|
||||
console.error('删除角色失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || !formData.code) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit && formData.id) {
|
||||
await updateRole(formData)
|
||||
} else {
|
||||
await saveRole(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchRoles()
|
||||
} catch (error) {
|
||||
console.error('保存角色失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / searchParams.pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">角色管理</h1>
|
||||
<p className="text-muted-foreground">管理系统角色和权限分配</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加角色
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 max-w-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索角色名称..."
|
||||
value={searchParams.keyword}
|
||||
onChange={e => setSearchParams({ ...searchParams, keyword: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 角色列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
角色列表
|
||||
<Badge variant="secondary" className="ml-2">{total}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>角色名称</TableHead>
|
||||
<TableHead>角色编码</TableHead>
|
||||
<TableHead>排序</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="w-[80px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map(role => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-2">
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium">{role.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-0.5 rounded">
|
||||
{role.code}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{role.sort}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{role.createdAtStr || role.createdAt}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canWrite && (
|
||||
<DropdownMenuItem onClick={() => handleEdit(role)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canGrant && (
|
||||
<DropdownMenuItem onClick={() => handleGrantMenu(role)}>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
授权菜单
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteConfirm(role)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current === 1}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{totalPages > 0 && Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page = i + 1
|
||||
if (totalPages > 5) {
|
||||
if (searchParams.current <= 3) {
|
||||
page = i + 1
|
||||
} else if (searchParams.current >= totalPages - 2) {
|
||||
page = totalPages - 4 + i
|
||||
} else {
|
||||
page = searchParams.current - 2 + i
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={searchParams.current === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0"
|
||||
onClick={() => setSearchParams({ ...searchParams, current: page })}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{totalPages === 0 && (
|
||||
<Button variant="default" size="sm" className="w-8 h-8 p-0" disabled>1</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current >= totalPages || totalPages === 0}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Select
|
||||
value={String(searchParams.pageSize)}
|
||||
onValueChange={v => setSearchParams({ ...searchParams, pageSize: Number(v), current: 1 })}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 条/页</SelectItem>
|
||||
<SelectItem value="20">20 条/页</SelectItem>
|
||||
<SelectItem value="50">50 条/页</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑角色' : '添加角色'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? '修改角色信息' : '创建新的角色'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>角色名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:管理员、运营"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>角色编码 *</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={e => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="如:admin、operator"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>排序</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.sort}
|
||||
onChange={e => setFormData({ ...formData, sort: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 授权菜单对话框 */}
|
||||
<Dialog open={menuDialogOpen} onOpenChange={setMenuDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>授权菜单</DialogTitle>
|
||||
<DialogDescription>
|
||||
为角色 "{selectedRole?.name}" 分配菜单权限
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4 max-h-[50vh] overflow-y-auto border rounded-lg p-4">
|
||||
{menus.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
暂无菜单数据
|
||||
</div>
|
||||
) : (
|
||||
<MenuTreeSelect
|
||||
menus={menus}
|
||||
selectedIds={selectedMenuIds}
|
||||
onToggle={handleMenuToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>已选择 {selectedMenuIds.length} 个菜单</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedMenuIds(getAllMenuIds(menus))}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedMenuIds([])}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setMenuDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmitGrant} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '确定'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除角色 "{selectedRole?.name}" 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Search, MoreHorizontal, Pencil, Trash2, Users, Key } 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
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, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
getUserList,
|
||||
getUserDetail,
|
||||
saveUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getRoleList,
|
||||
grantRole,
|
||||
type SystemUser,
|
||||
type SystemRole,
|
||||
type GetUserListParams,
|
||||
} from '@/api/system'
|
||||
|
||||
interface UserFormData {
|
||||
id?: string
|
||||
account: string
|
||||
name: string
|
||||
nickName: string
|
||||
phone: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
const defaultFormData: UserFormData = {
|
||||
account: '',
|
||||
name: '',
|
||||
nickName: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const [users, setUsers] = useState<SystemUser[]>([])
|
||||
const [roles, setRoles] = useState<SystemRole[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchParams, setSearchParams] = useState<GetUserListParams>({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
})
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<SystemUser | null>(null)
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([])
|
||||
const [formData, setFormData] = useState<UserFormData>(defaultFormData)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const canWrite = hasPermission('user:add') || hasPermission('user:edit')
|
||||
const canDelete = hasPermission('user:delete')
|
||||
const canGrant = hasPermission('user:grant')
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getUserList(searchParams)
|
||||
setUsers(res.data?.list || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await getRoleList({ current: 1, pageSize: 100 })
|
||||
setRoles(res.data?.list || [])
|
||||
} catch (error) {
|
||||
console.error('获取角色列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [searchParams.current, searchParams.pageSize, searchParams.keyword])
|
||||
|
||||
// 只在组件挂载时获取角色列表
|
||||
useEffect(() => {
|
||||
fetchRoles()
|
||||
}, [])
|
||||
|
||||
// 搜索 - 只更新 searchParams,让 useEffect 触发请求
|
||||
const handleSearch = () => {
|
||||
setSearchParams(prev => ({ ...prev, current: 1 }))
|
||||
}
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
setFormData(defaultFormData)
|
||||
setIsEdit(false)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = async (user: SystemUser) => {
|
||||
try {
|
||||
const res = await getUserDetail(user.id)
|
||||
const detail = res.data
|
||||
setFormData({
|
||||
id: detail.id,
|
||||
account: detail.account || '',
|
||||
name: detail.name || '',
|
||||
nickName: detail.nickName || '',
|
||||
phone: detail.phone || '',
|
||||
})
|
||||
setIsEdit(true)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('获取用户详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分配角色
|
||||
const handleGrantRole = (user: SystemUser) => {
|
||||
setSelectedUser(user)
|
||||
setSelectedRoleIds(user.roles?.map(r => r.id) || [])
|
||||
setRoleDialogOpen(true)
|
||||
}
|
||||
|
||||
// 提交分配角色
|
||||
const handleSubmitGrant = async () => {
|
||||
if (!selectedUser) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await grantRole({ userId: selectedUser.id, roleIds: selectedRoleIds })
|
||||
setRoleDialogOpen(false)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('分配角色失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = (user: SystemUser) => {
|
||||
setSelectedUser(user)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
if (!selectedUser) return
|
||||
|
||||
try {
|
||||
await deleteUser([selectedUser.id])
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedUser(null)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.account || !formData.name) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit && formData.id) {
|
||||
await updateUser(formData)
|
||||
} else {
|
||||
await saveUser({ ...formData, clientId: 'pc' })
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('保存用户失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / searchParams.pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">用户管理</h1>
|
||||
<p className="text-muted-foreground">管理系统用户和权限</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加用户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 max-w-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名或手机号..."
|
||||
value={searchParams.keyword}
|
||||
onChange={e => setSearchParams({ ...searchParams, keyword: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
用户列表
|
||||
<Badge variant="secondary" className="ml-2">{total}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>账号</TableHead>
|
||||
<TableHead>手机号</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="w-[80px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map(user => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar?.url} alt={user.name} />
|
||||
<AvatarFallback>{(user.name || user.account)?.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{user.name || user.nickName}</p>
|
||||
{user.nickName && user.name !== user.nickName && (
|
||||
<p className="text-xs text-muted-foreground">{user.nickName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{user.account}</TableCell>
|
||||
<TableCell>{user.phone || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles?.length ? (
|
||||
user.roles.map(role => (
|
||||
<Badge key={role.id} variant="secondary">{role.name}</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">未分配</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.createdAtStr || user.createdAt}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canWrite && (
|
||||
<DropdownMenuItem onClick={() => handleEdit(user)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canGrant && (
|
||||
<DropdownMenuItem onClick={() => handleGrantRole(user)}>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
分配角色
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteConfirm(user)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current === 1}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{totalPages > 0 && Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page = i + 1
|
||||
if (totalPages > 5) {
|
||||
if (searchParams.current <= 3) {
|
||||
page = i + 1
|
||||
} else if (searchParams.current >= totalPages - 2) {
|
||||
page = totalPages - 4 + i
|
||||
} else {
|
||||
page = searchParams.current - 2 + i
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={searchParams.current === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="w-8 h-8 p-0"
|
||||
onClick={() => setSearchParams({ ...searchParams, current: page })}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{totalPages === 0 && (
|
||||
<Button variant="default" size="sm" className="w-8 h-8 p-0" disabled>1</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={searchParams.current >= totalPages || totalPages === 0}
|
||||
onClick={() => setSearchParams({ ...searchParams, current: searchParams.current + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Select
|
||||
value={String(searchParams.pageSize)}
|
||||
onValueChange={v => setSearchParams({ ...searchParams, pageSize: Number(v), current: 1 })}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 条/页</SelectItem>
|
||||
<SelectItem value="20">20 条/页</SelectItem>
|
||||
<SelectItem value="50">50 条/页</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑用户' : '添加用户'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? '修改用户信息' : '创建新的系统用户'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>账号 *</Label>
|
||||
<Input
|
||||
value={formData.account}
|
||||
onChange={e => setFormData({ ...formData, account: e.target.value })}
|
||||
placeholder="登录账号"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>姓名 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="用户姓名"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>昵称</Label>
|
||||
<Input
|
||||
value={formData.nickName}
|
||||
onChange={e => setFormData({ ...formData, nickName: e.target.value })}
|
||||
placeholder="用户昵称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
value={formData.phone}
|
||||
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||
placeholder="手机号码"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div className="space-y-2">
|
||||
<Label>密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="初始密码"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 分配角色对话框 */}
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>分配角色</DialogTitle>
|
||||
<DialogDescription>
|
||||
为用户 "{selectedUser?.name || selectedUser?.account}" 分配角色
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{roles.map(role => (
|
||||
<div key={role.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted">
|
||||
<Checkbox
|
||||
id={`role-${role.id}`}
|
||||
checked={selectedRoleIds.includes(role.id)}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setSelectedRoleIds([...selectedRoleIds, role.id])
|
||||
} else {
|
||||
setSelectedRoleIds(selectedRoleIds.filter(id => id !== role.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`role-${role.id}`} className="flex-1 cursor-pointer">
|
||||
<p className="font-medium">{role.name}</p>
|
||||
{role.code && (
|
||||
<p className="text-xs text-muted-foreground">{role.code}</p>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmitGrant} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '确定'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除用户 "{selectedUser?.name || selectedUser?.account}" 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// User & Auth Types
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
roleId: string
|
||||
status: 'active' | 'inactive'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
permissions: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
description: string
|
||||
module: string
|
||||
}
|
||||
|
||||
// Community Topic Types
|
||||
export interface Topic {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
authorId: string
|
||||
authorName: string
|
||||
status: 'draft' | 'published' | 'archived'
|
||||
viewCount: number
|
||||
likeCount: number
|
||||
commentCount: number
|
||||
tags: string[]
|
||||
coverImage?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Encyclopedia Category Types
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
parentId: string | null
|
||||
order: number
|
||||
icon?: string
|
||||
children?: Category[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Plant Encyclopedia Types
|
||||
export interface Plant {
|
||||
id: string
|
||||
name: string
|
||||
scientificName: string
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
description: string
|
||||
careGuide: string
|
||||
wateringFrequency: string
|
||||
sunlightRequirement: 'low' | 'medium' | 'high'
|
||||
difficultyLevel: 'easy' | 'medium' | 'hard'
|
||||
images: string[]
|
||||
status: 'draft' | 'published'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Auth Types
|
||||
export interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
// Table Types
|
||||
export interface PaginationState {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface TableFilters {
|
||||
search?: string
|
||||
status?: string
|
||||
categoryId?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
+4132
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Path alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8889',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user