diff --git a/.cursor/rules/ai.mdc b/.cursor/rules/ai.mdc new file mode 100644 index 00000000..16f7f593 --- /dev/null +++ b/.cursor/rules/ai.mdc @@ -0,0 +1,102 @@ +--- +description: +globs: +alwaysApply: true +--- +## 智能体工作流程(多智能体协作) + +### 角色定义(按执行顺序标注) +- S1 需求分析体(Analyzer): 解析需求、对应 PHP/Nest 规范、输出任务切分与验收标准 +- S2 架构治理体(Architect): 校验分层/依赖/目录规范,给出重构建议与边界清单 +- S3 基建接入体(InfraOperator): 接入/校验 Kafka、Redis、队列、事务与配置,提供接入差异与示例 +- S4 开发执行体(Developer): 按规范编码、编写测试、修复构建 +- S5 安全基线体(SecurityGuard): 检查守卫、跨租户(site_id)隔离、敏感信息暴露(开发中与提测前各执行一次) +- S6 质量门禁体(QualityGate): 聚合 ESLint/TS/覆盖率/e2e 结果,低于阈值阻断合并 +- S7 规范审计体(Auditor): 按清单逐项核查,出具差异报告与修复项 +- S8 上线管控体(Release): 构建、变更说明、灰度计划与回滚预案 +- S9 性能优化体(PerfTuner): 建议缓存/异步化/批处理,识别大对象传输与 N+1(开发后期与上线后持续执行) + +### 串联流程(带顺序) +1) S1 Analyzer +- 输入: 业务需求/接口变更/对齐 PHP 的说明 +- 输出: 模块划分、路由表、DTO、实体字段清单、与 DB/ThinkPHP 对照 + +2) S2 Architect +- 校验: 模块目录、分层(Application/Core/Infrastructure)、依赖方向(App→Common→Core→Vendor) +- 输出: 设计说明、端口(Repository/Provider)定义、删除/迁移建议 + +3) S3 InfraOperator +- 接入: Kafka/Redis/队列/事务的工程化接入与配置 +- 产物: 接入差异与示例代码(见 integration.md),健康检查/配置项校验清单 + +4) S4 Developer +- 实现: Controller 仅路由+DTO校验;AppService 编排;Core 规则;Infra 实现;Entity 对齐 DB +- 接入: 守卫(RBAC)、Pipes(JSON/Timestamp)、拦截器(请求日志)、事件与队列 +- 测试: 单测/集成/e2e,构建通过 + +5) S5 SecurityGuard(第一次,开发阶段) +- 检查: 控制器守卫、site_id 隔离、敏感字段输出、配置权限 + +6) S6 QualityGate(CI 阶段) +- 指标: ESLint/TS 无报错;覆盖率≥阈值;e2e 关键路径通过 +- 动作: 不达标阻断合并 + +7) S7 Auditor(提测前) +- 检查: 规范清单(见 checklists.md),字段/命名/路由/守卫/事务/队列/事件 与 PHP/DB 对齐 +- 产物: 差异报告与修复任务 + +8) S5 SecurityGuard(第二次,提测前) +- 复检: 重要接口的鉴权/越权/敏感输出 + +9) S9 PerfTuner(并行/持续) +- 建议: 缓存、异步化、批量化、索引与查询优化;识别 N+1、大对象传输 + +10) S8 Release +- 产出: 变更日志、部署步骤、数据迁移脚本、回滚预案 + +### 关键约束 +- 与 PHP 业务/数据100%一致;与 NestJS 规范100%匹配 +- 禁止创建 DB 不存在字段;`sys_config.value(JSON)` 统一 +- 管理端路由 `/adminapi`,前台 `/api`;统一守卫与响应格式 + +### 基础能力检查点(Kafka / Redis / 队列 / 事务) +- 事务: 仅在 Application 开启;多仓储共享同一 EntityManager;Core 不直接操作事务对象 +- 队列: 用例完成后入队;载荷仅传关键 ID;处理器在 Infrastructure;按队列名分域 +- 事件: 统一用 DomainEventService;事件名 `domain.aggregate.action`;默认 DB Outbox,可切 Kafka +- Redis: 短缓存配置读取、上传限流/防刷(计数器)、幂等(SETNX+TTL) + +### 命名与对齐 +- PHP 业务命名优先(不违反 Nest/TS 规范前提下),包括服务方法、DTO 字段、配置键 +- Nest 特有类型按规范命名:`*.module.ts`、`*.controller.ts`、`*.app.service.ts`、`*.core.service.ts` + +### 核心链接 +- 模块映射: `./mapping.md` +- 能力集成: `./integration.md` +- 规则与清单: `./rules.md`、`./checklists.md` + +### 执行与验收(CI/PR 建议) +- PR 必须通过: build、单测/集成/e2e +- 审计体根据 `checklists.md` 自动评论差异(字段/命名/路由/守卫/事务/队列/事件) +- 安全基线: 管理端控制器统一 `JwtAuthGuard + RolesGuard`;/adminapi 与 /api 路由前缀 + +### 目录职能速查(防误用) +- common/(框架通用服务层) + - 放可被业务复用的通用功能:用户/权限/菜单/上传/通知/设置等模块 + - 内部模块按 Controller / Application / Core / Infrastructure / Entities / DTO 分层 + - 禁止依赖 App 层;允许依赖 core/, config/, vendor/ +- config/(配置与适配) + - 环境变量、数据库/HTTP/安全/队列/第三方等配置模块与注入工厂 + - 仅存放配置与适配代码,不放业务逻辑 +- core/(核心基础设施与通用规则) + - 通用规则/策略与仓储接口(Core 层),以及全局基础设施(如队列、事件、健康、拦截器) + - 不直接依赖业务模块;面向 common/app 提供能力 +- vendor/(第三方适配层) + - 外部服务适配:存储/支付/短信/HTTP/Kafka/Redis 等 Provider + - 通过接口注入到 Infrastructure 或 Application,避免在 Controller 直接使用 +- lang/(多语言) + - 多语言资源与语言包,供接口/异常/文案统一输出 + - 智能体在涉及文案/错误消息时,优先调用多语言键值而非写死文本 +- test/(测试) + - 单元/集成/e2e 测试,包含关键业务与基础能力(事务/队列/事件/权限)覆盖 + + - PR 必须通过测试基线,质量门禁体(QualityGate)据此决策 \ No newline at end of file diff --git a/.trae/rules/development_constraints.md b/.trae/rules/development_constraints.md index 7923b52b..5911deaf 100644 --- a/.trae/rules/development_constraints.md +++ b/.trae/rules/development_constraints.md @@ -10,7 +10,6 @@ **主要数据库文件:** - **WWJCloud 主数据库**:[/g:/wwjcloud-nestjs/sql/wwjcloud.sql](../sql/wwjcloud.sql) -- **WWJAuth 认证数据库**:[/g:/wwjcloud-nestjs/sql/wwjauth.sql](../sql/wwjauth.sql) **核心表结构:** - `sys_user` - 系统用户表 (uid, username, password, real_name, last_ip, last_time, create_time, login_num, status, delete_time) diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index b9c0ff12..b1a93271 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -297,8 +297,8 @@ JSON 字段 → 使用 @Column('json') [ ] 查询语法使用TypeORM操作符 ✅ 开发后检查 [ ] npm run build 无错误 -[ ] 与PHP项目字段名100%一致 -[ ] 业务逻辑与PHP保持一致 +[ ] 与PHP项目字段名100%一致,并列出php的model层,数据库,nestjs字段名清单。 +[ ] 业务逻辑与PHP保持一致,并列出,php与nestjs的,命名规范对比清单 [ ] 类型安全无警告 🚀 简化处理步骤 三步快速修复法 @@ -310,4 +310,5 @@ JSON 字段 → 使用 @Column('json') 中优先级:类型不匹配(遵守NestJS规范) 低优先级:语法优化(保持代码整洁) 一句话总结 -"用NestJS的语法,写PHP的逻辑,保持数据库的一致性" +" +用NestJS的语法,写PHP的逻辑,保持数据库的一致性" \ No newline at end of file diff --git a/admin/.gitignore b/admin/.gitignore index 3399f39c..c2a8a771 100644 --- a/admin/.gitignore +++ b/admin/.gitignore @@ -49,4 +49,3 @@ vite.config.ts.* *.sln *.sw? .history -.cursor diff --git a/admin/README.ja-JP.md b/admin/README.ja-JP.md index 4ce285a7..f7847a1d 100644 --- a/admin/README.ja-JP.md +++ b/admin/README.ja-JP.md @@ -140,12 +140,8 @@ pnpm build ## 貢献者 - - Contribution Leaderboard - - - Contributors + Contributors ## Discord diff --git a/admin/README.md b/admin/README.md index b9dd73eb..e027949a 100644 --- a/admin/README.md +++ b/admin/README.md @@ -140,12 +140,8 @@ If you think this project is helpful to you, you can help the author buy a cup o ## Contributors - - Contribution Leaderboard - - - Contributors + Contributors ## Discord diff --git a/admin/README.zh-CN.md b/admin/README.zh-CN.md index d3193ef6..5a6b191b 100644 --- a/admin/README.zh-CN.md +++ b/admin/README.zh-CN.md @@ -140,12 +140,8 @@ pnpm build ## 贡献者 - - Contribution Leaderboard - - - Contributors + Contributors ## Discord diff --git a/admin/apps/backend-mock/api/auth/codes.ts b/admin/apps/backend-mock/api/auth/codes.ts index e610b338..7ba01270 100644 --- a/admin/apps/backend-mock/api/auth/codes.ts +++ b/admin/apps/backend-mock/api/auth/codes.ts @@ -1,7 +1,5 @@ -import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { MOCK_CODES } from '~/utils/mock-data'; -import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; +import { unAuthorizedResponse } from '~/utils/response'; export default eventHandler((event) => { const userinfo = verifyAccessToken(event); diff --git a/admin/apps/backend-mock/api/auth/login.post.ts b/admin/apps/backend-mock/api/auth/login.post.ts index e23942c4..df5737a2 100644 --- a/admin/apps/backend-mock/api/auth/login.post.ts +++ b/admin/apps/backend-mock/api/auth/login.post.ts @@ -1,15 +1,9 @@ -import { defineEventHandler, readBody, setResponseStatus } from 'h3'; import { clearRefreshTokenCookie, setRefreshTokenCookie, } from '~/utils/cookie-utils'; import { generateAccessToken, generateRefreshToken } from '~/utils/jwt-utils'; -import { MOCK_USERS } from '~/utils/mock-data'; -import { - forbiddenResponse, - useResponseError, - useResponseSuccess, -} from '~/utils/response'; +import { forbiddenResponse } from '~/utils/response'; export default defineEventHandler(async (event) => { const { password, username } = await readBody(event); diff --git a/admin/apps/backend-mock/api/auth/logout.post.ts b/admin/apps/backend-mock/api/auth/logout.post.ts index 74c8d315..ac6afe94 100644 --- a/admin/apps/backend-mock/api/auth/logout.post.ts +++ b/admin/apps/backend-mock/api/auth/logout.post.ts @@ -1,9 +1,7 @@ -import { defineEventHandler } from 'h3'; import { clearRefreshTokenCookie, getRefreshTokenFromCookie, } from '~/utils/cookie-utils'; -import { useResponseSuccess } from '~/utils/response'; export default defineEventHandler(async (event) => { const refreshToken = getRefreshTokenFromCookie(event); diff --git a/admin/apps/backend-mock/api/auth/refresh.post.ts b/admin/apps/backend-mock/api/auth/refresh.post.ts index 7d8d3a51..7df4d34f 100644 --- a/admin/apps/backend-mock/api/auth/refresh.post.ts +++ b/admin/apps/backend-mock/api/auth/refresh.post.ts @@ -1,11 +1,9 @@ -import { defineEventHandler } from 'h3'; import { clearRefreshTokenCookie, getRefreshTokenFromCookie, setRefreshTokenCookie, } from '~/utils/cookie-utils'; -import { generateAccessToken, verifyRefreshToken } from '~/utils/jwt-utils'; -import { MOCK_USERS } from '~/utils/mock-data'; +import { verifyRefreshToken } from '~/utils/jwt-utils'; import { forbiddenResponse } from '~/utils/response'; export default defineEventHandler(async (event) => { diff --git a/admin/apps/backend-mock/api/demo/bigint.ts b/admin/apps/backend-mock/api/demo/bigint.ts index 00d6c28c..880cc5ea 100644 --- a/admin/apps/backend-mock/api/demo/bigint.ts +++ b/admin/apps/backend-mock/api/demo/bigint.ts @@ -1,7 +1,3 @@ -import { eventHandler, setHeader } from 'h3'; -import { verifyAccessToken } from '~/utils/jwt-utils'; -import { unAuthorizedResponse } from '~/utils/response'; - export default eventHandler(async (event) => { const userinfo = verifyAccessToken(event); if (!userinfo) { diff --git a/admin/apps/backend-mock/api/menu/all.ts b/admin/apps/backend-mock/api/menu/all.ts index 7923f7ca..580cee4f 100644 --- a/admin/apps/backend-mock/api/menu/all.ts +++ b/admin/apps/backend-mock/api/menu/all.ts @@ -1,7 +1,5 @@ -import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { MOCK_MENUS } from '~/utils/mock-data'; -import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; +import { unAuthorizedResponse } from '~/utils/response'; export default eventHandler(async (event) => { const userinfo = verifyAccessToken(event); diff --git a/admin/apps/backend-mock/api/status.ts b/admin/apps/backend-mock/api/status.ts index 43782095..41773e1d 100644 --- a/admin/apps/backend-mock/api/status.ts +++ b/admin/apps/backend-mock/api/status.ts @@ -1,6 +1,3 @@ -import { eventHandler, getQuery, setResponseStatus } from 'h3'; -import { useResponseError } from '~/utils/response'; - export default eventHandler((event) => { const { status } = getQuery(event); setResponseStatus(event, Number(status)); diff --git a/admin/apps/backend-mock/api/system/dept/.post.ts b/admin/apps/backend-mock/api/system/dept/.post.ts index 9a4896af..c529ea1b 100644 --- a/admin/apps/backend-mock/api/system/dept/.post.ts +++ b/admin/apps/backend-mock/api/system/dept/.post.ts @@ -1,4 +1,3 @@ -import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { sleep, diff --git a/admin/apps/backend-mock/api/system/dept/[id].delete.ts b/admin/apps/backend-mock/api/system/dept/[id].delete.ts index eac0f584..e48f051c 100644 --- a/admin/apps/backend-mock/api/system/dept/[id].delete.ts +++ b/admin/apps/backend-mock/api/system/dept/[id].delete.ts @@ -1,4 +1,3 @@ -import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { sleep, diff --git a/admin/apps/backend-mock/api/system/dept/[id].put.ts b/admin/apps/backend-mock/api/system/dept/[id].put.ts index 6805e139..aa55c085 100644 --- a/admin/apps/backend-mock/api/system/dept/[id].put.ts +++ b/admin/apps/backend-mock/api/system/dept/[id].put.ts @@ -1,4 +1,3 @@ -import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { sleep, diff --git a/admin/apps/backend-mock/api/system/dept/list.ts b/admin/apps/backend-mock/api/system/dept/list.ts index a649a0d2..ae819b62 100644 --- a/admin/apps/backend-mock/api/system/dept/list.ts +++ b/admin/apps/backend-mock/api/system/dept/list.ts @@ -1,5 +1,4 @@ import { faker } from '@faker-js/faker'; -import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; diff --git a/admin/apps/backend-mock/api/system/menu/list.ts b/admin/apps/backend-mock/api/system/menu/list.ts index ce96bb14..5328b2fd 100644 --- a/admin/apps/backend-mock/api/system/menu/list.ts +++ b/admin/apps/backend-mock/api/system/menu/list.ts @@ -1,4 +1,3 @@ -import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { MOCK_MENU_LIST } from '~/utils/mock-data'; import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; diff --git a/admin/apps/backend-mock/api/system/menu/name-exists.ts b/admin/apps/backend-mock/api/system/menu/name-exists.ts index 7d5551b3..5599c22b 100644 --- a/admin/apps/backend-mock/api/system/menu/name-exists.ts +++ b/admin/apps/backend-mock/api/system/menu/name-exists.ts @@ -1,7 +1,6 @@ -import { eventHandler, getQuery } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { MOCK_MENU_LIST } from '~/utils/mock-data'; -import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; +import { unAuthorizedResponse } from '~/utils/response'; const namesMap: Record = {}; diff --git a/admin/apps/backend-mock/api/system/menu/path-exists.ts b/admin/apps/backend-mock/api/system/menu/path-exists.ts index f3c3be99..64774f79 100644 --- a/admin/apps/backend-mock/api/system/menu/path-exists.ts +++ b/admin/apps/backend-mock/api/system/menu/path-exists.ts @@ -1,7 +1,6 @@ -import { eventHandler, getQuery } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { MOCK_MENU_LIST } from '~/utils/mock-data'; -import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; +import { unAuthorizedResponse } from '~/utils/response'; const pathMap: Record = { '/': 0 }; diff --git a/admin/apps/backend-mock/api/system/role/list.ts b/admin/apps/backend-mock/api/system/role/list.ts index bad29a51..4d5f923e 100644 --- a/admin/apps/backend-mock/api/system/role/list.ts +++ b/admin/apps/backend-mock/api/system/role/list.ts @@ -1,5 +1,4 @@ import { faker } from '@faker-js/faker'; -import { eventHandler, getQuery } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { getMenuIds, MOCK_MENU_LIST } from '~/utils/mock-data'; import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response'; diff --git a/admin/apps/backend-mock/api/table/list.ts b/admin/apps/backend-mock/api/table/list.ts index 6664b583..3e6f705b 100644 --- a/admin/apps/backend-mock/api/table/list.ts +++ b/admin/apps/backend-mock/api/table/list.ts @@ -1,11 +1,6 @@ import { faker } from '@faker-js/faker'; -import { eventHandler, getQuery } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { - sleep, - unAuthorizedResponse, - usePageResponseSuccess, -} from '~/utils/response'; +import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response'; function generateMockDataList(count: number) { const dataList = []; @@ -49,69 +44,30 @@ export default eventHandler(async (event) => { await sleep(600); const { page, pageSize, sortBy, sortOrder } = getQuery(event); - // 规范化分页参数,处理 string[] - const pageRaw = Array.isArray(page) ? page[0] : page; - const pageSizeRaw = Array.isArray(pageSize) ? pageSize[0] : pageSize; - const pageNumber = Math.max( - 1, - Number.parseInt(String(pageRaw ?? '1'), 10) || 1, - ); - const pageSizeNumber = Math.min( - 100, - Math.max(1, Number.parseInt(String(pageSizeRaw ?? '10'), 10) || 10), - ); const listData = structuredClone(mockData); - - // 规范化 query 入参,兼容 string[] - const sortKeyRaw = Array.isArray(sortBy) ? sortBy[0] : sortBy; - const sortOrderRaw = Array.isArray(sortOrder) ? sortOrder[0] : sortOrder; - // 检查 sortBy 是否是 listData 元素的合法属性键 - if ( - typeof sortKeyRaw === 'string' && - listData[0] && - Object.prototype.hasOwnProperty.call(listData[0], sortKeyRaw) - ) { - // 定义数组元素的类型 - type ItemType = (typeof listData)[0]; - const sortKey = sortKeyRaw as keyof ItemType; // 将 sortBy 断言为合法键 - const isDesc = sortOrderRaw === 'desc'; + if (sortBy && Reflect.has(listData[0], sortBy as string)) { listData.sort((a, b) => { - const aValue = a[sortKey] as unknown; - const bValue = b[sortKey] as unknown; - - let result = 0; - - if (typeof aValue === 'number' && typeof bValue === 'number') { - result = aValue - bValue; - } else if (aValue instanceof Date && bValue instanceof Date) { - result = aValue.getTime() - bValue.getTime(); - } else if (typeof aValue === 'boolean' && typeof bValue === 'boolean') { - if (aValue === bValue) { - result = 0; + if (sortOrder === 'asc') { + if (sortBy === 'price') { + return ( + Number.parseFloat(a[sortBy as string]) - + Number.parseFloat(b[sortBy as string]) + ); } else { - result = aValue ? 1 : -1; + return a[sortBy as string] > b[sortBy as string] ? 1 : -1; } } else { - const aStr = String(aValue); - const bStr = String(bValue); - const aNum = Number(aStr); - const bNum = Number(bStr); - result = - Number.isFinite(aNum) && Number.isFinite(bNum) - ? aNum - bNum - : aStr.localeCompare(bStr, undefined, { - numeric: true, - sensitivity: 'base', - }); + if (sortBy === 'price') { + return ( + Number.parseFloat(b[sortBy as string]) - + Number.parseFloat(a[sortBy as string]) + ); + } else { + return a[sortBy as string] < b[sortBy as string] ? 1 : -1; + } } - - return isDesc ? -result : result; }); } - return usePageResponseSuccess( - String(pageNumber), - String(pageSizeNumber), - listData, - ); + return usePageResponseSuccess(page as string, pageSize as string, listData); }); diff --git a/admin/apps/backend-mock/api/test.get.ts b/admin/apps/backend-mock/api/test.get.ts index dc2ceef7..ca4a500b 100644 --- a/admin/apps/backend-mock/api/test.get.ts +++ b/admin/apps/backend-mock/api/test.get.ts @@ -1,3 +1 @@ -import { defineEventHandler } from 'h3'; - export default defineEventHandler(() => 'Test get handler'); diff --git a/admin/apps/backend-mock/api/test.post.ts b/admin/apps/backend-mock/api/test.post.ts index 0e9e337a..698cf211 100644 --- a/admin/apps/backend-mock/api/test.post.ts +++ b/admin/apps/backend-mock/api/test.post.ts @@ -1,3 +1 @@ -import { defineEventHandler } from 'h3'; - export default defineEventHandler(() => 'Test post handler'); diff --git a/admin/apps/backend-mock/api/upload.ts b/admin/apps/backend-mock/api/upload.ts index 436b63cb..1bb9e602 100644 --- a/admin/apps/backend-mock/api/upload.ts +++ b/admin/apps/backend-mock/api/upload.ts @@ -1,6 +1,5 @@ -import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; +import { unAuthorizedResponse } from '~/utils/response'; export default eventHandler((event) => { const userinfo = verifyAccessToken(event); diff --git a/admin/apps/backend-mock/api/user/info.ts b/admin/apps/backend-mock/api/user/info.ts index 138cb433..cfa2346c 100644 --- a/admin/apps/backend-mock/api/user/info.ts +++ b/admin/apps/backend-mock/api/user/info.ts @@ -1,6 +1,5 @@ -import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; +import { unAuthorizedResponse } from '~/utils/response'; export default eventHandler((event) => { const userinfo = verifyAccessToken(event); diff --git a/admin/apps/backend-mock/middleware/1.api.ts b/admin/apps/backend-mock/middleware/1.api.ts index 339cda4d..bad9a41a 100644 --- a/admin/apps/backend-mock/middleware/1.api.ts +++ b/admin/apps/backend-mock/middleware/1.api.ts @@ -1,4 +1,3 @@ -import { defineEventHandler } from 'h3'; import { forbiddenResponse, sleep } from '~/utils/response'; export default defineEventHandler(async (event) => { diff --git a/admin/apps/backend-mock/routes/[...].ts b/admin/apps/backend-mock/routes/[...].ts index 5a22563d..99f544b6 100644 --- a/admin/apps/backend-mock/routes/[...].ts +++ b/admin/apps/backend-mock/routes/[...].ts @@ -1,5 +1,3 @@ -import { defineEventHandler } from 'h3'; - export default defineEventHandler(() => { return `

