init: initial commit

This commit is contained in:
Blizzard
2026-02-11 16:08:37 +08:00
commit d948a39ad5
64 changed files with 22049 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [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...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+19
View File
@@ -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>
+6427
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="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

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

After

Width:  |  Height:  |  Size: 4.0 KiB

+53
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.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 }
+38
View File
@@ -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 }
+57
View File
@@ -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 }
+76
View File
@@ -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 }
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.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 }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.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,
}
+199
View File
@@ -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,
}
+22
View File
@@ -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 }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.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 }
+46
View File
@@ -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 }
+157
View File
@@ -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,
}
+29
View File
@@ -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 }
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.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 }
+120
View File
@@ -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,
}
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.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 }
+22
View File
@@ -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 }
+139
View File
@@ -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
}
+277
View File
@@ -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
View File
@@ -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;
}
+434
View File
@@ -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>
)
}
+136
View File
@@ -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
}
+20
View File
@@ -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)
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+401
View File
@@ -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>
)
}
+225
View File
@@ -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>
)
}
+196
View File
@@ -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>
)
}
+517
View File
@@ -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>
)
}
+397
View File
@@ -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>
)
}
+521
View File
@@ -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>
)
}
+525
View File
@@ -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>
)
}
+51
View File
@@ -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>
)
}
+461
View File
@@ -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>
)
}
+381
View File
@@ -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
+491
View File
@@ -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>
)
}
+493
View File
@@ -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>
)
}
+519
View File
@@ -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>
)
}
+646
View File
@@ -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>
)
}
+574
View File
@@ -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>
)
}
+102
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -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/, ''),
},
},
},
})