init
This commit is contained in:
75
.gitea/workflows/release.yaml
Normal file
75
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Docker Build and Push
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'release/*' # 匹配所有release开头的标签,如release/v0.1.6
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "🎉 该作业由 ${{ gitea.event_name }} 事件自动触发。"
|
||||
- run: echo "🐧 此作业当前在 Gitea 托管的 ${{ runner.os }} 服务器上运行!"
|
||||
- run: echo "🔎 您的标签名称是 ${{ gitea.ref_name }},仓库是 ${{ gitea.repository }}。"
|
||||
- name: 检出仓库代码
|
||||
uses: actions/checkout@v4
|
||||
- run: echo "💡 ${{ gitea.repository }} 仓库已克隆到运行器。"
|
||||
- run: echo "🖥️ 工作流现在已准备好在运行器上测试您的代码。"
|
||||
- name: 列出仓库中的文件
|
||||
run: |
|
||||
ls ${{ gitea.workspace }}
|
||||
- run: echo "🍏 此作业的状态是 ${{ job.status }}。"
|
||||
|
||||
- name: 设置 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 从标签名中提取版本号
|
||||
id: extract_version
|
||||
run: |
|
||||
# 从标签名中提取版本号,例如从 release/v0.1.6 中提取 v0.1.6
|
||||
VERSION=$(echo "${{ gitea.ref_name }}" | sed 's|release/||')
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "提取的版本号:$VERSION"
|
||||
|
||||
- name: 登录 Docker 注册表
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.cn-beijing.aliyuncs.com
|
||||
username: ${{ secrets.ALI_DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.ALI_DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: 构建并推送
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
registry.cn-beijing.aliyuncs.com/d8dcloud/d8d-ai-design-prd:release-${{ env.VERSION }}
|
||||
- name: 更新服务器上的 Docker 容器
|
||||
uses: appleboy/ssh-action@v1.2.2
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
port: ${{ secrets.SERVER_PORT }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
script: |
|
||||
# 进入项目目录
|
||||
cd /mnt/k8s_home/app/d8d-fun-vite
|
||||
|
||||
|
||||
# 备份当前的 deployment.yaml 文件
|
||||
cp deployment.yaml deployment.yaml.bak
|
||||
|
||||
# 更新 deployment.yaml 中的镜像版本
|
||||
sed -i "s|registry.cn-beijing.aliyuncs.com/d8dcloud/d8d-ai-design-prd:.*|registry.cn-beijing.aliyuncs.com/d8dcloud/d8d-ai-design-prd:release-${{ env.VERSION }}|g" deployment.yaml
|
||||
|
||||
# 检查文件是否成功更新
|
||||
echo "更新后的 deployment.yaml 内容:"
|
||||
cat deployment.yaml
|
||||
|
||||
# 更新
|
||||
kubectl apply -f deployment.yaml
|
||||
|
||||
# 显示d8d-fun-vite pod状态
|
||||
kubectl get pods -n default -l app=d8d-fun-vite
|
||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# prod
|
||||
dist/
|
||||
dist-server/
|
||||
|
||||
bun.lock
|
||||
package-lock.json
|
||||
|
||||
# dev
|
||||
.yarn/
|
||||
!.yarn/releases
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/*.code-snippets
|
||||
.idea/workspace.xml
|
||||
.idea/usage.statistics.xml
|
||||
.idea/shelf
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
.wrangler
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.production
|
||||
.env.development
|
||||
.dev.vars
|
||||
|
||||
# logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
.pnpm-store
|
||||
old
|
||||
15
.roo/mcp.json
Normal file
15
.roo/mcp.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"openapi": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"mcp-openapi-schema-explorer@latest",
|
||||
"https://pre-136-107-template-6.r.d8d.fun/doc",
|
||||
"--output-format",
|
||||
"json"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
.roo/rules/01-general.md
Normal file
25
.roo/rules/01-general.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 基础规范
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── client/ # 前端代码 (React + Vite)
|
||||
├── server/ # 后端代码 (Hono + TypeORM)
|
||||
│ ├── api/ # API路由
|
||||
│ ├── migrations/ # 数据库迁移脚本
|
||||
│ ├── modules/ # 业务模块
|
||||
│ └── middleware/ # 中间件
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
- React 18
|
||||
- TypeScript (严格模式)
|
||||
- Vite 构建工具
|
||||
|
||||
### 后端
|
||||
- Hono 框架
|
||||
- TypeORM (mysql)
|
||||
- Redis (缓存/会话管理)
|
||||
5
.roo/rules/02-typescript.md
Normal file
5
.roo/rules/02-typescript.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# TypeScript规范
|
||||
|
||||
1. **严格模式**
|
||||
- 启用所有严格类型检查选项
|
||||
- 避免使用`any`类型
|
||||
8
.roo/rules/03-modules.md
Normal file
8
.roo/rules/03-modules.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 模块化规范
|
||||
|
||||
1. **模块组织**
|
||||
- 按功能划分模块
|
||||
- 每个模块包含:
|
||||
- 实体定义
|
||||
- 服务层
|
||||
- 路由控制器
|
||||
22
.roo/rules/04-api.md
Normal file
22
.roo/rules/04-api.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 接口定义规范
|
||||
|
||||
1. **DTO定义**
|
||||
- 必须包含`description`字段说明用途
|
||||
- 必须包含`example`字段提供示例值
|
||||
- 示例:
|
||||
```typescript
|
||||
export const CreateUserDto = z.object({
|
||||
username: z.string().min(3).max(20).openapi({
|
||||
example: 'john_doe',
|
||||
description: '用户名, 3-20个字符'
|
||||
}),
|
||||
password: z.string().min(6).openapi({
|
||||
example: 'password123',
|
||||
description: '密码, 最少6位'
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
2. **API响应**
|
||||
- 统一的API响应格式
|
||||
- 完善的Swagger文档
|
||||
5
.roo/rules/05-database.md
Normal file
5
.roo/rules/05-database.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 数据库规范
|
||||
|
||||
1. **迁移管理**
|
||||
- 使用迁移脚本管理表结构变更
|
||||
- 实体类与数据库表严格映射
|
||||
19
.roo/rules/06-service-di.md
Normal file
19
.roo/rules/06-service-di.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 依赖注入规范
|
||||
|
||||
1. **依赖注入原则**
|
||||
- 服务类必须通过构造函数注入依赖
|
||||
- 禁止直接实例化全局对象(AppDataSource等)
|
||||
- 不需要import { Injectable } from '@nestjs/common';, 本项目没用@nestjs
|
||||
- 示例:
|
||||
```typescript
|
||||
// Good - 通过构造函数注入
|
||||
export class UserService {
|
||||
constructor(private dataSource: DataSource) {}
|
||||
}
|
||||
|
||||
// Bad - 使用全局实例
|
||||
export class UserService {
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(User);
|
||||
}
|
||||
}
|
||||
272
.roo/rules/07-openapi.md
Normal file
272
.roo/rules/07-openapi.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# Hono OpenAPI规范
|
||||
|
||||
## 常见不规范问题
|
||||
1. **路径参数问题**:
|
||||
- ❌ 使用冒号定义路径参数: `/:id`
|
||||
- ✅ 必须使用花括号: `/{id}`
|
||||
|
||||
2. **参数Schema缺失**:
|
||||
- ❌ 未定义params Schema
|
||||
- ✅ 必须定义并添加OpenAPI元数据
|
||||
|
||||
3. **参数获取方式**:
|
||||
- ❌ 使用`c.req.param()`
|
||||
- ✅ 必须使用`c.req.valid('param')`
|
||||
|
||||
4. **URL参数类型转换**:
|
||||
- ❌ 直接使用z.number()验证URL查询参数
|
||||
- ✅ 必须使用z.coerce.number()自动转换字符串参数
|
||||
|
||||
5. **OpenAPI元数据**:
|
||||
- ❌ 路径参数缺少OpenAPI描述
|
||||
- ✅ 必须包含example和description
|
||||
|
||||
6. **api响应**:
|
||||
- ❌ 200响应码缺少
|
||||
- ✅ 200也必须写,c.json(result, 200)
|
||||
|
||||
7. **认证中间件**:
|
||||
- ❌ security: [{ Bearer: [] }],
|
||||
- ✅ middleware: [authMiddleware],
|
||||
|
||||
8. **子路由路径**:
|
||||
- ❌ path: '/users',
|
||||
- ✅ path: '/',
|
||||
- ❌ path: '/users/{id}',
|
||||
- ✅ path: '/{id}',
|
||||
|
||||
## 核心规范
|
||||
### 1. 路由定义
|
||||
|
||||
### 2. 查询参数处理
|
||||
- **URL参数类型**:
|
||||
- URL查询参数总是以字符串形式传递
|
||||
- 必须正确处理字符串到其他类型的转换
|
||||
- **数字参数处理**:
|
||||
```typescript
|
||||
// 错误方式 - 直接使用z.number()
|
||||
z.number().int().positive() // 无法处理字符串参数
|
||||
|
||||
// 正确方式 - 使用z.coerce.number()
|
||||
z.coerce.number().int().positive() // 自动转换字符串参数
|
||||
```
|
||||
- **布尔参数处理**:
|
||||
```typescript
|
||||
// 错误方式 - 直接使用z.boolean()
|
||||
z.boolean() // 无法处理字符串参数
|
||||
|
||||
// 正确方式 - 使用z.coerce.boolean()
|
||||
z.coerce.boolean() // 自动转换字符串参数
|
||||
```
|
||||
- **路径参数**:
|
||||
- 必须使用花括号 `{}` 定义 (例: `/{id}`)
|
||||
- 必须定义 params Schema 并添加 OpenAPI 元数据:
|
||||
```typescript
|
||||
const GetParams = z.object({
|
||||
id: z.string().openapi({
|
||||
param: { name: 'id', in: 'path' },
|
||||
example: '1',
|
||||
description: '资源ID'
|
||||
})
|
||||
});
|
||||
```
|
||||
- 路由定义中必须包含 params 定义:
|
||||
```typescript
|
||||
request: { params: GetParams }
|
||||
```
|
||||
- 必须使用 `c.req.valid('param')` 获取路径参数
|
||||
|
||||
- **请求定义**:
|
||||
```typescript
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'application/json': { schema: YourZodSchema }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **响应定义**:
|
||||
```typescript
|
||||
responses: {
|
||||
200: {
|
||||
description: '成功响应描述',
|
||||
content: { 'application/json': { schema: SuccessSchema } }
|
||||
},
|
||||
400: {
|
||||
description: '客户端错误',
|
||||
content: { 'application/json': { schema: ErrorSchema } }
|
||||
},
|
||||
500: {
|
||||
description: '服务器错误',
|
||||
content: { 'application/json': { schema: ErrorSchema } }
|
||||
}
|
||||
}
|
||||
```
|
||||
列表响应定义示例
|
||||
```typescript
|
||||
// 列表响应Schema, 响应时,data应统一用实体中定义的schema
|
||||
import { RackInfoSchema } from '@/server/modules/racks/rack-info.entity';
|
||||
const RackListResponse = z.object({
|
||||
data: z.array(RackInfoSchema),
|
||||
pagination: z.object({
|
||||
total: z.number().openapi({
|
||||
example: 100,
|
||||
description: '总记录数'
|
||||
}),
|
||||
current: z.number().openapi({
|
||||
example: 1,
|
||||
description: '当前页码'
|
||||
}),
|
||||
pageSize: z.number().openapi({
|
||||
example: 10,
|
||||
description: '每页数量'
|
||||
})
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
- **路由示例**:
|
||||
```typescript
|
||||
const routeDef = createRoute({
|
||||
method: 'post',
|
||||
path: '/',
|
||||
middleware: [authMiddleware],
|
||||
request: {
|
||||
body: {
|
||||
content: { 'application/json': { schema: CreateSchema } }
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: { ... },
|
||||
400: { ... },
|
||||
500: { ... }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 错误处理
|
||||
- 错误响应必须使用统一格式: `{ code: number, message: string }`
|
||||
- 必须与OpenAPI定义完全一致
|
||||
- 处理示例:
|
||||
```typescript
|
||||
try {
|
||||
// 业务逻辑
|
||||
} catch (error) {
|
||||
return c.json({ code: 500, message: '操作失败' }, 500);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. dataSource引入
|
||||
- 示例:
|
||||
```typescript
|
||||
import { AppDataSource } from '@/server/data-source';
|
||||
```
|
||||
|
||||
### 4. service初始化
|
||||
- 示例:
|
||||
```typescript
|
||||
import { WorkspaceService } from '@/server/modules/workspaces/workspace.service';
|
||||
const workspaceService = new WorkspaceService(AppDataSource);
|
||||
|
||||
### 5. 用户context获取
|
||||
- 示例:
|
||||
```typescript
|
||||
const user = c.get('user');
|
||||
```
|
||||
- 注意: 确保 `c.get('user')` 已经在 `authMiddleware` 中设置
|
||||
|
||||
### 6. AuthContext引用
|
||||
- 示例:
|
||||
```typescript
|
||||
import { AuthContext } from '@/server/types/context';
|
||||
```
|
||||
|
||||
### 7. createRoute, OpenAPIHono 引入
|
||||
- 示例:
|
||||
```typescript
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
|
||||
```
|
||||
|
||||
### 8. ErrorSchema 引入
|
||||
- 示例:
|
||||
```typescript
|
||||
import { ErrorSchema } from '@/server/utils/errorHandler';
|
||||
```
|
||||
|
||||
## 进阶规范
|
||||
### 1. 路由聚合
|
||||
当多个相关路由需要组合时:
|
||||
1. **文件结构**:
|
||||
- 拆分为独立文件 (`create.ts`, `list.ts` 等)
|
||||
- 创建 `index.ts` 聚合所有子路由
|
||||
|
||||
```
|
||||
src/server/api/
|
||||
├── [resource]/ # 资源路由目录
|
||||
│ ├── [id]/ # 带ID的子路由
|
||||
│ │ ├── get.ts # 获取单条
|
||||
│ │ ├── put.ts # 更新单条
|
||||
│ │ └── delete.ts # 删除单条
|
||||
│ ├── get.ts # 列表查询
|
||||
│ ├── post.ts # 创建资源
|
||||
│ └── index.ts # 聚合导出
|
||||
```
|
||||
|
||||
2. **实现**:
|
||||
```typescript
|
||||
import listRoute from './get';
|
||||
import createRackRoute from './post';
|
||||
import getByIdRoute from './[id]/get';
|
||||
import updateRoute from './[id]/put';
|
||||
import deleteRoute from './[id]/delete';
|
||||
import { OpenAPIHono } from '@hono/zod-openapi';
|
||||
|
||||
const app = new OpenAPIHono()
|
||||
.route('/', listRoute)
|
||||
.route('/', createRackRoute)
|
||||
.route('/', getByIdRoute)
|
||||
.route('/', updateRoute)
|
||||
.route('/', deleteRoute)
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
3. **优势**:
|
||||
- 保持模块化
|
||||
- 简化维护
|
||||
- 统一API入口
|
||||
|
||||
## 路由文件代码结构规范
|
||||
+imports: 依赖导入
|
||||
+serviceInit: 服务初始化
|
||||
+paramsSchema: 路径参数定义
|
||||
+responseSchema: 响应定义
|
||||
+errorSchema: 错误定义
|
||||
+routeDef: 路由定义
|
||||
+app: 路由实例
|
||||
|
||||
## src/server/api.ts 统一引入
|
||||
```ts
|
||||
import authRoute from '@/server/api/auth/index'
|
||||
const routes = api.route('/api/v1/auth', authRoute)
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
```typescript
|
||||
// 路由实例
|
||||
const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
|
||||
try {
|
||||
// 业务逻辑
|
||||
return c.json(result, 200);
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
code: 500,
|
||||
message: error instanceOf Error ? error.message : '操作失败'
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
```
|
||||
82
.roo/rules/08-rpc.md
Normal file
82
.roo/rules/08-rpc.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# RPC 调用规范
|
||||
|
||||
## 常见不规范问题
|
||||
1. **InferResponseType有[':id']时问题**:
|
||||
- ❌ InferResponseType<typeof zichanClient[':id'].$get, 200>
|
||||
- ✅ $get要加中括号 InferResponseType<typeof zichanClient[':id']['$get'], 200>
|
||||
|
||||
## 核心原则
|
||||
1. **类型安全**:
|
||||
- 所有RPC调用必须基于OpenAPI定义的类型
|
||||
- 客户端和服务端类型必须严格匹配
|
||||
|
||||
2. **一致性**:
|
||||
- RPC调用路径必须与OpenAPI路由定义一致
|
||||
- 错误处理格式必须统一
|
||||
|
||||
3. **api版本**:
|
||||
- 所有RPC调用必须基于OpenAPI定义的版本
|
||||
- 版本号必须与OpenAPI版本号一致
|
||||
目前仅支持v1版本
|
||||
示例:
|
||||
```typescript
|
||||
import { authClient } from '@/client/api';
|
||||
```
|
||||
|
||||
## 客户端规范
|
||||
### 1. 客户端初始化
|
||||
```typescript
|
||||
import { hc } from 'hono/client'
|
||||
import { AuthRoutes } from '@/server/api';
|
||||
|
||||
export const authClient = hc<AuthRoutes>('/', {
|
||||
fetch: axiosFetch,
|
||||
}).api.v1.auth;
|
||||
```
|
||||
|
||||
### 2. 方法调用
|
||||
- 必须使用解构方式组织RPC方法
|
||||
- 方法命名必须与OpenAPI路由定义一致
|
||||
- 示例:
|
||||
```typescript
|
||||
const res = await authClient.templates.blank[':templateType'].$get({
|
||||
param: {
|
||||
templateType
|
||||
}
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw new Error(res.message);
|
||||
}
|
||||
const templateInfo = await res.json();
|
||||
```
|
||||
|
||||
### 3. 类型提取规范
|
||||
- **响应类型提取**:
|
||||
- 使用 `InferResponseType` 从客户端方法提取响应类型
|
||||
- 必须指定正确的响应状态码(通常为200)
|
||||
- 示例:
|
||||
```typescript
|
||||
type ResponseType = InferResponseType<typeof client.method.$get, 200>['data'];
|
||||
```
|
||||
|
||||
- **请求类型提取**:
|
||||
- 使用 `InferRequestType` 从客户端方法提取请求类型
|
||||
- 必须指定正确的请求参数类型('json'|'form'|'param')
|
||||
- 示例:
|
||||
```typescript
|
||||
type RequestType = InferRequestType<typeof client.method.$post>['json'];
|
||||
```
|
||||
|
||||
- **类型命名规范**:
|
||||
- 响应类型: `[ResourceName]`
|
||||
- 请求类型: `[ResourceName]Post` 或 `[ResourceName]Put`
|
||||
- 示例:
|
||||
```typescript
|
||||
import type { InferRequestType, InferResponseType } from 'hono/client'
|
||||
type ZichanInfo = InferResponseType<typeof zichanClient.$get, 200>['data'][0];
|
||||
type ZichanListResponse = InferResponseType<typeof zichanClient.$get, 200>;
|
||||
type ZichanDetailResponse = InferResponseType<typeof zichanClient[':id']['$get'], 200>;
|
||||
type CreateZichanRequest = InferRequestType<typeof zichanClient.$post>['json'];
|
||||
type UpdateZichanRequest = InferRequestType<typeof zichanClient[':id']['$put']>['json'];
|
||||
type DeleteZichanResponse = InferResponseType<typeof zichanClient[':id']['$delete'], 200>;
|
||||
```
|
||||
71
.roo/rules/09-logging.md
Normal file
71
.roo/rules/09-logging.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 日志规范
|
||||
|
||||
## 1. 基础配置
|
||||
- 前后端统一使用debug库
|
||||
- 开发环境:启用所有日志级别
|
||||
- 生产环境:通过环境变量`DEBUG`控制日志级别
|
||||
- 安装依赖:已安装`debug@4.4.1`和`@types/debug`
|
||||
|
||||
## 2. 命名空间规范
|
||||
格式:`<应用>:<模块>:<功能>`
|
||||
示例:
|
||||
```
|
||||
frontend:auth:login # 前端-认证-登录
|
||||
backend:api:middleware # 后端-API-中间件
|
||||
backend:db:query # 后端-数据库-查询
|
||||
k8s:deployment:create # K8S-部署-创建
|
||||
```
|
||||
|
||||
## 3. 日志级别
|
||||
| 级别 | 使用场景 |
|
||||
|--------|----------------------------|
|
||||
| error | 系统错误、异常情况 |
|
||||
| warn | 警告性事件 |
|
||||
| info | 重要业务流程信息 |
|
||||
| debug | 调试信息 |
|
||||
| trace | 详细跟踪信息(慎用) |
|
||||
|
||||
## 4. 实现示例
|
||||
|
||||
### 前端示例
|
||||
```typescript
|
||||
// src/client/utils/logger.ts
|
||||
import debug from 'debug';
|
||||
|
||||
export const logger = {
|
||||
error: debug('frontend:error'),
|
||||
api: debug('frontend:api'),
|
||||
auth: debug('frontend:auth'),
|
||||
ui: debug('frontend:ui')
|
||||
};
|
||||
```
|
||||
|
||||
### 后端示例
|
||||
```typescript
|
||||
// src/server/utils/logger.ts
|
||||
import debug from 'debug';
|
||||
|
||||
export const logger = {
|
||||
error: debug('backend:error'),
|
||||
api: debug('backend:api'),
|
||||
db: debug('backend:db'),
|
||||
middleware: debug('backend:middleware')
|
||||
};
|
||||
```
|
||||
|
||||
## 5. 最佳实践
|
||||
1. 错误日志必须包含错误堆栈和上下文
|
||||
2. 禁止记录密码、token等敏感信息
|
||||
3. 生产环境避免使用trace级别
|
||||
4. 复杂对象使用JSON.stringify()
|
||||
|
||||
## 6. 环境配置
|
||||
```bash
|
||||
# 开发环境
|
||||
DEBUG=*
|
||||
|
||||
# 生产环境
|
||||
DEBUG=*:error,*:warn
|
||||
|
||||
# 特定模块调试
|
||||
DEBUG=backend:api,backend:db
|
||||
117
.roo/rules/10-entity.md
Normal file
117
.roo/rules/10-entity.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# 数据库实体规范
|
||||
|
||||
## 1. 实体基础结构
|
||||
|
||||
```typescript
|
||||
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
@Entity('table_name') // 使用小写下划线命名表名
|
||||
export class EntityName {
|
||||
// 字段定义...
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 主键定义规范
|
||||
|
||||
```typescript
|
||||
@PrimaryGeneratedColumn({ unsigned: true }) // 必须使用无符号整数
|
||||
id!: number; // 使用非空断言(!)和明确类型
|
||||
```
|
||||
|
||||
## 3. 列定义规范
|
||||
|
||||
```typescript
|
||||
@Column({
|
||||
name: '字段名称', // 必须添加并与数据表字段名称一致
|
||||
type: 'varchar', // 明确指定数据库类型
|
||||
length: 255, // 字符串必须指定长度
|
||||
nullable: true, // 明确是否可为空
|
||||
default: undefined, // 默认值(可选)
|
||||
comment: '字段说明' // 必须添加中文注释
|
||||
})
|
||||
fieldName!: FieldType; // 类型必须明确
|
||||
```
|
||||
|
||||
## 4. 数据类型规范
|
||||
|
||||
| 业务类型 | 数据库类型 | TypeScript 类型 |
|
||||
|----------------|-------------------------|-----------------------|
|
||||
| 主键ID | int unsigned | number |
|
||||
| 短文本 | varchar(length) | string |
|
||||
| 长文本 | text | string |
|
||||
| 整数 | int | number |
|
||||
| 小数 | decimal(precision,scale)| number |
|
||||
| 布尔状态 | tinyint | number (0/1) |
|
||||
| 日期时间 | timestamp | Date |
|
||||
|
||||
## 5. 状态字段规范
|
||||
|
||||
```typescript
|
||||
// 禁用状态 (0启用 1禁用)
|
||||
@Column({ name: 'is_disabled', type: 'tinyint', default: 1 })
|
||||
isDisabled!: number;
|
||||
|
||||
// 删除状态 (0未删除 1已删除)
|
||||
@Column({ name: 'is_deleted',type: 'tinyint', default: 0 })
|
||||
isDeleted!: number;
|
||||
```
|
||||
|
||||
## 6. 时间字段规范
|
||||
|
||||
```typescript
|
||||
// 创建时间 (自动设置)
|
||||
@Column({
|
||||
name: 'created_at',
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP'
|
||||
})
|
||||
createdAt!: Date;
|
||||
|
||||
// 更新时间 (自动更新)
|
||||
@Column({
|
||||
name: 'updated_at',
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP',
|
||||
onUpdate: 'CURRENT_TIMESTAMP'
|
||||
})
|
||||
updatedAt!: Date;
|
||||
```
|
||||
|
||||
## 7. Zod Schema 规范
|
||||
|
||||
```typescript
|
||||
export const EntitySchema = z.object({
|
||||
id: z.number().int().positive().openapi({ description: 'ID说明' }),
|
||||
// 字符串字段
|
||||
fieldName: z.string()
|
||||
.max(255)
|
||||
.nullable()
|
||||
.openapi({
|
||||
description: '字段说明',
|
||||
example: '示例值'
|
||||
}),
|
||||
// 数字字段
|
||||
numberField: z.number()
|
||||
.default(默认值)
|
||||
.openapi({...}),
|
||||
// 日期字段
|
||||
dateField: z.date().openapi({...})
|
||||
});
|
||||
```
|
||||
|
||||
## 8. 命名规范
|
||||
|
||||
- 实体类名:PascalCase (如 RackInfo)
|
||||
- 表名:snake_case (如 rack_info)
|
||||
- 字段名:camelCase (如 rackName)
|
||||
- 数据库列名:snake_case (如 rack_name)
|
||||
|
||||
## 9. 最佳实践
|
||||
|
||||
1. 所有字段必须添加字段名称(name)及注释(comment)
|
||||
2. 必须明确指定 nullable 属性
|
||||
3. 状态字段使用 tinyint 并注明取值含义
|
||||
4. 必须包含 createdAt/updatedAt 时间字段
|
||||
5. 每个实体必须配套 Zod Schema 定义
|
||||
6. Schema 必须包含 OpenAPI 元数据(description/example)
|
||||
51
.roo/rules/11-entity-creation.md
Normal file
51
.roo/rules/11-entity-creation.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 新实体创建流程规范
|
||||
|
||||
## 完整开发流程
|
||||
|
||||
1. **创建实体**
|
||||
- 位置: `src/server/modules/[模块名]/[实体名].entity.ts`
|
||||
- 参考已有实体文件如`user.entity.ts`
|
||||
- 注意: 必须包含Zod Schema定义
|
||||
|
||||
2. **创建Service**
|
||||
- 位置: `src/server/modules/[模块名]/[实体名].service.ts`
|
||||
- 通过构造函数注入DataSource
|
||||
- 使用实体Schema进行输入输出验证
|
||||
|
||||
3. **创建API路由**
|
||||
- 目录结构:
|
||||
```
|
||||
src/server/api/[实体名]/
|
||||
├── get.ts # 列表
|
||||
├── post.ts # 创建
|
||||
├── [id]/
|
||||
│ ├── get.ts # 详情
|
||||
│ ├── put.ts # 更新
|
||||
│ └── delete.ts # 删除
|
||||
└── index.ts # 路由聚合
|
||||
```
|
||||
- 必须使用实体Schema作为请求/响应Schema
|
||||
- 参考`users`模块的实现
|
||||
|
||||
4. **注册路由**
|
||||
- 在`src/server/api.ts`中添加路由注册
|
||||
|
||||
5. **创建客户端API**
|
||||
- 在`src/client/api.ts`中添加客户端定义
|
||||
|
||||
6. **前端调用**
|
||||
- 在页面组件(如`pages_users.tsx`)中:
|
||||
- 使用`InferResponseType`提取响应类型
|
||||
- 使用`InferRequestType`提取请求类型
|
||||
- 示例:
|
||||
```typescript
|
||||
type EntityResponse = InferResponseType<typeof entityClient.$get, 200>;
|
||||
type CreateRequest = InferRequestType<typeof entityClient.$post>['json'];
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 实体Schema必须在实体文件中定义,路由中直接引用,不要重复定义
|
||||
2. 前端表格/表单字段必须与实体定义保持一致
|
||||
3. 确保所有API调用都有正确的类型推断
|
||||
4. 参考现有模块实现保持风格一致
|
||||
3
.rooignore
Normal file
3
.rooignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.gitea
|
||||
scripts
|
||||
src/client/admin/api
|
||||
26
Dockerfile.release
Normal file
26
Dockerfile.release
Normal file
@@ -0,0 +1,26 @@
|
||||
# 使用指定基础镜像
|
||||
FROM node:20.18.3
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y python3 build-essential
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制package.json .npmrc和package-lock.json
|
||||
COPY package.json .npmrc package-lock.json* ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制项目文件
|
||||
COPY . .
|
||||
|
||||
# 构建项目
|
||||
RUN npm run build
|
||||
|
||||
# 暴露端口(根据实际需要调整)
|
||||
EXPOSE 23972
|
||||
|
||||
# 启动命令
|
||||
CMD ["npm", "run", "start"]
|
||||
21
Dockerfile.test
Normal file
21
Dockerfile.test
Normal file
@@ -0,0 +1,21 @@
|
||||
# 使用轻量级测试基础镜像
|
||||
FROM node:20.18.3-alpine
|
||||
|
||||
RUN apk update && apk add python3 build-base
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制package.json .npmrc和package-lock.json
|
||||
COPY package.json .npmrc package-lock.json* ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制项目文件
|
||||
COPY . .
|
||||
|
||||
EXPOSE 23972
|
||||
|
||||
# 运行测试
|
||||
CMD ["sleep", "infinity"]
|
||||
63
docker-compose.yml
Normal file
63
docker-compose.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
node:
|
||||
image: 'docker.1ms.run/node:20.18.3'
|
||||
container_name: node
|
||||
restart: always
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- /mnt/app:/app
|
||||
ports:
|
||||
- '8080:8080'
|
||||
command: 'sleep infinity'
|
||||
|
||||
mysql:
|
||||
image: 'docker.1ms.run/mysql:8.0.36'
|
||||
container_name: mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
|
||||
volumes:
|
||||
- /mnt/mysql-data:/var/lib/mysql
|
||||
ports:
|
||||
- '3306:3306'
|
||||
|
||||
redis:
|
||||
image: 'docker.1ms.run/redis:7.0.4'
|
||||
container_name: redis
|
||||
restart: always
|
||||
volumes:
|
||||
- /mnt/redis-data:/data
|
||||
ports:
|
||||
- '6379:6379'
|
||||
|
||||
phpmyadmin:
|
||||
image: 'docker.1ms.run/phpmyadmin:latest'
|
||||
container_name: phpmyadmin
|
||||
restart: always
|
||||
environment:
|
||||
APACHE_PORT: 80 # 容器内端口,通常为80
|
||||
PMA_ABSOLUTE_URI: 'http://localhost:38090/'
|
||||
PMA_USER: 'root'
|
||||
PMA_HOST: mysql # 修正拼写错误: 从myssql改为mysql
|
||||
PMA_PORT: 3306 # 整数或字符串均可,但建议保持一致
|
||||
ports:
|
||||
- '38090:80' # 宿主机端口:容器内端口
|
||||
depends_on:
|
||||
- mysql
|
||||
|
||||
minio:
|
||||
image: 'docker.1ms.run/minio:latest'
|
||||
container_name: minio
|
||||
restart: always
|
||||
volumes:
|
||||
- /mnt/minio-data:/data
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
MINIO_DEFAULT_BUCKETS: 'd8dai'
|
||||
MINIO_BROWSER: "on"
|
||||
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "monitor",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "export NODE_ENV='development' && export DEBUG=backend:* && vite",
|
||||
"build": "export NODE_ENV='production' && vite build && vite build --ssr",
|
||||
"start": "export NODE_ENV='production' && node dist-server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/react-renderer": "^1.0.1",
|
||||
"@hono/swagger-ui": "^0.5.1",
|
||||
"@hono/vite-dev-server": "^0.19.1",
|
||||
"@hono/zod-openapi": "^0.19.7",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@tanstack/react-query": "^5.77.2",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"antd": "^5.26.0",
|
||||
"axios": "^1.9.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"debug": "^4.4.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"formdata-node": "^6.0.3",
|
||||
"hono": "^4.7.11",
|
||||
"ioredis": "^5.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"minio": "^8.0.5",
|
||||
"mysql2": "^3.14.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"react-router": "^7.6.1",
|
||||
"react-router-dom": "^7.6.1",
|
||||
"react-toastify": "^11.0.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.15.23",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^19.1.1",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/three": "^0.177.0",
|
||||
"hono-vite-react-stack-node": "^0.2.1",
|
||||
"node-cron": "^4.1.0",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
7350
pnpm-lock.yaml
generated
Normal file
7350
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
scripts/release_tag.sh
Normal file
55
scripts/release_tag.sh
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 版本标签创建与推送脚本
|
||||
# 使用方法: ./release_tag.sh v0.0.3
|
||||
|
||||
# 检查是否提供了版本号参数
|
||||
if [ -z "$1" ]; then
|
||||
echo "错误: 请提供版本号作为参数"
|
||||
echo "用法: $0 <版本号>"
|
||||
echo "示例: $0 v1.2.3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 获取版本号参数
|
||||
VERSION=$1
|
||||
|
||||
# 检查是否在 Git 仓库中
|
||||
if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
|
||||
echo "错误: 当前目录不是 Git 仓库"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否有未提交的更改
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "警告: 有未提交的更改,建议先提交"
|
||||
read -p "是否继续? (y/n): " response
|
||||
if [[ ! $response =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查远程仓库是否存在
|
||||
if ! git remote | grep -q "gitea"; then
|
||||
echo "错误: 未找到名为 'gitea' 的远程仓库"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建并推送标签
|
||||
echo "正在创建标签: $VERSION"
|
||||
git tag "$VERSION"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "错误: 创建标签失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "正在推送标签到 gitea 远程仓库..."
|
||||
git push gitea "$VERSION"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "错误: 推送标签失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "成功! 标签 $VERSION 已创建并推送到 gitea 远程仓库"
|
||||
46
src/client/admin/components/ErrorPage.tsx
Normal file
46
src/client/admin/components/ErrorPage.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { useRouteError, useNavigate } from 'react-router';
|
||||
import { Alert, Button } from 'antd';
|
||||
import { useTheme } from '../hooks/ThemeProvider';
|
||||
|
||||
export const ErrorPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { isDark } = useTheme();
|
||||
const error = useRouteError() as any;
|
||||
const errorMessage = error?.statusText || error?.message || '未知错误';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-grow p-4"
|
||||
style={{ color: isDark ? '#fff' : 'inherit' }}
|
||||
>
|
||||
<div className="max-w-3xl w-full">
|
||||
<h1 className="text-2xl font-bold mb-4">发生错误</h1>
|
||||
<Alert
|
||||
type="error"
|
||||
message={error?.message || '未知错误'}
|
||||
description={
|
||||
error?.stack ? (
|
||||
<pre className="text-xs overflow-auto p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
{error.stack}
|
||||
</pre>
|
||||
) : null
|
||||
}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate(0)}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate('/admin')}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
30
src/client/admin/components/NotFoundPage.tsx
Normal file
30
src/client/admin/components/NotFoundPage.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from 'antd';
|
||||
import { useTheme } from '../hooks/ThemeProvider';
|
||||
|
||||
export const NotFoundPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-grow p-4"
|
||||
style={{ color: isDark ? '#fff' : 'inherit' }}
|
||||
>
|
||||
<div className="max-w-3xl w-full">
|
||||
<h1 className="text-2xl font-bold mb-4">404 - 页面未找到</h1>
|
||||
<p className="mb-6 text-gray-600 dark:text-gray-300">
|
||||
您访问的页面不存在或已被移除
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate('/admin')}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
src/client/admin/components/ProtectedRoute.tsx
Normal file
37
src/client/admin/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
useNavigate,
|
||||
} from 'react-router';
|
||||
import { useAuth } from '../hooks/AuthProvider';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// 只有在加载完成且未认证时才重定向
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
navigate('/admin/login', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, isLoading, navigate]);
|
||||
|
||||
// 显示加载状态,直到认证检查完成
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<div className="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果未认证且不再加载中,不显示任何内容(等待重定向)
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
140
src/client/admin/hooks/AuthProvider.tsx
Normal file
140
src/client/admin/hooks/AuthProvider.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useState, useEffect, createContext, useContext } from 'react';
|
||||
|
||||
import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import type {
|
||||
AuthContextType
|
||||
} from '@/share/types';
|
||||
import { authClient } from '@/client/api';
|
||||
import type { InferResponseType, InferRequestType } from 'hono/client';
|
||||
|
||||
type User = InferResponseType<typeof authClient.me.$get, 200>;
|
||||
|
||||
|
||||
// 创建认证上下文
|
||||
const AuthContext = createContext<AuthContextType<User> | null>(null);
|
||||
|
||||
// 认证提供器组件
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 声明handleLogout函数
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// 如果已登录,调用登出API
|
||||
if (token) {
|
||||
await authClient.logout.$post();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登出请求失败:', error);
|
||||
} finally {
|
||||
// 清除本地状态
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
localStorage.removeItem('token');
|
||||
// 清除Authorization头
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
console.log('登出时已删除全局Authorization头');
|
||||
// 清除所有查询缓存
|
||||
queryClient.clear();
|
||||
}
|
||||
};
|
||||
|
||||
// 使用useQuery检查登录状态
|
||||
const { isLoading } = useQuery({
|
||||
queryKey: ['auth', 'status', token],
|
||||
queryFn: async () => {
|
||||
if (!token) {
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置全局默认请求头
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
// 使用API验证当前用户
|
||||
const res = await authClient.me.$get();
|
||||
if (res.status !== 200) {
|
||||
const result = await res.json();
|
||||
throw new Error(result.message)
|
||||
}
|
||||
const currentUser = await res.json();
|
||||
setUser(currentUser);
|
||||
setIsAuthenticated(true);
|
||||
return { isValid: true, user: currentUser };
|
||||
} catch (error) {
|
||||
return { isValid: false };
|
||||
}
|
||||
},
|
||||
enabled: !!token,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false
|
||||
});
|
||||
|
||||
const handleLogin = async (username: string, password: string, latitude?: number, longitude?: number): Promise<void> => {
|
||||
try {
|
||||
// 使用AuthAPI登录
|
||||
const response = await authClient.login.$post({
|
||||
json: {
|
||||
username,
|
||||
password
|
||||
}
|
||||
})
|
||||
if (response.status !== 200) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// 保存token和用户信息
|
||||
const { token: newToken, user: newUser } = result;
|
||||
|
||||
// 设置全局默认请求头
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
|
||||
|
||||
// 保存状态
|
||||
setToken(newToken);
|
||||
setUser(newUser);
|
||||
setIsAuthenticated(true);
|
||||
localStorage.setItem('token', newToken);
|
||||
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
login: handleLogin,
|
||||
logout: handleLogout,
|
||||
isAuthenticated,
|
||||
isLoading
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// 使用上下文的钩子
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth必须在AuthProvider内部使用');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
42
src/client/admin/index.tsx
Normal file
42
src/client/admin/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { App as AntdApp } from 'antd'
|
||||
import dayjs from 'dayjs';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import localeData from 'dayjs/plugin/localeData';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { AuthProvider } from './hooks/AuthProvider';
|
||||
import { router } from './routes';
|
||||
|
||||
// 配置 dayjs 插件
|
||||
dayjs.extend(weekday);
|
||||
dayjs.extend(localeData);
|
||||
|
||||
// 设置 dayjs 语言
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
// 创建QueryClient实例
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// 应用入口组件
|
||||
const App = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AntdApp>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</AntdApp>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
};
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement)
|
||||
root.render(
|
||||
<App />
|
||||
)
|
||||
}
|
||||
222
src/client/admin/layouts/MainLayout.tsx
Normal file
222
src/client/admin/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Outlet,
|
||||
useLocation,
|
||||
} from 'react-router';
|
||||
import {
|
||||
Layout, Button, Space, Badge, Avatar, Dropdown, Typography, Input, Menu,
|
||||
} from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
BellOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
UserOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../hooks/AuthProvider';
|
||||
import { useMenu, useMenuSearch, type MenuItem } from '../menu';
|
||||
import { getGlobalConfig } from '@/client/utils/utils';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
/**
|
||||
* 主布局组件
|
||||
* 包含侧边栏、顶部导航和内容区域
|
||||
*/
|
||||
export const MainLayout = () => {
|
||||
const { user } = useAuth();
|
||||
const [showBackTop, setShowBackTop] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// 使用菜单hook
|
||||
const {
|
||||
menuItems,
|
||||
userMenuItems,
|
||||
openKeys,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
handleMenuClick: handleRawMenuClick,
|
||||
onOpenChange
|
||||
} = useMenu();
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = (key: string) => {
|
||||
const item = findMenuItem(menuItems, key);
|
||||
if (item && 'label' in item) {
|
||||
handleRawMenuClick(item);
|
||||
}
|
||||
};
|
||||
|
||||
// 查找菜单项
|
||||
const findMenuItem = (items: MenuItem[], key: string): MenuItem | null => {
|
||||
for (const item of items) {
|
||||
if (!item) continue;
|
||||
if (item.key === key) return item;
|
||||
if (item.children) {
|
||||
const found = findMenuItem(item.children, key);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 使用菜单搜索hook
|
||||
const {
|
||||
searchText,
|
||||
setSearchText,
|
||||
filteredMenuItems
|
||||
} = useMenuSearch(menuItems);
|
||||
|
||||
// 获取当前选中的菜单项
|
||||
const selectedKey = useMemo(() => {
|
||||
const findSelectedKey = (items: MenuItem[]): string | null => {
|
||||
for (const item of items) {
|
||||
if (!item) continue;
|
||||
if (item.path === location.pathname) return item.key || null;
|
||||
if (item.children) {
|
||||
const childKey = findSelectedKey(item.children);
|
||||
if (childKey) return childKey;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return findSelectedKey(menuItems) || '';
|
||||
}, [location.pathname, menuItems]);
|
||||
|
||||
// 检测滚动位置,控制回到顶部按钮显示
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setShowBackTop(window.pageYOffset > 300);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// 回到顶部
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// 应用名称 - 从CONFIG中获取或使用默认值
|
||||
const appName = getGlobalConfig('APP_NAME') || '应用Starter';
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={240}
|
||||
className="custom-sider"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<div className="p-4">
|
||||
<Typography.Title level={2} className="text-xl font-bold truncate">
|
||||
{collapsed ? '应用' : appName}
|
||||
</Typography.Title>
|
||||
|
||||
{/* 菜单搜索框 */}
|
||||
{!collapsed && (
|
||||
<div className="mb-4">
|
||||
<Input.Search
|
||||
placeholder="搜索菜单..."
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 菜单列表 */}
|
||||
<Menu
|
||||
theme='light'
|
||||
mode="inline"
|
||||
items={filteredMenuItems}
|
||||
openKeys={openKeys}
|
||||
selectedKeys={[selectedKey]}
|
||||
onOpenChange={onOpenChange}
|
||||
onClick={({ key }) => handleMenuClick(key)}
|
||||
inlineCollapsed={collapsed}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
|
||||
<Header className="p-0 flex justify-between items-center"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 99,
|
||||
boxShadow: '0 1px 4px rgba(0,21,41,0.08)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-16 h-16"
|
||||
/>
|
||||
|
||||
<Space size="middle" className="mr-4">
|
||||
<Badge count={5} offset={[0, 5]}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
/>
|
||||
</Badge>
|
||||
|
||||
<Dropdown menu={{ items: userMenuItems }}>
|
||||
<Space className="cursor-pointer">
|
||||
<Avatar
|
||||
src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
|
||||
icon={!user?.avatar && !navigator.onLine && <UserOutlined />}
|
||||
/>
|
||||
<span>
|
||||
{user?.nickname || user?.username}
|
||||
</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
<Content className="m-6" style={{ overflow: 'initial' }}>
|
||||
<div className="site-layout-content p-6 rounded-lg">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
{showBackTop && (
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
size="large"
|
||||
onClick={scrollToTop}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 30,
|
||||
bottom: 30,
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 3px 6px rgba(0,0,0,0.16)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
125
src/client/admin/menu.tsx
Normal file
125
src/client/admin/menu.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
DashboardOutlined,
|
||||
TeamOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
export interface MenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: MenuItem[];
|
||||
path?: string;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单搜索 Hook
|
||||
* 封装菜单搜索相关逻辑
|
||||
*/
|
||||
export const useMenuSearch = (menuItems: MenuItem[]) => {
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
|
||||
// 过滤菜单项
|
||||
const filteredMenuItems = React.useMemo(() => {
|
||||
if (!searchText) return menuItems;
|
||||
|
||||
const filterItems = (items: MenuItem[]): MenuItem[] => {
|
||||
return items
|
||||
.map(item => {
|
||||
// 克隆对象避免修改原数据
|
||||
const newItem = { ...item };
|
||||
if (newItem.children) {
|
||||
newItem.children = filterItems(newItem.children);
|
||||
}
|
||||
return newItem;
|
||||
})
|
||||
.filter(item => {
|
||||
// 保留匹配项或其子项匹配的项
|
||||
const match = item.label.toLowerCase().includes(searchText.toLowerCase());
|
||||
if (match) return true;
|
||||
if (item.children?.length) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
return filterItems(menuItems);
|
||||
}, [menuItems, searchText]);
|
||||
|
||||
// 清除搜索
|
||||
const clearSearch = () => {
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
return {
|
||||
searchText,
|
||||
setSearchText,
|
||||
filteredMenuItems,
|
||||
clearSearch
|
||||
};
|
||||
};
|
||||
|
||||
export const useMenu = () => {
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
const [openKeys, setOpenKeys] = React.useState<string[]>([]);
|
||||
|
||||
// 基础菜单项配置
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: '控制台',
|
||||
icon: <DashboardOutlined />,
|
||||
path: '/admin/dashboard'
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: '用户管理',
|
||||
icon: <TeamOutlined />,
|
||||
path: '/admin/users',
|
||||
permission: 'user:manage'
|
||||
},
|
||||
];
|
||||
|
||||
// 用户菜单项
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '个人资料',
|
||||
icon: <UserOutlined />
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
icon: <InfoCircleOutlined />,
|
||||
danger: true
|
||||
}
|
||||
];
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = (item: MenuItem) => {
|
||||
if (item.path) {
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理菜单展开变化
|
||||
const onOpenChange = (keys: string[]) => {
|
||||
const latestOpenKey = keys.find(key => openKeys.indexOf(key) === -1);
|
||||
setOpenKeys(latestOpenKey ? [latestOpenKey] : []);
|
||||
};
|
||||
|
||||
return {
|
||||
menuItems,
|
||||
userMenuItems,
|
||||
openKeys,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
handleMenuClick,
|
||||
onOpenChange
|
||||
};
|
||||
};
|
||||
44
src/client/admin/pages/Dashboard.tsx
Normal file
44
src/client/admin/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card, Row, Col, Typography, Statistic
|
||||
} from 'antd';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// 仪表盘页面
|
||||
export const DashboardPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<Title level={2}>仪表盘</Title>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="活跃用户"
|
||||
value={112893}
|
||||
loading={false}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="系统消息"
|
||||
value={93}
|
||||
loading={false}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="在线用户"
|
||||
value={1128}
|
||||
loading={false}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
114
src/client/admin/pages/Login.tsx
Normal file
114
src/client/admin/pages/Login.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Card,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
useAuth,
|
||||
} from '../hooks/AuthProvider';
|
||||
|
||||
|
||||
// 登录页面
|
||||
export const LoginPage = () => {
|
||||
const { login } = useAuth();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 获取地理位置
|
||||
let latitude: number | undefined;
|
||||
let longitude: number | undefined;
|
||||
|
||||
try {
|
||||
if (navigator.geolocation) {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject);
|
||||
});
|
||||
latitude = position.coords.latitude;
|
||||
longitude = position.coords.longitude;
|
||||
}
|
||||
} catch (geoError) {
|
||||
console.warn('获取地理位置失败:', geoError);
|
||||
}
|
||||
|
||||
await login(values.username, values.password, latitude, longitude);
|
||||
// 登录成功后跳转到管理后台首页
|
||||
navigate('/admin/dashboard');
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.error || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
登录管理后台
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
name="login"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
block
|
||||
loading={loading}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="mt-4 text-center text-gray-500">
|
||||
<p>测试账号: admin / admin123</p>
|
||||
{/* <p>普通账号: user1 / 123456</p> */}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
313
src/client/admin/pages/Users.tsx
Normal file
313
src/client/admin/pages/Users.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Button, Table, Space, Form, Input, Select,
|
||||
message, Modal, Card, Typography, Tag, Popconfirm
|
||||
} from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { userClient } from '@/client/api';
|
||||
import type { InferResponseType, InferRequestType } from 'hono/client';
|
||||
|
||||
type UserListResponse = InferResponseType<typeof userClient.$get, 200>;
|
||||
type UserDetailResponse = InferResponseType<typeof userClient[':id']['$get'], 200>;
|
||||
type CreateUserRequest = InferRequestType<typeof userClient.$post>['json'];
|
||||
type UpdateUserRequest = InferRequestType<typeof userClient[':id']['$put']>['json'];
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// 用户管理页面
|
||||
export const UsersPage = () => {
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
search: ''
|
||||
});
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: usersData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['users', searchParams],
|
||||
queryFn: async () => {
|
||||
const res = await userClient.$get({
|
||||
query: {
|
||||
page: searchParams.page,
|
||||
pageSize: searchParams.limit,
|
||||
keyword: searchParams.search
|
||||
}
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw new Error('获取用户列表失败');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
});
|
||||
|
||||
const users = usersData?.data || [];
|
||||
const pagination = {
|
||||
current: searchParams.page,
|
||||
pageSize: searchParams.limit,
|
||||
total: usersData?.pagination?.total || 0
|
||||
};
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = (values: any) => {
|
||||
setSearchParams(prev => ({
|
||||
...prev,
|
||||
search: values.search || '',
|
||||
page: 1
|
||||
}));
|
||||
};
|
||||
|
||||
// 处理分页变化
|
||||
const handleTableChange = (newPagination: any) => {
|
||||
setSearchParams(prev => ({
|
||||
...prev,
|
||||
page: newPagination.current,
|
||||
limit: newPagination.pageSize
|
||||
}));
|
||||
};
|
||||
|
||||
// 打开创建用户模态框
|
||||
const showCreateModal = () => {
|
||||
setModalTitle('创建用户');
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// 打开编辑用户模态框
|
||||
const showEditModal = (user: any) => {
|
||||
setModalTitle('编辑用户');
|
||||
setEditingUser(user);
|
||||
form.setFieldsValue(user);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// 处理模态框确认
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingUser) {
|
||||
// 编辑用户
|
||||
const res = await userClient[':id']['$put']({
|
||||
param: { id: editingUser.id },
|
||||
json: values
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw new Error('更新用户失败');
|
||||
}
|
||||
message.success('用户更新成功');
|
||||
} else {
|
||||
// 创建用户
|
||||
const res = await userClient.$post({
|
||||
json: values
|
||||
});
|
||||
if (res.status !== 201) {
|
||||
throw new Error('创建用户失败');
|
||||
}
|
||||
message.success('用户创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
refetch(); // 刷新用户列表
|
||||
} catch (error) {
|
||||
console.error('表单提交失败:', error);
|
||||
message.error('操作失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除用户
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const res = await userClient[':id']['$delete']({
|
||||
param: { id }
|
||||
});
|
||||
if (res.status !== 204) {
|
||||
throw new Error('删除用户失败');
|
||||
}
|
||||
message.success('用户删除成功');
|
||||
refetch(); // 刷新用户列表
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error);
|
||||
message.error('删除失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: string) => (
|
||||
<Tag color={role === 'admin' ? 'red' : 'blue'}>
|
||||
{role === 'admin' ? '管理员' : '普通用户'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: any) => (
|
||||
<Space size="middle">
|
||||
<Button type="link" onClick={() => showEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除此用户吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={2}>用户管理</Title>
|
||||
<Card>
|
||||
<Form layout="inline" onFinish={handleSearch} style={{ marginBottom: 16 }}>
|
||||
<Form.Item name="search" label="搜索">
|
||||
<Input placeholder="用户名/昵称/邮箱" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button type="primary" onClick={showCreateModal}>
|
||||
创建用户
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑用户模态框 */}
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={modalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => {
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '用户名至少3个字符' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="nickname"
|
||||
label="昵称"
|
||||
rules={[{ required: true, message: '请输入昵称' }]}
|
||||
>
|
||||
<Input placeholder="请输入昵称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{ required: false, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{ required: false, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入手机号" />
|
||||
</Form.Item>
|
||||
|
||||
{!editingUser && (
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6个字符' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="isDisabled"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
>
|
||||
<Select placeholder="请选择状态">
|
||||
<Select.Option value={0}>启用</Select.Option>
|
||||
<Select.Option value={1}>禁用</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
src/client/admin/routes.tsx
Normal file
54
src/client/admin/routes.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { MainLayout } from './layouts/MainLayout';
|
||||
import { ErrorPage } from './components/ErrorPage';
|
||||
import { NotFoundPage } from './components/NotFoundPage';
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { UsersPage } from './pages/Users';
|
||||
import { LoginPage } from './pages/Login';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Navigate to="/admin" replace />
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
element: <LoginPage />
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/admin/dashboard" />
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: <DashboardPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
element: <UsersPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <NotFoundPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <NotFoundPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
]);
|
||||
62
src/client/api.ts
Normal file
62
src/client/api.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import axios, { isAxiosError } from 'axios';
|
||||
import { hc } from 'hono/client'
|
||||
import type {
|
||||
AuthRoutes, UserRoutes,
|
||||
} from '@/server/api';
|
||||
|
||||
// 创建 axios 适配器
|
||||
const axiosFetch = async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const requestHeaders:Record<string, string> = {};
|
||||
|
||||
if(init?.headers instanceof Headers) {
|
||||
init.headers.forEach((value, key) => {
|
||||
requestHeaders[key] = value;
|
||||
})
|
||||
}
|
||||
|
||||
const response = await axios.request({
|
||||
url: url.toString(),
|
||||
method: init?.method || 'GET',
|
||||
headers: requestHeaders,
|
||||
data: init?.body,
|
||||
}).catch((error) => {
|
||||
console.log('axiosFetch error', error)
|
||||
|
||||
if(isAxiosError(error)) {
|
||||
return {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
headers: error.response?.headers
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
if (response.headers) {
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
responseHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return new Response(
|
||||
responseHeaders.get('content-type')?.includes('application/json') ?
|
||||
JSON.stringify(response.data) : response.data,
|
||||
{
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export const authClient = hc<AuthRoutes>('/', {
|
||||
fetch: axiosFetch,
|
||||
}).api.v1.auth;
|
||||
|
||||
export const userClient = hc<UserRoutes>('/', {
|
||||
fetch: axiosFetch,
|
||||
}).api.v1.users;
|
||||
52
src/client/home/index.tsx
Normal file
52
src/client/home/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { getGlobalConfig } from '../utils/utils'
|
||||
|
||||
const Home = () => {
|
||||
return (
|
||||
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* 系统介绍区域 */}
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
{getGlobalConfig('APP_NAME')}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
全功能应用Starter
|
||||
</p>
|
||||
<p className="text-base text-gray-500 mb-8">
|
||||
这是一个基于Hono和React的应用Starter,提供了用户认证、文件管理、图表分析、地图集成和主题切换等常用功能。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 管理入口按钮 */}
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-lg font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
进入管理后台
|
||||
</Link>
|
||||
|
||||
{/* 移动端入口按钮 */}
|
||||
<Link
|
||||
to="/mobile"
|
||||
className="w-full flex justify-center py-3 px-4 border border-blue-600 rounded-md shadow-sm text-lg font-medium text-blue-600 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
进入移动端
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement)
|
||||
root.render(
|
||||
<Home />
|
||||
)
|
||||
}
|
||||
6
src/client/index.tsx
Normal file
6
src/client/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// 如果当前是在 /big 下
|
||||
if (window.location.pathname.startsWith('/admin')) {
|
||||
import('./admin/index')
|
||||
}else{
|
||||
import('./home/index')
|
||||
}
|
||||
14
src/client/utils/logger.ts
Normal file
14
src/client/utils/logger.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import debug from 'debug';
|
||||
|
||||
export const logger = {
|
||||
error: debug('frontend:error'),
|
||||
api: debug('frontend:api'),
|
||||
auth: debug('frontend:auth'),
|
||||
ui: debug('frontend:ui'),
|
||||
info: debug('frontend:info')
|
||||
};
|
||||
|
||||
// 开发环境默认启用所有日志
|
||||
if (import.meta.env.DEV) {
|
||||
debug.enable('frontend:*');
|
||||
}
|
||||
43
src/client/utils/utils.ts
Normal file
43
src/client/utils/utils.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { GlobalConfig } from '@/share/types';
|
||||
|
||||
export function getEnumOptions<T extends string | number, M extends Record<T, string>>(enumObj: Record<string, T>, nameMap: M) {
|
||||
return Object.entries(enumObj)
|
||||
.filter(([_key, value]) => !isNaN(Number(value)) || typeof value === 'string') // 保留数字和字符串类型的值
|
||||
.filter(([key, _value]) => isNaN(Number(key))) // 过滤掉数字键(枚举的反向映射)
|
||||
.map(([_key, value]) => ({
|
||||
label: nameMap[value as T],
|
||||
value: value
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局配置项 (严格类型版本)
|
||||
* @param key 配置键名
|
||||
* @returns 配置值或undefined
|
||||
*/
|
||||
export function getGlobalConfig<T extends keyof GlobalConfig>(key: T): GlobalConfig[T] | undefined {
|
||||
return (window as typeof window & { CONFIG?: GlobalConfig }).CONFIG?.[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证URL格式
|
||||
* @param url 待验证URL
|
||||
* @returns 验证结果
|
||||
*/
|
||||
export const validateUrl = (url: string): boolean => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证Authorization头格式
|
||||
* @param auth 待验证字符串
|
||||
* @returns 验证结果
|
||||
*/
|
||||
export const validateAuthHeader = (auth: string): boolean => {
|
||||
return /^Basic [A-Za-z0-9+/]+={0,2}$/.test(auth);
|
||||
};
|
||||
58
src/server/api.ts
Normal file
58
src/server/api.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { OpenAPIHono } from '@hono/zod-openapi'
|
||||
import { errorHandler } from './utils/errorHandler'
|
||||
import usersRouter from './api/users/index'
|
||||
import authRoute from './api/auth/index'
|
||||
import { AuthContext } from './types/context'
|
||||
import { AppDataSource } from './data-source'
|
||||
|
||||
const api = new OpenAPIHono<AuthContext>()
|
||||
|
||||
api.onError(errorHandler)
|
||||
|
||||
// Rate limiting
|
||||
api.use('/api/v1/*', async (c, next) => {
|
||||
const ip = c.req.header('x-forwarded-for') || c.req.header('cf-connecting-ip')
|
||||
// 实现速率限制逻辑
|
||||
await next()
|
||||
})
|
||||
|
||||
// 数据库初始化中间件
|
||||
api.use('/api/v1/*', async (c, next) => {
|
||||
if(!AppDataSource.isInitialized) {
|
||||
await AppDataSource.initialize();
|
||||
}
|
||||
await next();
|
||||
})
|
||||
|
||||
// 注册Bearer认证方案
|
||||
api.openAPIRegistry.registerComponent('securitySchemes','bearerAuth',{
|
||||
type:'http',
|
||||
scheme:'bearer',
|
||||
bearerFormat:'JWT',
|
||||
description:'使用JWT进行认证'
|
||||
})
|
||||
|
||||
// OpenAPI documentation endpoint
|
||||
if(!import.meta.env.PROD){
|
||||
api.doc31('/doc', {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'API Documentation',
|
||||
version: '1.0.0'
|
||||
},
|
||||
security: [{
|
||||
bearerAuth: []
|
||||
}]
|
||||
// servers: [{ url: '/api/v1' }]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const userRoutes = api.route('/api/v1/users', usersRouter)
|
||||
const authRoutes = api.route('/api/v1/auth', authRoute)
|
||||
|
||||
export type AuthRoutes = typeof authRoutes
|
||||
export type UserRoutes = typeof userRoutes
|
||||
|
||||
export default api
|
||||
15
src/server/api/auth/index.ts
Normal file
15
src/server/api/auth/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { OpenAPIHono } from '@hono/zod-openapi';
|
||||
import loginRoute from './login/password';
|
||||
import logoutRoute from './logout';
|
||||
import meRoute from './me/get';
|
||||
import registerRoute from './register/create';
|
||||
import ssoVerify from './sso-verify';
|
||||
|
||||
const app = new OpenAPIHono()
|
||||
.route('/', loginRoute)
|
||||
.route('/', logoutRoute)
|
||||
.route('/', meRoute)
|
||||
.route('/', registerRoute)
|
||||
.route('/', ssoVerify);
|
||||
|
||||
export default app;
|
||||
71
src/server/api/auth/login/password.ts
Normal file
71
src/server/api/auth/login/password.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi'
|
||||
import { AuthService } from '../../../modules/auth/auth.service'
|
||||
import { UserService } from '../../../modules/users/user.service'
|
||||
import { z } from 'zod'
|
||||
import { ErrorSchema } from '../../../utils/errorHandler'
|
||||
import { AppDataSource } from '../../../data-source'
|
||||
import { AuthContext } from '../../../types/context'
|
||||
import { UserSchema } from '@/server/modules/users/user.entity'
|
||||
|
||||
const userService = new UserService(AppDataSource)
|
||||
const authService = new AuthService(userService)
|
||||
|
||||
const LoginSchema = z.object({
|
||||
username: z.string().min(3).openapi({
|
||||
example: 'admin',
|
||||
description: '用户名'
|
||||
}),
|
||||
password: z.string().min(6).openapi({
|
||||
example: 'admin123',
|
||||
description: '密码'
|
||||
})
|
||||
})
|
||||
|
||||
const UserResponseSchema = UserSchema
|
||||
|
||||
const TokenResponseSchema = z.object({
|
||||
token: z.string().openapi({
|
||||
example: 'jwt.token.here',
|
||||
description: 'JWT Token'
|
||||
}),
|
||||
user: UserResponseSchema
|
||||
})
|
||||
|
||||
const loginRoute = createRoute({
|
||||
method: 'post',
|
||||
path: '/login',
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: LoginSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '登录成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: TokenResponseSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
description: '用户名或密码错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const app = new OpenAPIHono<AuthContext>().openapi(loginRoute, async (c) => {
|
||||
const { username, password } = c.req.valid('json')
|
||||
const result = await authService.login(username, password)
|
||||
return c.json(result, 200)
|
||||
});
|
||||
|
||||
export default app
|
||||
68
src/server/api/auth/logout.ts
Normal file
68
src/server/api/auth/logout.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
|
||||
import { z } from 'zod'
|
||||
import { AuthContext } from '@/server/types/context';
|
||||
import { authMiddleware } from '@/server/middleware/auth.middleware';
|
||||
import { AppDataSource } from '@/server/data-source';
|
||||
import { AuthService } from '@/server/modules/auth/auth.service';
|
||||
import { UserService } from '@/server/modules/users/user.service';
|
||||
import { ErrorSchema } from '@/server/utils/errorHandler';
|
||||
|
||||
// 初始化服务
|
||||
const userService = new UserService(AppDataSource);
|
||||
const authService = new AuthService(userService);
|
||||
|
||||
const SuccessSchema = z.object({
|
||||
message: z.string().openapi({ example: '登出成功' })
|
||||
})
|
||||
|
||||
// 定义路由
|
||||
const routeDef = createRoute({
|
||||
method: 'post',
|
||||
path: '/logout',
|
||||
security: [{ Bearer: [] }],
|
||||
middleware: [authMiddleware],
|
||||
responses: {
|
||||
200: {
|
||||
description: '登出成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: SuccessSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
description: '未授权',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
description: '服务器错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
|
||||
try {
|
||||
const token = c.get('token');
|
||||
const decoded = authService.verifyToken(token);
|
||||
if (!decoded) {
|
||||
return c.json({ code: 401, message: '未授权' }, 401);
|
||||
}
|
||||
|
||||
await authService.logout(token);
|
||||
return c.json({ message: '登出成功' }, 200);
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error);
|
||||
return c.json({ code: 500, message: '登出失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
40
src/server/api/auth/me/get.ts
Normal file
40
src/server/api/auth/me/get.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi'
|
||||
import { ErrorSchema } from '@/server/utils/errorHandler'
|
||||
import { authMiddleware } from '@/server/middleware/auth.middleware'
|
||||
import { AuthContext } from '@/server/types/context'
|
||||
import { UserSchema } from '../../../modules/users/user.entity'
|
||||
|
||||
const UserResponseSchema = UserSchema.omit({
|
||||
password: true
|
||||
});
|
||||
|
||||
const routeDef = createRoute({
|
||||
method: 'get',
|
||||
path: '/me',
|
||||
middleware: authMiddleware,
|
||||
responses: {
|
||||
200: {
|
||||
description: '获取当前用户信息成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: UserResponseSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
description: '未授权',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const app = new OpenAPIHono<AuthContext>().openapi(routeDef, (c) => {
|
||||
const user = c.get('user')
|
||||
return c.json(user, 200)
|
||||
})
|
||||
|
||||
export default app
|
||||
76
src/server/api/auth/register/create.ts
Normal file
76
src/server/api/auth/register/create.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi'
|
||||
import { AuthService } from '../../../modules/auth/auth.service'
|
||||
import { UserService } from '../../../modules/users/user.service'
|
||||
import { z } from 'zod'
|
||||
import { AppDataSource } from '../../../data-source'
|
||||
import { ErrorSchema } from '../../../utils/errorHandler'
|
||||
import { AuthContext } from '../../../types/context'
|
||||
|
||||
const RegisterSchema = z.object({
|
||||
username: z.string().min(3).openapi({
|
||||
example: 'john_doe',
|
||||
description: '用户名'
|
||||
}),
|
||||
password: z.string().min(6).openapi({
|
||||
example: 'password123',
|
||||
description: '密码'
|
||||
}),
|
||||
email: z.string().email().openapi({
|
||||
example: 'john@example.com',
|
||||
description: '邮箱'
|
||||
})
|
||||
})
|
||||
|
||||
const TokenResponseSchema = z.object({
|
||||
token: z.string().openapi({
|
||||
example: 'jwt.token.here',
|
||||
description: 'JWT Token'
|
||||
}),
|
||||
user: z.object({
|
||||
id: z.number(),
|
||||
username: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
const userService = new UserService(AppDataSource)
|
||||
const authService = new AuthService(userService)
|
||||
|
||||
const registerRoute = createRoute({
|
||||
method: 'post',
|
||||
path: '/register',
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: RegisterSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: '注册成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: TokenResponseSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: '用户名已存在',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const app = new OpenAPIHono<AuthContext>().openapi(registerRoute, async (c) => {
|
||||
const { username, password, email } = c.req.valid('json')
|
||||
const user = await userService.createUser({ username, password, email })
|
||||
const token = authService.generateToken(user)
|
||||
return c.json({ token, user }, 201)
|
||||
})
|
||||
export default app
|
||||
66
src/server/api/auth/sso-verify.ts
Normal file
66
src/server/api/auth/sso-verify.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi'
|
||||
import { AuthService } from '@/server/modules/auth/auth.service'
|
||||
import { UserService } from '@/server/modules/users/user.service'
|
||||
import { ErrorSchema } from '@/server/utils/errorHandler'
|
||||
import { AppDataSource } from '@/server/data-source'
|
||||
import { AuthContext } from '@/server/types/context'
|
||||
|
||||
const userService = new UserService(AppDataSource)
|
||||
const authService = new AuthService(userService)
|
||||
|
||||
const routeDef = createRoute({
|
||||
method: 'get',
|
||||
path: '/sso-verify',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'SSO验证成功',
|
||||
headers: {
|
||||
'X-Username': {
|
||||
schema: { type: 'string' },
|
||||
description: '格式化后的用户名'
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
description: '未授权或令牌无效',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
description: '服务器错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const app = new OpenAPIHono().openapi(routeDef, async (c) => {
|
||||
try {
|
||||
const token = c.req.header('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
return c.json({ code: 401, message: '未提供授权令牌' }, 401)
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = await authService.verifyToken(token)
|
||||
if (!userData) {
|
||||
return c.json({ code: 401, message: '无效令牌' }, 401)
|
||||
}
|
||||
|
||||
return c.text('OK', 200)
|
||||
} catch (tokenError) {
|
||||
return c.json({ code: 401, message: '令牌验证失败' }, 401)
|
||||
}
|
||||
} catch (error) {
|
||||
return c.json({ code: 500, message: 'SSO验证失败' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
export default app
|
||||
54
src/server/api/users/[id]/delete.ts
Normal file
54
src/server/api/users/[id]/delete.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
|
||||
import { UserService } from '@/server/modules/users/user.service';
|
||||
import { z } from 'zod';
|
||||
import { authMiddleware } from '@/server/middleware/auth.middleware';
|
||||
import { ErrorSchema } from '@/server/utils/errorHandler';
|
||||
import { AppDataSource } from '@/server/data-source';
|
||||
import { AuthContext } from '@/server/types/context';
|
||||
|
||||
const userService = new UserService(AppDataSource);
|
||||
|
||||
const DeleteParams = z.object({
|
||||
id: z.coerce.number().openapi({
|
||||
param: { name: 'id', in: 'path' },
|
||||
example: 1,
|
||||
description: '用户ID'
|
||||
})
|
||||
});
|
||||
|
||||
const routeDef = createRoute({
|
||||
method: 'delete',
|
||||
path: '/{id}',
|
||||
middleware: [authMiddleware],
|
||||
request: {
|
||||
params: DeleteParams
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: '用户删除成功'
|
||||
},
|
||||
404: {
|
||||
description: '用户不存在',
|
||||
content: { 'application/json': { schema: ErrorSchema } }
|
||||
},
|
||||
500: {
|
||||
description: '服务器错误',
|
||||
content: { 'application/json': { schema: ErrorSchema } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
|
||||
try {
|
||||
const { id } = c.req.valid('param');
|
||||
await userService.deleteUser(id);
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
code: 500,
|
||||
message: error instanceof Error ? error.message : '删除用户失败'
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
59
src/server/api/users/[id]/get.ts
Normal file
59
src/server/api/users/[id]/get.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
|
||||
import { UserService } from '@/server/modules/users/user.service';
|
||||
import { z } from 'zod';
|
||||
import { authMiddleware } from '@/server/middleware/auth.middleware';
|
||||
import { ErrorSchema } from '@/server/utils/errorHandler';
|
||||
import { AppDataSource } from '@/server/data-source';
|
||||
import { AuthContext } from '@/server/types/context';
|
||||
import { UserSchema } from '@/server/modules/users/user.entity';
|
||||
|
||||
const userService = new UserService(AppDataSource);
|
||||
|
||||
const GetParams = z.object({
|
||||
id: z.string().openapi({
|
||||
param: { name: 'id', in: 'path' },
|
||||
example: '1',
|
||||
description: '用户ID'
|
||||
})
|
||||
});
|
||||
|
||||
const routeDef = createRoute({
|
||||
method: 'get',
|
||||
path: '/{id}',
|
||||
middleware: [authMiddleware],
|
||||
request: {
|
||||
params: GetParams
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '成功获取用户详情',
|
||||
content: { 'application/json': { schema: UserSchema } }
|
||||
},
|
||||
404: {
|
||||
description: '用户不存在',
|
||||
content: { 'application/json': { schema: ErrorSchema } }
|
||||
},
|
||||
500: {
|
||||
description: '服务器错误',
|
||||
content: { 'application/json': { schema: ErrorSchema } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
|
||||
try {
|
||||
const { id } = c.req.valid('param');
|
||||
const user = await userService.getUserById(parseInt(id));
|
||||
if (!user) {
|
||||
return c.json({ code: 404, message: '用户不存在' }, 404);
|
||||
}
|
||||
return c.json(user, 200);
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
code: 500,
|
||||
message: error instanceof Error ? error.message : '获取用户详情失败'
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
77
src/server/api/users/[id]/put.ts
Normal file
77
src/server/api/users/[id]/put.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
|
||||
import { UserService } from '@/server/modules/users/user.service';
|
||||
import { z } from 'zod';
|
||||
import { authMiddleware } from '@/server/middleware/auth.middleware';
|
||||
import { ErrorSchema } from '@/server/utils/errorHandler';
|
||||
import { AppDataSource } from '@/server/data-source';
|
||||
import { AuthContext } from '@/server/types/context';
|
||||
import { UserSchema } from '@/server/modules/users/user.entity';
|
||||
|
||||
const userService = new UserService(AppDataSource);
|
||||
|
||||
const UpdateParams = z.object({
|
||||
id: z.coerce.number().openapi({
|
||||
param: { name: 'id', in: 'path' },
|
||||
example: 1,
|
||||
description: '用户ID'
|
||||
})
|
||||
});
|
||||
|
||||
const UpdateUserSchema = UserSchema.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
}).partial();
|
||||
|
||||
const routeDef = createRoute({
|
||||
method: 'put',
|
||||
path: '/{id}',
|
||||
middleware: [authMiddleware],
|
||||
request: {
|
||||
params: UpdateParams,
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: UpdateUserSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '用户更新成功',
|
||||
content: { 'application/json': { schema: UserSchema } }
|
||||
},
|
||||
400: {
|
||||
description: '无效输入',
|
||||
content: { 'application/json': { schema: ErrorSchema } }
|
||||
},
|
||||
404: {
|
||||
description: '用户不存在',
|
||||
content: { 'application/json': { schema: ErrorSchema } }
|
||||
},
|
||||
500: {
|
||||
description: '服务器错误',
|
||||
content: { 'application/json': { schema: ErrorSchema } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
|
||||
try {
|
||||
const { id } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const user = await userService.updateUser(id, data);
|
||||
if (!user) {
|
||||
return c.json({ code: 404, message: '用户不存在' }, 404);
|
||||
}
|
||||
return c.json(user, 200);
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
code: 500,
|
||||
message: error instanceof Error ? error.message : '更新用户失败'
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
108
src/server/api/users/get.ts
Normal file
108
src/server/api/users/get.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
|
||||
import { UserService } from '../../modules/users/user.service';
|
||||
import { z } from 'zod';
|
||||
import { authMiddleware } from '../../middleware/auth.middleware';
|
||||
import { ErrorSchema } from '../../utils/errorHandler';
|
||||
import { AppDataSource } from '../../data-source';
|
||||
import { AuthContext } from '../../types/context';
|
||||
import { UserSchema } from '../../modules/users/user.entity';
|
||||
|
||||
const userService = new UserService(AppDataSource);
|
||||
|
||||
const PaginationQuery = z.object({
|
||||
page: z.coerce.number().int().positive().default(1).openapi({
|
||||
example: 1,
|
||||
description: '页码,从1开始'
|
||||
}),
|
||||
pageSize: z.coerce.number().int().positive().default(10).openapi({
|
||||
example: 10,
|
||||
description: '每页数量'
|
||||
}),
|
||||
keyword: z.string().optional().openapi({
|
||||
example: 'admin',
|
||||
description: '搜索关键词(用户名/昵称/手机号)'
|
||||
})
|
||||
});
|
||||
|
||||
const UserListResponse = z.object({
|
||||
data: z.array(UserSchema),
|
||||
pagination: z.object({
|
||||
total: z.number().openapi({
|
||||
example: 100,
|
||||
description: '总记录数'
|
||||
}),
|
||||
current: z.number().openapi({
|
||||
example: 1,
|
||||
description: '当前页码'
|
||||
}),
|
||||
pageSize: z.number().openapi({
|
||||
example: 10,
|
||||
description: '每页数量'
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
const listUsersRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/',
|
||||
middleware: [authMiddleware],
|
||||
request: {
|
||||
query: PaginationQuery
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '成功获取用户列表',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: UserListResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: '参数错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
description: '获取用户列表失败',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const app = new OpenAPIHono<AuthContext>().openapi(listUsersRoute, async (c) => {
|
||||
try {
|
||||
const { page, pageSize, keyword } = c.req.valid('query');
|
||||
const [users, total] = await userService.getUsersWithPagination({
|
||||
page,
|
||||
pageSize,
|
||||
keyword
|
||||
});
|
||||
|
||||
return c.json({
|
||||
data: users,
|
||||
pagination: {
|
||||
total,
|
||||
current: page,
|
||||
pageSize
|
||||
}
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ code: 400, message: '参数错误' }, 400);
|
||||
}
|
||||
return c.json({
|
||||
code: 500,
|
||||
message: error instanceof Error ? error.message : '获取用户列表失败'
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
15
src/server/api/users/index.ts
Normal file
15
src/server/api/users/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { OpenAPIHono } from '@hono/zod-openapi';
|
||||
import listUsersRoute from './get';
|
||||
import createUserRoute from './post';
|
||||
import getUserByIdRoute from './[id]/get';
|
||||
import updateUserRoute from './[id]/put';
|
||||
import deleteUserRoute from './[id]/delete';
|
||||
|
||||
const app = new OpenAPIHono()
|
||||
.route('/', listUsersRoute)
|
||||
.route('/', createUserRoute)
|
||||
.route('/', getUserByIdRoute)
|
||||
.route('/', updateUserRoute)
|
||||
.route('/', deleteUserRoute);
|
||||
|
||||
export default app;
|
||||
69
src/server/api/users/post.ts
Normal file
69
src/server/api/users/post.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
|
||||
import { UserService } from '../../modules/users/user.service';
|
||||
import { z } from 'zod';
|
||||
import { authMiddleware } from '../../middleware/auth.middleware';
|
||||
import { ErrorSchema } from '../../utils/errorHandler';
|
||||
import { AppDataSource } from '../../data-source';
|
||||
import { AuthContext } from '../../types/context';
|
||||
import { UserSchema } from '@/server/modules/users/user.entity';
|
||||
|
||||
const userService = new UserService(AppDataSource);
|
||||
|
||||
|
||||
const CreateUserSchema = UserSchema.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
})
|
||||
|
||||
const createUserRoute = createRoute({
|
||||
method: 'post',
|
||||
path: '/',
|
||||
middleware: authMiddleware,
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: CreateUserSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: '创建成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: UserSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: '输入数据无效',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
description: '服务器错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const app = new OpenAPIHono<AuthContext>().openapi(createUserRoute, async (c) => {
|
||||
try {
|
||||
const data = c.req.valid('json');
|
||||
const user = await userService.createUser(data);
|
||||
return c.json(user, 201);
|
||||
} catch (error) {
|
||||
return c.json({ code: 500, message: '服务器错误' }, 500);
|
||||
}
|
||||
});
|
||||
export default app;
|
||||
22
src/server/data-source.ts
Normal file
22
src/server/data-source.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import "reflect-metadata"
|
||||
import { DataSource } from "typeorm"
|
||||
import process from 'node:process'
|
||||
|
||||
// 实体类导入
|
||||
import { UserEntity as User } from "./modules/users/user.entity"
|
||||
import { Role } from "./modules/users/role.entity"
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: "mysql",
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "3306"),
|
||||
username: process.env.DB_USERNAME || "root",
|
||||
password: process.env.DB_PASSWORD || "",
|
||||
database: process.env.DB_DATABASE || "d8dai",
|
||||
entities: [
|
||||
User, Role
|
||||
],
|
||||
migrations: [],
|
||||
synchronize: process.env.DB_SYNCHRONIZE === "true",
|
||||
logging: process.env.DB_LOGGING === "true",
|
||||
});
|
||||
74
src/server/index.tsx
Normal file
74
src/server/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'dotenv/config'
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { logger } from 'hono/logger'
|
||||
import { swaggerUI } from '@hono/swagger-ui'
|
||||
import * as fs from 'fs/promises'
|
||||
import { renderer } from './renderer'
|
||||
import createApi from './api'
|
||||
|
||||
|
||||
const app = new Hono();
|
||||
// Middleware chain
|
||||
app.use('*', logger())
|
||||
app.use('*', cors(
|
||||
// {
|
||||
// origin: ['http://localhost:3000'],
|
||||
// allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
// credentials: true
|
||||
// }
|
||||
))
|
||||
|
||||
|
||||
app.route('/', createApi)
|
||||
|
||||
if(!import.meta.env.PROD){
|
||||
app.get('/ui', swaggerUI({
|
||||
url: '/doc',
|
||||
persistAuthorization: true
|
||||
}))
|
||||
}
|
||||
|
||||
if(import.meta.env.PROD){
|
||||
app.get('/assets/:filename', async (c) => {
|
||||
const filename = c.req.param('filename')
|
||||
const filePath = import.meta.env.PROD? `./dist/assets/${filename}` : `./public/assets/${filename}`
|
||||
const content = await fs.readFile(filePath);
|
||||
const modifyDate = (await fs.stat(filePath))?.mtime?.toUTCString()?? new Date().toUTCString();
|
||||
|
||||
|
||||
const fileExt = filePath.split('.').pop()?.toLowerCase()
|
||||
// 根据文件扩展名设置适当的 Content-Type
|
||||
if (fileExt === 'tsx' || fileExt === 'ts') {
|
||||
c.header('Content-Type', 'text/typescript; charset=utf-8')
|
||||
} else if (fileExt === 'js' || fileExt === 'mjs') {
|
||||
c.header('Content-Type', 'application/javascript; charset=utf-8')
|
||||
} else if (fileExt === 'json') {
|
||||
c.header('Content-Type', 'application/json; charset=utf-8')
|
||||
} else if (fileExt === 'html') {
|
||||
c.header('Content-Type', 'text/html; charset=utf-8')
|
||||
} else if (fileExt === 'css') {
|
||||
c.header('Content-Type', 'text/css; charset=utf-8')
|
||||
} else if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExt || '')) {
|
||||
c.header('Content-Type', `image/${fileExt}`)
|
||||
}
|
||||
|
||||
return c.body(content, {
|
||||
headers: {
|
||||
// 'Content-Type': 'text/html; charset=utf-8',
|
||||
'Last-Modified': modifyDate
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
app.use(renderer)
|
||||
app.get('/*', (c) => {
|
||||
return c.render(
|
||||
<>
|
||||
<div id="root"></div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default app
|
||||
36
src/server/middleware/auth.middleware.ts
Normal file
36
src/server/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { AuthService } from '../modules/auth/auth.service';
|
||||
import { UserService } from '../modules/users/user.service';
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { AuthContext } from '../types/context';
|
||||
|
||||
export async function authMiddleware(c: Context<AuthContext>, next: Next) {
|
||||
try {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader) {
|
||||
return c.json({ message: 'Authorization header missing' }, 401);
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (!token) {
|
||||
return c.json({ message: 'Token missing' }, 401);
|
||||
}
|
||||
|
||||
const userService = new UserService(AppDataSource);
|
||||
const authService = new AuthService(userService);
|
||||
const decoded = authService.verifyToken(token);
|
||||
|
||||
const user = await userService.getUserById(decoded.id);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ message: 'User not found' }, 401);
|
||||
}
|
||||
|
||||
c.set('user', user);
|
||||
c.set('token', token);
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
return c.json({ message: 'Invalid token' }, 401);
|
||||
}
|
||||
}
|
||||
39
src/server/middleware/permission.middleware.ts
Normal file
39
src/server/middleware/permission.middleware.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { UserEntity as User } from '../modules/users/user.entity';
|
||||
|
||||
type PermissionCheck = (user: User) => boolean | Promise<boolean>;
|
||||
|
||||
export function checkPermission(requiredRoles: string[]): PermissionCheck {
|
||||
return (user: User) => {
|
||||
if (!user.roles) return false;
|
||||
return user.roles.some(role => requiredRoles.includes(role.name));
|
||||
};
|
||||
}
|
||||
|
||||
export function permissionMiddleware(check: PermissionCheck) {
|
||||
return async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user') as User | undefined;
|
||||
if (!user) {
|
||||
return c.json({ message: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const hasPermission = await check(user);
|
||||
if (!hasPermission) {
|
||||
return c.json({ message: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('Permission check error:', error);
|
||||
return c.json({ message: 'Internal server error' }, 500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 示例用法:
|
||||
// app.get('/admin',
|
||||
// authMiddleware,
|
||||
// permissionMiddleware(checkPermission(['admin'])),
|
||||
// (c) => {...}
|
||||
// )
|
||||
70
src/server/modules/auth/auth.service.ts
Normal file
70
src/server/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { UserService } from '../users/user.service';
|
||||
import { UserEntity as User } from '../users/user.entity';
|
||||
|
||||
const JWT_SECRET = 'your-secret-key'; // 生产环境应使用环境变量
|
||||
const JWT_EXPIRES_IN = '7d'; // 7天有效期
|
||||
|
||||
export class AuthService {
|
||||
private userService: UserService;
|
||||
|
||||
constructor(userService: UserService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<{ token: string; user: User }> {
|
||||
try {
|
||||
const user = await this.userService.getUserByUsername(username);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const isPasswordValid = await this.userService.verifyPassword(user, password);
|
||||
if (!isPasswordValid) {
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
const token = this.generateToken(user);
|
||||
return { token, user };
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
generateToken(user: User): string {
|
||||
const payload = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roles: user.roles?.map(role => role.name) || []
|
||||
};
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
}
|
||||
|
||||
verifyToken(token: string): any {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch (error) {
|
||||
console.error('Token verification failed:', error);
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
async logout(token: string): Promise<void> {
|
||||
try {
|
||||
// 验证token有效性
|
||||
const decoded = this.verifyToken(token);
|
||||
if (!decoded) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
// 实际项目中这里可以添加token黑名单逻辑
|
||||
// 或者调用Redis等缓存服务使token失效
|
||||
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/server/modules/users/role.entity.ts
Normal file
48
src/server/modules/users/role.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type Permission = string;
|
||||
|
||||
export const RoleSchema = z.object({
|
||||
id: z.number().int().positive().openapi({
|
||||
description: '角色ID',
|
||||
example: 1
|
||||
}),
|
||||
name: z.string().max(50).openapi({
|
||||
description: '角色名称,唯一标识',
|
||||
example: 'admin'
|
||||
}),
|
||||
description: z.string().max(500).nullable().openapi({
|
||||
description: '角色描述',
|
||||
example: '系统管理员角色'
|
||||
}),
|
||||
permissions: z.array(z.string()).min(1).openapi({
|
||||
description: '角色权限列表',
|
||||
example: ['user:create', 'user:delete']
|
||||
})
|
||||
});
|
||||
|
||||
export const CreateRoleDto = RoleSchema.omit({ id: true });
|
||||
export const UpdateRoleDto = RoleSchema.partial();
|
||||
|
||||
@Entity({ name: 'role' })
|
||||
export class Role {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, unique: true })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'simple-array', nullable: false })
|
||||
permissions: Permission[] = [];
|
||||
|
||||
constructor(partial?: Partial<Role>) {
|
||||
Object.assign(this, partial);
|
||||
if (!this.permissions) {
|
||||
this.permissions = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/server/modules/users/user.entity.ts
Normal file
97
src/server/modules/users/user.entity.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
|
||||
import { Role, RoleSchema } from './role.entity';
|
||||
import { z } from '@hono/zod-openapi';
|
||||
import { DeleteStatus, DisabledStatus } from '@/share/types';
|
||||
|
||||
@Entity({ name: 'users' })
|
||||
export class UserEntity {
|
||||
@PrimaryGeneratedColumn({ unsigned: true, comment: '用户ID' })
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'username', type: 'varchar', length: 255, unique: true, comment: '用户名' })
|
||||
username!: string;
|
||||
|
||||
@Column({ name: 'password', type: 'varchar', length: 255, comment: '密码' })
|
||||
password!: string;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 255, nullable: true, comment: '手机号' })
|
||||
phone!: string | null;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 255, nullable: true, comment: '邮箱' })
|
||||
email!: string | null;
|
||||
|
||||
@Column({ name: 'nickname', type: 'varchar', length: 255, nullable: true, comment: '昵称' })
|
||||
nickname!: string | null;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 255, nullable: true, comment: '真实姓名' })
|
||||
name!: string | null;
|
||||
|
||||
@Column({ name: 'avatar', type: 'varchar', length: 255, nullable: true, comment: '头像' })
|
||||
avatar!: string | null;
|
||||
|
||||
@Column({ name: 'is_disabled', type: 'int', default: DisabledStatus.ENABLED, comment: '是否禁用(0:启用,1:禁用)' })
|
||||
isDisabled!: DisabledStatus;
|
||||
|
||||
@Column({ name: 'is_deleted', type: 'int', default: DeleteStatus.NOT_DELETED, comment: '是否删除(0:未删除,1:已删除)' })
|
||||
isDeleted!: DeleteStatus;
|
||||
|
||||
@ManyToMany(() => Role)
|
||||
@JoinTable()
|
||||
roles!: Role[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt!: Date;
|
||||
|
||||
constructor(partial?: Partial<UserEntity>) {
|
||||
Object.assign(this, partial);
|
||||
}
|
||||
}
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.number().int().positive().openapi({ description: '用户ID' }),
|
||||
username: z.string().min(3).max(255).openapi({
|
||||
example: 'admin',
|
||||
description: '用户名,3-255个字符'
|
||||
}),
|
||||
password: z.string().min(6).max(255).openapi({
|
||||
example: 'password123',
|
||||
description: '密码,最少6位'
|
||||
}),
|
||||
phone: z.string().max(255).nullable().openapi({
|
||||
example: '13800138000',
|
||||
description: '手机号'
|
||||
}),
|
||||
email: z.string().email().max(255).nullable().openapi({
|
||||
example: 'user@example.com',
|
||||
description: '邮箱'
|
||||
}),
|
||||
nickname: z.string().max(255).nullable().openapi({
|
||||
example: '昵称',
|
||||
description: '用户昵称'
|
||||
}),
|
||||
name: z.string().max(255).nullable().openapi({
|
||||
example: '张三',
|
||||
description: '真实姓名'
|
||||
}),
|
||||
avatar: z.string().max(255).nullable().openapi({
|
||||
example: 'https://example.com/avatar.jpg',
|
||||
description: '用户头像'
|
||||
}),
|
||||
isDisabled: z.number().int().min(0).max(1).default(DisabledStatus.ENABLED).openapi({
|
||||
example: DisabledStatus.ENABLED,
|
||||
description: '是否禁用(0:启用,1:禁用)'
|
||||
}),
|
||||
isDeleted: z.number().int().min(0).max(1).default(DeleteStatus.NOT_DELETED).openapi({
|
||||
example: DeleteStatus.NOT_DELETED,
|
||||
description: '是否删除(0:未删除,1:已删除)'
|
||||
}),
|
||||
roles: z.array(RoleSchema).optional().openapi({
|
||||
example: [{ id: 1, name: 'admin',description:'管理员', permissions: ['user:create'] }],
|
||||
description: '用户角色列表'
|
||||
}),
|
||||
createdAt: z.date().openapi({ description: '创建时间' }),
|
||||
updatedAt: z.date().openapi({ description: '更新时间' })
|
||||
});
|
||||
167
src/server/modules/users/user.service.ts
Normal file
167
src/server/modules/users/user.service.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { HTTPException } from 'hono/http-exception'
|
||||
import { DataSource } from 'typeorm';
|
||||
import { UserEntity as User } from './user.entity';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Role } from './role.entity';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
export class UserService {
|
||||
private userRepository: Repository<User>;
|
||||
private roleRepository: Repository<Role>;
|
||||
private readonly dataSource: DataSource;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.dataSource = dataSource;
|
||||
this.userRepository = this.dataSource.getRepository(User);
|
||||
this.roleRepository = this.dataSource.getRepository(Role);
|
||||
}
|
||||
|
||||
async createUser(userData: Partial<User>): Promise<User> {
|
||||
try {
|
||||
if (userData.password) {
|
||||
userData.password = await bcrypt.hash(userData.password, SALT_ROUNDS);
|
||||
}
|
||||
const user = this.userRepository.create(userData);
|
||||
return await this.userRepository.save(user);
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
throw new HTTPException(400,{ message: 'Failed to create user', cause: error})
|
||||
}
|
||||
}
|
||||
|
||||
async getUserById(id: number): Promise<User | null> {
|
||||
try {
|
||||
return await this.userRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['roles']
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting user:', error);
|
||||
throw new Error('Failed to get user');
|
||||
}
|
||||
}
|
||||
|
||||
async getUserByUsername(username: string): Promise<User | null> {
|
||||
try {
|
||||
return await this.userRepository.findOne({
|
||||
where: { username },
|
||||
relations: ['roles']
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting user:', error);
|
||||
throw new Error('Failed to get user');
|
||||
}
|
||||
}
|
||||
|
||||
async getUserByPhone(phone: string): Promise<User | null> {
|
||||
try {
|
||||
return await this.userRepository.findOne({
|
||||
where: { phone: phone },
|
||||
relations: ['roles']
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting user by phone:', error);
|
||||
throw new Error('Failed to get user by phone');
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(id: number, updateData: Partial<User>): Promise<User | null> {
|
||||
try {
|
||||
if (updateData.password) {
|
||||
updateData.password = await bcrypt.hash(updateData.password, SALT_ROUNDS);
|
||||
}
|
||||
await this.userRepository.update(id, updateData);
|
||||
return this.getUserById(id);
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
throw new Error('Failed to update user');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUser(id: number): Promise<void> {
|
||||
try {
|
||||
await this.userRepository.delete(id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
throw new Error('Failed to delete user');
|
||||
}
|
||||
}
|
||||
|
||||
async getUsersWithPagination(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
}): Promise<[User[], number]> {
|
||||
try {
|
||||
const { page, pageSize, keyword } = params;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const queryBuilder = this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.leftJoinAndSelect('user.roles', 'roles')
|
||||
.skip(skip)
|
||||
.take(pageSize);
|
||||
|
||||
if (keyword) {
|
||||
queryBuilder.where(
|
||||
'user.username LIKE :keyword OR user.nickname LIKE :keyword OR user.phone LIKE :keyword',
|
||||
{ keyword: `%${keyword}%` }
|
||||
);
|
||||
}
|
||||
|
||||
return await queryBuilder.getManyAndCount();
|
||||
} catch (error) {
|
||||
console.error('Error getting users with pagination:', error);
|
||||
throw new Error('Failed to get users');
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPassword(user: User, password: string): Promise<boolean> {
|
||||
return password === user.password || bcrypt.compare(password, user.password)
|
||||
}
|
||||
|
||||
async assignRoles(userId: number, roleIds: number[]): Promise<User | null> {
|
||||
try {
|
||||
const user = await this.getUserById(userId);
|
||||
if (!user) return null;
|
||||
|
||||
const roles = await this.roleRepository.findByIds(roleIds);
|
||||
user.roles = roles;
|
||||
return await this.userRepository.save(user);
|
||||
} catch (error) {
|
||||
console.error('Error assigning roles:', error);
|
||||
throw new Error('Failed to assign roles');
|
||||
}
|
||||
}
|
||||
|
||||
async getUsers(): Promise<User[]> {
|
||||
try {
|
||||
const users = await this.userRepository.find({
|
||||
relations: ['roles']
|
||||
});
|
||||
return users;
|
||||
} catch (error) {
|
||||
console.error('Error getting users:', error);
|
||||
throw new HTTPException(500, { message: 'Failed to get users', cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getUserRepository(): Repository<User> {
|
||||
return this.userRepository;
|
||||
}
|
||||
|
||||
async getUserByAccount(account: string): Promise<User | null> {
|
||||
try {
|
||||
return await this.userRepository.findOne({
|
||||
where: [{ username: account }, { email: account }],
|
||||
relations: ['roles']
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting user by account:', error);
|
||||
throw new Error('Failed to get user by account');
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/server/renderer.tsx
Normal file
41
src/server/renderer.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { GlobalConfig } from '@/share/types'
|
||||
import { reactRenderer } from '@hono/react-renderer'
|
||||
import { Script, Link } from 'hono-vite-react-stack-node/components'
|
||||
import process from 'node:process'
|
||||
|
||||
// 全局配置常量
|
||||
const GLOBAL_CONFIG: GlobalConfig = {
|
||||
OSS_BASE_URL: process.env.OSS_BASE_URL || 'https://oss.d8d.fun',
|
||||
APP_NAME: process.env.APP_NAME || '多八多Aider',
|
||||
}
|
||||
|
||||
export const renderer = reactRenderer(({ children }) => {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<meta charSet="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<Script />
|
||||
<Link href="/src/style.css" rel="stylesheet" />
|
||||
<script src="https://ai-oss.d8d.fun/umd/vconsole.3.15.1.min.js"></script>
|
||||
<script dangerouslySetInnerHTML={{ __html: `
|
||||
const init = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (${import.meta.env?.PROD ? "true":"false"} && !urlParams.has('vconsole')) return;
|
||||
var vConsole = new VConsole({
|
||||
theme: urlParams.get('vconsole_theme') || 'light',
|
||||
onReady: function() {
|
||||
console.log('vConsole is ready');
|
||||
}
|
||||
});
|
||||
}
|
||||
init();
|
||||
`}} />
|
||||
{/* 注入全局配置 */}
|
||||
<script dangerouslySetInnerHTML={{ __html: `window.CONFIG = ${JSON.stringify(GLOBAL_CONFIG)};` }} />
|
||||
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
})
|
||||
9
src/server/types/context.ts
Normal file
9
src/server/types/context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { UserEntity } from "../modules/users/user.entity";
|
||||
|
||||
// 扩展Context类型
|
||||
export type Variables = {
|
||||
user: UserEntity;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export type AuthContext = { Variables: Variables }
|
||||
34
src/server/utils/errorHandler.ts
Normal file
34
src/server/utils/errorHandler.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Context } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { HTTPException } from 'hono/http-exception'
|
||||
|
||||
export const ErrorSchema = z.object({
|
||||
code: z.number().openapi({
|
||||
example: 400,
|
||||
}),
|
||||
message: z.string().openapi({
|
||||
example: 'Bad Request',
|
||||
}),
|
||||
})
|
||||
|
||||
export const errorHandler = async (err: Error, c: Context) => {
|
||||
if (err instanceof HTTPException) {
|
||||
const details = err.cause ? { details: err.cause instanceof Error ? err.cause.message : err.cause } : {}
|
||||
return c.json(
|
||||
{
|
||||
code: err.status,
|
||||
message: err.message,
|
||||
...details
|
||||
},
|
||||
err.status
|
||||
)
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
code: 500,
|
||||
message: err.message || 'Internal Server Error'
|
||||
},
|
||||
500
|
||||
)
|
||||
}
|
||||
8
src/server/utils/logger.ts
Normal file
8
src/server/utils/logger.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import debug from 'debug';
|
||||
|
||||
export const logger = {
|
||||
error: debug('backend:error'),
|
||||
api: debug('backend:api'),
|
||||
db: debug('backend:db'),
|
||||
middleware: debug('backend:middleware'),
|
||||
};
|
||||
46
src/share/types.ts
Normal file
46
src/share/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// 全局配置常量
|
||||
export interface GlobalConfig {
|
||||
OSS_BASE_URL: string;
|
||||
APP_NAME: string;
|
||||
}
|
||||
|
||||
// 认证上下文类型
|
||||
export interface AuthContextType<T> {
|
||||
user: T | null;
|
||||
token: string | null;
|
||||
login: (username: string, password: string, latitude?: number, longitude?: number) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// 启用/禁用状态枚举
|
||||
export enum EnableStatus {
|
||||
DISABLED = 0, // 禁用
|
||||
ENABLED = 1 // 启用
|
||||
}
|
||||
|
||||
// 启用/禁用状态中文映射
|
||||
export const EnableStatusNameMap: Record<EnableStatus, string> = {
|
||||
[EnableStatus.DISABLED]: '禁用',
|
||||
[EnableStatus.ENABLED]: '启用'
|
||||
};
|
||||
|
||||
// 删除状态枚举
|
||||
export enum DeleteStatus {
|
||||
NOT_DELETED = 0, // 未删除
|
||||
DELETED = 1 // 已删除
|
||||
}
|
||||
|
||||
// 删除状态中文映射
|
||||
export const DeleteStatusNameMap: Record<DeleteStatus, string> = {
|
||||
[DeleteStatus.NOT_DELETED]: '未删除',
|
||||
[DeleteStatus.DELETED]: '已删除'
|
||||
};
|
||||
|
||||
// 启用/禁用状态枚举
|
||||
export enum DisabledStatus {
|
||||
DISABLED = 1, // 禁用
|
||||
ENABLED = 0 // 启用
|
||||
}
|
||||
|
||||
1
src/style.css
Normal file
1
src/style.css
Normal file
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"incremental": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
39
vite.config.ts
Normal file
39
vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import reactStack from 'hono-vite-react-stack-node'
|
||||
import { defineConfig } from 'vite'
|
||||
import i18nextLoader from 'vite-plugin-i18next-loader'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
i18nextLoader({
|
||||
paths: ['src/client/i18n/locales']
|
||||
}),
|
||||
reactStack({
|
||||
minify: false,
|
||||
port: 8080
|
||||
}),
|
||||
],
|
||||
// 配置 @ 别名
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
build:{
|
||||
// assetsDir: 'ai-assets',
|
||||
},
|
||||
ssr:{
|
||||
external:[
|
||||
'dotenv','typeorm','bcrypt', '@d8d-appcontainer/api',
|
||||
'mysql2', 'ioredis','reflect-metadata',
|
||||
'@hono/node-server', 'jsonwebtoken', 'minio',
|
||||
'node-fetch', 'node-cron',
|
||||
'@alicloud/dysmsapi20170525', '@alicloud/openapi-client',
|
||||
'@alicloud/tea-util'
|
||||
]
|
||||
},
|
||||
server:{
|
||||
host:'0.0.0.0',
|
||||
port: 8080,
|
||||
allowedHosts: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user