feat:初始化项目
This commit is contained in:
+87
@@ -0,0 +1,87 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
dist-ssr
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.settings/
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# NestJS
|
||||||
|
dist/
|
||||||
|
*.js.map
|
||||||
|
|
||||||
|
# React
|
||||||
|
.eslintcache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Platform Doodle
|
||||||
|
|
||||||
|
Doodle 平台 Monorepo 项目,包含后端 API 和后台管理系统。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
platform-doodle/
|
||||||
|
├── packages/
|
||||||
|
│ ├── api/ # NestJS 后端服务
|
||||||
|
│ └── admin/ # React 后台管理系统
|
||||||
|
└── package.json # 根配置文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **后端**: NestJS
|
||||||
|
- **前端**: React
|
||||||
|
- **包管理**: pnpm
|
||||||
|
- **架构**: Monorepo
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动后端 API
|
||||||
|
pnpm dev:api
|
||||||
|
|
||||||
|
# 启动后台管理系统
|
||||||
|
pnpm dev:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建后端
|
||||||
|
pnpm build:api
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
pnpm build:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 子项目
|
||||||
|
|
||||||
|
### API (NestJS)
|
||||||
|
|
||||||
|
后端 API 服务,为小程序和后台管理系统提供接口。
|
||||||
|
|
||||||
|
### Admin (React)
|
||||||
|
|
||||||
|
后台管理系统,用于管理平台数据和配置。
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
|
- 使用 pnpm workspace 管理依赖
|
||||||
|
- 共享代码放在 `packages/shared`(如需要)
|
||||||
|
- 遵循各子项目的代码规范
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:3301
|
||||||
|
VITE_PORT=3300
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:3301
|
||||||
|
VITE_PORT=3300
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 涂鸦丫后台管理系统 - 前端应用
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- React 19
|
||||||
|
- TypeScript
|
||||||
|
- Vite
|
||||||
|
- Ant Design
|
||||||
|
- Axios
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
1. 复制 `.env.example` 为 `.env`
|
||||||
|
2. 配置API地址:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_URL=http://localhost:3301
|
||||||
|
VITE_PORT=3300
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预览
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
### 汉字管理
|
||||||
|
|
||||||
|
1. **卡片列表**
|
||||||
|
- 每页显示9个汉字卡片
|
||||||
|
- 支持汉字搜索
|
||||||
|
- 按年级排序(低年级在前)
|
||||||
|
|
||||||
|
2. **卡片内容**
|
||||||
|
- 左上角:汉字(大号字体)
|
||||||
|
- 右上角上方:拼音(多音字用"|"分割)
|
||||||
|
- 右上角下方:SVG标识(绿色=有,灰色=无)
|
||||||
|
- 下方:第一张关联图片
|
||||||
|
|
||||||
|
3. **详情弹窗**
|
||||||
|
- 点击卡片右侧滑出弹窗
|
||||||
|
- 田字格+Canvas渲染字体
|
||||||
|
- 有SVG:黑色笔画渲染
|
||||||
|
- 无SVG:灰色文字显示
|
||||||
|
- 拼音展示
|
||||||
|
- 笔画顺序展示
|
||||||
|
- 图片管理(最多4张)
|
||||||
|
- 词语管理(待实现)
|
||||||
|
- 句子管理(支持添加、删除)
|
||||||
|
|
||||||
|
4. **编辑模式**
|
||||||
|
- 图片:支持删除、排序
|
||||||
|
- 句子:支持添加、删除
|
||||||
|
- 汉字、拼音、SVG不可编辑(通过其他方式导入)
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 确保API服务已启动
|
||||||
|
- 图片路径需要正确配置
|
||||||
|
- SVG数据格式需要符合规范
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
'no-console': 'off',
|
||||||
|
'@typescript-eslint/no-require-imports': 'warn',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<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>admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"start": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.1.0",
|
||||||
|
"antd": "^6.2.1",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router": "^7.12.0",
|
||||||
|
"styled-components": "^6.3.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @see https://prettier.io/docs/en/configuration.html
|
||||||
|
* @type {import("prettier").Config}
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
printWidth: 100,
|
||||||
|
tabWidth: 4,
|
||||||
|
useTabs: false,
|
||||||
|
semi: true,
|
||||||
|
singleQuote: true,
|
||||||
|
quoteProps: 'as-needed',
|
||||||
|
jsxSingleQuote: false,
|
||||||
|
trailingComma: 'es5',
|
||||||
|
bracketSpacing: true,
|
||||||
|
bracketSameLine: false,
|
||||||
|
arrowParens: 'always',
|
||||||
|
endOfLine: 'lf',
|
||||||
|
embeddedLanguageFormatting: 'auto',
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
/* 应用全局样式 */
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { RouterProvider } from 'react-router';
|
||||||
|
import { ConfigProvider, App as AntdApp } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import { router } from './router';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#93D333',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AntdApp>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AntdApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import ErrorPage from './ErrorPage';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
errorInfo: ErrorInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
errorInfo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
// 记录错误信息
|
||||||
|
console.error('ErrorBoundary 捕获到错误:', error, errorInfo);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 可以在这里将错误发送到错误监控服务
|
||||||
|
// 例如:Sentry, LogRocket 等
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorPage
|
||||||
|
error={this.state.error}
|
||||||
|
errorInfo={this.state.errorInfo}
|
||||||
|
onReset={this.handleReset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
.error-page {
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page-card {
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page-content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 80px;
|
||||||
|
color: #93D333;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-description {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
margin-top: 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details {
|
||||||
|
margin-top: 24px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details-content {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-detail-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-detail-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-detail-section h5 {
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-stack {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #f9fafb;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { Button, Card, Typography, Space, Collapse } from 'antd';
|
||||||
|
import { ReloadOutlined, HomeOutlined, BugOutlined, FileSearchOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate, useRouteError, isRouteErrorResponse } from 'react-router';
|
||||||
|
import type { ErrorInfo } from 'react';
|
||||||
|
import './ErrorPage.css';
|
||||||
|
|
||||||
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
error?: Error | null;
|
||||||
|
errorInfo?: ErrorInfo | null;
|
||||||
|
onReset?: () => void;
|
||||||
|
is404?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorPage = ({ error: propError, errorInfo, onReset, is404: propIs404 }: ErrorPageProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const routeError = useRouteError();
|
||||||
|
|
||||||
|
// 判断是否为 404 错误
|
||||||
|
const is404 =
|
||||||
|
propIs404 ||
|
||||||
|
(isRouteErrorResponse(routeError) && routeError.status === 404) ||
|
||||||
|
(routeError instanceof Error && routeError.message.includes('404'));
|
||||||
|
|
||||||
|
// 获取错误信息
|
||||||
|
const error = propError || (routeError instanceof Error ? routeError : null);
|
||||||
|
const errorMessage = is404
|
||||||
|
? '页面未找到'
|
||||||
|
: isRouteErrorResponse(routeError)
|
||||||
|
? routeError.statusText || '页面加载失败'
|
||||||
|
: error?.message || '未知错误';
|
||||||
|
|
||||||
|
const handleGoHome = () => {
|
||||||
|
navigate('/');
|
||||||
|
if (onReset) {
|
||||||
|
onReset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReload = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoBack = () => {
|
||||||
|
navigate(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="error-page">
|
||||||
|
<Card className="error-page-card">
|
||||||
|
<div className="error-page-content">
|
||||||
|
<div className="error-icon">
|
||||||
|
{is404 ? <FileSearchOutlined /> : <BugOutlined />}
|
||||||
|
</div>
|
||||||
|
<Title level={2} className="error-title">
|
||||||
|
{is404 ? '页面未找到' : '哎呀,出错了!'}
|
||||||
|
</Title>
|
||||||
|
<Paragraph className="error-description">
|
||||||
|
{is404 ? (
|
||||||
|
<>
|
||||||
|
抱歉,您访问的页面不存在。
|
||||||
|
<br />
|
||||||
|
请检查 URL 是否正确,或返回首页继续浏览。
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
应用遇到了一个意外错误,我们已经记录了这个问题。
|
||||||
|
<br />
|
||||||
|
您可以尝试刷新页面或返回首页。
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
{errorMessage && !is404 && (
|
||||||
|
<div className="error-message">
|
||||||
|
<Text type="danger" strong>
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space size="middle" className="error-actions">
|
||||||
|
{!is404 && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleReload}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
刷新页面
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type={is404 ? 'primary' : 'default'}
|
||||||
|
icon={<HomeOutlined />}
|
||||||
|
onClick={handleGoHome}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
{is404 && (
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleGoBack} size="large">
|
||||||
|
返回上页
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{import.meta.env.DEV &&
|
||||||
|
!is404 &&
|
||||||
|
(error ||
|
||||||
|
errorInfo ||
|
||||||
|
(routeError !== null && routeError !== undefined)) && (
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
className="error-details"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: '错误详情(开发模式)',
|
||||||
|
children: (
|
||||||
|
<div className="error-details-content">
|
||||||
|
{error && (
|
||||||
|
<div className="error-detail-section">
|
||||||
|
<Title level={5}>错误信息:</Title>
|
||||||
|
<pre className="error-stack">
|
||||||
|
{error.stack || error.toString()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isRouteErrorResponse(routeError) && (
|
||||||
|
<div className="error-detail-section">
|
||||||
|
<Title level={5}>路由错误:</Title>
|
||||||
|
<pre className="error-stack">
|
||||||
|
{`状态码: ${routeError.status}\n状态文本: ${routeError.statusText}\n数据: ${JSON.stringify(
|
||||||
|
routeError.data as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errorInfo && (
|
||||||
|
<div className="error-detail-section">
|
||||||
|
<Title level={5}>组件堆栈:</Title>
|
||||||
|
<pre className="error-stack">
|
||||||
|
{errorInfo.componentStack}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{routeError !== null &&
|
||||||
|
routeError !== undefined &&
|
||||||
|
!(routeError instanceof Error) &&
|
||||||
|
!isRouteErrorResponse(routeError) && (
|
||||||
|
<div className="error-detail-section">
|
||||||
|
<Title level={5}>路由错误详情:</Title>
|
||||||
|
<pre className="error-stack">
|
||||||
|
{JSON.stringify(
|
||||||
|
routeError as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorPage;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Navigate } from 'react-router';
|
||||||
|
import { authService } from '../services/auth';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由守卫组件
|
||||||
|
* 用于保护需要登录才能访问的页面
|
||||||
|
*/
|
||||||
|
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
|
const isAuthenticated = authService.isAuthenticated();
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// 未登录,重定向到登录页
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录,渲染子组件
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.tianzige-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tianzige-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
interface TianZiGeProps {
|
||||||
|
character: string;
|
||||||
|
svgData?: any;
|
||||||
|
hasSvg: boolean;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TianZiGe: React.FC<TianZiGeProps> = ({
|
||||||
|
character,
|
||||||
|
svgData,
|
||||||
|
hasSvg,
|
||||||
|
size = 120,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// 设置画布大小
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
|
||||||
|
// 清空画布
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// 绘制田字格
|
||||||
|
const padding = size * 0.1;
|
||||||
|
const gridSize = size - padding * 2;
|
||||||
|
const centerX = size / 2;
|
||||||
|
const centerY = size / 2;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#d9d9d9';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
// 外框
|
||||||
|
ctx.strokeRect(padding, padding, gridSize, gridSize);
|
||||||
|
|
||||||
|
// 横中线
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding, centerY);
|
||||||
|
ctx.lineTo(size - padding, centerY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 竖中线
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(centerX, padding);
|
||||||
|
ctx.lineTo(centerX, size - padding);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (hasSvg && svgData) {
|
||||||
|
// 有SVG数据,使用黑色渲染笔画
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.lineWidth = Math.max(2, size / 60);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
// 处理不同的SVG数据格式
|
||||||
|
const strokes = svgData.strokes || svgData.paths || [];
|
||||||
|
|
||||||
|
strokes.forEach((stroke: any) => {
|
||||||
|
if (!stroke) return;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
// 支持多种数据格式
|
||||||
|
let points: any[] = [];
|
||||||
|
if (Array.isArray(stroke.path)) {
|
||||||
|
points = stroke.path;
|
||||||
|
} else if (Array.isArray(stroke)) {
|
||||||
|
points = stroke;
|
||||||
|
} else if (stroke.points && Array.isArray(stroke.points)) {
|
||||||
|
points = stroke.points;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (points.length > 0) {
|
||||||
|
points.forEach((point: any, index: number) => {
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
|
||||||
|
if (typeof point === 'object') {
|
||||||
|
x = point.x || point[0] || 0;
|
||||||
|
y = point.y || point[1] || 0;
|
||||||
|
} else if (Array.isArray(point)) {
|
||||||
|
x = point[0] || 0;
|
||||||
|
y = point[1] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将坐标映射到画布(假设SVG坐标范围是0-100)
|
||||||
|
const mappedX = padding + x * (gridSize / 100);
|
||||||
|
const mappedY = padding + y * (gridSize / 100);
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
ctx.moveTo(mappedX, mappedY);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(mappedX, mappedY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 没有SVG数据,直接显示文字(灰色)
|
||||||
|
ctx.fillStyle = '#d9d9d9';
|
||||||
|
ctx.font = `bold ${size * 0.6}px Arial`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(character, centerX, centerY);
|
||||||
|
}
|
||||||
|
}, [character, svgData, hasSvg, size]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tianzige-container" style={{ width: size, height: size }}>
|
||||||
|
<canvas ref={canvasRef} className="tianzige-canvas" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
.word-card {
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card-content {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
color: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card-pinyin {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card-svg-indicator {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card-image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card } from 'antd';
|
||||||
|
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||||
|
import type { Word } from '@/types/words';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
interface WordCardProps {
|
||||||
|
word: Word;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WordCard: React.FC<WordCardProps> = ({ word, onClick }) => {
|
||||||
|
const hasSvg = !!word.svg_data;
|
||||||
|
const firstImage = word.images?.[0];
|
||||||
|
|
||||||
|
// 格式化拼音显示
|
||||||
|
const pinyinDisplay = word.pinyins?.join(' | ') || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
className="word-card"
|
||||||
|
onClick={onClick}
|
||||||
|
cover={
|
||||||
|
firstImage ? (
|
||||||
|
<div className="word-card-image">
|
||||||
|
<img
|
||||||
|
src={firstImage.file_path}
|
||||||
|
alt={word.content}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="word-card-image-placeholder">暂无图片</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="word-card-header">
|
||||||
|
<div className="word-card-content">{word.content}</div>
|
||||||
|
<div className="word-card-meta">
|
||||||
|
<div className="word-card-pinyin">{pinyinDisplay}</div>
|
||||||
|
<div className="word-card-svg-indicator">
|
||||||
|
{hasSvg ? (
|
||||||
|
<CheckCircleOutlined style={{ color: '#93D333' }} />
|
||||||
|
) : (
|
||||||
|
<CloseCircleOutlined style={{ color: '#d9d9d9' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
.word-detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-detail-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-detail-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-detail-tianzige {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-detail-pinyin-section {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-detail-pinyin {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-detail-strokes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stroke-item {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-detail-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-detail-section h3 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-detail-images {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Drawer, Button, Space, Image, List, Input, Form, message } from 'antd';
|
||||||
|
import { EditOutlined, SaveOutlined, CloseOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import type { Word, Sentence } from '@/types/words';
|
||||||
|
import { wordsService } from '@/services/words';
|
||||||
|
import { TianZiGe } from '@/components/TianZiGe';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
interface WordDetailDrawerProps {
|
||||||
|
word: Word | null;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WordDetailDrawer: React.FC<WordDetailDrawerProps> = ({
|
||||||
|
word,
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onRefresh,
|
||||||
|
}) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [sentences, setSentences] = useState<Sentence[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [newSentence, setNewSentence] = useState('');
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (word && visible) {
|
||||||
|
loadSentences();
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}, [word, visible]);
|
||||||
|
|
||||||
|
const loadSentences = async () => {
|
||||||
|
if (!word) return;
|
||||||
|
try {
|
||||||
|
const data = await wordsService.getWordSentences(word.id);
|
||||||
|
setSentences(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load sentences:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!word) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const values = await form.validateFields();
|
||||||
|
// 这里可以添加保存逻辑
|
||||||
|
message.success('保存成功');
|
||||||
|
setIsEditing(false);
|
||||||
|
onRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSentence = async () => {
|
||||||
|
if (!word) return;
|
||||||
|
if (!newSentence.trim()) {
|
||||||
|
message.warning('请输入句子内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await wordsService.addSentence(word.id, { content: newSentence.trim() });
|
||||||
|
message.success('添加成功');
|
||||||
|
setNewSentence('');
|
||||||
|
loadSentences();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('添加失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSentence = async (sentenceId: number) => {
|
||||||
|
if (!word) return;
|
||||||
|
try {
|
||||||
|
await wordsService.deleteSentence(word.id, sentenceId);
|
||||||
|
message.success('删除成功');
|
||||||
|
loadSentences();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!word) return null;
|
||||||
|
|
||||||
|
const images = word.images || [];
|
||||||
|
const hasSvg = !!word.svg_data;
|
||||||
|
const pinyinDisplay = word.pinyins?.join(' | ') || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<div className="word-detail-header">
|
||||||
|
<span>{word.content}</span>
|
||||||
|
<Space>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button icon={<CloseOutlined />} onClick={() => setIsEditing(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="right"
|
||||||
|
width={600}
|
||||||
|
onClose={onClose}
|
||||||
|
open={visible}
|
||||||
|
>
|
||||||
|
<div className="word-detail-content">
|
||||||
|
{/* 第一行:田字格和拼音 */}
|
||||||
|
<div className="word-detail-row">
|
||||||
|
<div className="word-detail-tianzige">
|
||||||
|
<TianZiGe
|
||||||
|
character={word.content}
|
||||||
|
svgData={word.svg_data}
|
||||||
|
hasSvg={hasSvg}
|
||||||
|
size={120}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="word-detail-pinyin-section">
|
||||||
|
<div className="word-detail-pinyin">{pinyinDisplay}</div>
|
||||||
|
{/* 笔画展示区域 */}
|
||||||
|
{hasSvg && word.svg_data && (
|
||||||
|
<div className="word-detail-strokes">
|
||||||
|
{(word.svg_data.strokes || word.svg_data.paths || []).map(
|
||||||
|
(stroke: any, index: number) => (
|
||||||
|
<div key={index} className="stroke-item">
|
||||||
|
<TianZiGe
|
||||||
|
character={word.content}
|
||||||
|
svgData={{ strokes: [stroke] }}
|
||||||
|
hasSvg={true}
|
||||||
|
size={40}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第二行:图片 */}
|
||||||
|
<div className="word-detail-section">
|
||||||
|
<h3>图片 ({images.length}/4)</h3>
|
||||||
|
{isEditing ? (
|
||||||
|
<div>
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<div className="word-detail-images">
|
||||||
|
{images.map((img) => (
|
||||||
|
<div key={img.id} className="image-item">
|
||||||
|
<Image
|
||||||
|
src={img.file_path}
|
||||||
|
alt={word.content}
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
style={{ objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await wordsService.deleteWordImage(word.id, img.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
onRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
{images.length < 4 && (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: 实现图片上传
|
||||||
|
message.info('图片上传功能待实现');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
上传图片
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<div className="word-detail-images">
|
||||||
|
{images.map((img) => (
|
||||||
|
<Image
|
||||||
|
key={img.id}
|
||||||
|
src={img.file_path}
|
||||||
|
alt={word.content}
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
style={{ objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第三行:词语 */}
|
||||||
|
<div className="word-detail-section">
|
||||||
|
<h3>词语</h3>
|
||||||
|
{isEditing ? (
|
||||||
|
<div>
|
||||||
|
<List
|
||||||
|
dataSource={[]}
|
||||||
|
renderItem={() => null}
|
||||||
|
footer={
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
message.info('词语编辑功能待实现');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加词语
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>暂无词语</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第四行:句子 */}
|
||||||
|
<div className="word-detail-section">
|
||||||
|
<h3>句子</h3>
|
||||||
|
{isEditing ? (
|
||||||
|
<div>
|
||||||
|
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="输入句子内容"
|
||||||
|
onPressEnter={handleAddSentence}
|
||||||
|
value={newSentence}
|
||||||
|
onChange={(e) => setNewSentence(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={handleAddSentence}>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
<List
|
||||||
|
dataSource={sentences}
|
||||||
|
renderItem={(sentence) => (
|
||||||
|
<List.Item
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleDeleteSentence(sentence.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{sentence.content}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={sentences}
|
||||||
|
renderItem={(sentence) => <List.Item>{sentence.content}</List.Item>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 环境变量配置
|
||||||
|
* 在 Vite 中,只有以 VITE_ 开头的环境变量才会暴露给客户端代码
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface EnvConfig {
|
||||||
|
// API 配置
|
||||||
|
apiBaseUrl: string;
|
||||||
|
|
||||||
|
// 应用配置
|
||||||
|
appName: string;
|
||||||
|
appTitle: string;
|
||||||
|
|
||||||
|
// 环境标识
|
||||||
|
env: string;
|
||||||
|
|
||||||
|
// 功能开关
|
||||||
|
useMock: boolean;
|
||||||
|
debug: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取环境变量,提供默认值
|
||||||
|
const getEnv = (key: string, defaultValue: string = ''): string => {
|
||||||
|
return import.meta.env[key] || defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取布尔类型环境变量
|
||||||
|
const getBoolEnv = (key: string, defaultValue: boolean = false): boolean => {
|
||||||
|
const value = import.meta.env[key];
|
||||||
|
if (value === undefined) return defaultValue;
|
||||||
|
return value === 'true' || value === '1';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 环境配置
|
||||||
|
export const envConfig: EnvConfig = {
|
||||||
|
// API 基础地址
|
||||||
|
apiBaseUrl: getEnv('VITE_API_URL', 'http://localhost:3301'),
|
||||||
|
|
||||||
|
// 应用配置
|
||||||
|
appName: getEnv('VITE_APP_NAME', '涂鸦丫管理后台'),
|
||||||
|
appTitle: getEnv('VITE_APP_TITLE', '涂鸦丫管理后台'),
|
||||||
|
|
||||||
|
// 环境标识
|
||||||
|
env: getEnv('VITE_ENV', import.meta.env.MODE || 'development'),
|
||||||
|
|
||||||
|
// 功能开关
|
||||||
|
useMock: getBoolEnv('VITE_USE_MOCK', false),
|
||||||
|
debug: getBoolEnv('VITE_DEBUG', import.meta.env.DEV),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 是否为开发环境
|
||||||
|
export const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
|
// 是否为生产环境
|
||||||
|
export const isProd = import.meta.env.PROD;
|
||||||
|
|
||||||
|
// 导出环境变量(用于调试)
|
||||||
|
if (envConfig.debug) {
|
||||||
|
console.log('环境配置:', envConfig);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
.main-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 侧边栏样式 */
|
||||||
|
.main-sider {
|
||||||
|
background: linear-gradient(180deg, #93D333 0%, #7CB518 100%) !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部栏样式 */
|
||||||
|
.main-header {
|
||||||
|
background: white !important;
|
||||||
|
padding: 0 24px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||||
|
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: 64px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-trigger {
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #1f2937;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-trigger:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details .ant-badge {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details .ant-badge-status-text {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区域 */
|
||||||
|
.main-content {
|
||||||
|
padding: 24px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||||
|
import { Layout, Avatar, Badge, Dropdown, message } from 'antd';
|
||||||
|
import {
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
|
import { authService } from '@/services/auth';
|
||||||
|
import type { UserInfo } from '@/services/auth';
|
||||||
|
import SidebarMenu from './SidebarMenu';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
import { getPageInfo } from './menuConfig';
|
||||||
|
import './MainLayout.css';
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
const MainLayout = () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [user, setUser] = useState<UserInfo | null>(null);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
useEffect(() => {
|
||||||
|
const currentUser = authService.getUser();
|
||||||
|
setUser(currentUser);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 根据路由获取页面标题
|
||||||
|
const pageInfo = useMemo(() => {
|
||||||
|
return getPageInfo(location.pathname);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
// 处理登出
|
||||||
|
const handleLogout = () => {
|
||||||
|
authService.logout();
|
||||||
|
message.success('已退出登录');
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 用户下拉菜单
|
||||||
|
const userMenuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: '退出登录',
|
||||||
|
danger: true,
|
||||||
|
onClick: handleLogout,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 获取角色显示文本
|
||||||
|
const getRoleText = (role: string) => {
|
||||||
|
const roleMap: Record<string, string> = {
|
||||||
|
user: '普通用户',
|
||||||
|
admin: '管理员',
|
||||||
|
super_admin: '超级管理员',
|
||||||
|
};
|
||||||
|
return roleMap[role] || '普通用户';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取角色状态
|
||||||
|
const getRoleStatus = (role: string): 'success' | 'warning' | 'error' => {
|
||||||
|
if (role === 'admin' || role === 'super_admin') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
return 'success';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout className="main-layout">
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
<Sider
|
||||||
|
className="main-sider"
|
||||||
|
width={240}
|
||||||
|
collapsed={collapsed}
|
||||||
|
theme="dark"
|
||||||
|
style={{
|
||||||
|
overflow: 'auto',
|
||||||
|
height: '100vh',
|
||||||
|
position: 'fixed',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<div className="logo">{collapsed ? '涂' : '涂鸦丫'}</div>
|
||||||
|
{!collapsed && <div className="logo-subtitle">管理后台</div>}
|
||||||
|
</div>
|
||||||
|
<SidebarMenu collapsed={collapsed} user={user} />
|
||||||
|
</Sider>
|
||||||
|
|
||||||
|
{/* 主内容区 */}
|
||||||
|
<Layout
|
||||||
|
className="main-content-layout"
|
||||||
|
style={{
|
||||||
|
marginLeft: collapsed ? 80 : 240,
|
||||||
|
transition: 'margin-left 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 顶部栏 */}
|
||||||
|
<Header className="main-header">
|
||||||
|
<div className="header-left">
|
||||||
|
<div
|
||||||
|
className="collapse-trigger"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
>
|
||||||
|
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
</div>
|
||||||
|
<div className="page-title-group">
|
||||||
|
<div className="page-title">{pageInfo.title}</div>
|
||||||
|
<div className="page-subtitle">{pageInfo.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="header-right">
|
||||||
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
|
<div className="user-info" style={{ cursor: 'pointer' }}>
|
||||||
|
<Avatar
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#93D333',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
|
src={user?.avatarUrl}
|
||||||
|
>
|
||||||
|
{user?.nickname?.[0] || user?.username?.[0] || 'U'}
|
||||||
|
</Avatar>
|
||||||
|
<div className="user-details">
|
||||||
|
<div className="user-name">
|
||||||
|
{user?.nickname || user?.username || '用户'}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
status={user ? getRoleStatus(user.role) : 'success'}
|
||||||
|
text={
|
||||||
|
<span className="user-role">
|
||||||
|
{user ? getRoleText(user.role) : '普通用户'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<Content className="main-content">
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Outlet />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainLayout;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
.sidebar-menu-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 12px 20px !important;
|
||||||
|
height: auto !important;
|
||||||
|
line-height: 1.5 !important;
|
||||||
|
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transition: all 0.3s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 菜单文本样式 - 加大字体并加粗 */
|
||||||
|
.sidebar-menu .ant-menu-item {
|
||||||
|
font-size: 15px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item .ant-menu-title-content {
|
||||||
|
font-size: 15px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hover状态 - 不缩进,只改变背景 */
|
||||||
|
.sidebar-menu .ant-menu-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item:hover .ant-menu-title-content {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中状态 - 文本缩进 */
|
||||||
|
.sidebar-menu .ant-menu-item-selected {
|
||||||
|
background: rgba(255, 255, 255, 0.2) !important;
|
||||||
|
border-left: 3px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
|
||||||
|
transform: translateX(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-selected:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
|
||||||
|
transform: translateX(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 菜单分组标题 */
|
||||||
|
.sidebar-menu .ant-menu-item-group-title {
|
||||||
|
padding: 12px 20px 8px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: rgba(255, 255, 255, 0.7) !important;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .menu-section-title .ant-menu-item-group-title {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠状态下隐藏分组标题 */
|
||||||
|
.sidebar-menu .ant-menu-item-group-title:empty {
|
||||||
|
opacity: 0;
|
||||||
|
height: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分隔线 */
|
||||||
|
.sidebar-menu .ant-menu-item-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
margin: 8px 20px !important;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
margin: 8px 20px !important;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 管理员功能区域动画 */
|
||||||
|
.sidebar-menu .admin-section {
|
||||||
|
animation: fadeInDown 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 菜单项图标样式和动画 */
|
||||||
|
.sidebar-menu .ant-menu-item-icon {
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
font-size: 18px !important;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hover状态 - 图标不缩进,只轻微放大 */
|
||||||
|
.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中状态下图标也跟随文本缩进 */
|
||||||
|
.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
|
||||||
|
transform: translateX(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-item-icon {
|
||||||
|
transform: translateX(8px) scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠状态下的样式调整 */
|
||||||
|
.sidebar-menu .ant-menu-item-group-title {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
import { Menu } from 'antd';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
|
import type { UserInfo } from '@/services/auth';
|
||||||
|
import { getMainMenuItems, getAdminMenuItems } from './menuConfig';
|
||||||
|
import './SidebarMenu.css';
|
||||||
|
|
||||||
|
interface SidebarMenuProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
user: UserInfo | null;
|
||||||
|
onMenuClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarMenu = ({ collapsed, user, onMenuClick }: SidebarMenuProps) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 判断是否为管理员
|
||||||
|
const isAdmin = useMemo(() => {
|
||||||
|
return user?.role === 'admin' || user?.role === 'super_admin';
|
||||||
|
}, [user?.role]);
|
||||||
|
|
||||||
|
// 合并菜单项(带分组)
|
||||||
|
const menuItems: MenuProps['items'] = useMemo(() => {
|
||||||
|
// 获取主要功能菜单项
|
||||||
|
const mainMenuConfigs = getMainMenuItems();
|
||||||
|
const mainMenuItems: MenuProps['items'] = mainMenuConfigs.map((config) => ({
|
||||||
|
key: config.key,
|
||||||
|
icon: config.icon,
|
||||||
|
label: config.label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 获取管理员功能菜单项
|
||||||
|
const adminMenuConfigs = getAdminMenuItems();
|
||||||
|
const adminMenuItems: MenuProps['items'] = adminMenuConfigs.map((config) => ({
|
||||||
|
key: config.key,
|
||||||
|
icon: config.icon,
|
||||||
|
label: config.label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const items: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
label: collapsed ? '' : '主要功能',
|
||||||
|
className: 'menu-section-title',
|
||||||
|
children: mainMenuItems,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 如果是管理员,添加分隔线和管理员功能
|
||||||
|
if (isAdmin && adminMenuItems.length > 0) {
|
||||||
|
items.push({
|
||||||
|
type: 'divider',
|
||||||
|
className: 'menu-divider',
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
type: 'group',
|
||||||
|
label: collapsed ? '' : '管理员功能',
|
||||||
|
className: 'menu-section-title admin-section',
|
||||||
|
children: adminMenuItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [collapsed, isAdmin]);
|
||||||
|
|
||||||
|
// 处理菜单点击
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
navigate(key);
|
||||||
|
if (onMenuClick) {
|
||||||
|
onMenuClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取当前选中的菜单项
|
||||||
|
const selectedKeys = useMemo(() => {
|
||||||
|
const path = location.pathname;
|
||||||
|
if (path === '/' || path === '/words') {
|
||||||
|
return ['/words'];
|
||||||
|
}
|
||||||
|
return [path];
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar-menu-container">
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
className="sidebar-menu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarMenu;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
FileTextOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由菜单配置项
|
||||||
|
*/
|
||||||
|
export interface RouteMenuConfig {
|
||||||
|
/** 路由路径 */
|
||||||
|
path: string;
|
||||||
|
/** 菜单键值(通常与 path 相同) */
|
||||||
|
key: string;
|
||||||
|
/** 菜单图标 */
|
||||||
|
icon: ReactNode;
|
||||||
|
/** 菜单标签 */
|
||||||
|
label: string;
|
||||||
|
/** 页面标题 */
|
||||||
|
title: string;
|
||||||
|
/** 页面副标题 */
|
||||||
|
subtitle: string;
|
||||||
|
/** 菜单分组:'main' 主要功能,'admin' 管理员功能 */
|
||||||
|
group: 'main' | 'admin';
|
||||||
|
/** 是否需要管理员权限 */
|
||||||
|
requireAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由菜单配置列表
|
||||||
|
*/
|
||||||
|
export const routeMenuConfig: RouteMenuConfig[] = [
|
||||||
|
{
|
||||||
|
path: '/words',
|
||||||
|
key: '/words',
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
label: '汉字管理',
|
||||||
|
title: '汉字管理',
|
||||||
|
subtitle: '管理汉字信息',
|
||||||
|
group: 'main',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
key: '/settings',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: '系统设置',
|
||||||
|
title: '系统设置',
|
||||||
|
subtitle: '系统配置管理',
|
||||||
|
group: 'admin',
|
||||||
|
requireAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/analytics',
|
||||||
|
key: '/analytics',
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
label: '数据统计',
|
||||||
|
title: '数据统计',
|
||||||
|
subtitle: '了解系统数据',
|
||||||
|
group: 'admin',
|
||||||
|
requireAdmin: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据路径获取页面标题信息
|
||||||
|
*/
|
||||||
|
export const getPageInfo = (path: string): { title: string; subtitle: string } => {
|
||||||
|
// 处理根路径
|
||||||
|
if (path === '/' || path === '') {
|
||||||
|
const defaultRoute = routeMenuConfig.find((item) => item.path === '/words');
|
||||||
|
return defaultRoute
|
||||||
|
? { title: defaultRoute.title, subtitle: defaultRoute.subtitle }
|
||||||
|
: { title: '汉字管理', subtitle: '管理汉字信息' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = routeMenuConfig.find((item) => item.path === path);
|
||||||
|
return config
|
||||||
|
? { title: config.title, subtitle: config.subtitle }
|
||||||
|
: { title: '汉字管理', subtitle: '管理汉字信息' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主要功能菜单项
|
||||||
|
*/
|
||||||
|
export const getMainMenuItems = () => {
|
||||||
|
return routeMenuConfig.filter((item) => item.group === 'main');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取管理员功能菜单项
|
||||||
|
*/
|
||||||
|
export const getAdminMenuItems = () => {
|
||||||
|
return routeMenuConfig.filter((item) => item.group === 'admin' && item.requireAdmin);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面标题映射(用于向后兼容)
|
||||||
|
*/
|
||||||
|
export const pageTitles: Record<string, { title: string; subtitle: string }> = (() => {
|
||||||
|
const titles: Record<string, { title: string; subtitle: string }> = {
|
||||||
|
'/': getPageInfo('/words'),
|
||||||
|
};
|
||||||
|
|
||||||
|
routeMenuConfig.forEach((config) => {
|
||||||
|
titles[config.path] = {
|
||||||
|
title: config.title,
|
||||||
|
subtitle: config.subtitle,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return titles;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App.tsx';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #93D333 0%, #7CB518 100%);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Form, Input, Button, Card, message } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { authService } from '@/services/auth';
|
||||||
|
import type { LoginRequest } from '@/services/auth';
|
||||||
|
import './LoginPage.css';
|
||||||
|
|
||||||
|
const LoginPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const handleLogin = async (values: LoginRequest) => {
|
||||||
|
try {
|
||||||
|
// TODO: 实现实际的登录逻辑
|
||||||
|
// await authService.login(values);
|
||||||
|
// navigate('/words');
|
||||||
|
message.info('登录功能待实现');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('登录失败,请检查用户名和密码');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<Card className="login-card">
|
||||||
|
<div className="login-header">
|
||||||
|
<h1>涂鸦丫管理后台</h1>
|
||||||
|
<p>请登录您的账户</p>
|
||||||
|
</div>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
name="login"
|
||||||
|
onFinish={handleLogin}
|
||||||
|
autoComplete="off"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
placeholder="用户名"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder="密码"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.words-page {
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: white;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.words-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.words-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Input, Pagination, Spin, Empty } from 'antd';
|
||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { WordCard } from '@/components/WordCard';
|
||||||
|
import { WordDetailDrawer } from '@/components/WordDetailDrawer';
|
||||||
|
import { wordsService } from '@/services/words';
|
||||||
|
import type { Word } from '@/types/words';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
export const WordsPage: React.FC = () => {
|
||||||
|
const [words, setWords] = useState<Word[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedWord, setSelectedWord] = useState<Word | null>(null);
|
||||||
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [limit] = useState(9);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWords();
|
||||||
|
}, [page, search]);
|
||||||
|
|
||||||
|
const loadWords = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await wordsService.getWords({
|
||||||
|
search: search || undefined,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
setWords(response.data);
|
||||||
|
setTotal(response.total);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load words:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardClick = async (word: Word) => {
|
||||||
|
try {
|
||||||
|
// 加载完整数据
|
||||||
|
const fullWord = await wordsService.getWord(word.id);
|
||||||
|
setSelectedWord(fullWord);
|
||||||
|
setDrawerVisible(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load word details:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearch(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
setPage(newPage);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="words-page">
|
||||||
|
<div className="words-page-header">
|
||||||
|
<h1>汉字管理</h1>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索汉字"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 300 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{words.length === 0 ? (
|
||||||
|
<Empty description="暂无数据" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="words-grid">
|
||||||
|
{words.map((word) => (
|
||||||
|
<WordCard
|
||||||
|
key={word.id}
|
||||||
|
word={word}
|
||||||
|
onClick={() => handleCardClick(word)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="words-pagination">
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
total={total}
|
||||||
|
pageSize={limit}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
showSizeChanger={false}
|
||||||
|
showTotal={(total) => `共 ${total} 个汉字`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<WordDetailDrawer
|
||||||
|
word={selectedWord}
|
||||||
|
visible={drawerVisible}
|
||||||
|
onClose={() => setDrawerVisible(false)}
|
||||||
|
onRefresh={loadWords}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
import { createBrowserRouter } from 'react-router';
|
||||||
|
import MainLayout from '../layouts/MainLayout';
|
||||||
|
import ProtectedRoute from '../components/ProtectedRoute';
|
||||||
|
import ErrorPage from '../components/ErrorPage';
|
||||||
|
|
||||||
|
const WordsPage = lazy(() => import('../pages/Words'));
|
||||||
|
const LoginPage = lazy(() => import('../pages/LoginPage'));
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <LoginPage />,
|
||||||
|
errorElement: <ErrorPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
errorElement: <ErrorPage />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: <WordsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'words',
|
||||||
|
element: <WordsPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
element: <ErrorPage is404={true} />,
|
||||||
|
},
|
||||||
|
]);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
import { envConfig } from '../config/env';
|
||||||
|
|
||||||
|
// Token 存储键名(与 auth service 保持一致)
|
||||||
|
const TOKEN_KEY = 'access_token';
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: envConfig.apiBaseUrl,
|
||||||
|
timeout: 30000, // 30秒超时
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// 自动添加 token 到请求头
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envConfig.debug) {
|
||||||
|
console.log('请求:', config.method?.toUpperCase(), config.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (envConfig.debug) {
|
||||||
|
console.error('请求错误:', error);
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
if (envConfig.debug) {
|
||||||
|
console.log('响应:', response.config.url, response.data);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (envConfig.debug) {
|
||||||
|
console.error('响应错误:', error.response?.data || error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理常见错误
|
||||||
|
if (error.response) {
|
||||||
|
switch (error.response.status) {
|
||||||
|
case 401:
|
||||||
|
// 未授权,清除 token 并跳转到登录页
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem('user_info');
|
||||||
|
// 避免在登录页重复跳转
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
console.error('没有权限访问该资源');
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
console.error('请求的资源不存在');
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
console.error('服务器内部错误');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error('请求失败:', error.response.data?.message || error.message);
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
console.error('网络错误,请检查网络连接');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 导出常用的请求方法
|
||||||
|
export const api = {
|
||||||
|
get: <T = any>(url: string, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.get<T>(url, config).then((res) => res.data),
|
||||||
|
|
||||||
|
post: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.post<T>(url, data, config).then((res) => res.data),
|
||||||
|
|
||||||
|
put: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.put<T>(url, data, config).then((res) => res.data),
|
||||||
|
|
||||||
|
patch: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.patch<T>(url, data, config).then((res) => res.data),
|
||||||
|
|
||||||
|
delete: <T = any>(url: string, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.delete<T>(url, config).then((res) => res.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出 axios 实例(用于特殊需求)
|
||||||
|
export default apiClient;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token 存储键名
|
||||||
|
*/
|
||||||
|
const TOKEN_KEY = 'access_token';
|
||||||
|
const USER_KEY = 'user_info';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户信息接口(简化版,根据实际需求调整)
|
||||||
|
*/
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
nickname?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录请求参数
|
||||||
|
*/
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录响应
|
||||||
|
*/
|
||||||
|
export interface LoginResponse {
|
||||||
|
accessToken: string;
|
||||||
|
user: UserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证服务
|
||||||
|
*/
|
||||||
|
class AuthService {
|
||||||
|
/**
|
||||||
|
* 登录
|
||||||
|
*/
|
||||||
|
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||||
|
// TODO: 根据实际 API 调整
|
||||||
|
const response = await api.post<LoginResponse>('/auth/login', credentials);
|
||||||
|
this.setToken(response.accessToken);
|
||||||
|
this.setUser(response.user);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出
|
||||||
|
*/
|
||||||
|
logout(): void {
|
||||||
|
this.removeToken();
|
||||||
|
this.removeUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前 token
|
||||||
|
*/
|
||||||
|
getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 token
|
||||||
|
*/
|
||||||
|
setToken(token: string): void {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 token
|
||||||
|
*/
|
||||||
|
removeToken(): void {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户信息
|
||||||
|
*/
|
||||||
|
getUser(): UserInfo | null {
|
||||||
|
const userStr = localStorage.getItem(USER_KEY);
|
||||||
|
if (!userStr) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(userStr);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置用户信息
|
||||||
|
*/
|
||||||
|
setUser(user: UserInfo): void {
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除用户信息
|
||||||
|
*/
|
||||||
|
removeUser(): void {
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已登录
|
||||||
|
*/
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
return !!this.getToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为管理员
|
||||||
|
*/
|
||||||
|
isAdmin(): boolean {
|
||||||
|
const user = this.getUser();
|
||||||
|
return user?.role === 'admin' || user?.role === 'super_admin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const authService = new AuthService();
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type {
|
||||||
|
Word,
|
||||||
|
WordImage,
|
||||||
|
Sentence,
|
||||||
|
QueryWordsParams,
|
||||||
|
WordsResponse,
|
||||||
|
} from '@/types/words';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 汉字服务
|
||||||
|
*/
|
||||||
|
class WordsService {
|
||||||
|
/**
|
||||||
|
* 获取汉字列表
|
||||||
|
*/
|
||||||
|
async getWords(params: QueryWordsParams = {}): Promise<WordsResponse> {
|
||||||
|
const response = await api.get<WordsResponse>('/words', { params });
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个汉字详情
|
||||||
|
*/
|
||||||
|
async getWord(id: number): Promise<Word> {
|
||||||
|
const response = await api.get<Word>(`/words/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新汉字
|
||||||
|
*/
|
||||||
|
async updateWord(id: number, data: Partial<Word>): Promise<Word> {
|
||||||
|
const response = await api.patch<Word>(`/words/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取汉字图片
|
||||||
|
*/
|
||||||
|
async getWordImages(wordId: number): Promise<WordImage[]> {
|
||||||
|
const response = await api.get<WordImage[]>(`/words/${wordId}/images`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新图片排序
|
||||||
|
*/
|
||||||
|
async updateWordImagesSort(
|
||||||
|
wordId: number,
|
||||||
|
imageIds: number[]
|
||||||
|
): Promise<WordImage[]> {
|
||||||
|
const response = await api.patch<WordImage[]>(
|
||||||
|
`/words/${wordId}/images/sort`,
|
||||||
|
{ imageIds }
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除图片
|
||||||
|
*/
|
||||||
|
async deleteWordImage(wordId: number, imageId: number): Promise<void> {
|
||||||
|
await api.delete(`/words/${wordId}/images/${imageId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取汉字句子
|
||||||
|
*/
|
||||||
|
async getWordSentences(wordId: number): Promise<Sentence[]> {
|
||||||
|
const response = await api.get<Sentence[]>(
|
||||||
|
`/words/${wordId}/sentences`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加句子
|
||||||
|
*/
|
||||||
|
async addSentence(
|
||||||
|
wordId: number,
|
||||||
|
data: { content: string; translation?: string }
|
||||||
|
): Promise<Sentence> {
|
||||||
|
const response = await api.post<Sentence>(
|
||||||
|
`/words/${wordId}/sentences`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除句子
|
||||||
|
*/
|
||||||
|
async deleteSentence(wordId: number, sentenceId: number): Promise<void> {
|
||||||
|
await api.delete(`/words/${wordId}/sentences/${sentenceId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wordsService = new WordsService();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* API 响应包装
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: T;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export interface Word {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
type: string;
|
||||||
|
grade: number | null;
|
||||||
|
pinyins: string[] | null;
|
||||||
|
pronunciations: string[] | null;
|
||||||
|
svg_data: string[] | null;
|
||||||
|
audio_files: string[] | null;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
images?: WordImage[];
|
||||||
|
wordSentences?: WordSentence[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordImage {
|
||||||
|
id: number;
|
||||||
|
word_id: number;
|
||||||
|
image_type: string;
|
||||||
|
file_path: string;
|
||||||
|
file_name: string;
|
||||||
|
file_size: number | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
mime_type: string | null;
|
||||||
|
is_primary: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sentence {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
translation: string | null;
|
||||||
|
audio_path: string | null;
|
||||||
|
source: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordSentence {
|
||||||
|
id: number;
|
||||||
|
word_id: number;
|
||||||
|
sentence_id: number;
|
||||||
|
sort_order: number;
|
||||||
|
sentence: Sentence;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryWordsParams {
|
||||||
|
search?: string;
|
||||||
|
type?: string;
|
||||||
|
grade?: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordsResponse {
|
||||||
|
data: Word[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"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 aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react-swc';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: Number(process.env.VITE_PORT) || 3300,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 服务器配置
|
||||||
|
PORT=3301
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USERNAME=
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_DATABASE=doodle_dev
|
||||||
|
|
||||||
|
# 跨域配置(支持多个域名,用逗号分隔)
|
||||||
|
CORS_ORIGIN=http://localhost:3300,http://localhost:3000,http://127.0.0.1:3300
|
||||||
|
|
||||||
|
# JWT 配置
|
||||||
|
JWT_SECRET=dev-secret-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# 用户种子数据配置(开发环境自动创建初始管理员用户)
|
||||||
|
ENABLE_USER_SEEDER=true
|
||||||
|
SUPER_ADMIN_USERNAME=superadmin
|
||||||
|
SUPER_ADMIN_PASSWORD=admin123
|
||||||
|
SUPER_ADMIN_EMAIL=superadmin@doodle.com
|
||||||
|
SUPER_ADMIN_REAL_NAME=超级管理员
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
ADMIN_EMAIL=admin@doodle.com
|
||||||
|
ADMIN_REAL_NAME=系统管理员
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 服务器配置
|
||||||
|
PORT=3301
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USERNAME=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_DATABASE=doodle_db
|
||||||
|
|
||||||
|
# 跨域配置(支持多个域名,用逗号分隔)
|
||||||
|
# 单个域名: CORS_ORIGIN=http://localhost:3300
|
||||||
|
# 多个域名: CORS_ORIGIN=http://localhost:3300,http://localhost:3000,http://127.0.0.1:3300
|
||||||
|
CORS_ORIGIN=http://localhost:3300
|
||||||
|
|
||||||
|
# JWT 配置
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# 用户种子数据配置(可选,用于自动创建初始管理员用户)
|
||||||
|
# 生产环境建议设置为 false,手动创建管理员用户
|
||||||
|
ENABLE_USER_SEEDER=false
|
||||||
|
SUPER_ADMIN_USERNAME=superadmin
|
||||||
|
SUPER_ADMIN_PASSWORD=admin123
|
||||||
|
SUPER_ADMIN_EMAIL=superadmin@doodle.com
|
||||||
|
SUPER_ADMIN_REAL_NAME=超级管理员
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
ADMIN_EMAIL=admin@doodle.com
|
||||||
|
ADMIN_REAL_NAME=系统管理员
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"embeddedLanguageFormatting": "auto"
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 涂鸦丫后台管理系统 - API服务
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- NestJS 11
|
||||||
|
- TypeORM
|
||||||
|
- PostgreSQL
|
||||||
|
- class-validator
|
||||||
|
- class-transformer
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
1. 复制 `.env.example` 为 `.env`
|
||||||
|
2. 配置数据库连接信息:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3301
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USERNAME=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_DATABASE=doodle_db
|
||||||
|
CORS_ORIGIN=http://localhost:3300
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库迁移
|
||||||
|
|
||||||
|
项目使用 TypeORM 的 `synchronize` 选项(仅开发环境),生产环境应使用迁移。
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 汉字管理
|
||||||
|
|
||||||
|
- `GET /words` - 获取汉字列表(分页,每页9个)
|
||||||
|
- `GET /words/:id` - 获取汉字详情
|
||||||
|
- `PATCH /words/:id` - 更新汉字信息
|
||||||
|
- `GET /words/:id/images` - 获取汉字图片列表
|
||||||
|
- `PATCH /words/:id/images/sort` - 更新图片排序
|
||||||
|
- `DELETE /words/:id/images/:imageId` - 删除图片
|
||||||
|
- `GET /words/:id/sentences` - 获取汉字句子列表
|
||||||
|
- `POST /words/:id/sentences` - 添加句子
|
||||||
|
- `DELETE /words/:id/sentences/:sentenceId` - 删除句子
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 汉字、拼音、SVG数据通过其他方式导入,不支持在后台新建
|
||||||
|
- 图片最多支持4张
|
||||||
|
- 列表按年级升序排序(低年级在前)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unsafe-call': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-return': 'off',
|
||||||
|
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"start": "nest start --watch",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^3.2.3",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/mapped-types": "^2.0.5",
|
||||||
|
"@nestjs/jwt": "^11.0.1",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^0.3.20"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/swagger": "^8.1.1",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {
|
||||||
|
it('should return "Hello World!"', () => {
|
||||||
|
expect(appController.getHello()).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getHello(): string {
|
||||||
|
return this.appService.getHello();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
import { WordsModule } from './modules/words/words.module';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { UsersModule } from './modules/users/users.module';
|
||||||
|
import { User } from './modules/users/user.entity';
|
||||||
|
import { Word } from './modules/words/word.entity';
|
||||||
|
import { WordImage } from './modules/words/word-image.entity';
|
||||||
|
import { Sentence } from './modules/words/sentence.entity';
|
||||||
|
import { WordSentence } from './modules/words/word-sentence.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: [`.env.${process.env.NODE_ENV || 'development'}`, '.env'],
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
type: 'postgres',
|
||||||
|
host: configService.get('DB_HOST', 'localhost'),
|
||||||
|
port: configService.get<number>('DB_PORT', 5432),
|
||||||
|
username: configService.get('DB_USERNAME'),
|
||||||
|
password: configService.get('DB_PASSWORD'),
|
||||||
|
database: configService.get('DB_DATABASE', 'doodle'),
|
||||||
|
entities: [Word, WordImage, Sentence, WordSentence, User],
|
||||||
|
synchronize: configService.get('NODE_ENV') !== 'production', // 生产环境应设为false
|
||||||
|
logging: configService.get('NODE_ENV') === 'development',
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
WordsModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
],
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Word 相关实体已移动到 modules/words 目录
|
||||||
|
export * from '../modules/users/user.entity';
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// 启用 CORS - 支持多个跨域域名
|
||||||
|
const corsOrigin = process.env.CORS_ORIGIN || 'http://localhost:3300';
|
||||||
|
const allowedOrigins = corsOrigin.split(',').map((origin) => origin.trim());
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: allowedOrigins.length === 1 ? allowedOrigins[0] : allowedOrigins,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启用全局验证管道
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 配置 Swagger
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('涂鸦丫后台管理系统 API')
|
||||||
|
.setDescription('涂鸦丫后台管理系统 API 文档')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addTag('words', '汉字管理相关接口')
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api-docs', app, document);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3301;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Application is running on: http://localhost:${port}`);
|
||||||
|
console.log(`Swagger API docs available at: http://localhost:${port}/api-docs`);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Controller, Post, Body, HttpCode, HttpStatus, Ip } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { LoginResponse } from './interfaces/login-response.interface';
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
*/
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '用户登录',
|
||||||
|
description: '使用用户名/邮箱和密码登录,返回 access_token 和用户信息',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '登录成功',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
accessToken: {
|
||||||
|
type: 'string',
|
||||||
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'number', example: 1 },
|
||||||
|
username: { type: 'string', example: 'admin' },
|
||||||
|
email: { type: 'string', example: 'admin@example.com' },
|
||||||
|
role: { type: 'string', example: 'admin' },
|
||||||
|
status: { type: 'string', example: 'active' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: '用户名或密码错误' })
|
||||||
|
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||||
|
async login(@Body() loginDto: LoginDto, @Ip() ip: string): Promise<LoginResponse> {
|
||||||
|
return this.authService.login(loginDto, ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { User } from '../users/user.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([User]),
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
// @ts-expect-error - JWT expiresIn accepts string but type definition is strict
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
const expiresIn = configService.get<string>('JWT_EXPIRES_IN', '7d');
|
||||||
|
const secret = configService.get<string>(
|
||||||
|
'JWT_SECRET',
|
||||||
|
'your-secret-key-change-in-production',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
secret: secret,
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: expiresIn || '7d', // 默认7天过期
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
exports: [AuthService, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { User } from '../users/user.entity';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||||
|
import { LoginResponse } from './interfaces/login-response.interface';
|
||||||
|
import { UserStatus } from '../users/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
*/
|
||||||
|
async login(loginDto: LoginDto, ip?: string): Promise<LoginResponse> {
|
||||||
|
// 根据用户名或邮箱查找用户
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: [{ username: loginDto.usernameOrEmail }, { email: loginDto.usernameOrEmail }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('用户名或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户状态
|
||||||
|
if (user.status !== UserStatus.ACTIVE) {
|
||||||
|
throw new UnauthorizedException('用户已被禁用');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
const isPasswordValid = await bcrypt.compare(loginDto.password, user.passwordHash);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('用户名或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后登录时间和IP
|
||||||
|
user.lastLoginAt = new Date();
|
||||||
|
if (ip) {
|
||||||
|
user.lastLoginIp = ip;
|
||||||
|
}
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
// 生成 JWT token
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
|
||||||
|
// 返回 token 和用户信息(排除密码)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { passwordHash, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
user: userWithoutPassword,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色权限装饰器
|
||||||
|
*
|
||||||
|
* 这个装饰器用于在控制器或方法上标记需要的角色权限。
|
||||||
|
* 配合 RolesGuard 使用,实现基于角色的访问控制(RBAC)。
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* @Roles('admin', 'super_admin')
|
||||||
|
* @Get()
|
||||||
|
* findAll() { ... }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元数据键名
|
||||||
|
* 用于在 Reflector 中存储和读取角色信息
|
||||||
|
*/
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色权限装饰器工厂函数
|
||||||
|
*
|
||||||
|
* @param roles - 允许访问的角色列表(可变参数)
|
||||||
|
* @returns 返回一个装饰器函数,用于设置元数据
|
||||||
|
*/
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { IsString, IsNotEmpty, MinLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '用户名或邮箱',
|
||||||
|
example: 'admin',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(3)
|
||||||
|
usernameOrEmail: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '密码',
|
||||||
|
example: 'password123',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(6)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
handleRequest<TUser = unknown>(
|
||||||
|
err: Error | null,
|
||||||
|
user: TUser | false,
|
||||||
|
info: Error | string | undefined,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_context: ExecutionContext,
|
||||||
|
): TUser {
|
||||||
|
// 如果认证失败(user 为 false 或 undefined,或者有错误)
|
||||||
|
if (err || !user || info) {
|
||||||
|
throw new UnauthorizedException('身份验证失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { User, UserRole } from '../../users/user.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源所有者或管理员权限 Guard
|
||||||
|
*
|
||||||
|
* 用途:检查用户是否有权限访问资源
|
||||||
|
* - 管理员(admin、super_admin)可以访问任何资源
|
||||||
|
* - 普通用户只能访问自己的资源(通过比较 id)
|
||||||
|
*
|
||||||
|
* 使用场景:
|
||||||
|
* - 查询用户信息:管理员可以查询任何用户,普通用户只能查询自己
|
||||||
|
* - 更新用户信息:管理员可以更新任何用户,普通用户只能更新自己
|
||||||
|
* - 删除用户:管理员可以删除任何用户,普通用户只能删除自己
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OwnerOrAdminGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<{
|
||||||
|
user?: User;
|
||||||
|
params: { id?: string };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException('未授权访问');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取请求的资源ID(从路由参数中)
|
||||||
|
const requestedId = request.params?.id;
|
||||||
|
|
||||||
|
if (!requestedId) {
|
||||||
|
// 如果没有提供资源ID,只允许管理员访问
|
||||||
|
const isAdmin = user.role === UserRole.ADMIN || user.role === UserRole.SUPER_ADMIN;
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new ForbiddenException('权限不足,需要管理员权限');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedUserId = +requestedId;
|
||||||
|
|
||||||
|
// 检查权限:管理员可以访问任何资源,普通用户只能访问自己的资源
|
||||||
|
const isAdmin = user.role === UserRole.ADMIN || user.role === UserRole.SUPER_ADMIN;
|
||||||
|
const isOwner = user.id === requestedUserId;
|
||||||
|
|
||||||
|
if (!isAdmin && !isOwner) {
|
||||||
|
throw new ForbiddenException('权限不足,只能访问自己的资源或需要管理员权限');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles) {
|
||||||
|
// 如果没有设置角色要求,允许访问
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<{
|
||||||
|
user?: { role: string };
|
||||||
|
}>();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException('未授权访问');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRole = requiredRoles.some((role) => user.role === role);
|
||||||
|
|
||||||
|
if (!hasRole) {
|
||||||
|
throw new ForbiddenException(`需要以下角色之一:${requiredRoles.join('、')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface JwtPayload {
|
||||||
|
sub: number; // 用户ID
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { User } from '../../users/user.entity';
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
accessToken: string;
|
||||||
|
user: Omit<User, 'passwordHash'>; // 排除密码哈希
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { User, UserStatus } from '../../users/user.entity';
|
||||||
|
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get<string>(
|
||||||
|
'JWT_SECRET',
|
||||||
|
'your-secret-key-change-in-production',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload): Promise<User> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||||
|
throw new UnauthorizedException('用户不存在或已被禁用');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 用户管理模块
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
用户管理模块提供了完整的用户管理功能,包括用户创建、查询、更新、删除和密码修改。
|
||||||
|
|
||||||
|
## 环境变量配置
|
||||||
|
|
||||||
|
### JWT 配置
|
||||||
|
|
||||||
|
```env
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户种子数据配置
|
||||||
|
|
||||||
|
在 `.env.development` 中配置(开发环境自动创建):
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENABLE_USER_SEEDER=true
|
||||||
|
SUPER_ADMIN_USERNAME=superadmin
|
||||||
|
SUPER_ADMIN_PASSWORD=admin123
|
||||||
|
SUPER_ADMIN_EMAIL=superadmin@doodle.com
|
||||||
|
SUPER_ADMIN_REAL_NAME=超级管理员
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
ADMIN_EMAIL=admin@doodle.com
|
||||||
|
ADMIN_REAL_NAME=系统管理员
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意:**
|
||||||
|
- 生产环境建议设置 `ENABLE_USER_SEEDER=false`,手动创建管理员用户
|
||||||
|
- 默认密码请在生产环境部署后立即修改
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 认证接口
|
||||||
|
|
||||||
|
- `POST /auth/login` - 用户登录(返回 JWT token)
|
||||||
|
|
||||||
|
### 用户管理接口
|
||||||
|
|
||||||
|
- `POST /users` - 创建用户(需要超级管理员权限)
|
||||||
|
- `GET /users` - 查询用户列表(需要管理员权限,支持分页和筛选)
|
||||||
|
- `GET /users/:id` - 查询单个用户(需要管理员权限或用户本人)
|
||||||
|
- `PATCH /users/:id` - 更新用户信息(需要管理员权限或用户本人)
|
||||||
|
- `PATCH /users/:id/password` - 修改密码(需要管理员权限或用户本人)
|
||||||
|
- `DELETE /users/:id` - 删除用户(需要超级管理员权限,软删除)
|
||||||
|
|
||||||
|
## 权限说明
|
||||||
|
|
||||||
|
- **super_admin(超级管理员)**:所有权限
|
||||||
|
- **admin(管理员)**:可以管理用户,但不能创建/删除用户
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 登录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3301/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"usernameOrEmail": "admin",
|
||||||
|
"password": "admin123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询用户列表(需要 JWT token)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:3301/users \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建用户(需要超级管理员权限)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3301/users \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "newuser",
|
||||||
|
"password": "password123",
|
||||||
|
"email": "newuser@example.com",
|
||||||
|
"role": "admin",
|
||||||
|
"realName": "新用户"
|
||||||
|
}'
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ChangePasswordDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '旧密码',
|
||||||
|
example: 'OldPassword123!',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
oldPassword: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '新密码',
|
||||||
|
example: 'NewPassword123!',
|
||||||
|
minLength: 6,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(6)
|
||||||
|
@MaxLength(100)
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsEmail,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
IsEnum,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { UserRole } from '../user.entity';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '用户名',
|
||||||
|
example: 'admin',
|
||||||
|
maxLength: 50,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(50)
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '密码(明文,服务端会使用 bcrypt 加密后存储)',
|
||||||
|
example: 'SecurePassword123!',
|
||||||
|
minLength: 6,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(6)
|
||||||
|
@MaxLength(100)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '邮箱',
|
||||||
|
example: 'admin@example.com',
|
||||||
|
maxLength: 100,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(100)
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '用户角色',
|
||||||
|
enum: UserRole,
|
||||||
|
example: UserRole.ADMIN,
|
||||||
|
})
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
@IsNotEmpty()
|
||||||
|
role: UserRole;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '真实姓名',
|
||||||
|
example: '张三',
|
||||||
|
maxLength: 50,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
realName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '头像URL',
|
||||||
|
example: 'https://example.com/avatar.jpg',
|
||||||
|
maxLength: 500,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { User } from '../user.entity';
|
||||||
|
|
||||||
|
export class PaginationInfo {
|
||||||
|
@ApiProperty({ description: '总记录数', example: 100 })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总页数', example: 10 })
|
||||||
|
total_page: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '每页数量', example: 10 })
|
||||||
|
page_size: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '当前页码', example: 1 })
|
||||||
|
current_page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaginatedUserData {
|
||||||
|
@ApiProperty({ description: '用户列表', type: [User] })
|
||||||
|
list: Omit<User, 'passwordHash'>[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '分页信息', type: PaginationInfo })
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { IsOptional, IsString, IsNumber, Min, IsEnum } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { UserRole, UserStatus } from '../user.entity';
|
||||||
|
|
||||||
|
export class QueryUserDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '用户名',
|
||||||
|
example: 'admin',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '邮箱',
|
||||||
|
example: 'admin@example.com',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '角色',
|
||||||
|
enum: UserRole,
|
||||||
|
example: UserRole.ADMIN,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
role?: UserRole;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '状态',
|
||||||
|
enum: UserStatus,
|
||||||
|
example: UserStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(UserStatus)
|
||||||
|
status?: UserStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '页码',
|
||||||
|
example: 1,
|
||||||
|
minimum: 1,
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '每页数量',
|
||||||
|
example: 10,
|
||||||
|
minimum: 1,
|
||||||
|
default: 10,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number = 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { UserRole, UserStatus } from '../user.entity';
|
||||||
|
|
||||||
|
export class UpdateUserDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '邮箱',
|
||||||
|
example: 'newemail@example.com',
|
||||||
|
maxLength: 100,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(100)
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '真实姓名',
|
||||||
|
example: '张三',
|
||||||
|
maxLength: 50,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
realName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '头像URL',
|
||||||
|
example: 'https://example.com/avatar.jpg',
|
||||||
|
maxLength: 500,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
avatar?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '用户状态',
|
||||||
|
enum: UserStatus,
|
||||||
|
example: UserStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(UserStatus)
|
||||||
|
status?: UserStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '用户角色',
|
||||||
|
enum: UserRole,
|
||||||
|
example: UserRole.ADMIN,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
role?: UserRole;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export enum UserRole {
|
||||||
|
SUPER_ADMIN = 'super_admin',
|
||||||
|
ADMIN = 'admin',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
export class User {
|
||||||
|
@ApiProperty({ description: '用户ID', example: 1 })
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '用户名',
|
||||||
|
example: 'admin',
|
||||||
|
maxLength: 50,
|
||||||
|
})
|
||||||
|
@Column({ type: 'varchar', length: 50, unique: true })
|
||||||
|
@Index()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '邮箱',
|
||||||
|
example: 'admin@example.com',
|
||||||
|
maxLength: 100,
|
||||||
|
})
|
||||||
|
@Column({ type: 'varchar', length: 100, unique: true, nullable: true })
|
||||||
|
@Index()
|
||||||
|
email: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '密码哈希值(bcrypt加密)',
|
||||||
|
example: '$2b$10$...',
|
||||||
|
maxLength: 255,
|
||||||
|
})
|
||||||
|
@Column({ type: 'varchar', length: 255, name: 'password_hash' })
|
||||||
|
passwordHash: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '用户角色',
|
||||||
|
example: 'admin',
|
||||||
|
enum: UserRole,
|
||||||
|
default: UserRole.ADMIN,
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 20,
|
||||||
|
default: UserRole.ADMIN,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
@Index()
|
||||||
|
role: UserRole;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '真实姓名',
|
||||||
|
example: '张三',
|
||||||
|
maxLength: 50,
|
||||||
|
})
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true, name: 'real_name' })
|
||||||
|
realName: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '头像URL',
|
||||||
|
example: 'https://example.com/avatar.jpg',
|
||||||
|
maxLength: 500,
|
||||||
|
})
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||||
|
avatar: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '用户状态',
|
||||||
|
example: 'active',
|
||||||
|
enum: UserStatus,
|
||||||
|
default: UserStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 20,
|
||||||
|
default: UserStatus.ACTIVE,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
@Index()
|
||||||
|
status: UserStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '最后登录时间',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'last_login_at' })
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '最后登录IP',
|
||||||
|
example: '192.168.1.1',
|
||||||
|
maxLength: 50,
|
||||||
|
})
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true, name: 'last_login_ip' })
|
||||||
|
lastLoginIp: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '创建时间',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '更新时间',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
ParseIntPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { User } from './user.entity';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
|
import { QueryUserDto } from './dto/query-user.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { OwnerOrAdminGuard } from '../auth/guards/owner-or-admin.guard';
|
||||||
|
import { PaginatedUserData } from './dto/paginated-response.dto';
|
||||||
|
|
||||||
|
@ApiTags('users')
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用户
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '创建用户',
|
||||||
|
description: '创建新用户,需要超级管理员权限',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: '创建成功',
|
||||||
|
type: User,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 409,
|
||||||
|
description: '用户名或邮箱已存在',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
create(@Body() createUserDto: CreateUserDto): Promise<User> {
|
||||||
|
return this.usersService.create(createUserDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询所有用户(支持分页和筛选)
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '查询用户列表',
|
||||||
|
description: '获取用户列表,支持分页和多种筛选条件(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '查询成功',
|
||||||
|
type: PaginatedUserData,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
findAll(@Query() queryDto: QueryUserDto): Promise<PaginatedUserData> {
|
||||||
|
return this.usersService.findAllPaginated(queryDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 查询单个用户
|
||||||
|
* 需要管理员权限或者是用户本人
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '根据ID查询用户',
|
||||||
|
description: '根据用户ID获取详细信息,需要管理员权限或者是用户本人',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '查询成功',
|
||||||
|
type: User,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 403,
|
||||||
|
description: '权限不足,只能查询自己的信息或需要管理员权限',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||||
|
findOneById(@Param('id', ParseIntPipe) id: number): Promise<User> {
|
||||||
|
return this.usersService.findOneById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息
|
||||||
|
*/
|
||||||
|
@Patch(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '更新用户信息',
|
||||||
|
description: '更新用户信息,不允许修改 username',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '更新成功',
|
||||||
|
type: User,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||||
|
@ApiResponse({ status: 409, description: '邮箱已存在' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() updateUserDto: UpdateUserDto,
|
||||||
|
): Promise<User> {
|
||||||
|
return this.usersService.update(id, updateUserDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*/
|
||||||
|
@Patch(':id/password')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '修改密码',
|
||||||
|
description: '修改用户密码,需要先验证旧密码',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
||||||
|
@ApiResponse({ status: 204, description: '密码修改成功' })
|
||||||
|
@ApiResponse({ status: 400, description: '旧密码错误' })
|
||||||
|
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||||
|
changePassword(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() changePasswordDto: ChangePasswordDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.usersService.changePassword(id, changePasswordDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户(软删除)
|
||||||
|
*/
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '删除用户',
|
||||||
|
description: '软删除用户,将状态更新为 inactive(需要超级管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
||||||
|
@ApiResponse({ status: 204, description: '删除成功' })
|
||||||
|
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.usersService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersSeeder } from './users.seeder';
|
||||||
|
import { User } from './user.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService, UsersSeeder],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, In } from 'typeorm';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { User, UserRole, UserStatus } from './user.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户数据种子(Seeder)
|
||||||
|
*
|
||||||
|
* 用途:在应用启动时自动创建初始用户(超级管理员、管理员)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 创建超级管理员和管理员各一名(从环境变量读取配置)
|
||||||
|
*
|
||||||
|
* 性能优化:
|
||||||
|
* - 使用批量查询检查用户是否存在
|
||||||
|
* - 只创建不存在的用户,保证幂等性
|
||||||
|
*
|
||||||
|
* 优点:
|
||||||
|
* 1. 代码化管理,版本控制友好
|
||||||
|
* 2. 自动执行,无需手动操作
|
||||||
|
* 3. 可以使用业务逻辑(密码加密、验证等)
|
||||||
|
* 4. 环境变量配置,灵活性强
|
||||||
|
* 5. 幂等性:如果用户已存在,不会重复创建
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* 1. 通过环境变量配置管理员信息
|
||||||
|
* 2. 应用启动时自动执行
|
||||||
|
* 3. 仅在开发/测试环境自动执行,生产环境建议手动创建
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UsersSeeder implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(UsersSeeder.name);
|
||||||
|
private readonly saltRounds = 10; // bcrypt 加盐轮数
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// 只在非生产环境自动执行,或通过环境变量控制
|
||||||
|
const isProduction = this.configService.get('NODE_ENV') === 'production';
|
||||||
|
const enableSeeder = this.configService.get('ENABLE_USER_SEEDER', 'false') === 'true';
|
||||||
|
|
||||||
|
if (isProduction && !enableSeeder) {
|
||||||
|
this.logger.log('生产环境跳过用户种子数据初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行种子数据创建
|
||||||
|
await this.seedAdminUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建管理员用户(超级管理员和管理员)
|
||||||
|
*/
|
||||||
|
async seedAdminUsers(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 从环境变量读取超级管理员配置
|
||||||
|
const superAdminUsername =
|
||||||
|
this.configService.get<string>('SUPER_ADMIN_USERNAME') || 'superadmin';
|
||||||
|
const superAdminPassword =
|
||||||
|
this.configService.get<string>('SUPER_ADMIN_PASSWORD') || 'admin123';
|
||||||
|
const superAdminEmail =
|
||||||
|
this.configService.get<string>('SUPER_ADMIN_EMAIL') || 'superadmin@doodle.com';
|
||||||
|
const superAdminRealName =
|
||||||
|
this.configService.get<string>('SUPER_ADMIN_REAL_NAME') || '超级管理员';
|
||||||
|
|
||||||
|
// 从环境变量读取管理员配置
|
||||||
|
const adminUsername = this.configService.get<string>('ADMIN_USERNAME') || 'admin';
|
||||||
|
const adminPassword = this.configService.get<string>('ADMIN_PASSWORD') || 'admin123';
|
||||||
|
const adminEmail = this.configService.get<string>('ADMIN_EMAIL') || 'admin@doodle.com';
|
||||||
|
const adminRealName = this.configService.get<string>('ADMIN_REAL_NAME') || '系统管理员';
|
||||||
|
|
||||||
|
// 构建管理员用户数据
|
||||||
|
const adminUsersToCreate = [
|
||||||
|
{
|
||||||
|
username: superAdminUsername,
|
||||||
|
email: superAdminEmail,
|
||||||
|
realName: superAdminRealName,
|
||||||
|
password: superAdminPassword,
|
||||||
|
role: UserRole.SUPER_ADMIN,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: adminUsername,
|
||||||
|
email: adminEmail,
|
||||||
|
realName: adminRealName,
|
||||||
|
password: adminPassword,
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 批量查询:一次检查所有管理员用户是否存在(性能优化)
|
||||||
|
const usernames = adminUsersToCreate.map((u) => u.username);
|
||||||
|
const existingAdminUsers = await this.userRepository.find({
|
||||||
|
where: { username: In(usernames) },
|
||||||
|
select: ['username'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingUsernamesSet = new Set(existingAdminUsers.map((u) => u.username));
|
||||||
|
|
||||||
|
// 过滤出需要创建的用户(不存在的用户)
|
||||||
|
const usersToCreate = adminUsersToCreate.filter(
|
||||||
|
(user) => !existingUsernamesSet.has(user.username),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (usersToCreate.length === 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`管理员用户已存在(超级管理员: ${superAdminUsername}, 管理员: ${adminUsername}),跳过创建`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量创建用户
|
||||||
|
const usersToSave = await Promise.all(
|
||||||
|
usersToCreate.map(async (userData) => {
|
||||||
|
const passwordHash = await bcrypt.hash(userData.password, this.saltRounds);
|
||||||
|
|
||||||
|
return this.userRepository.create({
|
||||||
|
username: userData.username,
|
||||||
|
passwordHash,
|
||||||
|
email: userData.email,
|
||||||
|
realName: userData.realName,
|
||||||
|
role: userData.role,
|
||||||
|
status: UserStatus.ACTIVE,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.userRepository.save(usersToSave);
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
const createdUsernames = usersToCreate.map((u) => u.username);
|
||||||
|
this.logger.log(`✅ 成功创建管理员用户: ${createdUsernames.join(', ')}`);
|
||||||
|
usersToCreate.forEach((user) => {
|
||||||
|
this.logger.warn(
|
||||||
|
`⚠️ ${user.role === UserRole.SUPER_ADMIN ? '超级管理员' : '管理员'} "${user.username}" 默认密码: ${user.password},请尽快修改!`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('创建管理员用户失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动执行种子数据(可用于 CLI 命令)
|
||||||
|
*/
|
||||||
|
async run(): Promise<void> {
|
||||||
|
await this.seedAdminUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { User, UserStatus } from './user.entity';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
import { QueryUserDto } from './dto/query-user.dto';
|
||||||
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
|
import { PaginatedUserData } from './dto/paginated-response.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
private readonly logger = new Logger(UsersService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用户
|
||||||
|
*/
|
||||||
|
async create(createUserDto: CreateUserDto): Promise<User> {
|
||||||
|
// 检查用户名是否已存在
|
||||||
|
const existingByUsername = await this.userRepository.findOne({
|
||||||
|
where: { username: createUserDto.username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingByUsername) {
|
||||||
|
throw new ConflictException(`用户名 "${createUserDto.username}" 已存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查邮箱是否已存在(如果提供了)
|
||||||
|
if (createUserDto.email) {
|
||||||
|
const existingByEmail = await this.userRepository.findOne({
|
||||||
|
where: { email: createUserDto.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingByEmail) {
|
||||||
|
throw new ConflictException(`邮箱 "${createUserDto.email}" 已存在`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 bcrypt 加密密码
|
||||||
|
const saltRounds = 10;
|
||||||
|
const passwordHash = await bcrypt.hash(createUserDto.password, saltRounds);
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
const user = this.userRepository.create({
|
||||||
|
username: createUserDto.username,
|
||||||
|
passwordHash,
|
||||||
|
email: createUserDto.email || null,
|
||||||
|
role: createUserDto.role,
|
||||||
|
realName: createUserDto.realName || null,
|
||||||
|
avatar: createUserDto.avatar || null,
|
||||||
|
status: UserStatus.ACTIVE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedUser = await this.userRepository.save(user);
|
||||||
|
|
||||||
|
// 移除 passwordHash 字段
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { passwordHash: _, ...userWithoutPassword } = savedUser;
|
||||||
|
return userWithoutPassword as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户(支持多种查询条件和分页)
|
||||||
|
*/
|
||||||
|
async findAllPaginated(queryDto: QueryUserDto): Promise<PaginatedUserData> {
|
||||||
|
const where: FindOptionsWhere<User> = {};
|
||||||
|
|
||||||
|
if (queryDto.username) {
|
||||||
|
where.username = queryDto.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryDto.email) {
|
||||||
|
where.email = queryDto.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryDto.role) {
|
||||||
|
where.role = queryDto.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryDto.status) {
|
||||||
|
where.status = queryDto.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页参数
|
||||||
|
const page = queryDto.page || 1;
|
||||||
|
const limit = queryDto.limit || 10;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// 查询总数
|
||||||
|
const total = await this.userRepository.count({ where });
|
||||||
|
|
||||||
|
// 查询分页数据
|
||||||
|
const list = await this.userRepository.find({
|
||||||
|
where,
|
||||||
|
order: {
|
||||||
|
createdAt: 'DESC',
|
||||||
|
id: 'ASC',
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 移除 passwordHash 字段
|
||||||
|
const listWithoutPassword = list.map((user) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { passwordHash, ...userWithoutPassword } = user;
|
||||||
|
return userWithoutPassword;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算总页数
|
||||||
|
const total_page = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: listWithoutPassword,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
total_page,
|
||||||
|
page_size: limit,
|
||||||
|
current_page: page,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 查询单个用户
|
||||||
|
*/
|
||||||
|
async findOneById(id: number): Promise<User> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(`未找到ID为 ${id} 的用户`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 passwordHash 字段
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { passwordHash, ...userWithoutPassword } = user;
|
||||||
|
return userWithoutPassword as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息(不允许修改 username)
|
||||||
|
*/
|
||||||
|
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(`未找到ID为 ${id} 的用户`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新邮箱,检查是否与其他用户冲突
|
||||||
|
if (updateUserDto.email && updateUserDto.email !== user.email) {
|
||||||
|
const existingByEmail = await this.userRepository.findOne({
|
||||||
|
where: { email: updateUserDto.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingByEmail) {
|
||||||
|
throw new ConflictException(`邮箱 "${updateUserDto.email}" 已存在`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
Object.assign(user, updateUserDto);
|
||||||
|
const savedUser = await this.userRepository.save(user);
|
||||||
|
|
||||||
|
// 移除 passwordHash 字段
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { passwordHash, ...userWithoutPassword } = savedUser;
|
||||||
|
return userWithoutPassword as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码(需要先验证旧密码)
|
||||||
|
*/
|
||||||
|
async changePassword(id: number, changePasswordDto: ChangePasswordDto): Promise<void> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(`未找到ID为 ${id} 的用户`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证旧密码
|
||||||
|
const isOldPasswordValid = await bcrypt.compare(
|
||||||
|
changePasswordDto.oldPassword,
|
||||||
|
user.passwordHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOldPasswordValid) {
|
||||||
|
throw new BadRequestException('旧密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密新密码
|
||||||
|
const saltRounds = 10;
|
||||||
|
const newPasswordHash = await bcrypt.hash(changePasswordDto.newPassword, saltRounds);
|
||||||
|
|
||||||
|
// 更新密码
|
||||||
|
user.passwordHash = newPasswordHash;
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户(软删除,更新状态为 inactive)
|
||||||
|
*/
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(`未找到ID为 ${id} 的用户`);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.status = UserStatus.INACTIVE;
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { IsString, IsOptional, IsNotEmpty } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateSentenceDto {
|
||||||
|
@ApiProperty({ description: '句子内容', example: '这是一个例句。' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '翻译(英语句子需要)',
|
||||||
|
example: 'This is an example sentence.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
translation?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '音频文件路径',
|
||||||
|
example: '/audio/sentence1.mp3',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
audioPath?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '来源(如教材名称)',
|
||||||
|
example: '人教版语文一年级上册',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { IsString, IsOptional, IsInt, IsArray, Min, Max, IsEnum, IsNotEmpty } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { WordType } from '../word.entity';
|
||||||
|
|
||||||
|
export class CreateWordDto {
|
||||||
|
@ApiProperty({ description: '字词内容', example: '中' })
|
||||||
|
@IsString()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '字词类型',
|
||||||
|
enum: WordType,
|
||||||
|
example: WordType.CHINESE_CHAR,
|
||||||
|
})
|
||||||
|
@IsEnum(WordType)
|
||||||
|
@IsNotEmpty()
|
||||||
|
type: WordType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '年级(1-9)',
|
||||||
|
required: false,
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 9,
|
||||||
|
example: 1,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(9)
|
||||||
|
grade?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '拼音数组(支持多音字)',
|
||||||
|
required: false,
|
||||||
|
type: [String],
|
||||||
|
example: ['zhōng', 'zhòng'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
pinyins?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '读音数组',
|
||||||
|
required: false,
|
||||||
|
type: [String],
|
||||||
|
example: ['audio1.mp3', 'audio2.mp3'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
pronunciations?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'SVG笔画数据(字符串数组,每个元素是一个SVG字符串)',
|
||||||
|
type: [String],
|
||||||
|
example: ['<svg>...</svg>', '<svg>...</svg>'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
svgData?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '音频文件地址数组',
|
||||||
|
type: [String],
|
||||||
|
example: ['/audio/word1.mp3', '/audio/word1_alt.mp3'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
audioFiles?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '描述信息',
|
||||||
|
example: '这是一个汉字',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Word } from '../word.entity';
|
||||||
|
|
||||||
|
export class PaginationInfo {
|
||||||
|
@ApiProperty({ description: '总记录数', example: 100 })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总页数', example: 10 })
|
||||||
|
total_page: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '每页数量', example: 9 })
|
||||||
|
page_size: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '当前页码', example: 1 })
|
||||||
|
current_page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaginatedWordData {
|
||||||
|
@ApiProperty({ description: '汉字列表', type: [Word] })
|
||||||
|
list: Word[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '分页信息', type: PaginationInfo })
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { IsOptional, IsString, IsInt, IsEnum, Min, Max } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { WordType } from '../word.entity';
|
||||||
|
|
||||||
|
export class QueryWordsDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '搜索关键词(汉字内容)',
|
||||||
|
example: '中',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '字词类型',
|
||||||
|
enum: WordType,
|
||||||
|
example: WordType.CHINESE_CHAR,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WordType)
|
||||||
|
type?: WordType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '年级(1-9)',
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 9,
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(9)
|
||||||
|
grade?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '页码',
|
||||||
|
minimum: 1,
|
||||||
|
default: 1,
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '每页数量',
|
||||||
|
minimum: 1,
|
||||||
|
default: 9,
|
||||||
|
example: 9,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number = 9;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsArray, IsInt, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class WordImageItemDto {
|
||||||
|
@ApiProperty({ description: '图片ID', example: 1 })
|
||||||
|
@IsInt()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '排序顺序', example: 0 })
|
||||||
|
@IsInt()
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateWordImagesDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '图片列表(按新顺序)',
|
||||||
|
type: [WordImageItemDto],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => WordImageItemDto)
|
||||||
|
images: WordImageItemDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { CreateWordDto } from './create-word.dto';
|
||||||
|
import { IsOptional, IsArray, IsInt } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class UpdateWordDto extends PartialType(CreateWordDto) {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '关联词语ID数组',
|
||||||
|
type: [Number],
|
||||||
|
example: [1, 2, 3],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Type(() => Number)
|
||||||
|
relatedWordIds?: number[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '关联句子ID数组',
|
||||||
|
type: [Number],
|
||||||
|
example: [1, 2, 3],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Type(() => Number)
|
||||||
|
sentenceIds?: number[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './word.entity';
|
||||||
|
export * from './word-image.entity';
|
||||||
|
export * from './sentence.entity';
|
||||||
|
export * from './word-sentence.entity';
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { WordSentence } from './word-sentence.entity.js';
|
||||||
|
|
||||||
|
@Entity('sentences')
|
||||||
|
export class Sentence {
|
||||||
|
@ApiProperty({ description: '句子ID', example: 1 })
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '句子内容', example: '这是一个例句。' })
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
@Index()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '翻译(英语句子需要)',
|
||||||
|
example: 'This is an example sentence.',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
translation: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '音频文件路径',
|
||||||
|
example: '/audio/sentence1.mp3',
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true, name: 'audio_path' })
|
||||||
|
audioPath: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '来源(如教材名称)',
|
||||||
|
example: '人教版语文一年级上册',
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
source: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '创建时间',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '更新时间',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => WordSentence, (wordSentence) => wordSentence.sentence, {
|
||||||
|
cascade: true,
|
||||||
|
})
|
||||||
|
wordSentences: WordSentence[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Word } from './word.entity';
|
||||||
|
|
||||||
|
export enum ImageType {
|
||||||
|
ORIGINAL = 'original',
|
||||||
|
STANDARD = 'standard',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('word_images')
|
||||||
|
export class WordImage {
|
||||||
|
@ApiProperty({ description: '图片ID', example: 1 })
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '字词ID', example: 1 })
|
||||||
|
@Column({ type: 'bigint', name: 'word_id' })
|
||||||
|
@Index()
|
||||||
|
wordId: number;
|
||||||
|
|
||||||
|
@ManyToOne(() => Word, (word) => word.images, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'word_id' })
|
||||||
|
word: Word;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '图片类型',
|
||||||
|
enum: ImageType,
|
||||||
|
example: ImageType.ORIGINAL,
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ImageType,
|
||||||
|
default: ImageType.ORIGINAL,
|
||||||
|
name: 'image_type',
|
||||||
|
})
|
||||||
|
imageType: ImageType;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '图片文件路径', example: '/images/word1.jpg', maxLength: 500 })
|
||||||
|
@Column({ type: 'varchar', length: 500, name: 'file_path' })
|
||||||
|
filePath: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '原始文件名', example: 'word1.jpg', maxLength: 200 })
|
||||||
|
@Column({ type: 'varchar', length: 200, name: 'file_name' })
|
||||||
|
fileName: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '文件大小(字节)',
|
||||||
|
example: 102400,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'bigint', nullable: true, name: 'file_size' })
|
||||||
|
fileSize: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '图片宽度',
|
||||||
|
example: 300,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
width: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '图片高度',
|
||||||
|
example: 300,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
height: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'MIME类型',
|
||||||
|
example: 'image/jpeg',
|
||||||
|
maxLength: 50,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true, name: 'mime_type' })
|
||||||
|
mimeType: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '是否为主图',
|
||||||
|
example: false,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@Column({ type: 'boolean', default: false, name: 'is_primary' })
|
||||||
|
isPrimary: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '排序顺序',
|
||||||
|
example: 0,
|
||||||
|
default: 0,
|
||||||
|
})
|
||||||
|
@Column({ type: 'int', default: 0, name: 'sort_order' })
|
||||||
|
sortOrder: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '创建时间',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '更新时间',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
Unique,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Word } from './word.entity';
|
||||||
|
import { Sentence } from './sentence.entity';
|
||||||
|
|
||||||
|
@Entity('word_sentences')
|
||||||
|
@Unique(['wordId', 'sentenceId'])
|
||||||
|
export class WordSentence {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', name: 'word_id' })
|
||||||
|
@Index()
|
||||||
|
wordId: number;
|
||||||
|
|
||||||
|
@ManyToOne(() => Word, (word) => word.wordSentences, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'word_id' })
|
||||||
|
word: Word;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', name: 'sentence_id' })
|
||||||
|
@Index()
|
||||||
|
sentenceId: number;
|
||||||
|
|
||||||
|
@ManyToOne(() => Sentence, (sentence) => sentence.wordSentences, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'sentence_id' })
|
||||||
|
sentence: Sentence;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0, name: 'sort_order' })
|
||||||
|
sortOrder: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
ManyToMany,
|
||||||
|
JoinTable,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { WordImage } from './word-image.entity';
|
||||||
|
import { WordSentence } from './word-sentence.entity';
|
||||||
|
|
||||||
|
export enum WordType {
|
||||||
|
CHINESE_CHAR = 'chinese_char',
|
||||||
|
CHINESE_WORD = 'chinese_word',
|
||||||
|
ENGLISH_WORD = 'english_word',
|
||||||
|
ENGLISH_LETTER = 'english_letter',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum WordStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('words')
|
||||||
|
export class Word {
|
||||||
|
@ApiProperty({ description: '字词ID', example: 1 })
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '字词内容', example: '中', maxLength: 50 })
|
||||||
|
@Column({ type: 'varchar', length: 50 })
|
||||||
|
@Index()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '字词类型',
|
||||||
|
enum: WordType,
|
||||||
|
example: WordType.CHINESE_CHAR,
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: WordType,
|
||||||
|
default: WordType.CHINESE_CHAR,
|
||||||
|
})
|
||||||
|
@Index()
|
||||||
|
type: WordType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '年级(1-9)',
|
||||||
|
example: 1,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
grade: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '拼音数组(支持多音字)',
|
||||||
|
type: [String],
|
||||||
|
example: ['zhōng', 'zhòng'],
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'text', array: true, nullable: true })
|
||||||
|
pinyins: string[] | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '读音数组',
|
||||||
|
type: [String],
|
||||||
|
example: ['audio1.mp3', 'audio2.mp3'],
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'text', array: true, nullable: true })
|
||||||
|
pronunciations: string[] | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'SVG笔画数据(字符串数组)',
|
||||||
|
type: [String],
|
||||||
|
example: ['<svg>...</svg>', '<svg>...</svg>'],
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'text', array: true, nullable: true, name: 'svg_data' })
|
||||||
|
svgData: string[] | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '音频文件地址数组',
|
||||||
|
type: [String],
|
||||||
|
example: ['/audio/word1.mp3', '/audio/word1_alt.mp3'],
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'text', array: true, nullable: true, name: 'audio_files' })
|
||||||
|
audioFiles: string[] | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '描述信息',
|
||||||
|
example: '这是一个汉字',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '状态',
|
||||||
|
enum: WordStatus,
|
||||||
|
example: WordStatus.ACTIVE,
|
||||||
|
default: WordStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: WordStatus,
|
||||||
|
default: WordStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
@Index()
|
||||||
|
status: WordStatus;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '创建时间',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '更新时间',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '创建人ID',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'bigint', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '更新人ID',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Column({ type: 'bigint', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: number | null;
|
||||||
|
|
||||||
|
// 关联关系
|
||||||
|
@OneToMany(() => WordImage, (image) => image.word, { cascade: true })
|
||||||
|
images: WordImage[];
|
||||||
|
|
||||||
|
@OneToMany(() => WordSentence, (wordSentence) => wordSentence.word, {
|
||||||
|
cascade: true,
|
||||||
|
})
|
||||||
|
wordSentences: WordSentence[];
|
||||||
|
|
||||||
|
// 关联词语(通过关联表)
|
||||||
|
@ManyToMany(() => Word, (word) => word.id)
|
||||||
|
@JoinTable({
|
||||||
|
name: 'word_relations',
|
||||||
|
joinColumn: { name: 'source_word_id', referencedColumnName: 'id' },
|
||||||
|
inverseJoinColumn: { name: 'target_word_id', referencedColumnName: 'id' },
|
||||||
|
})
|
||||||
|
relatedWords: Word[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
ParseIntPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiParam,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { WordsService } from './words.service';
|
||||||
|
import { Word } from './word.entity';
|
||||||
|
import { CreateWordDto } from './dto/create-word.dto';
|
||||||
|
import { UpdateWordDto } from './dto/update-word.dto';
|
||||||
|
import { QueryWordsDto } from './dto/query-words.dto';
|
||||||
|
import { CreateSentenceDto } from './dto/create-sentence.dto';
|
||||||
|
import { PaginatedWordData } from './dto/paginated-response.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
|
||||||
|
@ApiTags('words')
|
||||||
|
@Controller('words')
|
||||||
|
export class WordsController {
|
||||||
|
constructor(private readonly wordsService: WordsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询所有汉字(支持分页和筛选)
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '查询汉字列表',
|
||||||
|
description: '获取汉字列表,支持分页和多种筛选条件(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '查询成功',
|
||||||
|
type: PaginatedWordData,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
findAll(@Query() queryDto: QueryWordsDto): Promise<PaginatedWordData> {
|
||||||
|
return this.wordsService.findAllPaginated(queryDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 查询单个汉字
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '根据ID查询汉字',
|
||||||
|
description: '根据汉字ID获取详细信息(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '查询成功',
|
||||||
|
type: Word,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
@ApiResponse({ status: 404, description: '汉字不存在' })
|
||||||
|
findOneById(@Param('id', ParseIntPipe) id: number): Promise<Word> {
|
||||||
|
return this.wordsService.findOneById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建汉字
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '创建汉字',
|
||||||
|
description: '创建新的汉字记录(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: CreateWordDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: '创建成功',
|
||||||
|
type: Word,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
create(@Body() createWordDto: CreateWordDto): Promise<Word> {
|
||||||
|
return this.wordsService.create(createWordDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新汉字信息
|
||||||
|
*/
|
||||||
|
@Patch(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '更新汉字',
|
||||||
|
description: '更新指定汉字的信息(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||||
|
@ApiBody({ type: UpdateWordDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '更新成功',
|
||||||
|
type: Word,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: '汉字不存在' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() updateWordDto: UpdateWordDto,
|
||||||
|
): Promise<Word> {
|
||||||
|
return this.wordsService.update(id, updateWordDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除汉字
|
||||||
|
*/
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '删除汉字',
|
||||||
|
description: '删除指定的汉字记录(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||||
|
@ApiResponse({ status: 204, description: '删除成功' })
|
||||||
|
@ApiResponse({ status: 404, description: '汉字不存在' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.wordsService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片相关接口
|
||||||
|
/**
|
||||||
|
* 获取汉字图片列表
|
||||||
|
*/
|
||||||
|
@Get(':id/images')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取汉字图片列表',
|
||||||
|
description: '获取指定汉字关联的所有图片(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||||
|
@ApiResponse({ status: 200, description: '成功返回图片列表' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
getWordImages(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.wordsService.getWordImages(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新图片排序
|
||||||
|
*/
|
||||||
|
@Patch(':id/images/sort')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '更新图片排序',
|
||||||
|
description: '更新汉字图片的排序顺序(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
imageIds: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'number' },
|
||||||
|
description: '图片ID数组,按新顺序排列',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '成功更新排序' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
updateWordImagesSort(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body('imageIds') imageIds: number[],
|
||||||
|
) {
|
||||||
|
return this.wordsService.updateWordImagesSort(id, imageIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除汉字图片
|
||||||
|
*/
|
||||||
|
@Delete(':id/images/:imageId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '删除汉字图片',
|
||||||
|
description: '删除指定汉字关联的图片(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||||
|
@ApiParam({ name: 'imageId', description: '图片ID', type: Number })
|
||||||
|
@ApiResponse({ status: 204, description: '删除成功' })
|
||||||
|
@ApiResponse({ status: 404, description: '图片不存在' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
deleteWordImage(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Param('imageId', ParseIntPipe) imageId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.wordsService.deleteWordImage(id, imageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 句子相关接口
|
||||||
|
/**
|
||||||
|
* 获取汉字句子列表
|
||||||
|
*/
|
||||||
|
@Get(':id/sentences')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取汉字句子列表',
|
||||||
|
description: '获取指定汉字关联的所有句子(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||||
|
@ApiResponse({ status: 200, description: '成功返回句子列表' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
getWordSentences(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.wordsService.getWordSentences(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加句子到汉字
|
||||||
|
*/
|
||||||
|
@Post(':id/sentences')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '添加句子',
|
||||||
|
description: '为指定汉字添加新的关联句子(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||||
|
@ApiBody({ type: CreateSentenceDto })
|
||||||
|
@ApiResponse({ status: 201, description: '成功添加句子' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
addSentenceToWord(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() createSentenceDto: CreateSentenceDto,
|
||||||
|
) {
|
||||||
|
return this.wordsService.addSentenceToWord(id, createSentenceDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从汉字中删除句子
|
||||||
|
*/
|
||||||
|
@Delete(':id/sentences/:sentenceId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin', 'super_admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '删除句子',
|
||||||
|
description: '删除指定汉字关联的句子(需要管理员权限)',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||||
|
@ApiParam({ name: 'sentenceId', description: '句子ID', type: Number })
|
||||||
|
@ApiResponse({ status: 204, description: '删除成功' })
|
||||||
|
@ApiResponse({ status: 404, description: '句子关联不存在' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
@ApiResponse({ status: 403, description: '权限不足' })
|
||||||
|
removeSentenceFromWord(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Param('sentenceId', ParseIntPipe) sentenceId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.wordsService.removeSentenceFromWord(id, sentenceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { WordsService } from './words.service';
|
||||||
|
import { WordsController } from './words.controller';
|
||||||
|
import { Word } from './word.entity';
|
||||||
|
import { WordImage } from './word-image.entity';
|
||||||
|
import { Sentence } from './sentence.entity';
|
||||||
|
import { WordSentence } from './word-sentence.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Word, WordImage, Sentence, WordSentence]),
|
||||||
|
],
|
||||||
|
controllers: [WordsController],
|
||||||
|
providers: [WordsService],
|
||||||
|
exports: [WordsService],
|
||||||
|
})
|
||||||
|
export class WordsModule {}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, FindOptionsWhere, In } from 'typeorm';
|
||||||
|
import { Word, WordType, WordStatus } from './word.entity';
|
||||||
|
import { WordImage } from './word-image.entity';
|
||||||
|
import { Sentence } from './sentence.entity';
|
||||||
|
import { WordSentence } from './word-sentence.entity';
|
||||||
|
import { CreateWordDto } from './dto/create-word.dto';
|
||||||
|
import { UpdateWordDto } from './dto/update-word.dto';
|
||||||
|
import { QueryWordsDto } from './dto/query-words.dto';
|
||||||
|
import { CreateSentenceDto } from './dto/create-sentence.dto';
|
||||||
|
import { PaginatedWordData } from './dto/paginated-response.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WordsService {
|
||||||
|
private readonly logger = new Logger(WordsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Word)
|
||||||
|
private readonly wordRepository: Repository<Word>,
|
||||||
|
@InjectRepository(WordImage)
|
||||||
|
private readonly wordImageRepository: Repository<WordImage>,
|
||||||
|
@InjectRepository(Sentence)
|
||||||
|
private readonly sentenceRepository: Repository<Sentence>,
|
||||||
|
@InjectRepository(WordSentence)
|
||||||
|
private readonly wordSentenceRepository: Repository<WordSentence>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建汉字
|
||||||
|
*/
|
||||||
|
async create(createWordDto: CreateWordDto): Promise<Word> {
|
||||||
|
const word = this.wordRepository.create(createWordDto);
|
||||||
|
return await this.wordRepository.save(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询汉字(支持多种查询条件和分页)
|
||||||
|
*/
|
||||||
|
async findAllPaginated(queryDto: QueryWordsDto): Promise<PaginatedWordData> {
|
||||||
|
const where: FindOptionsWhere<Word> = {};
|
||||||
|
|
||||||
|
// 只查询汉字
|
||||||
|
where.type = WordType.CHINESE_CHAR;
|
||||||
|
|
||||||
|
// 搜索条件
|
||||||
|
if (queryDto.search) {
|
||||||
|
// 使用 Like 进行模糊搜索
|
||||||
|
const queryBuilder = this.wordRepository.createQueryBuilder('word');
|
||||||
|
queryBuilder.where('word.type = :type', { type: WordType.CHINESE_CHAR });
|
||||||
|
queryBuilder.andWhere('word.content LIKE :search', {
|
||||||
|
search: `%${queryDto.search}%`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (queryDto.grade) {
|
||||||
|
queryBuilder.andWhere('word.grade = :grade', { grade: queryDto.grade });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序:按年级升序(低年级在前),年级相同时按创建时间
|
||||||
|
queryBuilder.orderBy('word.grade', 'ASC', 'NULLS LAST');
|
||||||
|
queryBuilder.addOrderBy('word.createdAt', 'ASC');
|
||||||
|
queryBuilder.addOrderBy('word.id', 'ASC');
|
||||||
|
|
||||||
|
// 分页参数
|
||||||
|
const page = queryDto.page || 1;
|
||||||
|
const limit = queryDto.limit || 9;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// 加载关联数据
|
||||||
|
queryBuilder.leftJoinAndSelect('word.images', 'images');
|
||||||
|
queryBuilder.leftJoinAndSelect('word.wordSentences', 'wordSentences');
|
||||||
|
queryBuilder.leftJoinAndSelect('wordSentences.sentence', 'sentence');
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
queryBuilder.skip(skip).take(limit);
|
||||||
|
|
||||||
|
const [list, total] = await queryBuilder.getManyAndCount();
|
||||||
|
|
||||||
|
// 计算总页数
|
||||||
|
const total_page = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
total_page,
|
||||||
|
page_size: limit,
|
||||||
|
current_page: page,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 年级筛选
|
||||||
|
if (queryDto.grade) {
|
||||||
|
where.grade = queryDto.grade;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页参数
|
||||||
|
const page = queryDto.page || 1;
|
||||||
|
const limit = queryDto.limit || 9;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// 查询总数
|
||||||
|
const total = await this.wordRepository.count({ where });
|
||||||
|
|
||||||
|
// 查询分页数据
|
||||||
|
const list = await this.wordRepository.find({
|
||||||
|
where,
|
||||||
|
relations: ['images', 'wordSentences', 'wordSentences.sentence'],
|
||||||
|
order: {
|
||||||
|
grade: 'ASC',
|
||||||
|
createdAt: 'ASC',
|
||||||
|
id: 'ASC',
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算总页数
|
||||||
|
const total_page = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
total_page,
|
||||||
|
page_size: limit,
|
||||||
|
current_page: page,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 查询单个汉字
|
||||||
|
*/
|
||||||
|
async findOneById(id: number): Promise<Word> {
|
||||||
|
const word = await this.wordRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['images', 'wordSentences', 'wordSentences.sentence'],
|
||||||
|
order: {
|
||||||
|
images: { sortOrder: 'ASC' },
|
||||||
|
wordSentences: { sortOrder: 'ASC' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!word) {
|
||||||
|
throw new NotFoundException(`未找到ID为 ${id} 的汉字`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新汉字信息
|
||||||
|
*/
|
||||||
|
async update(id: number, updateWordDto: UpdateWordDto): Promise<Word> {
|
||||||
|
const word = await this.findOneById(id);
|
||||||
|
|
||||||
|
// 更新基础信息
|
||||||
|
Object.assign(word, updateWordDto);
|
||||||
|
|
||||||
|
// 更新关联句子
|
||||||
|
if (updateWordDto.sentenceIds) {
|
||||||
|
// 删除现有关联
|
||||||
|
await this.wordSentenceRepository.delete({ wordId: id });
|
||||||
|
|
||||||
|
// 创建新关联
|
||||||
|
for (let i = 0; i < updateWordDto.sentenceIds.length; i++) {
|
||||||
|
const sentenceId = updateWordDto.sentenceIds[i];
|
||||||
|
const wordSentence = this.wordSentenceRepository.create({
|
||||||
|
wordId: id,
|
||||||
|
sentenceId: sentenceId,
|
||||||
|
sortOrder: i,
|
||||||
|
});
|
||||||
|
await this.wordSentenceRepository.save(wordSentence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.wordRepository.save(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除汉字
|
||||||
|
*/
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
const word = await this.findOneById(id);
|
||||||
|
await this.wordRepository.remove(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片相关方法
|
||||||
|
/**
|
||||||
|
* 获取汉字图片列表
|
||||||
|
*/
|
||||||
|
async getWordImages(wordId: number): Promise<WordImage[]> {
|
||||||
|
return await this.wordImageRepository.find({
|
||||||
|
where: { wordId: wordId },
|
||||||
|
order: { sortOrder: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新图片排序
|
||||||
|
*/
|
||||||
|
async updateWordImagesSort(wordId: number, imageIds: number[]): Promise<WordImage[]> {
|
||||||
|
for (let i = 0; i < imageIds.length; i++) {
|
||||||
|
await this.wordImageRepository.update(
|
||||||
|
{ id: imageIds[i], wordId: wordId },
|
||||||
|
{ sortOrder: i },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await this.getWordImages(wordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除汉字图片
|
||||||
|
*/
|
||||||
|
async deleteWordImage(wordId: number, imageId: number): Promise<void> {
|
||||||
|
const image = await this.wordImageRepository.findOne({
|
||||||
|
where: { id: imageId, wordId: wordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
throw new NotFoundException('图片不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.wordImageRepository.remove(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 句子相关方法
|
||||||
|
/**
|
||||||
|
* 获取汉字句子列表
|
||||||
|
*/
|
||||||
|
async getWordSentences(wordId: number): Promise<Sentence[]> {
|
||||||
|
const wordSentences = await this.wordSentenceRepository.find({
|
||||||
|
where: { wordId: wordId },
|
||||||
|
relations: ['sentence'],
|
||||||
|
order: { sortOrder: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return wordSentences.map((ws) => ws.sentence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加句子到汉字
|
||||||
|
*/
|
||||||
|
async addSentenceToWord(wordId: number, createSentenceDto: CreateSentenceDto): Promise<Sentence> {
|
||||||
|
// 创建句子
|
||||||
|
const sentence = this.sentenceRepository.create(createSentenceDto);
|
||||||
|
const savedSentence = await this.sentenceRepository.save(sentence);
|
||||||
|
|
||||||
|
// 创建关联
|
||||||
|
const maxSort = await this.wordSentenceRepository
|
||||||
|
.createQueryBuilder('ws')
|
||||||
|
.where('ws.wordId = :wordId', { wordId })
|
||||||
|
.select('MAX(ws.sortOrder)', 'max')
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
const wordSentence = this.wordSentenceRepository.create({
|
||||||
|
wordId: wordId,
|
||||||
|
sentenceId: savedSentence.id,
|
||||||
|
sortOrder: (maxSort?.max || 0) + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.wordSentenceRepository.save(wordSentence);
|
||||||
|
return savedSentence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从汉字中删除句子
|
||||||
|
*/
|
||||||
|
async removeSentenceFromWord(wordId: number, sentenceId: number): Promise<void> {
|
||||||
|
const wordSentence = await this.wordSentenceRepository.findOne({
|
||||||
|
where: { wordId: wordId, sentenceId: sentenceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wordSentence) {
|
||||||
|
throw new NotFoundException('句子关联不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.wordSentenceRepository.remove(wordSentence);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 词语相关方法(后续实现)
|
||||||
|
/**
|
||||||
|
* 获取关联词语
|
||||||
|
*/
|
||||||
|
async getRelatedWords(wordId: number): Promise<Word[]> {
|
||||||
|
// TODO: 实现词语关联查询
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('AppController (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "platform-doodle",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Doodle Platform Monorepo - API backend and admin management system",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm -r --parallel dev",
|
||||||
|
"dev:api": "pnpm --filter api start:dev",
|
||||||
|
"dev:web": "pnpm --filter web dev",
|
||||||
|
"start": "pnpm -r --parallel start",
|
||||||
|
"start:api": "pnpm --filter api start",
|
||||||
|
"start:api:prod": "pnpm --filter api start:prod",
|
||||||
|
"start:web": "pnpm --filter web preview",
|
||||||
|
"build": "pnpm -r build",
|
||||||
|
"build:api": "pnpm --filter api build",
|
||||||
|
"build:web": "pnpm --filter web build",
|
||||||
|
"lint": "pnpm -r lint",
|
||||||
|
"lint:api": "pnpm --filter api lint",
|
||||||
|
"lint:web": "pnpm --filter web lint",
|
||||||
|
"test": "pnpm -r test",
|
||||||
|
"test:api": "pnpm --filter api test",
|
||||||
|
"format": "pnpm -r format",
|
||||||
|
"format:api": "pnpm --filter api format"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"monorepo",
|
||||||
|
"doodle",
|
||||||
|
"nestjs",
|
||||||
|
"react",
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0",
|
||||||
|
"pnpm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48"
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user