This commit is contained in:
D8D Developer
2025-06-27 03:31:29 +00:00
commit d371fbaefa
68 changed files with 11263 additions and 0 deletions

View 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
View 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

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

15
.roo/mcp.json Normal file
View 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
View 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 (缓存/会话管理)

View File

@@ -0,0 +1,5 @@
# TypeScript规范
1. **严格模式**
- 启用所有严格类型检查选项
- 避免使用`any`类型

8
.roo/rules/03-modules.md Normal file
View File

@@ -0,0 +1,8 @@
# 模块化规范
1. **模块组织**
- 按功能划分模块
- 每个模块包含:
- 实体定义
- 服务层
- 路由控制器

22
.roo/rules/04-api.md Normal file
View 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文档

View File

@@ -0,0 +1,5 @@
# 数据库规范
1. **迁移管理**
- 使用迁移脚本管理表结构变更
- 实体类与数据库表严格映射

View 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
View 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
View 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
View 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
View 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)

View 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
View File

@@ -0,0 +1,3 @@
.gitea
scripts
src/client/admin/api

26
Dockerfile.release Normal file
View 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
View 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"]

0
README.md Normal file
View File

63
docker-compose.yml Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

55
scripts/release_tag.sh Normal file
View 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 远程仓库"

View 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>
);
};

View 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>
);
};

View 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;
};

View 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;
};

View 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 />
)
}

View 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
View 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
};
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
// 如果当前是在 /big 下
if (window.location.pathname.startsWith('/admin')) {
import('./admin/index')
}else{
import('./home/index')
}

View 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
View 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
View 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

View 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;

View 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

View 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;

View 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

View 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

View 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

View 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;

View 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;

View 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
View 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;

View 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;

View 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
View 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
View 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

View 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);
}
}

View 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) => {...}
// )

View 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;
}
}
}

View 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 = [];
}
}
}

View 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: '更新时间' })
});

View 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
View 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>
)
})

View 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 }

View 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
)
}

View 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
View 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
View File

@@ -0,0 +1 @@
@import 'tailwindcss';

28
tsconfig.json Normal file
View 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
View 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,
},
})