feat:初始化项目
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user