Compare commits

...

15 Commits

Author SHA1 Message Date
dayjoy
d474216047 fix: 增加bug修复描述 2025-10-15 14:39:19 +08:00
dayjoy
71a7d7ba8c fix: 页面上不要展示主题切换按钮 2025-10-14 15:11:11 +08:00
dayjoy
bfaa656406 fix: 禁止出现横向滚动条 2025-10-14 15:09:35 +08:00
dayjoy
b293b91697 fix: Adjust font size for inputs on iOS devices 2025-10-14 14:47:12 +08:00
dayjoy
5495c7bf25 fix: 更新viewport meta,禁止缩放 2025-10-14 14:46:16 +08:00
dayjoy
2f38d95e8c fix: example改为字段描述和example 2025-10-13 14:13:37 +08:00
dayjoy
defe5ac49b fix: user的createdAt还是补充上 2025-10-13 10:47:57 +08:00
dayjoy
97bd5d013b feat: 每个接口都需要写 @Example 装饰器 2025-10-11 18:10:40 +08:00
dayjoy
1659d03040 feat: 补充接口example 2025-10-11 18:02:08 +08:00
dayjoy
99c8fa5f2d feat: 明确OperationId 2025-10-11 14:20:29 +08:00
dayjoy
7f057a50ca feat: 提示词更新:尽量不出现横向滚动条 2025-10-11 11:40:33 +08:00
dayjoy
b19b9f8b5f feat: 添加过滤内部接口的脚本 2025-10-11 11:07:18 +08:00
dayjoy
339dccdaa5 feat: 添加/api/swagger/swagger.json 2025-10-11 10:50:28 +08:00
dayjoy
c6464fe1d1 feat: 强调不许修改client相对路径 2025-10-10 16:45:02 +08:00
dayjoy
2341e36aa9 chore(comment): 注释改为英文 2025-09-30 14:27:53 +08:00
18 changed files with 215 additions and 58 deletions

View File

@@ -15,6 +15,7 @@
- 专为移动端优化,**采用单一入口点架构**,所有功能模块通过组件化方式在同一页面内动态加载和切换,确保用户体验的连贯性和加载性能的优化
- **主题适配**: 确保组件支持亮色/暗色主题
- 确保页面背景色、文字色、按钮色等UI元素在不同主题下均有良好对比度和可读性
- 但是页面上不要展示主题切换按钮主题切换根据url参数 `?theme=dark``?theme=light` 来控制
- **错误展示**前端页面需要有统一的错误展示用于显示API请求失败或其他操作错误的信息
- 不需要实现登录页,默认访问应用的用户都是已登录状态
- 所有请求统一使用 `/src/api/index` 中的 `api` 方法进行调用,因为已经内置了必要的请求头封装
@@ -25,6 +26,19 @@
- 所有接口以 `/api` 为前缀
- 所有接口按照 `tsoa` 规范来编写,参考 `src/controllers` 目录下的 `UserController``GroupController` 示例
- 如果接口需要登录,请使用 `@Security('jwt')` 装饰器tsoa 会自动调用登录校验,并把 user 信息写到req上参考 `UserController` 示例
- **每个接口相关的类型定义(如interface、type)等都需要按照标准注释进行类型及字段的描述注释。不明确的字段如string类型的日期等还需要加上 @example 表明字段具体的格式示例**,例如:
```ts
/**
* 用户ID
* @example "123456"
*/
id: string;
/**
* 注册日期格式为yyyy-MM-dd
* @example "2025-01-01"
*/
registryDate: string;
```
### 3. 开发规范
- **文件命名**: 使用kebab-case命名文件和文件夹
@@ -33,6 +47,11 @@
- **类型定义**: 为所有数据结构定义TypeScript类型
- **代码组织**: 保持清晰的目录结构和模块化设计
### 4. 设计规范
- **响应式**: 确保所有页面在不同屏幕尺寸下均有良好展示效果
- **滚动条**: 根据屏幕宽度变化自适应,🈲禁止出现横向滚动条
## 任务执行流程
1. **需求分析**: 仔细解析JSON需求识别核心功能点
@@ -51,6 +70,8 @@
用户每次修改后,请在现有代码基础上**针对JSON中较上一次有变动的地方重点编辑实现**,确保所有变更都被正确反映。
此外用户还可能通过 `appBug` 字段反馈bug**如果 `appBug` 字段不为空字符串则必须优先修复对应的bug**并确保修复后代码依然符合用户最新的JSON需求。
## 注意事项
- 严格遵循现有的项目结构和编码规范
@@ -63,7 +84,9 @@
请基于以上规范和用户JSON需求生成完整的、可运行的、符合输出格式要求的代码实现。
接下来我会输出用户JSON需求其中前端需求放在 `webapp_requirements`, 后端需求放在 `node_backend_requirements`
接下来我会输出用户JSON需求其中前端需求放在 `webapp_requirements`, 后端需求放在 `node_backend_requirements`bug修复需求放在 `appBug`。
> 如果 webapp_requirements 中 is_needed = false代表不需要前端,只需要生成后端代码
> 如果 webapp_requirements 为 null代表不需要前端web页面或者web页面本次不需要变更,只需要生成后端代码
> 如果 node_backend_requirements 为 null代表后端代码本次不需要变更
> 如果 appBug 不为空字符串,代表本次需要修复 bug

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/great-egret-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<title>Egret App</title>
</head>
<body>