Hello Vben Admin

diff --git a/admin/apps/backend-mock/utils/cookie-utils.ts b/admin/apps/backend-mock/utils/cookie-utils.ts index 187ce2f0..78f3aab7 100644 --- a/admin/apps/backend-mock/utils/cookie-utils.ts +++ b/admin/apps/backend-mock/utils/cookie-utils.ts @@ -1,7 +1,5 @@ import type { EventHandlerRequest, H3Event } from 'h3'; -import { deleteCookie, getCookie, setCookie } from 'h3'; - export function clearRefreshTokenCookie(event: H3Event) { deleteCookie(event, 'jwt', { httpOnly: true, diff --git a/admin/apps/backend-mock/utils/jwt-utils.ts b/admin/apps/backend-mock/utils/jwt-utils.ts index 71858307..8cfc6843 100644 --- a/admin/apps/backend-mock/utils/jwt-utils.ts +++ b/admin/apps/backend-mock/utils/jwt-utils.ts @@ -1,11 +1,8 @@ import type { EventHandlerRequest, H3Event } from 'h3'; -import type { UserInfo } from './mock-data'; - -import { getHeader } from 'h3'; import jwt from 'jsonwebtoken'; -import { MOCK_USERS } from './mock-data'; +import { UserInfo } from './mock-data'; // TODO: Replace with your own secret key const ACCESS_TOKEN_SECRET = 'access_token_secret'; @@ -34,22 +31,12 @@ export function verifyAccessToken( return null; } - const tokenParts = authHeader.split(' '); - if (tokenParts.length !== 2) { - return null; - } - const token = tokenParts[1] as string; + const token = authHeader.split(' ')[1]; try { - const decoded = jwt.verify( - token, - ACCESS_TOKEN_SECRET, - ) as unknown as UserPayload; + const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET) as UserPayload; const username = decoded.username; const user = MOCK_USERS.find((item) => item.username === username); - if (!user) { - return null; - } const { password: _pwd, ...userinfo } = user; return userinfo; } catch { @@ -63,12 +50,7 @@ export function verifyRefreshToken( try { const decoded = jwt.verify(token, REFRESH_TOKEN_SECRET) as UserPayload; const username = decoded.username; - const user = MOCK_USERS.find( - (item) => item.username === username, - ) as UserInfo; - if (!user) { - return null; - } + const user = MOCK_USERS.find((item) => item.username === username); const { password: _pwd, ...userinfo } = user; return userinfo; } catch { diff --git a/admin/apps/backend-mock/utils/response.ts b/admin/apps/backend-mock/utils/response.ts index 2d4242e9..2a5a908f 100644 --- a/admin/apps/backend-mock/utils/response.ts +++ b/admin/apps/backend-mock/utils/response.ts @@ -1,7 +1,5 @@ import type { EventHandlerRequest, H3Event } from 'h3'; -import { setResponseStatus } from 'h3'; - export function useResponseSuccess(data: T) { return { code: 0, diff --git a/admin/apps/web-ele/.env.analyze b/admin/apps/web-antd/.env.analyze similarity index 100% rename from admin/apps/web-ele/.env.analyze rename to admin/apps/web-antd/.env.analyze diff --git a/admin/apps/web-ele/.env.development b/admin/apps/web-antd/.env.development similarity index 95% rename from admin/apps/web-ele/.env.development rename to admin/apps/web-antd/.env.development index 8bcb432e..c138f482 100644 --- a/admin/apps/web-ele/.env.development +++ b/admin/apps/web-antd/.env.development @@ -1,5 +1,5 @@ # 端口号 -VITE_PORT=5777 +VITE_PORT=5666 VITE_BASE=/ diff --git a/admin/apps/web-ele/.env.production b/admin/apps/web-antd/.env.production similarity index 100% rename from admin/apps/web-ele/.env.production rename to admin/apps/web-antd/.env.production diff --git a/admin/apps/web-antd/__tests__/e2e/auth-login.spec.ts b/admin/apps/web-antd/__tests__/e2e/auth-login.spec.ts new file mode 100644 index 00000000..bb6cd289 --- /dev/null +++ b/admin/apps/web-antd/__tests__/e2e/auth-login.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; + +import { authLogin } from './common/auth'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Auth Login Page Tests', () => { + test('check title and page elements', async ({ page }) => { + // 获取页面标题并断言标题包含 'Vben Admin' + const title = await page.title(); + expect(title).toContain('Vben Admin'); + }); + + // 测试用例: 成功登录 + test('should successfully login with valid credentials', async ({ page }) => { + await authLogin(page); + }); +}); diff --git a/admin/apps/web-antd/__tests__/e2e/common/auth.ts b/admin/apps/web-antd/__tests__/e2e/common/auth.ts new file mode 100644 index 00000000..26b526fd --- /dev/null +++ b/admin/apps/web-antd/__tests__/e2e/common/auth.ts @@ -0,0 +1,46 @@ +import type { Page } from '@playwright/test'; + +import { expect } from '@playwright/test'; + +export async function authLogin(page: Page) { + // 确保登录表单正常 + const usernameInput = await page.locator(`input[name='username']`); + await expect(usernameInput).toBeVisible(); + + const passwordInput = await page.locator(`input[name='password']`); + await expect(passwordInput).toBeVisible(); + + const sliderCaptcha = await page.locator(`div[name='captcha']`); + const sliderCaptchaAction = await page.locator(`div[name='captcha-action']`); + await expect(sliderCaptcha).toBeVisible(); + await expect(sliderCaptchaAction).toBeVisible(); + + // 拖动验证码滑块 + // 获取拖动按钮的位置 + const sliderCaptchaBox = await sliderCaptcha.boundingBox(); + if (!sliderCaptchaBox) throw new Error('滑块未找到'); + + const actionBoundingBox = await sliderCaptchaAction.boundingBox(); + if (!actionBoundingBox) throw new Error('要拖动的按钮未找到'); + + // 计算起始位置和目标位置 + const startX = actionBoundingBox.x + actionBoundingBox.width / 2; // div 中心的 x 坐标 + const startY = actionBoundingBox.y + actionBoundingBox.height / 2; // div 中心的 y 坐标 + + const targetX = startX + sliderCaptchaBox.width + actionBoundingBox.width; // 向右拖动容器的宽度 + const targetY = startY; // y 坐标保持不变 + + // 模拟鼠标拖动 + await page.mouse.move(startX, startY); // 移动到 action 的中心 + await page.mouse.down(); // 按下鼠标 + await page.mouse.move(targetX, targetY, { steps: 20 }); // 拖动到目标位置 + await page.mouse.up(); // 松开鼠标 + + // 在拖动后进行断言,检查action是否在预期位置, + const newActionBoundingBox = await sliderCaptchaAction.boundingBox(); + expect(newActionBoundingBox?.x).toBeGreaterThan(actionBoundingBox.x); + + // 到这里已经校验成功,点击进行登录 + await page.waitForTimeout(300); + await page.getByRole('button', { name: 'login' }).click(); +} diff --git a/admin/apps/web-ele/index.html b/admin/apps/web-antd/index.html similarity index 93% rename from admin/apps/web-ele/index.html rename to admin/apps/web-antd/index.html index 2b59b8d7..480eb84d 100644 --- a/admin/apps/web-ele/index.html +++ b/admin/apps/web-antd/index.html @@ -21,7 +21,7 @@ (function () { var hm = document.createElement('script'); hm.src = - 'https://hm.baidu.com/hm.js?97352b16ed2df8c3860cf5a1a65fb4dd'; + 'https://hm.baidu.com/hm.js?b38e689f40558f20a9a686d7f6f33edf'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(hm, s); })(); diff --git a/admin/apps/web-ele/package.json b/admin/apps/web-antd/package.json similarity index 83% rename from admin/apps/web-ele/package.json rename to admin/apps/web-antd/package.json index abbedb63..4f7b7603 100644 --- a/admin/apps/web-ele/package.json +++ b/admin/apps/web-antd/package.json @@ -1,12 +1,12 @@ { - "name": "@vben/web-ele", - "version": "5.5.9", + "name": "@vben/web-antd", + "version": "5.5.8", "homepage": "https://vben.pro", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", "repository": { "type": "git", "url": "git+https://github.com/vbenjs/vue-vben-admin.git", - "directory": "apps/web-ele" + "directory": "apps/web-antd" }, "license": "MIT", "author": { @@ -26,6 +26,8 @@ "#/*": "./src/*" }, "dependencies": { + "@tanstack/vue-query": "catalog:", + "@vben-core/menu-ui": "workspace:*", "@vben/access": "workspace:*", "@vben/common-ui": "workspace:*", "@vben/constants": "workspace:*", @@ -41,13 +43,14 @@ "@vben/types": "workspace:*", "@vben/utils": "workspace:*", "@vueuse/core": "catalog:", + "ant-design-vue": "catalog:", "dayjs": "catalog:", - "element-plus": "catalog:", + "json-bigint": "catalog:", "pinia": "catalog:", "vue": "catalog:", "vue-router": "catalog:" }, "devDependencies": { - "unplugin-element-plus": "catalog:" + "@types/json-bigint": "catalog:" } } diff --git a/admin/apps/web-ele/postcss.config.mjs b/admin/apps/web-antd/postcss.config.mjs similarity index 100% rename from admin/apps/web-ele/postcss.config.mjs rename to admin/apps/web-antd/postcss.config.mjs diff --git a/admin/apps/web-ele/public/favicon.ico b/admin/apps/web-antd/public/favicon.ico similarity index 100% rename from admin/apps/web-ele/public/favicon.ico rename to admin/apps/web-antd/public/favicon.ico diff --git a/admin/apps/web-antd/src/adapter/component/index.ts b/admin/apps/web-antd/src/adapter/component/index.ts new file mode 100644 index 00000000..094d4fc4 --- /dev/null +++ b/admin/apps/web-antd/src/adapter/component/index.ts @@ -0,0 +1,207 @@ +/** + * 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用 + * 可用于 vben-form、vben-modal、vben-drawer 等组件使用, + */ + +import type { Component } from 'vue'; + +import type { BaseFormComponentType } from '@vben/common-ui'; +import type { Recordable } from '@vben/types'; + +import { defineAsyncComponent, defineComponent, h, ref } from 'vue'; + +import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui'; +import { $t } from '@vben/locales'; + +import { notification } from 'ant-design-vue'; + +const AutoComplete = defineAsyncComponent( + () => import('ant-design-vue/es/auto-complete'), +); +const Button = defineAsyncComponent(() => import('ant-design-vue/es/button')); +const Checkbox = defineAsyncComponent( + () => import('ant-design-vue/es/checkbox'), +); +const CheckboxGroup = defineAsyncComponent(() => + import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup), +); +const DatePicker = defineAsyncComponent( + () => import('ant-design-vue/es/date-picker'), +); +const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider')); +const Input = defineAsyncComponent(() => import('ant-design-vue/es/input')); +const InputNumber = defineAsyncComponent( + () => import('ant-design-vue/es/input-number'), +); +const InputPassword = defineAsyncComponent(() => + import('ant-design-vue/es/input').then((res) => res.InputPassword), +); +const Mentions = defineAsyncComponent( + () => import('ant-design-vue/es/mentions'), +); +const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio')); +const RadioGroup = defineAsyncComponent(() => + import('ant-design-vue/es/radio').then((res) => res.RadioGroup), +); +const RangePicker = defineAsyncComponent(() => + import('ant-design-vue/es/date-picker').then((res) => res.RangePicker), +); +const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate')); +const Select = defineAsyncComponent(() => import('ant-design-vue/es/select')); +const Space = defineAsyncComponent(() => import('ant-design-vue/es/space')); +const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch')); +const Textarea = defineAsyncComponent(() => + import('ant-design-vue/es/input').then((res) => res.Textarea), +); +const TimePicker = defineAsyncComponent( + () => import('ant-design-vue/es/time-picker'), +); +const TreeSelect = defineAsyncComponent( + () => import('ant-design-vue/es/tree-select'), +); +const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload')); + +const withDefaultPlaceholder = ( + component: T, + type: 'input' | 'select', + componentProps: Recordable = {}, +) => { + return defineComponent({ + name: component.name, + inheritAttrs: false, + setup: (props: any, { attrs, expose, slots }) => { + const placeholder = + props?.placeholder || + attrs?.placeholder || + $t(`ui.placeholder.${type}`); + // 透传组件暴露的方法 + const innerRef = ref(); + // const publicApi: Recordable = {}; + expose( + new Proxy( + {}, + { + get: (_target, key) => innerRef.value?.[key], + has: (_target, key) => key in (innerRef.value || {}), + }, + ), + ); + // const instance = getCurrentInstance(); + // instance?.proxy?.$nextTick(() => { + // for (const key in innerRef.value) { + // if (typeof innerRef.value[key] === 'function') { + // publicApi[key] = innerRef.value[key]; + // } + // } + // }); + return () => + h( + component, + { ...componentProps, placeholder, ...props, ...attrs, ref: innerRef }, + slots, + ); + }, + }); +}; + +// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明 +export type ComponentType = + | 'ApiSelect' + | 'ApiTreeSelect' + | 'AutoComplete' + | 'Checkbox' + | 'CheckboxGroup' + | 'DatePicker' + | 'DefaultButton' + | 'Divider' + | 'IconPicker' + | 'Input' + | 'InputNumber' + | 'InputPassword' + | 'Mentions' + | 'PrimaryButton' + | 'Radio' + | 'RadioGroup' + | 'RangePicker' + | 'Rate' + | 'Select' + | 'Space' + | 'Switch' + | 'Textarea' + | 'TimePicker' + | 'TreeSelect' + | 'Upload' + | BaseFormComponentType; + +async function initComponentAdapter() { + const components: Partial> = { + // 如果你的组件体积比较大,可以使用异步加载 + // Button: () => + // import('xxx').then((res) => res.Button), + + ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', { + component: Select, + loadingSlot: 'suffixIcon', + modelPropName: 'value', + visibleEvent: 'onVisibleChange', + }), + ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', { + component: TreeSelect, + fieldNames: { label: 'label', value: 'value', children: 'children' }, + loadingSlot: 'suffixIcon', + modelPropName: 'value', + optionsPropName: 'treeData', + visibleEvent: 'onVisibleChange', + }), + AutoComplete, + Checkbox, + CheckboxGroup, + DatePicker, + // 自定义默认按钮 + DefaultButton: (props, { attrs, slots }) => { + return h(Button, { ...props, attrs, type: 'default' }, slots); + }, + Divider, + IconPicker: withDefaultPlaceholder(IconPicker, 'select', { + iconSlot: 'addonAfter', + inputComponent: Input, + modelValueProp: 'value', + }), + Input: withDefaultPlaceholder(Input, 'input'), + InputNumber: withDefaultPlaceholder(InputNumber, 'input'), + InputPassword: withDefaultPlaceholder(InputPassword, 'input'), + Mentions: withDefaultPlaceholder(Mentions, 'input'), + // 自定义主要按钮 + PrimaryButton: (props, { attrs, slots }) => { + return h(Button, { ...props, attrs, type: 'primary' }, slots); + }, + Radio, + RadioGroup, + RangePicker, + Rate, + Select: withDefaultPlaceholder(Select, 'select'), + Space, + Switch, + Textarea: withDefaultPlaceholder(Textarea, 'input'), + TimePicker, + TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'), + Upload, + }; + + // 将组件注册到全局共享状态中 + globalShareState.setComponents(components); + + // 定义全局共享状态中的消息提示 + globalShareState.defineMessage({ + // 复制成功消息提示 + copyPreferencesSuccess: (title, content) => { + notification.success({ + description: content, + message: title, + placement: 'bottomRight', + }); + }, + }); +} + +export { initComponentAdapter }; diff --git a/admin/apps/web-ele/src/adapter/form.ts b/admin/apps/web-antd/src/adapter/form.ts similarity index 76% rename from admin/apps/web-ele/src/adapter/form.ts rename to admin/apps/web-antd/src/adapter/form.ts index 936c3fe4..c5589aab 100644 --- a/admin/apps/web-ele/src/adapter/form.ts +++ b/admin/apps/web-antd/src/adapter/form.ts @@ -11,18 +11,25 @@ import { $t } from '@vben/locales'; async function initSetupVbenForm() { setupVbenForm({ config: { + // ant design vue组件库默认都是 v-model:value + baseModelPropName: 'value', + // 一些组件是 v-model:checked 或者 v-model:fileList modelPropNameMap: { + Checkbox: 'checked', + Radio: 'checked', + Switch: 'checked', Upload: 'fileList', - CheckboxGroup: 'model-value', }, }, defineRules: { + // 输入项目必填国际化适配 required: (value, _params, ctx) => { if (value === undefined || value === null || value.length === 0) { return $t('ui.formRules.required', [ctx.label]); } return true; }, + // 选择项目必填国际化适配 selectRequired: (value, _params, ctx) => { if (value === undefined || value === null) { return $t('ui.formRules.selectRequired', [ctx.label]); @@ -36,6 +43,5 @@ async function initSetupVbenForm() { const useVbenForm = useForm; export { initSetupVbenForm, useVbenForm, z }; - export type VbenFormSchema = FormSchema; export type { VbenFormProps }; diff --git a/admin/apps/web-antd/src/adapter/vxe-table.ts b/admin/apps/web-antd/src/adapter/vxe-table.ts new file mode 100644 index 00000000..24dfd4cd --- /dev/null +++ b/admin/apps/web-antd/src/adapter/vxe-table.ts @@ -0,0 +1,297 @@ +import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; +import type { Recordable } from '@vben/types'; + +import type { ComponentType } from './component'; + +import { h } from 'vue'; + +import { IconifyIcon } from '@vben/icons'; +import { $te } from '@vben/locales'; +import { + setupVbenVxeTable, + useVbenVxeGrid as useGrid, +} from '@vben/plugins/vxe-table'; +import { get, isFunction, isString } from '@vben/utils'; + +import { objectOmit } from '@vueuse/core'; +import { Button, Image, Popconfirm, Switch, Tag } from 'ant-design-vue'; + +import { $t } from '#/locales'; + +import { useVbenForm } from './form'; + +setupVbenVxeTable({ + configVxeTable: (vxeUI) => { + vxeUI.setConfig({ + grid: { + align: 'center', + border: false, + columnConfig: { + resizable: true, + }, + + formConfig: { + // 全局禁用vxe-table的表单配置,使用formOptions + enabled: false, + }, + minHeight: 180, + proxyConfig: { + autoLoad: true, + response: { + result: 'items', + total: 'total', + list: '', + }, + showActiveMsg: true, + showResponseMsg: false, + }, + round: true, + showOverflow: true, + size: 'small', + } as VxeTableGridOptions, + }); + + /** + * 解决vxeTable在热更新时可能会出错的问题 + */ + vxeUI.renderer.forEach((_item, key) => { + if (key.startsWith('Cell')) { + vxeUI.renderer.delete(key); + } + }); + + // 表格配置项可以用 cellRender: { name: 'CellImage' }, + vxeUI.renderer.add('CellImage', { + renderTableDefault(_renderOpts, params) { + const { column, row } = params; + return h(Image, { src: row[column.field] }); + }, + }); + + // 表格配置项可以用 cellRender: { name: 'CellLink' }, + vxeUI.renderer.add('CellLink', { + renderTableDefault(renderOpts) { + const { props } = renderOpts; + return h( + Button, + { size: 'small', type: 'link' }, + { default: () => props?.text }, + ); + }, + }); + + // 单元格渲染: Tag + vxeUI.renderer.add('CellTag', { + renderTableDefault({ options, props }, { column, row }) { + const value = get(row, column.field); + const tagOptions = options ?? [ + { color: 'success', label: $t('common.enabled'), value: 1 }, + { color: 'error', label: $t('common.disabled'), value: 0 }, + ]; + const tagItem = tagOptions.find((item) => item.value === value); + return h( + Tag, + { + ...props, + ...objectOmit(tagItem ?? {}, ['label']), + }, + { default: () => tagItem?.label ?? value }, + ); + }, + }); + + vxeUI.renderer.add('CellSwitch', { + renderTableDefault({ attrs, props }, { column, row }) { + const loadingKey = `__loading_${column.field}`; + const finallyProps = { + checkedChildren: $t('common.enabled'), + checkedValue: 1, + unCheckedChildren: $t('common.disabled'), + unCheckedValue: 0, + ...props, + checked: row[column.field], + loading: row[loadingKey] ?? false, + 'onUpdate:checked': onChange, + }; + async function onChange(newVal: any) { + row[loadingKey] = true; + try { + const result = await attrs?.beforeChange?.(newVal, row); + if (result !== false) { + row[column.field] = newVal; + } + } finally { + row[loadingKey] = false; + } + } + return h(Switch, finallyProps); + }, + }); + + /** + * 注册表格的操作按钮渲染器 + */ + vxeUI.renderer.add('CellOperation', { + renderTableDefault({ attrs, options, props }, { column, row }) { + const defaultProps = { size: 'small', type: 'link', ...props }; + let align = 'end'; + switch (column.align) { + case 'center': { + align = 'center'; + break; + } + case 'left': { + align = 'start'; + break; + } + default: { + align = 'end'; + break; + } + } + const presets: Recordable> = { + delete: { + danger: true, + text: $t('common.delete'), + }, + edit: { + text: $t('common.edit'), + }, + }; + const operations: Array> = ( + options || ['edit', 'delete'] + ) + .map((opt) => { + if (isString(opt)) { + return presets[opt] + ? { code: opt, ...presets[opt], ...defaultProps } + : { + code: opt, + text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt, + ...defaultProps, + }; + } else { + return { ...defaultProps, ...presets[opt.code], ...opt }; + } + }) + .map((opt) => { + const optBtn: Recordable = {}; + Object.keys(opt).forEach((key) => { + optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key]; + }); + return optBtn; + }) + .filter((opt) => opt.show !== false); + + function renderBtn(opt: Recordable, listen = true) { + return h( + Button, + { + ...props, + ...opt, + icon: undefined, + onClick: listen + ? () => + attrs?.onClick?.({ + code: opt.code, + row, + }) + : undefined, + }, + { + default: () => { + const content = []; + if (opt.icon) { + content.push( + h(IconifyIcon, { class: 'size-5', icon: opt.icon }), + ); + } + content.push(opt.text); + return content; + }, + }, + ); + } + + function renderConfirm(opt: Recordable) { + let viewportWrapper: HTMLElement | null = null; + return h( + Popconfirm, + { + /** + * 当popconfirm用在固定列中时,将固定列作为弹窗的容器时可能会因为固定列较窄而无法容纳弹窗 + * 将表格主体区域作为弹窗容器时又会因为固定列的层级较高而遮挡弹窗 + * 将body或者表格视口区域作为弹窗容器时又会导致弹窗无法跟随表格滚动。 + * 鉴于以上各种情况,一种折中的解决方案是弹出层展示时,禁止操作表格的滚动条。 + * 这样既解决了弹窗的遮挡问题,又不至于让弹窗随着表格的滚动而跑出视口区域。 + */ + getPopupContainer(el) { + viewportWrapper = el.closest('.vxe-table--viewport-wrapper'); + return document.body; + }, + placement: 'topLeft', + title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']), + ...props, + ...opt, + icon: undefined, + onOpenChange: (open: boolean) => { + // 当弹窗打开时,禁止表格的滚动 + if (open) { + viewportWrapper?.style.setProperty('pointer-events', 'none'); + } else { + viewportWrapper?.style.removeProperty('pointer-events'); + } + }, + onConfirm: () => { + attrs?.onClick?.({ + code: opt.code, + row, + }); + }, + }, + { + default: () => renderBtn({ ...opt }, false), + description: () => + h( + 'div', + { class: 'truncate' }, + $t('ui.actionMessage.deleteConfirm', [ + row[attrs?.nameField || 'name'], + ]), + ), + }, + ); + } + + const btns = operations.map((opt) => + opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt), + ); + return h( + 'div', + { + class: 'flex table-operations', + style: { justifyContent: align }, + }, + btns, + ); + }, + }); + + // 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化 + // vxeUI.formats.add + }, + useVbenForm, +}); + +export const useVbenVxeGrid = >( + ...rest: Parameters> +) => useGrid(...rest); + +export type OnActionClickParams> = { + code: string; + row: T; +}; +export type OnActionClickFn> = ( + params: OnActionClickParams, +) => void; +export type * from '@vben/plugins/vxe-table'; diff --git a/admin/apps/web-ele/src/api/core/auth.ts b/admin/apps/web-antd/src/api/core/auth.ts similarity index 80% rename from admin/apps/web-ele/src/api/core/auth.ts rename to admin/apps/web-antd/src/api/core/auth.ts index 71d9f994..b4627cbe 100644 --- a/admin/apps/web-ele/src/api/core/auth.ts +++ b/admin/apps/web-antd/src/api/core/auth.ts @@ -22,23 +22,29 @@ export namespace AuthApi { * 登录 */ export async function loginApi(data: AuthApi.LoginParams) { - return requestClient.post('/auth/login', data); + return requestClient.post('/auth/login', data, { + withCredentials: true, + }); } /** * 刷新accessToken */ export async function refreshTokenApi() { - return baseRequestClient.post('/auth/refresh', { - withCredentials: true, - }); + return baseRequestClient.post( + '/auth/refresh', + null, + { + withCredentials: true, + }, + ); } /** * 退出登录 */ export async function logoutApi() { - return baseRequestClient.post('/auth/logout', { + return baseRequestClient.post('/auth/logout', null, { withCredentials: true, }); } diff --git a/admin/apps/web-ele/src/api/core/index.ts b/admin/apps/web-antd/src/api/core/index.ts similarity index 100% rename from admin/apps/web-ele/src/api/core/index.ts rename to admin/apps/web-antd/src/api/core/index.ts diff --git a/admin/apps/web-ele/src/api/core/menu.ts b/admin/apps/web-antd/src/api/core/menu.ts similarity index 100% rename from admin/apps/web-ele/src/api/core/menu.ts rename to admin/apps/web-antd/src/api/core/menu.ts diff --git a/admin/apps/web-ele/src/api/core/user.ts b/admin/apps/web-antd/src/api/core/user.ts similarity index 100% rename from admin/apps/web-ele/src/api/core/user.ts rename to admin/apps/web-antd/src/api/core/user.ts diff --git a/admin/apps/web-antd/src/api/examples/download.ts b/admin/apps/web-antd/src/api/examples/download.ts new file mode 100644 index 00000000..0b4dcd36 --- /dev/null +++ b/admin/apps/web-antd/src/api/examples/download.ts @@ -0,0 +1,28 @@ +import type { RequestResponse } from '@vben/request'; + +import { requestClient } from '../request'; + +/** + * 下载文件,获取Blob + * @returns Blob + */ +async function downloadFile1() { + return requestClient.download( + 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp', + ); +} + +/** + * 下载文件,获取完整的Response + * @returns RequestResponse + */ +async function downloadFile2() { + return requestClient.download>( + 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp', + { + responseReturn: 'raw', + }, + ); +} + +export { downloadFile1, downloadFile2 }; diff --git a/admin/apps/web-antd/src/api/examples/index.ts b/admin/apps/web-antd/src/api/examples/index.ts new file mode 100644 index 00000000..c830b81f --- /dev/null +++ b/admin/apps/web-antd/src/api/examples/index.ts @@ -0,0 +1,2 @@ +export * from './status'; +export * from './table'; diff --git a/admin/apps/web-antd/src/api/examples/json-bigint.ts b/admin/apps/web-antd/src/api/examples/json-bigint.ts new file mode 100644 index 00000000..19e41e2e --- /dev/null +++ b/admin/apps/web-antd/src/api/examples/json-bigint.ts @@ -0,0 +1,10 @@ +import { requestClient } from '#/api/request'; + +/** + * 发起请求 + */ +async function getBigIntData() { + return requestClient.get('/demo/bigint'); +} + +export { getBigIntData }; diff --git a/admin/apps/web-antd/src/api/examples/params.ts b/admin/apps/web-antd/src/api/examples/params.ts new file mode 100644 index 00000000..6568ec64 --- /dev/null +++ b/admin/apps/web-antd/src/api/examples/params.ts @@ -0,0 +1,19 @@ +import type { Recordable } from '@vben/types'; + +import { requestClient } from '#/api/request'; + +/** + * 发起数组请求 + */ +async function getParamsData( + params: Recordable, + type: 'brackets' | 'comma' | 'indices' | 'repeat', +) { + return requestClient.get('/status', { + params, + paramsSerializer: type, + responseReturn: 'raw', + }); +} + +export { getParamsData }; diff --git a/admin/apps/web-antd/src/api/examples/status.ts b/admin/apps/web-antd/src/api/examples/status.ts new file mode 100644 index 00000000..4a75fe7e --- /dev/null +++ b/admin/apps/web-antd/src/api/examples/status.ts @@ -0,0 +1,10 @@ +import { requestClient } from '#/api/request'; + +/** + * 模拟任意状态码 + */ +async function getMockStatusApi(status: string) { + return requestClient.get('/status', { params: { status } }); +} + +export { getMockStatusApi }; diff --git a/admin/apps/web-antd/src/api/examples/table.ts b/admin/apps/web-antd/src/api/examples/table.ts new file mode 100644 index 00000000..4739ca98 --- /dev/null +++ b/admin/apps/web-antd/src/api/examples/table.ts @@ -0,0 +1,18 @@ +import { requestClient } from '#/api/request'; + +export namespace DemoTableApi { + export interface PageFetchParams { + [key: string]: any; + page: number; + pageSize: number; + } +} + +/** + * 获取示例表格数据 + */ +async function getExampleTableApi(params: DemoTableApi.PageFetchParams) { + return requestClient.get('/table/list', { params }); +} + +export { getExampleTableApi }; diff --git a/admin/apps/web-antd/src/api/examples/upload.ts b/admin/apps/web-antd/src/api/examples/upload.ts new file mode 100644 index 00000000..246d4f26 --- /dev/null +++ b/admin/apps/web-antd/src/api/examples/upload.ts @@ -0,0 +1,25 @@ +import { requestClient } from '#/api/request'; + +interface UploadFileParams { + file: File; + onError?: (error: Error) => void; + onProgress?: (progress: { percent: number }) => void; + onSuccess?: (data: any, file: File) => void; +} +export async function upload_file({ + file, + onError, + onProgress, + onSuccess, +}: UploadFileParams) { + try { + onProgress?.({ percent: 0 }); + + const data = await requestClient.upload('/upload', { file }); + + onProgress?.({ percent: 100 }); + onSuccess?.(data, file); + } catch (error) { + onError?.(error instanceof Error ? error : new Error(String(error))); + } +} diff --git a/admin/apps/web-antd/src/api/index.ts b/admin/apps/web-antd/src/api/index.ts new file mode 100644 index 00000000..3c3fa0d2 --- /dev/null +++ b/admin/apps/web-antd/src/api/index.ts @@ -0,0 +1,3 @@ +export * from './core'; +export * from './examples'; +export * from './system'; diff --git a/admin/apps/web-ele/src/api/request.ts b/admin/apps/web-antd/src/api/request.ts similarity index 80% rename from admin/apps/web-ele/src/api/request.ts rename to admin/apps/web-antd/src/api/request.ts index 203b35bf..e741552d 100644 --- a/admin/apps/web-ele/src/api/request.ts +++ b/admin/apps/web-antd/src/api/request.ts @@ -1,7 +1,7 @@ /** * 该文件可自行根据业务逻辑进行调整 */ -import type { RequestClientOptions } from '@vben/request'; +import type { AxiosResponseHeaders, RequestClientOptions } from '@vben/request'; import { useAppConfig } from '@vben/hooks'; import { preferences } from '@vben/preferences'; @@ -12,8 +12,10 @@ import { RequestClient, } from '@vben/request'; import { useAccessStore } from '@vben/stores'; +import { cloneDeep } from '@vben/utils'; -import { ElMessage } from 'element-plus'; +import { message } from 'ant-design-vue'; +import JSONBigInt from 'json-bigint'; import { useAuthStore } from '#/store'; @@ -25,6 +27,14 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { const client = new RequestClient({ ...options, baseURL, + transformResponse: (data: any, header: AxiosResponseHeaders) => { + // storeAsString指示将BigInt存储为字符串,设为false则会存储为内置的BigInt类型 + return header.getContentType()?.toString().includes('application/json') + ? cloneDeep( + JSONBigInt({ storeAsString: true, strict: true }).parse(data), + ) + : data; + }, }); /** @@ -99,7 +109,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { const responseData = error?.response?.data ?? {}; const errorMessage = responseData?.error ?? responseData?.message ?? ''; // 如果没有错误信息,则会根据状态码进行提示 - ElMessage.error(errorMessage || msg); + message.error(errorMessage || msg); }), ); @@ -111,3 +121,9 @@ export const requestClient = createRequestClient(apiURL, { }); export const baseRequestClient = new RequestClient({ baseURL: apiURL }); + +export interface PageFetchParams { + [key: string]: any; + pageNo?: number; + pageSize?: number; +} diff --git a/admin/apps/web-antd/src/api/system/dept.ts b/admin/apps/web-antd/src/api/system/dept.ts new file mode 100644 index 00000000..ce2b0de8 --- /dev/null +++ b/admin/apps/web-antd/src/api/system/dept.ts @@ -0,0 +1,54 @@ +import { requestClient } from '#/api/request'; + +export namespace SystemDeptApi { + export interface SystemDept { + [key: string]: any; + children?: SystemDept[]; + id: string; + name: string; + remark?: string; + status: 0 | 1; + } +} + +/** + * 获取部门列表数据 + */ +async function getDeptList() { + return requestClient.get>( + '/system/dept/list', + ); +} + +/** + * 创建部门 + * @param data 部门数据 + */ +async function createDept( + data: Omit, +) { + return requestClient.post('/system/dept', data); +} + +/** + * 更新部门 + * + * @param id 部门 ID + * @param data 部门数据 + */ +async function updateDept( + id: string, + data: Omit, +) { + return requestClient.put(`/system/dept/${id}`, data); +} + +/** + * 删除部门 + * @param id 部门 ID + */ +async function deleteDept(id: string) { + return requestClient.delete(`/system/dept/${id}`); +} + +export { createDept, deleteDept, getDeptList, updateDept }; diff --git a/admin/apps/web-antd/src/api/system/index.ts b/admin/apps/web-antd/src/api/system/index.ts new file mode 100644 index 00000000..f2a248f1 --- /dev/null +++ b/admin/apps/web-antd/src/api/system/index.ts @@ -0,0 +1,3 @@ +export * from './dept'; +export * from './menu'; +export * from './role'; diff --git a/admin/apps/web-antd/src/api/system/menu.ts b/admin/apps/web-antd/src/api/system/menu.ts new file mode 100644 index 00000000..507a5aec --- /dev/null +++ b/admin/apps/web-antd/src/api/system/menu.ts @@ -0,0 +1,158 @@ +import type { Recordable } from '@vben/types'; + +import { requestClient } from '#/api/request'; + +export namespace SystemMenuApi { + /** 徽标颜色集合 */ + export const BadgeVariants = [ + 'default', + 'destructive', + 'primary', + 'success', + 'warning', + ] as const; + /** 徽标类型集合 */ + export const BadgeTypes = ['dot', 'normal'] as const; + /** 菜单类型集合 */ + export const MenuTypes = [ + 'catalog', + 'menu', + 'embedded', + 'link', + 'button', + ] as const; + /** 系统菜单 */ + export interface SystemMenu { + [key: string]: any; + /** 后端权限标识 */ + authCode: string; + /** 子级 */ + children?: SystemMenu[]; + /** 组件 */ + component?: string; + /** 菜单ID */ + id: string; + /** 菜单元数据 */ + meta?: { + /** 激活时显示的图标 */ + activeIcon?: string; + /** 作为路由时,需要激活的菜单的Path */ + activePath?: string; + /** 固定在标签栏 */ + affixTab?: boolean; + /** 在标签栏固定的顺序 */ + affixTabOrder?: number; + /** 徽标内容(当徽标类型为normal时有效) */ + badge?: string; + /** 徽标类型 */ + badgeType?: (typeof BadgeTypes)[number]; + /** 徽标颜色 */ + badgeVariants?: (typeof BadgeVariants)[number]; + /** 在菜单中隐藏下级 */ + hideChildrenInMenu?: boolean; + /** 在面包屑中隐藏 */ + hideInBreadcrumb?: boolean; + /** 在菜单中隐藏 */ + hideInMenu?: boolean; + /** 在标签栏中隐藏 */ + hideInTab?: boolean; + /** 菜单图标 */ + icon?: string; + /** 内嵌Iframe的URL */ + iframeSrc?: string; + /** 是否缓存页面 */ + keepAlive?: boolean; + /** 外链页面的URL */ + link?: string; + /** 同一个路由最大打开的标签数 */ + maxNumOfOpenTab?: number; + /** 无需基础布局 */ + noBasicLayout?: boolean; + /** 是否在新窗口打开 */ + openInNewWindow?: boolean; + /** 菜单排序 */ + order?: number; + /** 额外的路由参数 */ + query?: Recordable; + /** 菜单标题 */ + title?: string; + }; + /** 菜单名称 */ + name: string; + /** 路由路径 */ + path: string; + /** 父级ID */ + pid: string; + /** 重定向 */ + redirect?: string; + /** 菜单类型 */ + type: (typeof MenuTypes)[number]; + } +} + +/** + * 获取菜单数据列表 + */ +async function getMenuList() { + return requestClient.get>( + '/system/menu/list', + ); +} + +async function isMenuNameExists( + name: string, + id?: SystemMenuApi.SystemMenu['id'], +) { + return requestClient.get('/system/menu/name-exists', { + params: { id, name }, + }); +} + +async function isMenuPathExists( + path: string, + id?: SystemMenuApi.SystemMenu['id'], +) { + return requestClient.get('/system/menu/path-exists', { + params: { id, path }, + }); +} + +/** + * 创建菜单 + * @param data 菜单数据 + */ +async function createMenu( + data: Omit, +) { + return requestClient.post('/system/menu', data); +} + +/** + * 更新菜单 + * + * @param id 菜单 ID + * @param data 菜单数据 + */ +async function updateMenu( + id: string, + data: Omit, +) { + return requestClient.put(`/system/menu/${id}`, data); +} + +/** + * 删除菜单 + * @param id 菜单 ID + */ +async function deleteMenu(id: string) { + return requestClient.delete(`/system/menu/${id}`); +} + +export { + createMenu, + deleteMenu, + getMenuList, + isMenuNameExists, + isMenuPathExists, + updateMenu, +}; diff --git a/admin/apps/web-antd/src/api/system/role.ts b/admin/apps/web-antd/src/api/system/role.ts new file mode 100644 index 00000000..60b465ae --- /dev/null +++ b/admin/apps/web-antd/src/api/system/role.ts @@ -0,0 +1,55 @@ +import type { Recordable } from '@vben/types'; + +import { requestClient } from '#/api/request'; + +export namespace SystemRoleApi { + export interface SystemRole { + [key: string]: any; + id: string; + name: string; + permissions: string[]; + remark?: string; + status: 0 | 1; + } +} + +/** + * 获取角色列表数据 + */ +async function getRoleList(params: Recordable) { + return requestClient.get>( + '/system/role/list', + { params }, + ); +} + +/** + * 创建角色 + * @param data 角色数据 + */ +async function createRole(data: Omit) { + return requestClient.post('/system/role', data); +} + +/** + * 更新角色 + * + * @param id 角色 ID + * @param data 角色数据 + */ +async function updateRole( + id: string, + data: Omit, +) { + return requestClient.put(`/system/role/${id}`, data); +} + +/** + * 删除角色 + * @param id 角色 ID + */ +async function deleteRole(id: string) { + return requestClient.delete(`/system/role/${id}`); +} + +export { createRole, deleteRole, getRoleList, updateRole }; diff --git a/admin/apps/web-antd/src/app.vue b/admin/apps/web-antd/src/app.vue new file mode 100644 index 00000000..bbaccce1 --- /dev/null +++ b/admin/apps/web-antd/src/app.vue @@ -0,0 +1,39 @@ + + + diff --git a/admin/apps/web-ele/src/bootstrap.ts b/admin/apps/web-antd/src/bootstrap.ts similarity index 76% rename from admin/apps/web-ele/src/bootstrap.ts rename to admin/apps/web-antd/src/bootstrap.ts index e5befb5a..f0a668b4 100644 --- a/admin/apps/web-ele/src/bootstrap.ts +++ b/admin/apps/web-antd/src/bootstrap.ts @@ -5,17 +5,16 @@ import { registerLoadingDirective } from '@vben/common-ui'; import { preferences } from '@vben/preferences'; import { initStores } from '@vben/stores'; import '@vben/styles'; -import '@vben/styles/ele'; +import '@vben/styles/antd'; import { useTitle } from '@vueuse/core'; -import { ElLoading } from 'element-plus'; import { $t, setupI18n } from '#/locales'; +import { router } from '#/router'; import { initComponentAdapter } from './adapter/component'; import { initSetupVbenForm } from './adapter/form'; import App from './app.vue'; -import { router } from './router'; async function bootstrap(namespace: string) { // 初始化组件适配器 @@ -24,22 +23,20 @@ async function bootstrap(namespace: string) { // 初始化表单组件 await initSetupVbenForm(); - // // 设置弹窗的默认配置 + // 设置弹窗的默认配置 // setDefaultModalProps({ // fullscreenButton: false, // }); - // // 设置抽屉的默认配置 + // 设置抽屉的默认配置 // setDefaultDrawerProps({ - // zIndex: 2000, + // zIndex: 1020, // }); + const app = createApp(App); - // 注册Element Plus提供的v-loading指令 - app.directive('loading', ElLoading.directive); - - // 注册Vben提供的v-loading和v-spinning指令 + // 注册v-loading指令 registerLoadingDirective(app, { - loading: false, // Vben提供的v-loading指令和Element Plus提供的v-loading指令二选一即可,此处false表示不注册Vben提供的v-loading指令 + loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令 spinning: 'spinning', }); @@ -59,6 +56,10 @@ async function bootstrap(namespace: string) { // 配置路由及路由守卫 app.use(router); + // 配置@tanstack/vue-query + const { VueQueryPlugin } = await import('@tanstack/vue-query'); + app.use(VueQueryPlugin); + // 配置Motion插件 const { MotionPlugin } = await import('@vben/plugins/motion'); app.use(MotionPlugin); diff --git a/admin/apps/web-ele/src/layouts/auth.vue b/admin/apps/web-antd/src/layouts/auth.vue similarity index 91% rename from admin/apps/web-ele/src/layouts/auth.vue rename to admin/apps/web-antd/src/layouts/auth.vue index 18d415bc..c33a632a 100644 --- a/admin/apps/web-ele/src/layouts/auth.vue +++ b/admin/apps/web-antd/src/layouts/auth.vue @@ -8,6 +8,7 @@ import { $t } from '#/locales'; const appName = computed(() => preferences.app.name); const logo = computed(() => preferences.logo.source); +const clickLogo = () => {};