View File

@@ -8,17 +8,16 @@ const App: React.FC = () => {
const { setTheme } = useTheme();
useEffect(() => {
// 在组件挂载时先处理URL中的authToken groupId
// Consume authToken & groupId from URL and persist
handleAuthTokenAndGroupIdFromUrl();
// 读取并处理URL中的 theme'light'|'dark')参数
// Read optional theme ('light' | 'dark') from URL and persist via ThemeProvider
const params = new URLSearchParams(window.location.search);
const themeParam = params.get('theme');
if (themeParam === 'light' || themeParam === 'dark') {
// 使用 ThemeProvider 的 setTheme 应用主题并存入本地storageKey: egret-ui-theme
setTheme(themeParam);
// 从URL中移除 theme 参数,保持地址整洁
// Clean up URL param after applying
const url = new URL(window.location.href);
url.searchParams.delete('theme');
window.history.replaceState(null, '', url.toString());

View File

@@ -5,8 +5,7 @@ interface RequestOptions extends RequestInit {
}
/**
* 统一的fetch请求封装
* 自动添加Authorization头部
* Unified fetch wrapper that adds auth and group headers
*/
export const apiRequest = async <T = any>(
endpoint: string,
@@ -17,34 +16,34 @@ export const apiRequest = async <T = any>(
const url = endpoint;
// 准备请求头
// Prepare request headers
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
};
// 如果有authToken添加Authorization头
// Attach Authorization header if token exists
if (authToken) {
headers.authorization = `Bearer ${authToken}`;
}
// 如果有groupId添加groupId头
// Attach groupId header if present
if (groupId) {
headers.groupId = groupId;
}
// 发起请求
// Fire request
const response = await fetch(url, {
...options,
headers,
});
// 处理响应
// Handle non-2xx
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 尝试解析JSON如果失败则返回响应对象
// Try to parse JSON; fallback to raw response
try {
return await response.json();
} catch {
@@ -52,7 +51,7 @@ export const apiRequest = async <T = any>(
}
};
// 便捷的HTTP方法封装
// Convenience HTTP helpers
export const api = {
get: <T = any>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> =>
apiRequest<ApiResponse<T>>(endpoint, { ...options, method: 'GET' }),

View File

@@ -12,7 +12,7 @@ export type UserInfo = {
}
/**
* 获取当前用户的信息
* Get current user information
*/
export const getUserInfo = async (): Promise<UserInfo | null> => {
const res = await api.get<UserInfo>('/api/user/info');
@@ -20,7 +20,7 @@ export const getUserInfo = async (): Promise<UserInfo | null> => {
};
/**
* 获取群组内所有用户的信息
* Get all users in the current group
*/
export const getGroupMembers = async (): Promise<UserInfo[]> => {
const res = await api.get<UserInfo[]>('/api/group/members');

View File

@@ -130,3 +130,18 @@
@apply bg-background text-foreground;
}
}
@media only screen and (max-device-width: 414px) and (orientation: portrait) and (-webkit-min-device-pixel-ratio: 2) {
/* Adjust font size for inputs on iOS devices */
.flex-input {
font-size: 16px !important;
}
}
@media screen and (-webkit-min-device-pixel-ratio:2) {
select,
textarea,
input {
font-size: 16px !important;
}
}

View File

@@ -1,17 +1,17 @@
// 处理认证相关的工具函数
// Auth utilities
const AUTH_TOKEN_KEY = 'authToken';
const GROUP_ID_KEY = 'groupId';
/**
* 从URL搜索参数中获取authToken
* Get authToken from URL search params
*/
export const getAuthTokenFromUrl = (): string | null => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('authToken');
};
/**
* 从URL搜索参数中获取groupId
* Get groupId from URL search params
*/
export const getGroupIdFromUrl = (): string | null => {
const urlParams = new URLSearchParams(window.location.search);
@@ -19,21 +19,21 @@ export const getGroupIdFromUrl = (): string | null => {
};
/**
* 保存authTokenlocalStorage
* Save authToken to localStorage
*/
export const saveAuthToken = (token: string): void => {
localStorage.setItem(AUTH_TOKEN_KEY, token);
};
/**
* 保存groupIdlocalStorage
* Save groupId to localStorage
*/
export const saveGroupId = (groupId: string): void => {
localStorage.setItem(GROUP_ID_KEY, groupId);
};
/**
* 从localStorage获取authToken
* Get authToken with URL fallback
*/
export const getAuthToken = (): string | null => {
const tokenFromUrl = getAuthTokenFromUrl();
@@ -41,7 +41,7 @@ export const getAuthToken = (): string | null => {
};
/**
* 从localStorage获取groupId
* Get groupId with URL fallback
*/
export const getGroupId = (): string | null => {
const groupIdFromUrl = getGroupIdFromUrl();
@@ -49,22 +49,21 @@ export const getGroupId = (): string | null => {
};
/**
* 清除authToken
* Clear authToken
*/
export const clearAuthToken = (): void => {
localStorage.removeItem(AUTH_TOKEN_KEY);
};
/**
* 清除groupId
* Clear groupId
*/
export const clearGroupId = (): void => {
localStorage.removeItem(GROUP_ID_KEY);
};
/**
* 检查并处理URL中的authToken
* 如果URL中有authToken参数则保存到localStorage并从URL中移除
* Consume authToken/groupId from URL and persist, then clean URL
*/
export const handleAuthTokenAndGroupIdFromUrl = (): void => {
const tokenFromUrl = getAuthTokenFromUrl();
@@ -80,7 +79,7 @@ export const handleAuthTokenAndGroupIdFromUrl = (): void => {
updated = true;
}
// Clean URL if we consumed any param
// Clean URL if any param was consumed
if (updated) {
const url = new URL(window.location.href);
url.searchParams.delete('authToken');

View File

@@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"build": "tsoa -c tsoaConfig.json spec-and-routes && tsc && tsc-alias",
"build": "tsoa -c tsoaConfig.json spec-and-routes && node scripts/clean-swagger.js && tsc && tsc-alias",
"serve": "yarn build && node ./build/index.js",
"start": "nodemon"
},

View File

@@ -0,0 +1,71 @@
import fs from "fs";
const filePath = "./build/swagger.json";
const swagger = JSON.parse(fs.readFileSync(filePath, "utf8"));
// ==============================
// ① 删除不希望公开的接口路径
// ==============================
const pathsToRemove = ["/api/test", "/api/user/info", "/api/group/members"];
for (const path of pathsToRemove) {
if (swagger.paths?.[path]) {
delete swagger.paths[path];
console.log(`🧹 Removed path: ${path}`);
}
}
// ==============================
// ② 收集引用的 schema 名称(递归)
// ==============================
const referenced = new Set();
function collectRefs(obj) {
if (!obj || typeof obj !== "object") return;
for (const key in obj) {
const value = obj[key];
if (key === "$ref" && typeof value === "string" && value.startsWith("#/components/schemas/")) {
const name = value.split("/").pop();
if (!referenced.has(name)) {
referenced.add(name);
// 递归收集该 schema 内的引用
const schema = swagger.components?.schemas?.[name];
if (schema) collectRefs(schema);
}
} else {
collectRefs(value);
}
}
}
// 先从 paths 开始收集
collectRefs(swagger.paths);
// 然后递归收集 schemas 内部互相引用的部分
let prevCount = 0;
do {
prevCount = referenced.size;
for (const name of Array.from(referenced)) {
const schema = swagger.components?.schemas?.[name];
if (schema) collectRefs(schema);
}
} while (referenced.size > prevCount);
// ==============================
// ③ 删除未被引用的 schema
// ==============================
if (swagger.components?.schemas) {
const allSchemas = Object.keys(swagger.components.schemas);
const unused = allSchemas.filter((name) => !referenced.has(name));
for (const name of unused) {
delete swagger.components.schemas[name];
}
console.log(`🧩 Removed ${unused.length} unused schemas`);
}
// ==============================
// ④ 保存结果
// ==============================
fs.writeFileSync(filePath, JSON.stringify(swagger, null, 2));
console.log("✅ Swagger cleanup complete!");

View File

@@ -1,4 +1,4 @@
import {Controller, Get, Route, Response, Tags, Request, Security} from 'tsoa';
import {Controller, Get, Route, Response, Tags, Request, Security, OperationId} from 'tsoa';
import {ApiError, ApiResponse} from '../types/api';
import type {Request as ExpressRequest} from 'express';
import axios from "axios";
@@ -40,11 +40,12 @@ export const getGroupMembers = async (groupId: number, token: string): Promise<U
@Tags('Chat Group')
export class GroupController extends Controller {
/**
* 获取当前聊天群组的全部用户信息
* @summary 获取当前聊天群组的全部用户信息
* @description 获取当前聊天群组的全部用户信息
* Get all users in the current chat group
* @summary Get group members
* @description Returns the full member list of the current chat group
*/
@Get('/members')
@OperationId('Group_GetGroupMembers') // MUST: Specify operationId for better API documentation
@Security("jwt")
@Response<ApiResponse<UserInfo[]>>(200, 'Success')
@Response<ApiResponse<null>>(400, 'Bad Request')

View File

@@ -1,15 +1,16 @@
import { Controller, Get, Route, Response, Tags } from 'tsoa';
import {Controller, Get, Route, Response, Tags, OperationId} from 'tsoa';
import type { ApiResponse } from '../types/api';
@Route('api')
@Tags('Test')
export class TestController extends Controller {
/**
* 测试接口
* @summary 测试接口
* @description 用于测试API连通性的基础接口
* Test endpoint
* @summary Test endpoint
* @description Basic endpoint to verify API connectivity
*/
@Get('/test')
@OperationId('Test_GetTest') // MUST: Specify operationId for better API documentation
@Response<ApiResponse>(200, 'Success')
public async getTest(): Promise<ApiResponse> {
return {

View File

@@ -1,4 +1,4 @@
import {Controller, Get, Route, Response, Tags, Middlewares, Request, Security} from 'tsoa';
import {Controller, Get, Route, Response, Tags, Request, Security, OperationId} from 'tsoa';
import type { ApiResponse } from '../types/api';
import type { Request as ExpressRequest } from 'express';
import type {UserInfo} from "../types/user";
@@ -8,11 +8,12 @@ import type {UserInfo} from "../types/user";
@Tags('User')
export class UserController extends Controller {
/**
* 获取当前用户的信息
* @summary 获取当前用户的信息
* @description 获取当前用户的信息
* Get current user information
* @summary Get current user information
* @description Returns the authenticated user's profile
*/
@Get('/info')
@OperationId('User_GetUserGroupInfo') // MUST: Specify operationId for better API documentation
@Security('jwt')
@Response<ApiResponse<UserInfo>>(200, 'Success')
@Response(401, 'Unauthorized')

View File

@@ -10,7 +10,7 @@ dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
import "./database";
import { sequelize } from "./database/instance";
import {errorHandler} from "./middleware/errorHandler";
import {RegisterTsoaRoutes} from "./middleware/tsoa.middleware"; // tsoa 生成的
import {RegisterTsoaRoutes} from "./middleware/tsoa.middleware";
const port = process.env.PORT || 3005;
@@ -20,7 +20,7 @@ app.use(nocache());
app.use(cors());
app.use(express.json({ limit: "100mb" }));
app.use(compression());
app.use(express.static(path.resolve(__dirname, "client")));
app.use(express.static(path.resolve(__dirname, "client"))); // DON'T MODIFY THIS LINE, IT IS USED IN BUILD
// Register tsoa routes
RegisterTsoaRoutes(app);

View File

@@ -16,7 +16,7 @@ export async function expressAuthentication(
throw new ApiError(401, "Unauthorized");
}
// 返回的对象会挂到 request.user
// Returned object is assigned to request.user
return await getUserInfoByToken(token);
}
throw new ApiError(401, "Unsupported security scheme");
@@ -45,7 +45,7 @@ export const getUserInfoByToken = async (token: string): Promise<UserInfo> => {
try {
const response = await axios.post<ApiResponse<Omit<UserInfo, 'token'>>>(
"https://egret.byteawake.com/api/user/info",
{}, // 请求体数据,这里为空对象
{}, // Empty request body
{
headers: {
Authorization: `Bearer ${token}`,
@@ -74,4 +74,3 @@ export const getUserInfoByToken = async (token: string): Promise<UserInfo> => {
};

View File

@@ -8,7 +8,7 @@ export function errorHandler(err: any, req: Request, res: Response, next: NextFu
return res.status(err.status).json({code: err.status, message: err.message, data: null});
}
// tsoa 内部生成的验证错误
// Validation errors generated by tsoa
if (err.status && err.status >= 400) {
return res.status(err.status).json({ message: err.message });
}

View File

@@ -2,11 +2,18 @@ import swaggerUi from 'swagger-ui-express';
import path from 'path';
export function RegisterTsoaRoutes(app: any) {
// Register tsoa routes - 动态导入以避免编译时错误
// Register tsoa routes via dynamic import to avoid compile-time errors
const { RegisterRoutes } = require('../routes/routes');
RegisterRoutes(app);
// Serve swagger documentation - 动态导入swagger文档
// Serve swagger documentation (dynamic import)
const swaggerDocument = require(path.join(__dirname, '../swagger.json'));
app.use('/v3/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
const options = {
swaggerOptions: {
url: "/api/swagger/swagger.json",
},
}
app.get("/api/swagger/swagger.json", (req: any, res: any) => res.json(swaggerDocument));
// @ts-ignore
app.use('/api/swagger', swaggerUi.serveFiles(null, options), swaggerUi.setup(null, options));
}

View File

@@ -1,6 +1,18 @@
/**
* Standard API response structure
*/
export interface ApiResponse<T = any> {
/**
* HTTP status code, e.g., 200 for success, 400 for client error, 500 for server error
*/
code: number;
/**
* Response message, e.g., "success" or error description
*/
message: string;
/**
* Response data; can be null if no data is returned
*/
data: T | null;
}

View File

@@ -1,11 +1,41 @@
/**
* User information interface
*/
export interface UserInfo {
/**
* User ID
*/
userId: number;
/**
* User nickname
*/
nickname: string;
/**
* Avatar URL
* @example "https://example.com/avatar.jpg"
*/
avatarUrl: string;
gender: 'MALE' | 'FEMALE' | 'UNKNOWN';
nimToken: string; // NetEase Cloud Communication token
nimAccountId: string; // NetEase Cloud Communication account ID
createdAt: string;
/**
* NetEase Cloud Communication token
*/
nimToken: string;
/**
* NetEase Cloud Communication account ID
*/
nimAccountId: string;
/**
* Authentication token
*/
token: string;
/**
* Last update time yyyy-MM-dd HH:mm:ss
* @example "2023-10-05 14:48:00"
*/
updatedAt: string;
token: string; // Authentication token
/**
* Account creation time yyyy-MM-dd HH:mm:ss
* @example "2023-10-05 14:48:00"
*/
createdAt: string;
}