init: test-frontend 初始化,基于 ghb-base [AI: Claude Code]

This commit is contained in:
zwt13703 2026-07-05 17:30:29 +08:00
parent 09892e90e3
commit 03afbe85f8
1699 changed files with 267672 additions and 0 deletions

217
.claude/backend.md Normal file
View File

@ -0,0 +1,217 @@
# 02 — 后端规则
> **适用**:所有后端 AI 开发任务。
---
## 分层不变式
```
Controller → 参数校验 + 调 Service + 返回统一格式
禁止:写业务逻辑、直接调 Mapper、处理事务
Service → 业务逻辑 + 事务管理
禁止:处理 HTTP 请求/响应、直接操作 Request/Response
Mapper → 数据访问MyBatis-Plus BaseMapper
禁止:写业务逻辑、调用其他 Mapper
Entity → 数据映射(@TableName 对应表名)
禁止:写业务方法、放非持久化字段(放 DTO/VO
```
### 包结构
```
com.ghb.base/
├── system/ # 系统模块(不改)
│ ├── entity/ SysUser, SysRole, SysMenu...
│ ├── controller/
│ └── service/
└── business/ # 业务模块 ← 你的代码放这里
├── entity/ # 继承 JeecgBoot 的 BaseEntity
├── controller/ # 继承 JeecgController
├── service/ # impl/ 继承 ServiceImpl
├── mapper/ # 继承 BaseMapper
│ └── xml/ # MyBatis XML复杂查询才需要
└── vo/ # 请求/响应 DTO (*VO, *Request, *ImportRow)
```
---
## 命名规则
| 元素 | 规则 | 示例(正确 → 错误) |
|------|------|---------------------|
| **类名** | 大驼峰,名词 | `TaskController` `OrderService` `UserMapper` |
| **方法名** | 小驼峰,动词开头 | `getById()` `saveTask()` `listByStatus()` |
| **变量** | 小驼峰,有意义 | `taskList` `userId` → ❌ `list` `id` `data` |
| **包名** | 全小写,点分隔 | `com.xxx.server.controller` |
| **Entity** | 大驼峰,对应表名 | 表 `task_apply` → 类 `TaskApply` |
| **DTO** | 大驼峰,后缀 DTO/VO/Request | `TaskCreateRequest` `UserListVO` |
| **常量** | 全大写,下划线分隔 | `MAX_RETRY_COUNT` `DEFAULT_PAGE_SIZE` |
| **SQL 表名** | 小写,下划线分隔 | `task_apply` `user_profile` |
| **SQL 字段** | 小写,下划线分隔 | `create_time` `task_id` `del_flag` |
| **URL 路径** | 小写,短横线或斜杠 | `/app/task/list` → ❌ `/app/Task/GetList` |
| **配置文件** | kebab-case 或小写 | `application-prod.yml` |
### 长度控制
类名、方法名、表名不要过长。超过 3 个单词或 30 个字符时,用通用缩写。
**前缀也要缩**:模块前缀用一个单词,别堆多个词。
| 完整 | 缩写 | JeecgBoot 实际案例 |
|------|------|-------------------|
| Department | Dept | `SysDepartRolePermission``SysDeptRolePerm` |
| Permission | Perm | 同上 |
| Announcement | Notice | `SysAnnouncementSend``SysNoticeSend` |
| Enterprise | Ent | `WechatEnterprise``WxEnt` |
| Message | Msg | `SysMessageTemplate``SysMsgTemplate` |
```java
// ❌ JeecgBoot 原版 — 太长
SysDepartRolePermissionServiceImpl // 35 字符
SysAnnouncementSendServiceImpl // 30 字符
ThirdAppWechatEnterpriseServiceImpl // 35 字符
// ✅ 缩写后
SysDeptRolePermServiceImpl // 26 字符
SysNoticeSendServiceImpl // 25 字符
ThirdAppWxEntServiceImpl // 27 字符
```
**原则**:优先用全称,超长再缩写。缩写必须一眼能认出含义,不自造别人看不懂的缩写。
---
## 注释规则
| 位置 | 要求 | 示例 |
|------|------|------|
| **类/接口** | 必须有 Javadoc说明职责 | `/** 任务管理 Controller处理任务的增删改查 */` |
| **公共方法** | 必须有 Javadoc说明入参/返回/异常 | `/** @param taskId 任务ID @return 任务详情 @throws 无 */` |
| **复杂逻辑** | 行内注释说明"为什么这么做" | `// 先查缓存再查库,避免击穿` |
| **常量** | 必须有注释说明含义 | `/** 最大重试次数 */` |
| **TODO/FIXME** | 必须有负责人和日期 | `// TODO(yaoshuli 2026-06) 后续改为配置项` |
| **禁止** | ❌ 注释写"做了什么"(代码本身已说明) | `// 查询任务列表` ← 删掉 |
| **禁止** | ❌ 注释掉的旧代码 | 直接删Git 有历史 |
| **禁止** | ❌ 无意义注释 | `// 定义一个变量` `// 返回结果` |
| **禁止** | ❌ Controller 方法加 Javadoc 说"这是xxx接口" | 写业务含义,不写技术废话 |
### 注释示例
```java
/** 任务服务,负责任务的创建、分配、状态流转 */
@Service
public class TaskServiceImpl implements TaskService {
/** 单次批量操作上限 */
private static final int BATCH_LIMIT = 100;
/**
* 根据状态和截止时间查询待处理任务。
* 先查 Redis 缓存,未命中再查 MySQL。
*
* @param status 任务状态,不能为 null
* @param deadline 截止时间,只查此时间之前的任务
* @return 任务列表,可能为空
*/
@Override
public List<Task> listPending(String status, LocalDateTime deadline) {
// 缓存 key: pending:{status}
String cacheKey = "pending:" + status;
List<Task> cached = cacheService.get(cacheKey);
if (cached != null) return cached;
// 查库(这里用复合索引 idx_status_deadline
List<Task> tasks = taskMapper.selectByStatusBefore(status, deadline);
cacheService.set(cacheKey, tasks, 300);
return tasks;
}
}
```
## 新增功能标准步骤
```
1. 建表 SQL → [项目数据库脚本目录]
2. Entity → 表映射
3. Mapper → 继承 BaseMapper
4. Service 接口+实现 → 业务逻辑
5. Controller → 接口暴露
6. 编译验证 → 构建通过
```
---
## CRUD 模板(填空式)
```java
// ===== 分页列表 =====
@GetMapping("/list")
public Result<IPage<Xxx>> list(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
Page<Xxx> p = new Page<>(page, size);
// [填空] 构建查询条件
return Result.ok(service.page(p, wrapper));
}
// ===== 详情 =====
@GetMapping("/{id}")
public Result<Xxx> detail(@PathVariable Long id) {
Xxx entity = service.getById(id);
if (entity == null) return Result.error("[资源]不存在");
return Result.ok(entity);
}
// ===== 新增 =====
@PostMapping
public Result<?> create(@RequestBody @Valid XxxDto dto) {
// [填空] DTO → Entity 转换
service.save(entity);
return Result.ok();
}
// ===== 更新 =====
@PutMapping("/{id}")
public Result<?> update(@PathVariable Long id, @RequestBody @Valid XxxDto dto) {
Xxx entity = service.getById(id);
if (entity == null) return Result.error("[资源]不存在");
// [填空] 字段更新
service.updateById(entity);
return Result.ok();
}
```
---
## 禁止清单
| 类别 | ❌ 禁止 |
|------|--------|
| **分层** | Controller 里写 if/else 业务判断 |
| **分层** | Controller 直接调 Mapper/Repository |
| **分层** | Entity 里写业务方法 |
| **数据** | SQL/查询条件字符串拼接 |
| **数据** | 查不到不判空,直接返回可能 NPE |
| **数据** | `selectOne` / `getOne` 不做唯一性约束 |
| **输入** | 入参不校验(该用 `@Valid` 的地方手撸 if |
| **输出** | 返回裸对象、裸字符串、自造格式 |
| **值** | 代码里硬编码魔法数字、魔法字符串 |
| **事务** | 该加事务的地方漏加 |
---
## 检查表
每完成一个后端任务,逐项自检:
- [ ] 编译通过(`mvn compile` / `go build` / 对应构建命令)
- [ ] 所有返回值是统一格式,无裸返回
- [ ] 分页接口格式正确(`list/total/pages/current/size`
- [ ] Controller 方法体不超过 20 行(超过说明写了业务逻辑)
- [ ] 查不到/null 路径有明确的 `fail` 返回
- [ ] 改了接口 → 已 grep 同步所有前端调用方
- [ ] 新增/改字段 → 已更新数据库脚本
- [ ] 无禁止清单中的违规项

108
.claude/contract.md Normal file
View File

@ -0,0 +1,108 @@
# 01 — 接口契约
> **这是前后端共享的唯一真相来源。** 修改本文件 = 修改协议,必须同步更新所有端。
---
## 契约生命周期(任务拆分时决定)
契约由**任务拆分时先开工的那一端**负责起草。这是任务产出物之一,不是额外工作。
```
任务拆分阶段:
┌─ 任务A后端-用户管理CRUD ──→ 产出:接口契约(后端起草)
│ + 后端代码
└─ 任务B前端-用户管理页面 ──→ 拿到契约后对接
反过来:
┌─ 任务A前端-首页改版 ──→ 产出:接口契约(前端起草)
│ + 前端页面
└─ 任务B后端-首页数据接口 ──→ 按契约实现
```
**铁律**
- 拆分任务时明确标注:**本任务包含接口契约起草**
- 契约随任务一起交付,不单独走审批
- 后开工的一方拿到契约后,发现问题直接跟起草方沟通修改
- 联调时对照本文件,不一致的以本文件为准
### 分仓库时契约放哪
```
Monorepo推荐 分仓库:
项目根/ 后端仓库/ 前端仓库/
└── rules/ ├── rules/ ├── rules/
└── 01-接口契约.md │ └── 01-接口契约.md │ └── 01-接口契约.md
← git submodule 引用同一份 →
```
---
## 返回格式(不变式)
```json
// ✅ 唯一正确格式
{ "code": 200, "message": "success", "data": { ... } }
{ "code": 500, "message": "错误原因", "data": null }
// ❌ 永远不许出现
return data; // 裸对象
return "ok"; // 裸字符串
{ "success": true, "result": {...} } // 自造格式
```
---
## 分页格式(不变式)
```json
{ "list": [...], "total": 100, "pages": 10, "current": 1, "size": 10 }
```
---
## 接口命名约定
| 前缀 | 用途 | 示例 |
|------|------|------|
| `/admin/**` | 管理后台 | `/admin/user/list` |
| `/app/**` | 移动端/H5/小程序 | `/app/task/list` |
| `/auth/**` | 登录注册(共享) | `/auth/login` |
| `/open/**` | 对外开放接口 | `/open/callback` |
---
## 状态值映射表
**← 改这里 = 改协议,必须同步后端 + 所有前端。**
| 含义 | 后端值 | 前端显示 | 前端 CSS 类 |
|------|--------|----------|------------|
| [状态1] | [值] | [文字] | [class] |
| [状态2] | [值] | [文字] | [class] |
---
## 字段名桥接表
同一个概念在不同端字段名不同时,在此登记:
| 概念 | 后端字段 | 前端A字段 | 前端B字段 |
|------|----------|-----------|-----------|
| | | | |
---
## 契约同步铁律
```
改后端接口 → grep 所有前端 → 逐个同步 → 两端都通过才算完成
步骤:
1. 改之前grep -r "接口路径" 所有前端目录/
2. 改后端 → 编译通过
3. 逐个改前端 → 构建通过
4. 更新本文件的映射表(如有新增状态值/字段差异)
```

175
.claude/database.md Normal file
View File

@ -0,0 +1,175 @@
# 04 — 数据库表设计规则
> **适用**:所有涉及建表、改表、索引设计的 AI 开发任务。
---
## 命名规则
| 元素 | 规则 | 示例(正确 → 错误) |
| -------- | ------------- | -------------------------------------------------------- |
| **表名** | 小写,下划线分隔,单数名词 | `task_apply` → ❌ `TaskApply` `task_applies` |
| **字段名** | 小写,下划线分隔 | `create_time` `task_id` → ❌ `createdAt` `taskId` |
| **主键** | 统一用 `id` | `id` → ❌ `task_id` `pk_id` |
| **外键** | `关联表名_id` | `task_id` `user_id` → ❌ `tid` `uid` |
| **布尔字段** | `del_flag` 或明确业务含义的 `is_` 前缀 | `del_flag` `is_active` → ❌ `deleted` `status` |
| **时间字段** | `_time` 后缀 | `create_time` `update_time` → ❌ `created_at` `updateTime` |
| **金额字段** | `_amount` 后缀 | `reward_amount` → ❌ `reward` `price` |
| **索引名** | `idx_表名_字段` | `idx_task_status` → ❌ `index1` `task_status_idx` |
| **唯一索引** | `uk_表名_字段` | `uk_user_phone` → ❌ `uq_phone` |
| **关联表** | 两表名用下划线连接 | `task_apply` `user_role` → ❌ `apply_task` |
### 长度控制
表名超过 3 个单词或 30 个字符时缩写。前缀也要缩成一个单词。
| 完整 | 缩写 | JeecgBoot 实际案例 |
|------|------|-------------------|
| department | dept | `sys_depart_role_permission``sys_dept_role_perm` |
| permission | perm | 同上 |
| announcement | notice | `sys_announcement_send``sys_notice_send` |
| message | msg | `sys_message_template``sys_msg_template` |
```sql
-- ❌ JeecgBoot 原版 — 太长
sys_depart_role_permission -- 26 字符
sys_permission_data_rule -- 24 字符
-- ✅ 缩写后
sys_dept_role_perm -- 19 字符
sys_perm_data_rule -- 20 字符
```
---
## 字段类型规范
| 场景 | 类型 | 说明 |
|------|------|------|
| 主键 | `BIGINT` 自增 或 `VARCHAR(32)` | 推荐 BIGINT 自增分布式用雪花ID |
| 短文本 | `VARCHAR(N)` | 姓名(50)、标题(200)、URL(500) |
| 长文本 | `TEXT` / `LONGTEXT` | 文章、JSON、描述 |
| 金额 | `DECIMAL(12,2)` | **禁止**用 FLOAT/DOUBLE |
| 状态/类型 | `VARCHAR(20)``TINYINT` | 枚举值,必须有注释说明 |
| 布尔 | `TINYINT(1)` | 0=否 1=是,注释必须写清含义 |
| 时间 | `DATETIME` | **禁止**用 TIMESTAMP2038 问题) |
| 日期 | `DATE` | 生日、截止日期 |
| 数量 | `INT``BIGINT` | |
---
## 每张表必须有的字段
```sql
CREATE TABLE xxx (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
-- [业务字段]
create_by VARCHAR(32) NULL DEFAULT NULL COMMENT '创建人',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(32) NULL DEFAULT NULL COMMENT '更新人',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
del_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除0-正常 1-已删除',
INDEX idx_xxx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表说明';
```
**强制要求**
- `create_by` — 创建人,建议保留;系统自动填充时可为空
- `create_time` — 创建时间,必须有
- `update_by` — 更新人,建议保留;系统自动填充时可为空
- `update_time` — 更新时间,必须有(`ON UPDATE CURRENT_TIMESTAMP` 自动更新)
- `del_flag` — 逻辑删除标记,除非明确不需要软删除
- 每张表必须有 `COMMENT`
- 引擎统一 `InnoDB`,字符集 `utf8mb4`
---
## 索引规则
| 规则 | 说明 |
|------|------|
| **主键** | 每张表必须有,推荐自增 BIGINT |
| **外键** | 必须加索引MySQL 自动加,但显式声明更清晰) |
| **查询条件** | WHERE 条件字段必须加索引 |
| **排序字段** | ORDER BY 字段考虑加索引 |
| **联合索引** | 遵循最左前缀原则,区分度高的在前 |
| **唯一约束** | 业务唯一字段必须加唯一索引 |
| **禁止** | ❌ 不加索引的 WHERE / JOIN / ORDER BY |
| **禁止** | ❌ 在大字段TEXT/BLOB上建索引 |
| **禁止** | ❌ 过多索引(单表建议 ≤ 5 个) |
### 索引命名
```sql
INDEX idx_表名_字段 -- 普通索引
UNIQUE uk_表名_字段 -- 唯一索引
INDEX idx_表名_字段1_字段2 -- 联合索引
```
---
## 字段约束规则
| 规则 | 示例 |
|------|------|
| 主键 | `PRIMARY KEY``NOT NULL AUTO_INCREMENT` |
| 非空 | 业务必填字段 `NOT NULL` |
| 默认值 | 有默认值的字段 `DEFAULT xxx`**禁止**依赖代码设默认值 |
| 唯一 | 业务唯一字段 `UNIQUE` |
| 外键 | 尽量用逻辑外键(代码维护),**不推荐**物理外键(`FOREIGN KEY` |
---
## 禁止清单
| ❌ 禁止 | 原因 |
|---------|------|
| 表名/字段名用大写或驼峰 | 跨平台兼容 |
| 金额用 FLOAT/DOUBLE | 精度丢失 |
| 时间用 TIMESTAMP | 2038 年溢出 |
| 用物理外键 | 分库分表/数据迁移困难 |
| 不加注释 | 无人知道字段含义 |
| 字符串代替布尔 | 空间浪费,索引效率低 |
| 大表无索引 | 性能灾难 |
| 字段用 NULL 代替默认值 | 查询需额外处理 `IS NULL` |
| 在代码里设默认值 | 数据一致性依赖应用层 |
---
## 修改表规则
```sql
-- ✅ 正确:显式命名约束
ALTER TABLE task ADD COLUMN priority TINYINT NOT NULL DEFAULT 0 COMMENT '优先级0-普通 1-紧急';
ALTER TABLE task ADD INDEX idx_task_priority (priority);
-- ❌ 禁止:不写 COMMENT
ALTER TABLE task ADD COLUMN priority TINYINT;
-- ❌ 禁止:不写默认值导致存量数据为 NULL
ALTER TABLE task ADD COLUMN priority TINYINT NOT NULL;
```
**修改表必须**
- 新字段有 `COMMENT`
- 非空字段有 `DEFAULT`
- 考虑对存量数据的影响
- 附带回滚 SQL
---
## 检查表
每涉及建表/改表,逐项自检:
- [ ] 表名、字段名全小写+下划线
- [ ] 有 `create_by` `create_time` `update_by` `update_time`
- [ ] 软删除字段 `del_flag`(如需要)
- [ ] 每张表有 `COMMENT`,每个字段有 `COMMENT`
- [ ] 金额用 `DECIMAL`,时间用 `DATETIME`
- [ ] 主键 + 外键 + WHERE 条件字段有索引
- [ ] 非空字段有 `NOT NULL DEFAULT`
- [ ] 无物理外键
- [ ] 索引命名 `idx_表名_字段` / `uk_表名_字段`
- [ ] 附带回滚 SQL
- [ ] 引擎 InnoDB字符集 utf8mb4

44
.claude/encoding.md Normal file
View File

@ -0,0 +1,44 @@
# 编码规范(字符编码)
> 本规则因一次事故而立:批量改包名时用了非 UTF-8 感知的脚本,把中文注释整成乱码(`鐗堟湰`=版本、`寰湇鍔`=微服务),且引入 BOM。以下为硬性约束。
## 不变式(违反即 Bug
1. **所有源码/配置/文本文件一律 UTF-8 编码**`.java` `.xml` `.yml` `.yaml` `.properties` `.sql` `.md` `.vue` `.ts` `.js` `.json` 等。
2. **禁止 BOM**UTF-8 文件不得带 BOM`EF BB BF`。BOM 会让 XML/YAML 解析、shell 脚本、diff 出错。
3. **禁止 GBK / GB2312 / GB18030 / Latin1 落盘**:任何环节(编辑器、脚本、终端重定向)都不得以非 UTF-8 写文件。
4. **禁止用非 UTF-8 感知的工具批量改写文本**:如必须批量替换,工具必须显式按 UTF-8 读、UTF-8 写Python 用 `io.open(p, encoding='utf-8')``sed`/`grep` 在 UTF-8 locale 下运行)。
5. **换行**:统一 LF`\n``.gitattributes` 固化,避免 CRLF 混入触发整文件 diff。
## 构建/工程配置(必须存在)
- **Maven**:父 pom 设
```xml
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
```
- **`.editorconfig`**(后端仓库和前端仓库根目录各一个 `.editorconfig` 文件):
```
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
```
- **前端**Vite/Node 默认 UTF-8源文件保持 UTF-8 无 BOM 即可。
- **Windows 终端**PowerShell 默认输出可能是 GBK**不要用 `>`/`>>` 重定向把含中文的内容写进源文件**(会按当前代码页编码)。要写文件用 UTF-8 感知的程序,不要靠 shell 重定向。
## 处理已损坏文件mojibake 还原)
经典双重编码UTF-8 被当 GBK 再存 UTF-8可逆转`坏文本.encode('gbk').decode('utf-8')`。
但若 mojibake 中已出现 `?`0x3F该字节已丢失、不可逆只能按上下文重写注释。
## 检查表(提交前)
- [ ] 新增/修改的文本文件是 UTF-8 无 BOM`file *.xml` 或编辑器状态栏确认)
- [ ] 没有出现 `鐗堟湰`/`寰湇`/`锛` 这类乱码片段
- [ ] 仓库根有 `.editorconfig`charset=utf-8
- [ ] 父 pom 有 `project.build.sourceEncoding=UTF-8`
- [ ] 批量文本替换用的是 UTF-8 感知工具,不是裸 `>` 重定向

199
.claude/frontend.md Normal file
View File

@ -0,0 +1,199 @@
# 03 — 前端规则
> **适用**:所有前端 AI 开发任务Web / H5 / 小程序)。
---
## 样式铁律
**禁止在组件文件中硬编码**:颜色值(`#xxx` / `rgb()`)、字号(`px` / `rpx` / `rem`)、间距(`margin` / `padding` 数值)、圆角(`border-radius` 数值)。
所有样式必须来自:
1. **Design Token 文件** → 变量(`$color-primary`
2. **全局 CSS 类** → 语义类(`.text-h1` `.card` `.gap-2`
3. **UI 框架内置类** → Tailwind / Ant Design 等
```vue
<!-- ❌ 禁止 -->
<style scoped>
.title { color: #333; font-size: 32rpx; margin: 20rpx; }
<!-- ✅ 正确 -->
<style scoped>
.title { color: $color-gray-800; @extend .text-h2; margin: $space-3; }
```
---
## 命名规则
| 元素 | 规则 | 示例(正确 → 错误) |
|------|------|---------------------|
| **组件文件** | 短横线分隔kebab-case | `task-card.vue` → ❌ `TaskCard.vue` |
| **页面文件** | 小写或短横线 | `task-detail.vue` `index.vue` |
| **组件 name** | 大驼峰PascalCase | `TaskCard` `UserAvatar` |
| **CSS 类名** | 短横线分隔 | `.task-card` `.btn-primary` → ❌ `.taskCard` |
| **JS 变量** | 小驼峰 | `taskList` `userName` → ❌ `task_list` |
| **JS 常量** | 全大写,下划线分隔 | `MAX_PAGE_SIZE` `API_BASE_URL` |
| **JS 函数** | 小驼峰,动词开头 | `fetchTasks()` `handleSubmit()` |
| **API 方法** | 小驼峰,动词+名词 | `taskApi.list()` `taskApi.create()` |
| **路由路径** | 小写,短横线 | `/task-detail` → ❌ `/taskDetail` |
| **文件目录** | 小写或短横线 | `task-hall/` `user-center/` |
| **静态资源** | 短横线分隔 | `icon-phone.svg` `bg-home.jpg` |
---
## 注释规则
| 位置 | 要求 | 示例 |
|------|------|------|
| **组件** | 顶部注释说明用途和 Props | `<!-- 任务卡片组件,用于列表展示 -->` |
| **复杂逻辑** | 行内注释说明"为什么" | `// 先查本地缓存,未命中再请求接口` |
| **计算属性** | 注释说明计算依据 | `// 根据任务状态和截止时间计算紧急程度` |
| **API 方法** | JSDoc 说明入参/返回 | `@param {Object} params @returns {Promise<{list,total}>}` |
| **TODO/FIXME** | 负责人+日期 | `// TODO(yaoshuli 2026-06) 待产品确认逻辑` |
| **禁止** | ❌ 注释写"做了什么" | `<!-- 循环渲染列表 -->` ← 删掉 |
| **禁止** | ❌ 注释掉的旧代码/旧模板 | 直接删Git 有历史 |
| **禁止** | ❌ 无意义分隔注释 | `// ========== 数据获取 ==========` |
```vue
<!--
任务详情页面
- 展示任务基本信息、报名列表、结算记录
- 支持接单、提交、结算等操作
-->
<template>
<app-layout>
<!-- 任务信息卡片 -->
<m-card>
...
</m-card>
<!-- 仅管理员可见 -->
<m-card v-if="isAdmin">
...
</m-card>
</app-layout>
</template>
<script setup>
/**
* 提交任务成果。
* 提交成功后自动刷新列表并发送通知。
*
* @param {string} taskId - 任务ID
* @param {Object} payload - 提交内容 { files, remark }
* @returns {Promise<void>}
*/
const handleSubmit = async (taskId, payload) => {
// 先本地校验,避免无效请求
if (!payload.files.length) return;
await taskApi.submit(taskId, payload);
// TODO(yaoshuli 2026-06) 成功后应跳转到提交记录页
router.push('/task-list');
};
</script>
```
---
## 组件使用铁律
| 场景 | ✅ 必须用 | ❌ 禁止 |
|------|----------|---------|
| 页面外壳 | 项目统一布局组件 | 自己写页头/侧栏/底栏/TabBar |
| 图标 | 项目统一图标组件 | 裸 `<img>` / emoji / Unicode |
| 按钮 | 项目统一按钮组件 | 自己写 button 样式 |
| 卡片 | 项目统一卡片组件 | 裸露 div+手写圆角阴影 |
---
## API 调用铁律
**所有 HTTP 请求走统一封装**,禁止组件内直调原生 API。
```javascript
// ✅ 正确:走项目统一封装
import { taskApi } from '@/services/task';
const res = await taskApi.list({ page: 1, size: 20 });
// ❌ 禁止
fetch('/api/task/list').then(...)
axios.get('/api/task/list')
uni.request({ url: '/api/task/list' })
```
---
## 新增页面模板(填空式)
```vue
<template>
<app-layout>
<!-- [填空] 页面内容 -->
<view v-if="loading">加载中...</view>
<view v-else-if="list.length === 0" class="empty-state">
<text>暂无数据</text>
</view>
<view v-else>
<!-- [填空] 数据展示 -->
</view>
</app-layout>
</template>
<script setup>
import { ref, onMounted } from 'vue';
// [填空] import API 方法
const list = ref([]);
const loading = ref(false);
const fetchData = async () => {
loading.value = true;
try {
const res = await [API方法]({ page: 1, size: 20 });
list.value = res.list;
} finally {
loading.value = false;
}
};
onMounted(() => fetchData());
</script>
<style scoped lang="scss">
// ⚠️ 不得超过 100 行,超过说明在造轮子
</style>
```
---
## 禁止清单
| 类别 | ❌ 禁止 |
|------|--------|
| **样式** | 硬编码颜色/字号/间距/圆角 |
| **样式** | 在 scoped 中定义全局重置 |
| **样式** | scoped 超过 100 行 |
| **组件** | 自己写布局外壳(页头/侧栏/TabBar |
| **组件** | 裸用 `<img>` 代替图标组件 |
| **网络** | 直调 `fetch` / `axios` / `uni.request` |
| **网络** | 不处理错误态 |
| **状态** | 无加载态/空态 |
---
## 检查表
每完成一个前端任务,逐项自检:
- [ ] 使用项目统一布局组件
- [ ] 无硬编码颜色/字号/间距/圆角
- [ ] 图标全部走统一图标组件
- [ ] API 调用走统一封装,无裸调
- [ ] 页面/组件有加载态 + 空态 + 错误态
- [ ] 关键操作有 loading 态(防重复点击)
- [ ] `<style scoped>` 不超过 100 行
- [ ] PC/移动端均正常(如项目需响应式)
- [ ] 无禁止清单中的违规项

246
.claude/git.md Normal file
View File

@ -0,0 +1,246 @@
# 05 — Git 协作规则
> **适用**:所有 AI 和人工开发任务。分支怎么拉、什么时候合、冲突怎么处理。
---
## 分支模型
```
master ──────────────────────────────────────────→ 生产
└── test ──────────────────────────────────────→ 测试主线
└── dev ──────────────────────────────────────→ 开发主线
├── feature/任务编号-简短描述 → 新功能
├── bugfix/任务编号-简短描述 → Bug修复
└── hotfix/任务编号-简短描述 (从 master 拉) → 紧急修复
```
### 什么时候从哪拉
| 场景 | 从哪拉 | 合回哪 |
|------|--------|--------|
| 新功能 | `dev` | `dev` |
| Bug 修复 | `dev` | `dev` |
| 紧急线上修复 | `master` | `master` + `dev`(两边都要合) |
### 分支命名
```
feature/2688-用户批量导入 ← 禅道任务号 + 功能描述
bugfix/2690-任务列表分页错误
hotfix/2691-登录验证码绕过
禁止:
feature/xxx ← 没任务号
zhangsan-test ← 没意义
dev-backup-20260603 ← 备份不是分支
```
---
## 日常操作
### 开始一个新任务
```bash
# 1. 切到 dev拉最新
git checkout dev
git pull origin dev
# 2. 从 dev 拉分支
git checkout -b feature/任务号-功能描述
# 3. 开发...
# 4. 定期提交(不要攒到最后一次性交)
git add .
git commit -m "feat: xxx"
# 5. 推送到远程(每天至少推一次,防止本地丢失)
git push origin feature/任务号-功能描述
```
### 合并回 dev
```bash
# 1. 确保本地 dev 最新
git checkout dev
git pull origin dev
# 2. 切回自己的分支rebase dev让历史干净
git checkout feature/任务号-功能描述
git rebase dev
# 如果有冲突:
# - 逐个文件解决冲突
# - git add <解决的文件>
# - git rebase --continue
# - 直到完成
# 3. 推 rebase 后的分支(需要 --force-with-lease不是 --force
git push --force-with-lease origin feature/任务号-功能描述
# 4. 到 Git 平台提 PR/MR 到 dev
# PR 标题feat: 用户管理增加批量导入
# PR 描述:改了什么、怎么测试
```
### 冲突处理
```
1. git status 看哪些文件冲突
2. 打开冲突文件,找到 <<<<<<< ======= >>>>>>> 标记
3. 逐行确认保留哪边的代码
4. 删掉标记符号
5. git add <文件>
6. git rebase --continue 或 git merge --continue
7. 编译验证
```
**常见错误**:看到冲突整块接受一边,把别人的代码覆盖了。必须逐行看。
---
## AI 开发特殊规则
### AI 提交必须标注
```
feat: 用户管理增加批量导入 [AI: Claude Code]
- 新增 UserBatchImportController
- 新增 UserImportService
- 新增 import_batch 表
- 前端增加导入按钮和上传弹窗
```
**为什么**review 时知道这是 AI 写的,检查更仔细(尤其是边界条件和安全性)。
### AI 解决冲突
AI 遇到冲突时,不能自己决定保留哪边。必须:
1. 列出冲突内容和两边来源
2. 让人类决定
3. 人类确认后再继续
### AI 禁止操作
```bash
# ❌ AI 绝对禁止
git push --force origin dev # 强制推共享分支
git push --force origin master # 同上
git reset --hard HEAD~10 && git push -f # 回退远程历史
git commit --amend && git push -f # 修改已推送的 commit
```
---
## Commit 格式
```
<type>: <简短描述> [来源标注]
类型:
feat: 新功能
fix: Bug 修复
refactor: 重构(不改功能)
style: 格式调整
docs: 文档
test: 测试
chore: 构建/依赖/工具
来源标注AI 写的必须加):
[AI: Claude Code]
[AI: Kimi]
[AI: Codex]
正确示例:
feat: 用户管理增加批量导入 [AI: Claude Code]
fix: 任务列表分页总数计算错误
refactor: 提取公共校验逻辑到 BaseService
chore: 升级 Spring Boot 到 3.5.6
禁止示例:
update ← 无意义
fix bug ← 不知道修的哪个 bug
修改 ← 中文且无具体内容
WIP / temp / test ← 临时提交应该 squash 掉
```
---
## PR / MR 规范
### PR 必须包含
```markdown
## 改了什么
简要描述改动内容
## 关联任务
禅道 #2688 / 需求文档链接
## 测试方式
- [ ] 本地编译通过
- [ ] 接口测试通过
- [ ] 前端页面正常
## 契约变更
- [ ] 无接口变更
- [ ] 有接口变更 → 已更新 contract/ 目录 → 已通知前端
```
### Review 规则
- 至少一人 approve 才能合
- AI 生成的代码必须至少一人 review
- 改动超过 500 行 → 拆分 PR
---
## .gitignore 必备
```
# Java
target/
*.class
*.jar
*.war
# IDE
.idea/
*.iml
.vscode/
.settings/
.project
.classpath
# 前端
node_modules/
dist/
.env.local
# 系统
.DS_Store
Thumbs.db
# 敏感信息
*.pem
*.key
application-prod.yml (如果有密码)
```
---
## 检查表
- [ ] 分支从正确的源头拉(功能/Bug → dev紧急 → master
- [ ] 分支名包含任务号
- [ ] commit message 有意义
- [ ] AI 提交标注了来源
- [ ] push 前 rebase 了 dev
- [ ] 没用 `--force` 推共享分支
- [ ] 冲突逐行解决,没整块覆盖
- [ ] PR 有描述和测试说明

125
.claude/security.md Normal file
View File

@ -0,0 +1,125 @@
# 06 — 安全与日志规则
> **适用**:所有后端开发任务。
---
## 安全规则
### 认证检查(不变式)
每个需要登录的接口必须通过 Shiro 认证。JeecgBoot 默认全局拦截。
```java
// JeecgBoot 认证机制:
// 1. 所有 /sys/** /admin/** 默认需要登录
// 2. 白名单在 application.yml 配置
// 3. 权限注解:@RequiresPermissions / @RequiresRoles
// ✅ 接口加权限
@RequiresPermissions("user:add")
@PostMapping("/add")
public Result<?> add(...) { ... }
// ❌ 不要为了调试临时关认证
// ❌ 不要把需要认证的路径加到 shiro.excludeUrls
```
### 数据权限(租户隔离)
JeecgBoot 已通过 MyBatis-Plus 拦截器自动注入 tenant_id**但如果手写 SQL 必须手动加**。
```java
// ✅ 用 MyBatis-Plus 自动处理
taskService.list(new LambdaQueryWrapper<Task>().eq(Task::getStatus, "pending"));
// ❌ 手写 SQL 忘了加租户 → 租户A 看到租户B 的数据
@Select("SELECT * FROM task WHERE status = #{status}")
// ✅ 手写 SQL 正确写法
@Select("SELECT * FROM task WHERE status = #{status} AND tenant_id = #{tenantId}")
```
### 敏感信息保护
```java
// ✅ 密码字段不返回
@Entity
public class SysUser {
@JsonIgnore // ← 关键
private String password;
}
// ✅ 手机号脱敏返回VO 层处理)
userVO.setPhone(phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"));
```
### 文件上传安全
```java
// ✅ 必须校验JeecgBoot 已内置部分,确认开启)
- 文件类型白名单:只允许 jpg/png/pdf/doc/xlsx
- 文件大小上限:单文件 ≤ 10MB
- 文件名处理:用 UUID 重命名,防路径穿越
- 存储路径:不暴露原始文件名给前端
```
### 接口防刷
```java
// ✅ 敏感接口(登录/短信/支付)加限流
// JeecgBoot 使用 Sentinel 或自定义注解
@RateLimiter(count = 5, time = 60) // 60秒内最多5次
@PostMapping("/send-sms")
public Result<?> sendSms(...) { ... }
```
---
## 日志规则
### 日志级别
| 级别 | 场景 |
|------|------|
| ERROR | 数据库连不上、第三方接口失败、业务异常需要人工介入 |
| WARN | 降级处理、重试成功、配置缺失但有默认值 |
| INFO | 用户登录、任务创建、状态变更、定时任务执行 |
| DEBUG | 开发调试用,不入生产 |
### 日志内容
```java
// ✅ 关键操作
log.info("任务状态变更 taskId={} from={} to={} operator={}", taskId, oldStatus, newStatus, userId);
// ✅ 异常
log.error("调用第三方接口失败 url={}", url, e); // 最后一个参数是异常对象
// ❌ 不要这样
log.info("进来了");
log.info("user=" + user.toString()); // 可能打印密码
System.out.println("debug: " + xxx); // 永远不
```
### 禁止
| ❌ | 原因 |
|----|------|
| 循环里打 INFO | 日志爆炸 |
| 打印完整大对象 | 刷屏且可能泄露敏感数据 |
| 用 System.out.println | 不入日志文件 |
| 用 e.printStackTrace() | 不入日志框架 |
---
## 检查表
- [ ] 新接口需要认证(不在白名单中)
- [ ] 手写 SQL 加了租户隔离
- [ ] 敏感字段不返回给前端
- [ ] 文件上传有类型/大小校验
- [ ] 关键操作有 INFO 日志
- [ ] 异常有 ERROR 日志(带栈)
- [ ] 无硬编码密码/密钥

146
.claude/testing.md Normal file
View File

@ -0,0 +1,146 @@
# 07 — 测试与错误处理规则
> **适用**所有后端开发任务。AI 最容易忽略的两件事:写测试、统一错误码。
---
## 测试规则
### 必须写测试的场景
| 场景 | 测试类型 | 要求 |
|------|----------|------|
| 新增 Service 方法 | 单元测试 | 必须 |
| 新增 Controller 接口 | 集成测试 | 必须 |
| 涉及金额计算 | 单元测试 | 必须(多组边界值) |
| 涉及状态流转 | 单元测试 | 必须(覆盖所有状态路径) |
| 涉及外部接口调用 | 集成测试 | 必须Mock 外部依赖) |
| 修 Bug | 回归测试 | 必须(防止复现) |
| 简单 CRUD | 可选 | 不强制 |
### AI 测试规则
**AI 完成任务后,必须同时交付测试代码。** 禁止说"功能写好了但测试你自己补"。
```java
// ✅ AI 必须产出完整测试
@SpringBootTest
class TaskServiceTest {
@Autowired
private TaskService taskService;
@Test
void shouldCreateTask() { ... }
@Test
void shouldNotCreateTaskWithoutTitle() { ... }
@Test
void shouldCalculateRewardCorrectly() { ... }
}
// ❌ AI 不能这样
// "功能已完成,测试代码未编写,建议后续补充"
```
### 测试命名
```
方法名_场景_预期结果
shouldCreateTask_whenValidInput
shouldNotCreateTask_whenTitleIsEmpty
shouldCalculateReward_whenDiscountApplied
shouldReturnFail_whenTaskNotFound
```
### 禁止
- 测试只测正常路径,不测异常路径
- 测试依赖执行顺序(每个测试必须独立)
- 测试里有 `Thread.sleep()`(用 Awaitility
- 测试代码复制粘贴业务代码(测了个寂寞)
---
## 错误处理规则
### 返回格式(不变式)
```java
// ✅ 统一返回
Result.ok(data) // 成功
Result.error("原因") // 业务失败(用户可见)
Result.error("系统错误") // 系统异常(用户不可见,只记录日志)
// JSON 格式
{ "code": 200, "message": "success", "data": {...} } // 成功
{ "code": 500, "message": "任务不存在", "data": null } // 业务失败
{ "code": 500, "message": "系统繁忙,请稍后重试", "data": null } // 系统异常
```
### 什么情况返回什么
```java
// 查不到 → 业务失败
if (task == null) return Result.error("任务不存在");
// 参数不合法 → 业务失败
if (page < 1) return Result.error("页码必须大于0");
// 没权限 → 业务失败
if (!hasPermission) return Result.error("无权操作");
// 数据库连接失败 → 系统异常(全局异常处理器统一捕获)
// 第三方接口超时 → 系统异常(记录日志 + 返回通用错误)
// 不需要手动 try-catch 每个地方,用全局异常处理器统一处理
```
### 全局异常处理器
```java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<?> handleBusiness(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return Result.error(e.getMessage()); // 用户可见
}
@ExceptionHandler(Exception.class)
public Result<?> handleException(Exception e) {
log.error("系统异常", e);
return Result.error("系统繁忙,请稍后重试"); // 用户不可见原文
}
}
```
### 禁止
| ❌ | ✅ |
|----|-----|
| `return Result.error(e.getMessage())` 把栈打印给用户 | `return Result.error("任务不存在")` |
| 每个方法都 try-catch 一遍 | 全局异常处理器统一处理 |
| 异常被吞掉 `catch(Exception e) {}` | 至少打日志 `log.error("xxx", e)` |
| `return null` 让前端爆 NPE | `return Result.error("原因")` |
| 成功和失败都用 code=200 | 失败用 code=500 |
---
## 检查表
### 测试
- [ ] 新增的非 CRUD 方法有测试
- [ ] 测试覆盖了异常路径(不是只测正常路径)
- [ ] 金额计算有边界值测试
- [ ] AI 交付包含测试代码
### 错误处理
- [ ] 错误信息用户能看懂(不是技术术语)
- [ ] 系统异常不暴露内部信息给用户
- [ ] 查不到/null 有明确 fail 返回
- [ ] 没有空 catch 块
- [ ] 没有 `return null` 代替错误返回

57
.claudeignore Normal file
View File

@ -0,0 +1,57 @@
# Git
.git/
.gitignore
.gitmodules
# SVN
.svn/
# IntelliJ IDEA
.idea/
*.iml
*.iws
*.ipr
out/
# Eclipse
.classpath
.project
.settings/
# VS Code
.vscode/
# Maven / Gradle build output
target/
build/
!.mvn/wrapper/maven-wrapper.jar
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Logs
*.log
logs/
# Node (frontend artifacts if any)
node_modules/
dist/
# Docker volumes / data
docker/data/
# Compiled classes
*.class
# Custom
*.qqy
代码修改.log
代码修改日志
*.zip
backup/
.history/
.cursor/
doc/
docs/

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

22
.env Normal file
View File

@ -0,0 +1,22 @@
# port
VITE_PORT = 3100
# 网站标题
VITE_GLOB_APP_TITLE = test 管理系统
# 简称,此变量只能是字符/下划线
VITE_GLOB_APP_SHORT_NAME = Test
# 单点登录服务端地址
VITE_GLOB_APP_CAS_BASE_URL=http://cas.test.com:8443/cas
# 是否开启单点登录
VITE_GLOB_APP_OPEN_SSO = false
# 开启微前端模式
VITE_GLOB_APP_OPEN_QIANKUN=true
# 文件预览地址
VITE_GLOB_ONLINE_VIEW_URL=http://fileview.jeecg.com/onlinePreview

39
.env.development Normal file
View File

@ -0,0 +1,39 @@
# 是否打开mock
VITE_USE_MOCK = true
# 发布路径
VITE_PUBLIC_PATH = /
#VITE_PROXY = [["/jeecgboot","http://localhost:9999"],["/upload","http://localhost:3300/upload"]]
#VITE_GLOB_DOMAIN_URL=http://localhost:9999
#VITE_GLOB_API_URL=/jeecgboot
# 跨域代理,您可以配置多个 ,请注意,没有换行符
VITE_PROXY = [["/test","http://127.0.0.1:8081/ghb"],["/upload","http://127.0.0.1:3300/upload"]]
#后台接口全路径地址(必填)
VITE_GLOB_DOMAIN_URL=http://localhost:8081/test
#后台接口父地址(必填)
VITE_GLOB_API_URL=/test
# 接口前缀
VITE_GLOB_API_URL_PREFIX=
#微前端qiankun应用,命名必须以VITE_APP_SUB_开头,jeecg-app-1为子应用的项目名称,也是子应用的路由父路径
VITE_APP_SUB_jeecg-app-1 = '//localhost:8092'
# 填写后将作为乾坤子应用启动主应用注册时AppName需保持一致放开 VITE_GLOB_QIANKUN_MICRO_APP_NAME 参数表示jeecg-vue3将以乾坤子应用模式启动
#VITE_GLOB_QIANKUN_MICRO_APP_NAME=jeecg-vue3
# 作为乾坤子应用启动时必填需与qiankun主应用注册子应用时填写的 entry 保持一致
#VITE_GLOB_QIANKUN_MICRO_APP_ENTRY=//localhost:3001/jeecg-vue3
# 全局隐藏哪些布局。可选属性sider,header,multi-tabs多个用逗号隔开
#VITE_GLOB_HIDE_LAYOUT_TYPES=sider,header,multi-tabs
# 在线文档编辑版本。可选属性wps, offlineWps(离线版) ,onlyoffice
VITE_GLOB_ONLINE_DOCUMENT_VERSION=wps
# icon组件的iconify图标库使用在线还是本地。可选属性online, local
VITE_GLOB_ICONIFY_USE_TYPE=online

34
.env.docker Normal file
View File

@ -0,0 +1,34 @@
# 是否启用mock
VITE_USE_MOCK = false
# 发布路径
VITE_PUBLIC_PATH = /
# 是否启用gzip或brotli压缩
# 选项值: gzip | brotli | none
# 如果需要多个可以使用“,”分隔
VITE_BUILD_COMPRESS = 'gzip'
# 使用压缩时是否删除原始文件默认为false
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
#后台接口父地址(必填)
VITE_GLOB_API_URL=/test
#后台接口全路径地址(必填)
VITE_GLOB_DOMAIN_URL=http://test-system:8080/test
# 接口父路径前缀
VITE_GLOB_API_URL_PREFIX=
# 填写后将作为乾坤子应用启动主应用注册时AppName需保持一致放开 VITE_GLOB_QIANKUN_MICRO_APP_NAME 参数表示jeecg-vue3将以乾坤子应用模式启动
#VITE_GLOB_QIANKUN_MICRO_APP_NAME=jeecg-vue3
# 作为乾坤子应用启动时必填需与qiankun主应用注册子应用时填写的 entry 保持一致
#VITE_GLOB_QIANKUN_MICRO_APP_ENTRY=//qiankun.boot3.jeecg.com/jeecg-vue3
# 全局隐藏哪些布局。可选属性sider,header,multi-tabs多个用逗号隔开
#VITE_GLOB_HIDE_LAYOUT_TYPES=sider,header,multi-tabs
# 在线文档编辑版本。可选属性wps, offlineWps(离线版), onlyoffice
VITE_GLOB_ONLINE_DOCUMENT_VERSION=wps

34
.env.dockercloud Normal file
View File

@ -0,0 +1,34 @@
# 是否启用mock
VITE_USE_MOCK = false
# 发布路径
VITE_PUBLIC_PATH = /
# 是否启用gzip或brotli压缩
# 选项值: gzip | brotli | none
# 如果需要多个可以使用“,”分隔
VITE_BUILD_COMPRESS = 'gzip'
# 使用压缩时是否删除原始文件默认为false
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
#后台接口父地址(必填)
VITE_GLOB_API_URL=/test
#后台接口全路径地址(必填)
VITE_GLOB_DOMAIN_URL=http://test-gateway:9999
# 接口父路径前缀
VITE_GLOB_API_URL_PREFIX=
# 填写后将作为乾坤子应用启动主应用注册时AppName需保持一致放开 VITE_GLOB_QIANKUN_MICRO_APP_NAME 参数表示jeecg-vue3将以乾坤子应用模式启动
#VITE_GLOB_QIANKUN_MICRO_APP_NAME=jeecg-vue3
# 作为乾坤子应用启动时必填需与qiankun主应用注册子应用时填写的 entry 保持一致
#VITE_GLOB_QIANKUN_MICRO_APP_ENTRY=//qiankun.boot3.jeecg.com/jeecg-vue3
# 全局隐藏哪些布局。可选属性sider,header,multi-tabs多个用逗号隔开
#VITE_GLOB_HIDE_LAYOUT_TYPES=sider,header,multi-tabs
# 在线文档编辑版本。可选属性wps, offlineWps(离线版), onlyoffice
VITE_GLOB_ONLINE_DOCUMENT_VERSION=wps

38
.env.prod_electron Normal file
View File

@ -0,0 +1,38 @@
# 是否启用mock
VITE_USE_MOCK = false
# 后台接口父地址(必填)
# 【Electron下需要与 VITE_GLOB_DOMAIN_URL 配置保持一致】
VITE_GLOB_API_URL=https://api3.boot.jeecg.com
# 后台接口全路径地址(必填)
VITE_GLOB_DOMAIN_URL=https://api3.boot.jeecg.com
# 接口父路径前缀
VITE_GLOB_API_URL_PREFIX=
# 在线文档编辑版本。可选属性wps, offlineWps(离线版), onlyoffice
VITE_GLOB_ONLINE_DOCUMENT_VERSION=wps
# 全局隐藏哪些布局。可选属性sider,header,multi-tabs多个用逗号隔开
#VITE_GLOB_HIDE_LAYOUT_TYPES=sider,header,multi-tabs
# -----------------------------------------
# ------------ 以下参数不建议修改 ------------
# -----------------------------------------
# 发布路径
# 【election下只能是 . 开头的相对路径,建议为 ./ 】
VITE_PUBLIC_PATH = ./
# 是否启用gzip或brotli压缩
# 选项值: gzip | brotli | none
# 如果需要多个可以使用“,”分隔
# 【electron下由于是本地html文件访问所以不需要压缩】
VITE_BUILD_COMPRESS = 'none'
# 使用压缩时是否删除原始文件默认为false
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
# ※ 请勿修改此项 ※
VITE_GLOB_RUN_PLATFORM=electron

37
.env.production Normal file
View File

@ -0,0 +1,37 @@
# 是否启用mock
VITE_USE_MOCK = false
# 发布路径
VITE_PUBLIC_PATH = /
# 是否启用gzip或brotli压缩
# 选项值: gzip | brotli | none
# 如果需要多个可以使用“,”分隔
VITE_BUILD_COMPRESS = 'gzip'
# 使用压缩时是否删除原始文件默认为false
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
#后台接口父地址(必填)
VITE_GLOB_API_URL=/test
#后台接口全路径地址(必填)
VITE_GLOB_DOMAIN_URL=http://127.0.0.1:8080/test
# 接口父路径前缀
VITE_GLOB_API_URL_PREFIX=
# 填写后将作为乾坤子应用启动主应用注册时AppName需保持一致放开 VITE_GLOB_QIANKUN_MICRO_APP_NAME 参数表示jeecg-vue3将以乾坤子应用模式启动
#VITE_GLOB_QIANKUN_MICRO_APP_NAME=jeecg-vue3
# 作为乾坤子应用启动时必填需与qiankun主应用注册子应用时填写的 entry 保持一致
#VITE_GLOB_QIANKUN_MICRO_APP_ENTRY=//qiankun.boot3.jeecg.com/jeecg-vue3
# 全局隐藏哪些布局。可选属性sider,header,multi-tabs多个用逗号隔开
#VITE_GLOB_HIDE_LAYOUT_TYPES=sider,header,multi-tabs
# 在线文档编辑版本。可选属性wps, offlineWps(离线版), onlyoffice
VITE_GLOB_ONLINE_DOCUMENT_VERSION=wps
# icon组件的iconify图标库使用在线还是本地。可选属性online, local
VITE_GLOB_ICONIFY_USE_TYPE=local

15
.eslintignore Normal file
View File

@ -0,0 +1,15 @@
*.sh
node_modules
*.md
*.woff
*.ttf
.vscode
.idea
dist
/public
/docs
.husky
.local
/bin
Dockerfile

79
.eslintrc.js Normal file
View File

@ -0,0 +1,79 @@
// @ts-check
const { defineConfig } = require('eslint-define-config');
module.exports = defineConfig({
root: true,
env: {
browser: true,
node: true,
es6: true,
},
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 2020,
sourceType: 'module',
jsxPragma: 'React',
ecmaFeatures: {
jsx: true,
},
},
extends: [
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'plugin:prettier/recommended',
'plugin:jest/recommended',
],
rules: {
'prettier/prettier': 'off', // 彻底关闭 prettier 校验规则
'vue/script-setup-uses-vars': 'error',
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-empty-function': 'off',
'vue/custom-event-name-casing': 'off',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'space-before-function-paren': 'off',
'vue/attributes-order': 'off',
'vue/one-component-per-file': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/max-attributes-per-line': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/attribute-hyphenation': 'off',
'vue/require-default-prop': 'off',
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'any',
component: 'any',
},
svg: 'always',
math: 'always',
},
],
},
});

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# 依赖与构建产物
**/node_modules/
**/dist/
**/target/
**/logs/
**/.cache/
**/docs/tasks/
**/docs/tasks/**
**/.eslintcache
# IDE / 编辑器
.idea/
**/*.iml
.vscode/
.history/
*.suo
*.sw?
# 独立子仓库(接口契约按规则单独管理,不并入主仓库)
# 本地环境与日志
**/.env.local
**/.env.*.local
**/*-debug.log*
**/docs/tasks/**
rebel.xml
derby.log
# 系统
.DS_Store
Thumbs.db
os_del.cmd
os_del_doc.cmd

6
.gitpod.yml Normal file
View File

@ -0,0 +1,6 @@
ports:
- port: 3344
onOpen: open-preview
tasks:
- init: yarn
command: yarn dev

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

5
.npmrc Normal file
View File

@ -0,0 +1,5 @@
shamefully-hoist=true
strict-peer-dependencies=false
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
/dist/*
.local
.output.js
/node_modules/**
**/*.svg
**/*.sh
/public/*

3
.stylelintignore Normal file
View File

@ -0,0 +1,3 @@
/dist/*
/public/*
public/*

48
.yarnclean Normal file
View File

@ -0,0 +1,48 @@
# test directories
__tests__
test
tests
powered-test
# asset directories
docs
doc
website
images
assets
# examples
example
examples
# code coverage directories
coverage
.nyc_output
# build scripts
Makefile
Gulpfile.js
Gruntfile.js
# configs
appveyor.yml
circle.yml
codeship-services.yml
codeship-steps.yml
wercker.yml
.tern-project
.gitattributes
.editorconfig
.*ignore
.eslintrc
.jshintrc
.flowconfig
.documentup.json
.yarn-metadata.json
.travis.yml
# misc
*.md
!istanbul-reports/lib/html/assets
!istanbul-api/node_modules/istanbul-reports/lib/html/assets

118
CLAUDE.md Normal file
View File

@ -0,0 +1,118 @@
# ghb-base — AI 开发总纲
> 所有 AI 编码助手首先读本文件。读完本文件后,必须继续读取 `.claude/` 目录下的全部规则文件,再开始编写代码。
## 项目信息
- **名称**ghb-base基于 JeecgBoot 3.9.2 精简)
- **技术栈**Spring Boot 3.5.5 / Java 17 / MyBatis-Plus / Shiro+JWT / MySQL / Redis
- **包名**`com.ghb.base` | 数据库:`ghb_base` | 上下文路径:`/ghb`
- **前端**Vue 3 + Vite + Ant Design Vue + TypeScript
## 模块
```
ghb-base-parent/
├── ghb-base-core/ # 核心框架(不改)
├── ghb-module-system/ # 租户/用户/角色/菜单/字典/日志(不改)
├── ghb-module-business/ # 业务代码 ← 写这里
└── ghb-server-cloud/ # 微服务(网关/Nacos/监控)
```
## 必读规则(全部读完再写代码)
1. `.claude/contract.md` — 返回格式 · 分页 · 状态值 · 契约生命周期 · 同步铁律
2. `.claude/backend.md` — 分层 · 命名 · 注释 · CRUD 模板 · 禁止清单 · 检查表
3. `.claude/frontend.md` — 样式 · 命名 · 注释 · 组件铁律 · 页面模板 · 检查表
4. `.claude/database.md` — 表设计 · 字段类型 · 索引 · 必有字段 · 检查表
5. `.claude/git.md` — 分支命名 · commit 格式 · AI 提交标注 · 禁止操作
6. `.claude/security.md` — 认证 · 数据权限 · 敏感信息 · 文件上传 · 日志
7. `.claude/testing.md` — 测试要求 · 错误码 · 全局异常处理 · 检查表
8. `.claude/encoding.md` — 字符编码(一律 UTF-8 无 BOM · 禁 GBK · 批量改写须 UTF-8 感知 · mojibake 还原)
## 接口契约
契约文件在 `contract/` 目录(独立 git 仓库,前后端 submodule 引用)。修改接口前必须先更新契约。
## 环境要求
- JDK 17+
- Maven 3.8+
- MySQL 8.0+
- Redis 6.0+
- Node.js 18+ / pnpm
```bash
# 后端启动
cd test-module-system/test-system-start
mvn spring-boot:run -Pdev
# 前端启动
pnpm install
pnpm dev
```
## 多工具兼容
| 工具 | 入口文件 | 操作 |
|------|----------|------|
| Claude Code | `CLAUDE.md` | 自动加载,无需操作 |
| Cursor | `.cursorrules` | 自动加载,无需操作 |
| Codex / OpenCode | `AGENTS.md` | 自动加载,无需操作 |
| GitHub Copilot | `.github/copilot-instructions.md` | 自动加载,无需操作 |
| Windsurf | `.windsurfrules` | 自动加载(把 CLAUDE.md 复制一份改名为 .windsurfrules |
| **Kimi / DeepSeek / GPT** | 无自动 | **手动粘贴**:把本文件 + `.claude/` 全部内容一次性贴到对话开头 |
| **人工开发** | 无自动 | 阅读 `开发规范-人工版.md`(同级目录) |
---
## 审查机制 — 怎么检查代码是否合规
### 自动化检查(提交前自动跑)
```bash
# 后端代码检查
mvn checkstyle:check # Java 代码风格
mvn test # 单元测试
# 前端代码检查
pnpm lint # ESLint
pnpm stylelint # 样式检查
# 数据库 SQL 检查
# 人工 review金额字段是不是 DECIMAL、有没有 COMMENT、有没有 create_time
```
### AI 产出专项检查
| 检查项 | 怎么查 | 不合格的表现 |
|--------|--------|-------------|
| 返回值是否包装 | `grep -r "return [^R]" --include="*.java" | grep -v Result` | 出现裸 `return data;` |
| 手写 SQL 是否带租户 | `grep -r "@Select" --include="*.java"` | SQL 里没有 `tenant_id` |
| Controller 是否写了业务逻辑 | 人工 review Controller 方法体 | 超过 20 行或有 `if/else` 嵌套 |
| Commit 是否标注 AI | `git log --oneline -20` | 没有 `[AI: xxx]` 标记 |
| 是否包含测试 | `git diff --stat` 看是否有测试文件 | 新功能没测试类 |
### PR Review 必查清单
```
□ 接口变更 → contract/ 目录有对应更新
□ 返回格式 → 全部用 Result 包装
□ 手写 SQL → 有 tenant_id 条件
□ 新增表 → 有 create_time / update_time / COMMENT
□ 金额字段 → 类型是 DECIMAL
□ 硬编码 → 无魔法数字、无裸色值、无裸字号
□ Commit → 格式正确AI 代码有标注
□ 测试 → 新功能有测试代码
```
### 常见违规信号
| 信号 | 可能的问题 |
|------|-----------|
| `return data;`(裸返回) | 没读规则 |
| `@Select("SELECT * FROM xxx WHERE ...")` 缺 tenant_id | 数据泄露风险 |
| 类名超过 35 字符 | 没缩写 |
| `color: #` 硬编码 | 没读前端规则 |
| `git commit -m "update"` | 没读 Git 规则 |
| Controller 超过 100 行 | 业务逻辑泄漏到接口层 |

44
Dockerfile Normal file
View File

@ -0,0 +1,44 @@
# ============================================
# 前端 Docker 镜像 — 多阶段构建
# Stage 1: Node + pnpm 编译
# Stage 2: Nginx 托管静态文件
# ============================================
# ---- Stage 1: 编译 ----
FROM node:22-alpine AS builder
# 限制 Node 内存,防止低配服务器 OOM
ENV NODE_OPTIONS="--max-old-space-size=512"
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /build
# 先复制依赖声明,利用缓存
COPY package.json pnpm-lock.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
# 复制源码
COPY . .
# 构建Vite 会把 .env.production 里的变量编译进去)
RUN pnpm build
# ---- Stage 2: Nginx ----
FROM nginx:alpine
LABEL maintainer="ghb-base deploy"
# 删除默认配置
RUN rm /etc/nginx/conf.d/default.conf
# 复制自定义 nginx 配置
COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf
# 复制构建产物
COPY --from=builder /build/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

30
Dockerfile.cloud Normal file
View File

@ -0,0 +1,30 @@
FROM registry.cn-hangzhou.aliyuncs.com/dockerhub_mirror/nginx
MAINTAINER jeecgos@163.com
VOLUME /tmp
ENV LANG en_US.UTF-8
RUN echo "server { \
listen 80; \
location /jeecgboot/ { \
proxy_pass http://jeecg-boot-gateway:9999/; \
proxy_redirect off; \
proxy_set_header Host jeecg-boot-system; \
proxy_set_header X-Real-IP \$remote_addr; \
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; \
} \
#解决Router(mode: 'history')模式下,刷新路由地址不能找到页面的问题 \
location / { \
root /var/www/html/; \
index index.html index.htm; \
if (!-e \$request_filename) { \
rewrite ^(.*)\$ /index.html?s=\$1 last; \
break; \
} \
} \
access_log /var/log/nginx/access.log ; \
} " > /etc/nginx/conf.d/default.conf \
&& mkdir -p /var/www \
&& mkdir -p /var/www/html
ADD dist/ /var/www/html/
EXPOSE 80
EXPOSE 443

33
LICENSE Normal file
View File

@ -0,0 +1,33 @@
MIT License
Copyright (c) 2020-present, Jeecg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
In any case, you must not make any such use of this software as to develop software which may be considered competitive with this software.
JeecgBoot 是由 北京国炬信息技术有限公司 发行的软件。 总部位于北京地址中国·北京·朝阳区科荟前街1号院奥林佳泰大厦。邮箱jeecgos@163.com
本软件受适用的国家软件著作权法(包括国际条约)和开源协议 双重保护许可。
开源协议中文释意如下:
1.JeecgBoot开源版本无任何限制在遵循本开源协议条款下允许商用使用不会造成侵权行为。
2.允许基于本平台软件开展业务系统开发。
3.在任何情况下,您不得使用本软件开发可能被认为与本软件竞争的软件。
最终解释权归http://www.jeecg.com

196
PWA-README.md Normal file
View File

@ -0,0 +1,196 @@
# PWA 功能说明
## 概述
项目集成了 `vite-plugin-pwa` 插件,**适配按需加载**,实现资源缓存优化和离线支持。
**升级亮点**:通过集成 vite-plugin-pwa 实现渐进式 Web 应用,提升了首屏加载速度,同时异步加载系统资源,点击菜单响应更迅速。
**核心设计**:只预缓存关键资源,按需加载的路由组件 chunk 通过运行时缓存策略处理,避免预缓存过多资源。
## 核心文件
### 构建生成的文件
- **`sw.js`** - Service Worker 文件,由 `vite-plugin-pwa` 自动生成,包含:
- 预缓存资源列表HTML、CSS、核心 JS、静态资源
- 运行时缓存策略JS chunk、CSS、图片、API 等)
- 缓存清理和更新逻辑
- **`workbox-*.js`** - Workbox 运行时库Service Worker 的核心依赖
- **`manifest.webmanifest`** - PWA 清单文件,定义应用元数据
### 配置文件
- **`build/vite/plugin/pwa.ts`** - PWA 插件配置
- **预缓存策略**:只缓存关键资源
- 入口文件:`index.html`、`manifest.webmanifest`
- 核心 JS入口 JS`js/index-*.js`、vendor chunk`js/*-vendor-*.js`
- 静态资源CSS、图片、字体等
- **不预缓存**:按需加载的路由组件 chunk避免预缓存过多资源
- **运行时缓存**:按需加载的资源通过运行时缓存策略处理
- 按需加载的 JS chunkNetworkFirst优先网络失败后使用缓存
- CSS、图片、API 等:按需缓存
- **注册方式**`injectRegister: 'inline'`(内联到 HTML避免缓存问题
## 功能特性
1. **资源缓存优化** - 通过缓存策略提升加载速度
2. **离线支持** - 缓存静态资源,支持离线访问
## 缓存策略
### 预缓存Precache
| 资源类型 | 说明 |
|---------|------|
| `index.html` | 入口 HTML 文件 |
| `manifest.webmanifest` | PWA 清单文件 |
| `js/index-*.js` | 入口 JS 文件 |
| `js/*-vendor-*.js` | 核心 vendor chunkVue、Ant Design Vue 等) |
| `assets/index-*.css` | **仅入口 CSS**(主样式文件) |
| `favicon.ico`、`logo.png` | **仅关键静态资源**logo、图标 |
**重要优化**
- ❌ **不预缓存**:路由组件的 CSS避免登录页加载全部 CSS
- ❌ **不预缓存**:路由组件的 JS chunk按需加载
- ❌ **不预缓存**:所有图片和字体(按需加载)
- ✅ **只预缓存**:登录页和首屏必需的关键资源
**效果**:访问登录页时,只加载登录页相关资源,不会预加载系统大部分资源。
### 运行时缓存Runtime Cache
| 资源类型 | 策略 | 有效期 | 说明 |
|---------|------|--------|------|
| 按需加载 JS chunk | NetworkFirst | 7天 | 优先网络,失败后使用缓存 |
| 路由组件 CSS | CacheFirst | 30天 | **按需加载**,优先缓存 |
| 图片 | CacheFirst | 30天 | 优先缓存 |
| API 请求 | NetworkFirst | 5分钟 | 优先网络,短时缓存 |
| Google Fonts | CacheFirst | 365天 | 长期缓存 |
**优势**
- ✅ **减少预缓存体积 80%+**:只预缓存关键资源,不预缓存路由组件 CSS/JS
- ✅ **登录页加载优化**:访问登录页时只加载登录页资源,不会加载系统大部分资源
- ✅ **按需加载**:路由组件的 CSS 和 JS 只在访问对应页面时加载和缓存
- ✅ **节省存储空间**:按需加载的 chunk 只在需要时缓存
- ✅ **网络优先策略**:确保用户获取最新代码
## 性能提升分析
### 首次访问(无缓存)
- **Service Worker 注册**~50-100ms后台异步不影响页面加载
- **预缓存安装**~200-500ms后台进行关键资源已加载
- **页面加载**无影响Service Worker 在后台工作)
### 二次访问(有缓存)
| 指标 | 无 PWA | 有 PWA | 提升 |
|------|--------|--------|------|
| **首屏加载时间** | 2-5s | 0.5-1.5s | **60-70%** ⬇️ |
| **关键资源加载** | 网络请求 | 缓存读取 | **90%+** ⬇️ |
| **CSS 加载** | 100-300ms | <10ms | **95%+** |
| **图片加载** | 200-500ms | <10ms | **95%+** |
| **离线访问** | ❌ 不可用 | ✅ 可用 | - |
### 按需加载优化
- **预缓存体积**:仅 ~1-3MB关键资源而非全部资源**减少 80%+**
- **Service Worker 安装时间**:减少 **60-80%**
- **登录页加载**:只加载登录页资源,**不加载系统大部分资源**
- **存储空间**:节省 **70-85%**(不预缓存路由组件 CSS/JS
### 实际场景性能提升
1. **弱网环境3G/4G**
- 首屏加载:**3-5s → 0.8-1.5s**(提升 60-70%
- 页面切换:**1-2s → 0.2-0.5s**(提升 75-80%
2. **离线访问**
- 已访问页面:**完全可用**
- 未访问页面:**部分可用**(关键资源已缓存)
3. **重复访问**
- 资源加载:**网络 → 缓存**(提升 90%+
- 用户体验:**秒开**<100ms
## 前端体验优化建议
### 1. 资源加载优化
- ✅ **已实现**
- 只预缓存关键资源(入口 JS、vendor、入口 CSS、logo
- 路由组件的 CSS 和 JS **不预缓存**,按需加载
- 访问登录页时只加载登录页资源,不会加载系统大部分资源
- 💡 **建议**:确保静态资源(图片、字体)使用 CDN配合缓存策略
### 2. 网络策略优化
- ✅ **已实现**JS chunk 使用 NetworkFirst3s 超时)
- 💡 **建议**:可根据实际网络情况调整 `networkTimeoutSeconds`
- 弱网环境可适当增加超时时间5-8s
- 强网环境可减少超时时间1-2s
### 3. 缓存策略优化
- ✅ **已实现**CSS、图片使用 CacheFirst30天
- 💡 **建议**
- 静态资源logo、图标可延长至 90-180 天
- 业务图片:保持 30 天,确保内容更新及时
### 4. 存储空间管理
- ✅ **已实现**:按需加载 chunk 限制 100 个7 天过期
- 💡 **建议**
- 监控缓存使用情况Chrome DevTools → Application → Storage
- 根据用户访问模式调整 `maxEntries``maxAgeSeconds`
### 5. 用户体验优化
- ✅ **已实现**Service Worker 后台注册,不阻塞页面加载
- 💡 **建议**
- 添加加载提示(可选):显示"正在准备离线功能"
- 错误处理Service Worker 注册失败时优雅降级
### 6. 性能监控
建议监控以下指标:
- **FCPFirst Contentful Paint**:目标 < 1.5s
- **LCPLargest Contentful Paint**:目标 < 2.5s
- **TTITime to Interactive**:目标 < 3.5s
- **缓存命中率**:目标 > 80%
## 注意事项
1. **仅生产环境生效** - 开发环境默认禁用
2. **HTTPS 要求** - Service Worker 仅在 HTTPS 或 localhost 下工作
3. **注册代码内联** - 使用 `injectRegister: 'inline'` 避免 `registerSW.js` 缓存问题
4. **手动注册** - Service Worker 通过内联代码自动注册,但**不包含自动更新检测功能**
5. **按需加载适配** - 配置已优化适配 Vue Router 的按需加载,只预缓存关键资源,路由组件 chunk 按需缓存
## 禁用 PWA
如需禁用 PWA 功能,在 `build/vite/plugin/index.ts` 中注释:
```typescript
// vitePlugins.push(configPwaPlugin(isBuild));
```
## 故障排查
### 清除 Service Worker
浏览器控制台执行:
```javascript
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => registration.unregister());
});
```
### 检查 Service Worker 状态
- Chrome DevTools → Application → Service Workers
- 查看注册状态和缓存内容

40
README.md Normal file
View File

@ -0,0 +1,40 @@
# ghb-base 前端ghb-frontend
> 这是 **ghb-base 基础框架**的前端(管理后台)。以后每个新系统都基于本框架开发。
> 基于 JeecgBoot 3.9.2 前端精简。
## 技术栈
- Vue 3 + Vite + TypeScript
- Ant Design Vue
- 包管理器:**pnpm**
## 环境要求
- Node.js 18+、pnpm
## 第一次启动
```bash
pnpm install
pnpm dev
```
- 开发服务器由 Vite 启动;接口走代理到后端(默认后端 `http://localhost:8080/ghb`)。
- **先启动后端再启动前端。** 后端地址在 `.env.development` / `vite.config.ts` 代理配置里调整。
## 构建
```bash
pnpm build # 生产构建
pnpm preview # 预览构建产物
pnpm lint # ESLint
pnpm stylelint # 样式检查
```
## 新人必读(开始写代码前全部读完)
1. **`CLAUDE.md`**(本目录)/ base 根 `../CLAUDE.md` — AI 开发总纲
2. **`../.claude/frontend.md`** — 样式/命名/注释/组件铁律/页面模板/检查表
3. **`../.claude/contract.md`** — 接口契约与返回格式
4. **`../contract/`** — 接口契约(独立 git submodule调接口前先看
## 铁律速记(详见 .claude/frontend.md
- 颜色/字号/间距禁硬编码,走主题变量/全局类(无裸 `color:#`、无裸字号)
- 组件、命名按规范;类名别过长
- 接口返回判成败看统一返回格式的 code不依赖 message 文案
- 改后端接口 → 先更新 `contract/`,前后端同步

View File

@ -0,0 +1,79 @@
import { generate } from '@ant-design/colors';
import setting from '/@/settings/projectSetting';
// 构建期默认主题色(与 src/settings 中默认 MIX 菜单模式 APP_PRESET_COLOR_LIST[2] 保持一致)
// 运行时会从 store 拿真实值并显式传入,这里只做 build 阶段的占位与默认色板生成
export const primaryColor = setting.themeColor;
export const darkMode = setting.themeMode;
type Fn = (...arg: any) => any;
type GenerateTheme = 'default' | 'dark';
export interface GenerateColorsParams {
mixLighten: Fn;
mixDarken: Fn;
tinycolor: any;
color?: string;
}
export function generateAntColors(color: string, theme: GenerateTheme = 'default') {
return generate(color, {
theme,
});
}
export function getThemeColors(color?: string) {
const tc = color || primaryColor;
const lightColors = generateAntColors(tc);
const primary = lightColors[5];
const modeColors = generateAntColors(primary, 'dark');
return [...lightColors, ...modeColors];
}
export function generateColors({
color = primaryColor,
mixLighten,
mixDarken,
tinycolor,
}: GenerateColorsParams) {
const arr = new Array(19).fill(0);
const lightens = arr.map((_t, i) => {
return mixLighten(color, i / 5);
});
const darkens = arr.map((_t, i) => {
return mixDarken(color, i / 5);
});
const alphaColors = arr.map((_t, i) => {
return tinycolor(color)
.setAlpha(i / 20)
.toRgbString();
});
const shortAlphaColors = alphaColors.map((item) => item.replace(/\s/g, '').replace(/0\./g, '.'));
const tinycolorLightens = arr
.map((_t, i) => {
return tinycolor(color)
.lighten(i * 5)
.toHexString();
})
.filter((item) => item !== '#ffffff');
const tinycolorDarkens = arr
.map((_t, i) => {
return tinycolor(color)
.darken(i * 5)
.toHexString();
})
.filter((item) => item !== '#000000');
return [
...lightens,
...darkens,
...alphaColors,
...shortAlphaColors,
...tinycolorDarkens,
...tinycolorLightens,
].filter((item) => !item.includes('-'));
}

6
build/constant.ts Normal file
View File

@ -0,0 +1,6 @@
/**
* The name of the configuration file entered in the production environment
*/
export const GLOB_CONFIG_FILE_NAME = '_app.config.js';
export const OUTPUT_DIR = 'dist';

View File

@ -0,0 +1,49 @@
import { primaryColor } from '../config/themeConfig';
// import { getThemeVariables } from 'ant-design-vue/dist/theme';
import { resolve } from 'path';
import { generate } from '@ant-design/colors';
import { theme } from 'ant-design-vue/lib';
import convertLegacyToken from 'ant-design-vue/lib/theme/convertLegacyToken';
const { defaultAlgorithm, defaultSeed } = theme;
function generateAntColors(color: string, theme: 'default' | 'dark' = 'default') {
return generate(color, {
theme,
});
}
/**
* less global variable
*/
export function generateModifyVars() {
const palettes = generateAntColors(primaryColor);
const primary = palettes[5];
const primaryColorObj: Record<string, string> = {};
for (let index = 0; index < 10; index++) {
primaryColorObj[`primary-${index + 1}`] = palettes[index];
}
const mapToken = defaultAlgorithm(defaultSeed);
const v3Token = convertLegacyToken(mapToken);
return {
...v3Token,
// ...modifyVars,
// Used for global import to avoid the need to import each style file separately
// reference: Avoid repeated references
hack: `true; @import (reference) "${resolve('src/design/config.less')}";`,
'primary-color': primary,
...primaryColorObj,
'info-color': primary,
'processing-color': primary,
'success-color': '#55D187', // Success color
'error-color': '#ED6F6F', // False color
'warning-color': '#EFBD47', // Warning color
//'border-color-base': '#EEEEEE',
'font-size-base': '14px', // Main font size
'border-radius-base': '2px', // Component/float fillet
'link-color': primary, // Link color
'app-content-background': '#fafafa', // Link color
};
}

View File

@ -0,0 +1,68 @@
import path from 'path';
import fs from 'fs-extra';
import inquirer from 'inquirer';
import colors from 'picocolors';
import pkg from '../../../package.json';
async function generateIcon() {
const dir = path.resolve(process.cwd(), 'node_modules/@iconify/json');
const raw = await fs.readJSON(path.join(dir, 'collections.json'));
const collections = Object.entries(raw).map(([id, v]) => ({
...(v as any),
id,
}));
const choices = collections.map((item) => ({ key: item.id, value: item.id, name: item.name }));
inquirer
.prompt([
{
type: 'list',
name: 'useType',
choices: [
{ key: 'local', value: 'local', name: 'Local' },
{ key: 'onLine', value: 'onLine', name: 'OnLine' },
],
message: 'How to use icons?',
},
{
type: 'list',
name: 'iconSet',
choices: choices,
message: 'Select the icon set that needs to be generated?',
},
{
type: 'input',
name: 'output',
message: 'Select the icon set that needs to be generated?',
default: 'src/components/Icon/data',
},
])
.then(async (answers) => {
const { iconSet, output, useType } = answers;
const outputDir = path.resolve(process.cwd(), output);
fs.ensureDir(outputDir);
const genCollections = collections.filter((item) => [iconSet].includes(item.id));
const prefixSet: string[] = [];
for (const info of genCollections) {
const data = await fs.readJSON(path.join(dir, 'json', `${info.id}.json`));
if (data) {
const { prefix } = data;
const isLocal = useType === 'local';
const icons = Object.keys(data.icons).map((item) => `${isLocal ? prefix + ':' : ''}${item}`);
await fs.writeFileSync(
path.join(output, `icons.data.ts`),
`export default ${isLocal ? JSON.stringify(icons) : JSON.stringify({ prefix, icons })}`
);
prefixSet.push(prefix);
}
}
fs.emptyDir(path.join(process.cwd(), 'node_modules/.vite'));
console.log(`${colors.cyan(`[${pkg.name}]`)}` + ' - Icon generated successfully:' + `[${prefixSet}]`);
});
}
generateIcon();

View File

@ -0,0 +1,7 @@
/**
* Get the configuration file variable name
* @param env
*/
export const getConfigFileName = (env: Record<string, any>) => {
return `__PRODUCTION__${env.VITE_GLOB_APP_SHORT_NAME || '__APP'}__CONF__`.toUpperCase().replace(/\s/g, '');
};

55
build/script/buildConf.ts Normal file
View File

@ -0,0 +1,55 @@
/**
*
*/
import { GLOB_CONFIG_FILE_NAME, OUTPUT_DIR } from '../constant';
import fs, { writeFileSync } from 'fs-extra';
import colors from 'picocolors';
import { getEnvConfig, getRootPath } from '../utils';
import { getConfigFileName } from '../getConfigFileName';
import pkg from '../../package.json';
interface CreateConfigParams {
configName: string;
config: any;
configFileName?: string;
}
function createConfig(params: CreateConfigParams) {
const { configName, config, configFileName } = params;
try {
const windowConf = `window.${configName}`;
// Ensure that the variable will not be modified
let configStr = `${windowConf}=${JSON.stringify(config)};`;
configStr += `
Object.freeze(${windowConf});
Object.defineProperty(window, "${configName}", {
configurable: false,
writable: false,
});
`.replace(/\s/g, '');
fs.mkdirp(getRootPath(OUTPUT_DIR));
writeFileSync(getRootPath(`${OUTPUT_DIR}/${configFileName}`), configStr);
console.log(colors.cyan(`✨ [${pkg.name}]`) + ` - configuration file is build successfully:`);
console.log(colors.gray(OUTPUT_DIR + '/' + colors.green(configFileName)) + '\n');
// 如果是 Electron 环境,还需要将配置文件写入到 JSON 文件中
if (config.VITE_GLOB_RUN_PLATFORM === 'electron') {
writeFileSync(getRootPath(`${OUTPUT_DIR}/electron/env.json`), JSON.stringify(config));
console.log(colors.cyan(`✨ [${pkg.name}]`) + ` - electron env file is build successfully:`);
console.log(colors.gray(OUTPUT_DIR + '/' + colors.green('electron/env.json')) + '\n');
}
} catch (error) {
console.log(colors.red('configuration file configuration file failed to package:\n' + error));
}
}
export function runBuildConfig() {
const config = getEnvConfig();
const configFileName = getConfigFileName(config);
createConfig({ config, configName: configFileName, configFileName: GLOB_CONFIG_FILE_NAME });
}

14
build/script/copyChat.ts Normal file
View File

@ -0,0 +1,14 @@
const fs = require('fs-extra');
const path = require('path');
const sourceDir = path.join(__dirname, '../../src/views/super/airag/aiapp/chat/js'); // 源目录
const destDir = path.join(__dirname, '../../dist', 'chat'); // 目标目录
// 复制目录
fs.copy(sourceDir, destDir)
.then(() => {
console.log(`成功将 ${sourceDir} 复制到 ${destDir}`);
})
.catch((err) => {
console.error(`复制失败: ${err}`);
});

23
build/script/postBuild.ts Normal file
View File

@ -0,0 +1,23 @@
// #!/usr/bin/env node
import { runBuildConfig } from './buildConf';
import colors from 'picocolors';
import pkg from '../../package.json';
export const runBuild = async () => {
try {
const argvList = process.argv.splice(2);
// Generate configuration file
if (!argvList.includes('disabled-config')) {
runBuildConfig();
}
console.log(`${colors.cyan(`[${pkg.name}]`)}` + ' - build successfully!');
} catch (error) {
console.log(colors.red('vite build error:\n' + error));
process.exit(1);
}
};
runBuild();

101
build/utils.ts Normal file
View File

@ -0,0 +1,101 @@
import fs from 'fs';
import path from 'path';
import dotenv from 'dotenv';
export function isDevFn(mode: string): boolean {
return mode === 'development';
}
export function isProdFn(mode: string): boolean {
return mode === 'production';
}
/**
* Whether to generate package preview
*/
export function isReportMode(): boolean {
return process.env.REPORT === 'true';
}
// Read all environment variable configuration files to process.env
export function wrapperEnv(envConf: Recordable): ViteEnv {
const ret: any = {};
for (const envName of Object.keys(envConf)) {
let realName = envConf[envName].replace(/\\n/g, '\n');
realName = realName === 'true' ? true : realName === 'false' ? false : realName;
if (envName === 'VITE_PORT') {
realName = Number(realName);
}
if (envName === 'VITE_PROXY' && realName) {
try {
realName = JSON.parse(realName.replace(/'/g, '"'));
} catch (error) {
console.log("PARSE VITE PROXY ERROR:", error);
realName = '';
}
}
ret[envName] = realName;
if (typeof realName === 'string') {
process.env[envName] = realName;
} else if (typeof realName === 'object') {
process.env[envName] = JSON.stringify(realName);
}
}
return ret;
}
/**
*
*/
function getConfFiles() {
// 代码逻辑说明: 【QQYUN-9685】构建 electron 桌面应用
const {VITE_GLOB_RUN_PLATFORM} = process.env
if (VITE_GLOB_RUN_PLATFORM === 'electron') {
return ['.env', '.env.prod_electron'];
}
const script = process.env.npm_lifecycle_script;
// 代码逻辑说明: 【QQYUN-8690】修正获取当前环境下的文件名
const reg = new RegExp('NODE_ENV=([a-z_\\d]+)');
const result = reg.exec(script as string) as any;
if (result) {
const mode = result[1] as string;
return ['.env', `.env.${mode}`];
}
return ['.env', '.env.production'];
}
/**
* Get the environment variables starting with the specified prefix
* @param match prefix
* @param confFiles ext
*/
export function getEnvConfig(match = 'VITE_GLOB_', confFiles = getConfFiles()) {
let envConfig = {};
confFiles.forEach((item) => {
try {
const env = dotenv.parse(fs.readFileSync(path.resolve(process.cwd(), item)));
envConfig = { ...envConfig, ...env };
} catch (e) {
console.error(`Error in parsing ${item}`, e);
}
});
const reg = new RegExp(`^(${match})`);
Object.keys(envConfig).forEach((key) => {
if (!reg.test(key)) {
Reflect.deleteProperty(envConfig, key);
}
});
return envConfig;
}
/**
* Get user root directory
* @param dir file path
*/
export function getRootPath(...dir: string[]) {
return path.resolve(process.cwd(), ...dir);
}

View File

@ -0,0 +1,36 @@
/**
* Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated
* https://github.com/anncwb/vite-plugin-compression
*/
import type { PluginOption } from 'vite';
import compressPlugin from 'vite-plugin-compression';
export function configCompressPlugin(compress: 'gzip' | 'brotli' | 'none', deleteOriginFile = false): PluginOption | PluginOption[] {
const compressList = compress.split(',');
const plugins: PluginOption[] = [];
if (compressList.includes('gzip')) {
plugins.push(
compressPlugin({
verbose: true,
disable: false,
threshold: 10240,
algorithm: 'gzip',
ext: '.gz',
deleteOriginFile,
})
);
}
if (compressList.includes('brotli')) {
plugins.push(
compressPlugin({
ext: '.br',
algorithm: 'brotliCompress',
deleteOriginFile,
})
);
}
return plugins;
}

View File

@ -0,0 +1,34 @@
// import electron from 'vite-plugin-electron/simple'
//
// export function configElectronPlugin(_viteEnv: ViteEnv, isBuild: boolean) {
// return electron({
// main: {
// // 主进程入口
// entry: 'electron/main.ts',
// vite: {
// build: {
// sourcemap: !isBuild,
// outDir: 'dist/electron',
// },
// },
// onstart: ({startup}) => {
// // 开发热重载
// startup()
// },
// },
// preload: {
// input: 'electron/preload/index.ts',
// vite: {
// build: {
// sourcemap: !isBuild,
// outDir: 'dist/electron/preload',
// },
// },
// onstart: ({startup}) => {
// // 开发热重载
// startup()
// },
// }
// })
//
// }

45
build/vite/plugin/html.ts Normal file
View File

@ -0,0 +1,45 @@
/**
* Plugin to minimize and use ejs template syntax in index.html.
* https://github.com/anncwb/vite-plugin-html
*/
import type { PluginOption } from 'vite';
import { createHtmlPlugin } from 'vite-plugin-html';
import pkg from '../../../package.json';
import { GLOB_CONFIG_FILE_NAME } from '../../constant';
export function configHtmlPlugin(env: ViteEnv, isBuild: boolean, isQiankunMicro: boolean) {
const { VITE_GLOB_APP_TITLE, VITE_PUBLIC_PATH } = env;
const path = VITE_PUBLIC_PATH.endsWith('/') ? VITE_PUBLIC_PATH : `${VITE_PUBLIC_PATH}/`;
const getAppConfigSrc = () => {
return `${path || '/'}${GLOB_CONFIG_FILE_NAME}?v=${pkg.version}-${new Date().getTime()}`;
};
// 【JEECG作为乾坤子应用】补充静态资源前缀
const {VITE_GLOB_QIANKUN_MICRO_APP_ENTRY} = env;
const basePublicPath = isQiankunMicro ? VITE_GLOB_QIANKUN_MICRO_APP_ENTRY : '';
const htmlPlugin: PluginOption[] = createHtmlPlugin({
minify: isBuild,
inject: {
// 修改模板html的标题
data: {
title: VITE_GLOB_APP_TITLE,
basePublicPath: basePublicPath,
},
// 将app.config.js文件注入到模板html中
tags: isBuild
? [
{
tag: 'script',
attrs: {
src: getAppConfigSrc(),
},
},
]
: [],
},
});
return htmlPlugin;
}

View File

@ -0,0 +1,35 @@
// 【图片压缩插件】
// Image resource files used to compress the output of the production environment
// https://github.com/anncwb/vite-plugin-imagemin
import viteImagemin from 'vite-plugin-imagemin';
export function configImageminPlugin() {
const plugin = viteImagemin({
gifsicle: {
optimizationLevel: 7,
interlaced: false,
},
optipng: {
optimizationLevel: 7,
},
mozjpeg: {
quality: 20,
},
pngquant: {
quality: [0.8, 0.9],
speed: 4,
},
svgo: {
plugins: [
{
name: 'removeViewBox',
},
{
name: 'removeEmptyAttrs',
active: false,
},
],
},
});
return plugin;
}

146
build/vite/plugin/index.ts Normal file
View File

@ -0,0 +1,146 @@
import { PluginOption } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import purgeIcons from 'vite-plugin-purge-icons';
// unplugin-icons 为 ESM-only用动态 import 避免 CJS 配置加载时 require 报错
import UnoCSS from 'unocss/vite';
import { presetTypography, presetUno } from 'unocss';
import Components from 'unplugin-vue-components/vite';
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
// 本地调试https配置方法
import VitePluginCertificate from 'vite-plugin-mkcert';
//[issues/555]开发环境vscode断点调试文件或行数对不上
import vueSetupExtend from 'vite-plugin-vue-setup-extend-plus';
import { configHtmlPlugin } from './html';
import { configMockPlugin } from './mock';
import { configCompressPlugin } from './compress';
import { configVisualizerConfig } from './visualizer';
import { configThemePlugin } from './theme';
import { configSvgIconsPlugin } from './svgSprite';
import { configQiankunMicroPlugin } from './qiankunMicro';
import { configPwaPlugin } from './pwa';
// // electron plugin
// import { configElectronPlugin } from "./electron";
// //预编译加载插件(不支持vite3作废)
// import OptimizationPersist from 'vite-plugin-optimize-persist';
// import PkgConfig from 'vite-plugin-package-config';
/**
*
* @param viteEnv
* @param isBuild
* @param isQiankunMicro JEECG作为乾坤子应用
*/
export async function createVitePlugins(
viteEnv: ViteEnv,
isBuild: boolean,
isQiankunMicro: boolean,
) {
const { VITE_USE_MOCK, VITE_BUILD_COMPRESS, VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE } = viteEnv;
// update-begin--author:liaozhiyang---date:20260306---for:【QQYUN-14833】unplugin-icons 使用esm
// 动态 import ESM-only避免 CJS 配置链 require 报 ERR_REQUIRE_ESM 解决node 20启动报错
const [
{ default: Icons },
{ default: IconsResolver },
] = await Promise.all([
import('unplugin-icons/vite'),
import('unplugin-icons/resolver'),
]);
// update-end--author:liaozhiyang---date:20260306---for:【QQYUN-14833】unplugin-icons 使用esm
const vitePlugins: (PluginOption | PluginOption[])[] = [
// have to
vue(),
// have to
vueJsx(),
// support name
vueSetupExtend(),
// @ts-ignore
VitePluginCertificate({
source: 'coding',
}),
];
vitePlugins.push(UnoCSS({ presets: [presetUno(), presetTypography()] }));
// update-begin--author:liaozhiyang---date:20260302---for:【QQYUN-14806】antd采用unplugin-vue-components实现按需加载
// unplugin-vue-components: ant-design-vue 组件自动按需导入
vitePlugins.push(
Components({
resolvers: [
AntDesignVueResolver({
importStyle: false,
// 排除antd的Button组件让项目中的a-button指向BasicButton组件
// 低版本是AButton高版本是Button
exclude: ['AButton', 'Button'],
}),
IconsResolver({
prefix: 'iconify',
}),
],
dirs: [],
dts: false,
})
);
// update-end--author:liaozhiyang---date:20260302---for:【QQYUN-14806】antd采用unplugin-vue-components实现按需加载
// vite-plugin-html
vitePlugins.push(configHtmlPlugin(viteEnv, isBuild, isQiankunMicro));
// vite-plugin-svg-icons
vitePlugins.push(configSvgIconsPlugin(isBuild));
// vite-plugin-mock
VITE_USE_MOCK && vitePlugins.push(configMockPlugin(isBuild));
// update-begin--author:liaozhiyang---date:20260304---for:【QQYUN-14802】新增unplugin-icons插件及icon支持online和local两种模式
if (viteEnv.VITE_GLOB_ICONIFY_USE_TYPE === 'local') {
vitePlugins.push(purgeIcons());
}
vitePlugins.push(
Icons({
compiler: 'vue3',
// 自动安装图标集
autoInstall: false,
scale: 1,
defaultClass: 'app-iconify anticon',
})
);
vitePlugins.push(purgeIcons());
// update-end--author:liaozhiyang---date:20260304---for:【QQYUN-14802】新增unplugin-icons插件及icon支持online和local两种模式
// rollup-plugin-visualizer
vitePlugins.push(configVisualizerConfig());
// vite-plugin-theme
vitePlugins.push(configThemePlugin(isBuild));
// 【JEECG作为乾坤子应用】注册乾坤子应用模式插件
if (isQiankunMicro) {
// vite-plugin-qiankun
vitePlugins.push(...configQiankunMicroPlugin(viteEnv))
}
// // electron plugin
const isElectron = viteEnv.VITE_GLOB_RUN_PLATFORM === 'electron';
// if (isElectron) {
// vitePlugins.push(configElectronPlugin(viteEnv, isBuild))
// }
// The following plugins only work in the production environment
if (isBuild) {
// rollup-plugin-gzip
vitePlugins.push(configCompressPlugin(VITE_BUILD_COMPRESS, VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE));
// vite-plugin-pwa (PWA 插件注册)
if (!isElectron) {
vitePlugins.push(configPwaPlugin(isBuild));
}
}
// //vite-plugin-theme【预编译加载插件解决vite首次打开界面加载慢问题】
// vitePlugins.push(PkgConfig());
// vitePlugins.push(OptimizationPersist());
return vitePlugins;
}

19
build/vite/plugin/mock.ts Normal file
View File

@ -0,0 +1,19 @@
/**
* Mock plugin for development and production.
* https://github.com/anncwb/vite-plugin-mock
*/
import { viteMockServe } from 'vite-plugin-mock';
export function configMockPlugin(isBuild: boolean) {
return viteMockServe({
ignore: /^\_/,
mockPath: 'mock',
localEnabled: !isBuild,
prodEnabled: isBuild,
injectCode: `
import { setupProdMockServer } from '../mock/_createProductionServer';
setupProdMockServer();
`,
});
}

130
build/vite/plugin/pwa.ts Normal file
View File

@ -0,0 +1,130 @@
/**
* PWA -
*/
import { VitePWA } from 'vite-plugin-pwa';
import type { VitePWAOptions } from 'vite-plugin-pwa';
import type { PluginOption } from 'vite';
export function configPwaPlugin(isBuild: boolean): PluginOption | PluginOption[] {
if (!isBuild) {
console.log('非生产环境不启用 PWA 插件!');
return [];
}
const pwaOptions: Partial<VitePWAOptions> = {
registerType: 'manual',
injectRegister: 'inline', // 将 Service Worker 注册代码内联到 HTML 中,避免缓存问题
includeAssets: ['favicon.ico', 'logo.png'],
manifest: {
name: 'JeecgBoot',
short_name: 'Jeecg',
theme_color: '#ffffff',
icons: [
{
src: '/logo.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/logo.png',
sizes: '512x512',
type: 'image/png',
},
],
},
workbox: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
cleanupOutdatedCaches: true,
// 预缓存:只缓存关键资源,不预缓存路由组件 CSS/JS避免登录页加载全部资源
globPatterns: [
'index.html', // 必须预缓存(避免 non-precached-url 错误)
'manifest.webmanifest',
'assets/index-*.css', // 仅入口 CSS
'favicon.ico',
'logo.png',
'js/index-*.js',
'js/*-vendor-*.js',
],
// 不使用导航回退功能
navigateFallback: undefined,
// 运行时缓存:按需加载的资源
runtimeCaching: [
{
urlPattern: /\/js\/.*\.js$/i,
handler: 'NetworkFirst',
options: {
cacheName: 'js-chunks-cache',
networkTimeoutSeconds: 3,
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 7, // 7天
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /\/assets\/.*\.css$/i,
handler: 'CacheFirst',
options: {
cacheName: 'css-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30天
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
// Google Fonts
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
//update-begin---author:scott ---date:20260417 for[issues/9564]PWA图片规则过宽导致/filereview/等业务接口走缓存-----------
// 图片资源(仅缓存构建产物,避免命中 /filereview/、/jeecgboot/ 等业务接口返回的图片)
{
urlPattern: /\/(?:assets|img|static|resource)\/.*\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
},
},
// API 请求JeecgBoot 实际前缀是 /jeecgboot/,原 /api/ 规则未生效)
{
urlPattern: /\/jeecgboot\/.*/i,
handler: 'NetworkOnly',
},
//update-end---author:scott ---date:20260417 for[issues/9564]PWA图片规则过宽导致/filereview/等业务接口走缓存-----------
],
// 启用立即更新:新 SW 立即激活并接管页面
skipWaiting: true,
clientsClaim: true,
},
devOptions: {
enabled: false,
},
};
return VitePWA(pwaOptions);
}

View File

@ -0,0 +1,16 @@
import qiankun from 'vite-plugin-qiankun';
/**
* JEECG作为乾坤子应用Vite适配乾坤以子应用模式运行
* @param env
*/
export function configQiankunMicroPlugin(env: ViteEnv) {
const {VITE_GLOB_QIANKUN_MICRO_APP_NAME} = env
return [
qiankun(VITE_GLOB_QIANKUN_MICRO_APP_NAME!, {
useDevMode: true,
})
]
}

View File

@ -0,0 +1,82 @@
/**
* antd的样式
* Introduces component library styles on demand.
* https://github.com/anncwb/vite-plugin-style-import
*/
import { createStyleImportPlugin } from 'vite-plugin-style-import';
export function configStyleImportPlugin(_isBuild: boolean) {
if (!_isBuild) {
return [];
}
const styleImportPlugin = createStyleImportPlugin({
libs: [
{
libraryName: 'ant-design-vue',
esModule: true,
resolveStyle: (name) => {
// 这里是无需额外引入样式文件的“子组件”列表
const ignoreList = [
'anchor-link',
'sub-menu',
'menu-item',
'menu-divider',
'menu-item-group',
'breadcrumb-item',
'breadcrumb-separator',
'form-item',
'step',
'select-option',
'select-opt-group',
'card-grid',
'card-meta',
'collapse-panel',
'descriptions-item',
'list-item',
'list-item-meta',
'table-column',
'table-column-group',
'tab-pane',
'tab-content',
'timeline-item',
'tree-node',
'skeleton-input',
'skeleton-avatar',
'skeleton-title',
'skeleton-paragraph',
'skeleton-image',
'skeleton-button',
];
// 这里是需要额外引入样式的子组件列表
// 单独引入子组件时需引入组件样式,否则会在打包后导致子组件样式丢失
const replaceList = {
'typography-text': 'typography',
'typography-title': 'typography',
'typography-paragraph': 'typography',
'typography-link': 'typography',
'dropdown-button': 'dropdown',
'input-password': 'input',
'input-search': 'input',
'input-group': 'input',
'radio-group': 'radio',
'checkbox-group': 'checkbox',
'layout-sider': 'layout',
'layout-content': 'layout',
'layout-footer': 'layout',
'layout-header': 'layout',
'month-picker': 'date-picker',
'range-picker': 'date-picker',
'image-preview-group': 'image',
};
return ignoreList.includes(name)
? ''
: replaceList.hasOwnProperty(name)
? `ant-design-vue/es/${replaceList[name]}/style/index`
: `ant-design-vue/es/${name}/style/index`;
},
},
],
});
return styleImportPlugin;
}

View File

@ -0,0 +1,17 @@
/**
* Vite Plugin for fast creating SVG sprites.
* https://github.com/anncwb/vite-plugin-svg-icons
*/
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
import path from 'path';
export function configSvgIconsPlugin(isBuild: boolean) {
const svgIconsPlugin = createSvgIconsPlugin({
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
svgoOptions: isBuild,
// default
symbolId: 'icon-[dir]-[name]',
});
return svgIconsPlugin;
}

View File

@ -0,0 +1,224 @@
import type { Plugin, ResolvedConfig } from 'vite';
import path from 'path';
import fs from 'fs-extra';
import less from 'less';
import { createFileHash, minifyCSS, extractVariable } from './utils';
import chalk from 'chalk';
import { colorRE, linkID } from './constants';
import { injectClientPlugin } from './injectClientPlugin';
import { lessPlugin } from './preprocessor/less';
export interface AntdDarkThemeOption {
darkModifyVars?: any;
fileName?: string;
verbose?: boolean;
selector?: string;
filter?: (id: string) => boolean;
extractCss?: boolean;
preloadFiles?: string[];
loadMethod?: 'link' | 'ajax';
}
export function antdDarkThemePlugin(options: AntdDarkThemeOption): Plugin[] {
const {
darkModifyVars,
verbose = true,
fileName = 'app-antd-dark-theme-style',
selector,
filter,
extractCss = true,
preloadFiles = [],
loadMethod = 'link',
} = options;
let isServer = false;
let needSourcemap = false;
let config: ResolvedConfig;
let extCssString = '';
const styleMap = new Map<string, string>();
const codeCache = new Map<string, { code: string; css: string }>();
const cssOutputName = `${fileName}.${createFileHash()}.css`;
const hrefProtocals = ['http://'];
const getCss = (css: string) => {
return `[${selector || 'data-theme="dark"'}] {${css}}`;
};
async function preloadLess() {
if (!preloadFiles || !preloadFiles.length) return;
for (const id of preloadFiles) {
const code = fs.readFileSync(id, 'utf-8');
less
.render(code, {
javascriptEnabled: true,
modifyVars: darkModifyVars,
filename: path.resolve(id),
plugins: [lessPlugin(id, config)],
})
.then(({ css }) => {
const colors = css.match(colorRE);
if (colors) {
css = extractVariable(css, colors.concat(['transparent']));
codeCache.set(id, { code, css });
}
})
.catch(() => void 0);
}
}
function getProtocal(p: string): string | undefined {
let protocal: string | undefined;
hrefProtocals.forEach((hrefProtocal) => {
if (p.startsWith(hrefProtocal)) protocal = hrefProtocal;
});
return protocal;
}
return [
injectClientPlugin('antdDarkPlugin', {
antdDarkCssOutputName: cssOutputName,
antdDarkExtractCss: extractCss,
antdDarkLoadLink: loadMethod === 'link',
}),
{
name: 'vite:antd-dark-theme',
enforce: 'pre',
configResolved(resolvedConfig) {
config = resolvedConfig;
isServer = resolvedConfig.command === 'serve';
needSourcemap = !!resolvedConfig.build.sourcemap;
if (isServer) preloadLess();
},
transformIndexHtml: {
order: 'pre',
handler(html) {
let href: string;
const protocal = getProtocal(config.base);
if (isServer || loadMethod !== 'link') return html;
if (protocal) {
href =
protocal +
path.posix.join(
config.base.slice(protocal.length),
config.build.assetsDir,
cssOutputName,
);
} else {
href = path.posix.join(config.base, config.build.assetsDir, cssOutputName);
}
return {
html,
tags: [
{
tag: 'link',
attrs: {
disabled: true,
id: linkID,
rel: 'alternate stylesheet',
href,
},
injectTo: 'head',
},
],
};
},
},
async transform(code, id) {
if (!id.endsWith('.less') || !code.includes('@')) return null;
if (typeof filter === 'function' && !filter(id)) return null;
const getResult = (content: string) => ({
map: needSourcemap ? this.getCombinedSourcemap() : null,
code: content,
});
let processCss = '';
const cache = codeCache.get(id);
const isUpdate = !cache || cache.code !== code;
if (isUpdate) {
let renderedCss = '';
try {
const { css } = await less.render(code, {
javascriptEnabled: true,
modifyVars: darkModifyVars,
filename: path.resolve(id),
plugins: [lessPlugin(id, config)],
});
renderedCss = css;
} catch {
// less 解析失败(典型场景:业务 less 用了 vite alias / virtual import
// 静默跳过该文件 —— 不影响主流程,也不参与 dark theme 提取。
return null;
}
const colors = renderedCss.match(colorRE);
if (colors) {
processCss = extractVariable(renderedCss, colors.concat(['transparent']));
}
} else {
processCss = cache!.css;
}
if (isServer || !extractCss) {
if (isUpdate) codeCache.set(id, { code, css: processCss });
return getResult(`${getCss(processCss)}\n` + code);
} else {
if (!styleMap.has(id) && processCss) {
try {
const { css } = await less.render(getCss(processCss), {
filename: path.resolve(id),
plugins: [lessPlugin(id, config)],
});
extCssString += `${css}\n`;
} catch {
// 同上:拼出来的 selector wrapper less 解析失败也跳过
}
}
styleMap.set(id, processCss);
}
return null;
},
async writeBundle() {
if (!extractCss) return;
const {
root,
build: { outDir, assetsDir, minify },
} = config;
if (minify) {
extCssString = await minifyCSS(extCssString, config);
}
const cssOutputPath = path.resolve(root, outDir, assetsDir, cssOutputName);
fs.writeFileSync(cssOutputPath, extCssString);
},
closeBundle() {
if (verbose && !isServer && extractCss) {
const {
build: { outDir, assetsDir },
} = config;
console.log(
chalk.cyan('\n✨ [vite-plugin-theme:antd-dark]') +
` - extract antd dark css code file is successfully:`,
);
try {
const { size } = fs.statSync(path.join(outDir, assetsDir, cssOutputName));
console.log(
chalk.dim(outDir + '/') +
chalk.magentaBright(`${assetsDir}/${cssOutputName}`) +
`\t\t${chalk.dim((size / 1024).toFixed(2) + 'kb')}` +
'\n',
);
} catch {}
}
},
},
];
}

View File

@ -0,0 +1,191 @@
// @ts-nocheck
// 以下 6 个 placeholder 标识符由 build/vite/plugin/theme-plugin/injectClientPlugin.ts
// 在 transform hook 中以正则单词边界字符串替换为最终值。
// 不要写 `declare const __X__: T` —— 替换会破坏 declare 语句结构。
// vite 用 oxc/esbuild 编译 .ts 不做严格类型检查,未声明标识符能编过。
export const globalField = '__VITE_THEME__';
export const styleTagId = '__VITE_PLUGIN_THEME__';
export const darkStyleTagId = '__VITE_PLUGIN_DARK_THEME__';
export const linkID = '__VITE_PLUGIN_THEME-ANTD_DARK_THEME_LINK__';
const colorPluginOutputFileName = __COLOR_PLUGIN_OUTPUT_FILE_NAME__;
const isProd = __PROD__;
const colorPluginOptions = __COLOR_PLUGIN_OPTIONS__;
const injectTo = colorPluginOptions && colorPluginOptions.injectTo;
const debounceThemeRender = debounce(200, renderTheme);
export let darkCssIsReady = false;
(() => {
if (!(window as any)[globalField]) {
(window as any)[globalField] = {
styleIdMap: new Map(),
styleRenderQueueMap: new Map(),
};
}
setGlobalOptions('replaceStyleVariables', replaceStyleVariables);
if (!getGlobalOptions('defaultOptions')) {
setGlobalOptions('defaultOptions', colorPluginOptions);
}
})();
export function addCssToQueue(id: string, styleString: string) {
const styleIdMap: Map<string, string> = getGlobalOptions('styleIdMap');
if (!styleIdMap.get(id)) {
(window as any)[globalField].styleRenderQueueMap.set(id, styleString);
debounceThemeRender();
}
}
function renderTheme() {
const variables = getGlobalOptions('colorVariables');
if (!variables) return;
const styleRenderQueueMap: Map<string, string> = getGlobalOptions('styleRenderQueueMap');
const styleDom = getStyleDom(styleTagId);
let html = styleDom.innerHTML;
for (const [id, css] of styleRenderQueueMap.entries()) {
html += css;
(window as any)[globalField].styleRenderQueueMap.delete(id);
(window as any)[globalField].styleIdMap.set(id, css);
}
replaceCssColors(html, variables).then((processCss) => {
appendCssToDom(styleDom, processCss, injectTo);
});
}
export async function replaceStyleVariables({
colorVariables,
customCssHandler,
}: {
colorVariables: string[];
customCssHandler?: (css: string) => string;
}) {
setGlobalOptions('colorVariables', colorVariables);
const styleIdMap: Map<string, string> = getGlobalOptions('styleIdMap');
const styleRenderQueueMap: Map<string, string> = getGlobalOptions('styleRenderQueueMap');
if (!isProd) {
for (const [id, css] of styleIdMap.entries()) {
styleRenderQueueMap.set(id, css);
}
renderTheme();
} else {
try {
const cssText = await fetchCss(colorPluginOutputFileName);
const styleDom = getStyleDom(styleTagId);
const processCss = await replaceCssColors(cssText, colorVariables, customCssHandler);
appendCssToDom(styleDom, processCss, injectTo);
} catch (error: any) {
throw new Error(error);
}
}
}
export async function loadDarkThemeCss() {
if (darkCssIsReady || !__ANTD_DARK_PLUGIN_EXTRACT_CSS__) return;
if (__ANTD_DARK_PLUGIN_LOAD_LINK__) {
const linkTag = document.getElementById(linkID);
if (linkTag) {
linkTag.removeAttribute('disabled');
linkTag.setAttribute('rel', 'stylesheet');
}
} else {
const cssText = await fetchCss(__ANTD_DARK_PLUGIN_OUTPUT_FILE_NAME__);
const styleDom = getStyleDom(darkStyleTagId);
appendCssToDom(styleDom, cssText, injectTo);
}
darkCssIsReady = true;
}
export async function replaceCssColors(
css: string,
colors: string[],
customCssHandler?: (css: string) => string,
) {
let retCss = css;
const defaultOptions = getGlobalOptions('defaultOptions');
const colorVariables: string[] = defaultOptions ? defaultOptions.colorVariables || [] : [];
colorVariables.forEach(function (color, index) {
const reg = new RegExp(
color.replace(/,/g, ',\\s*').replace(/\s/g, '').replace('(', `\\(`).replace(')', `\\)`) +
'([\\da-f]{2})?(\\b|\\)|,|\\s)?',
'ig',
);
retCss = retCss.replace(reg, colors[index] + '$1$2').replace('$1$2', '');
if (customCssHandler && typeof customCssHandler === 'function') {
retCss = customCssHandler(retCss) || retCss;
}
});
return retCss;
}
export function setGlobalOptions(key: string, value: any) {
(window as any)[globalField][key] = value;
}
export function getGlobalOptions(key: string) {
return (window as any)[globalField][key];
}
export function getStyleDom(id: string): HTMLStyleElement {
let style = document.getElementById(id) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.setAttribute('id', id);
}
return style;
}
export async function appendCssToDom(
styleDom: HTMLStyleElement,
cssText: string,
appendTo: 'head' | 'body' | 'body-prepend' = 'body',
) {
styleDom.innerHTML = cssText;
if (appendTo === 'head') {
document.head.appendChild(styleDom);
} else if (appendTo === 'body') {
document.body.appendChild(styleDom);
} else if (appendTo === 'body-prepend') {
const firstChildren = document.body.firstChild;
document.body.insertBefore(styleDom, firstChildren);
}
}
function fetchCss(fileName: string): Promise<string> {
return new Promise((resolve, reject) => {
const append = getGlobalOptions('appended');
if (append) {
setGlobalOptions('appended', false);
resolve('');
return;
}
const xhr = new XMLHttpRequest();
xhr.onload = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) resolve(xhr.responseText);
else reject(xhr.status);
}
};
xhr.onerror = function (e) {
reject(e);
};
xhr.ontimeout = function (e) {
reject(e);
};
xhr.open('GET', fileName, true);
xhr.send();
});
}
function debounce<T extends (...args: any[]) => any>(delay: number, fn: T) {
let timer: ReturnType<typeof setTimeout> | undefined;
return function (this: any, ...args: any[]) {
const ctx = this;
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(ctx, args);
}, delay);
};
}

View File

@ -0,0 +1,50 @@
import tinycolor from 'tinycolor2';
export function mixLighten(colorStr: string, weight: number) {
return mix('fff', colorStr, weight);
}
export function mixDarken(colorStr: string, weight: number) {
return mix('000', colorStr, weight);
}
export function mix(color1: string, color2: string, weight: number, alpha1?: number, alpha2?: number) {
color1 = dropPrefix(color1);
color2 = dropPrefix(color2);
if (weight === undefined) weight = 0.5;
if (alpha1 === undefined) alpha1 = 1;
if (alpha2 === undefined) alpha2 = 1;
const w = 2 * weight - 1;
const alphaDelta = alpha1 - alpha2;
const w1 = ((w * alphaDelta === -1 ? w : (w + alphaDelta) / (1 + w * alphaDelta)) + 1) / 2;
const w2 = 1 - w1;
const rgb1 = toNum3(color1);
const rgb2 = toNum3(color2);
const r = Math.round(w1 * rgb1[0] + w2 * rgb2[0]);
const g = Math.round(w1 * rgb1[1] + w2 * rgb2[1]);
const b = Math.round(w1 * rgb1[2] + w2 * rgb2[2]);
return '#' + pad2(r) + pad2(g) + pad2(b);
}
export function toNum3(colorStr: string) {
colorStr = dropPrefix(colorStr);
if (colorStr.length === 3) {
colorStr = colorStr[0] + colorStr[0] + colorStr[1] + colorStr[1] + colorStr[2] + colorStr[2];
}
const r = parseInt(colorStr.slice(0, 2), 16);
const g = parseInt(colorStr.slice(2, 4), 16);
const b = parseInt(colorStr.slice(4, 6), 16);
return [r, g, b];
}
export function dropPrefix(colorStr: string) {
return colorStr.replace('#', '');
}
export function pad2(num: number) {
let t = num.toString(16);
if (t.length === 1) t = '0' + t;
return t;
}
export { tinycolor };

View File

@ -0,0 +1,36 @@
import path from 'path';
import { normalizePath } from 'vite';
export const VITE_CLIENT_ENTRY = '/@vite/client';
// 客户端入口(指向项目内置的 client.ts —— 预构建后会落到 node_modules/.vite 缓存里)
// 这里给一个虚拟绝对路径injectClientPlugin 用它来匹配 transform 的 id
export const VITE_PLUGIN_THEME_CLIENT_ENTRY = normalizePath(
path.resolve(process.cwd(), 'build/vite/plugin/theme-plugin/client'),
);
export const CLIENT_PUBLIC_ABSOLUTE_PATH = normalizePath(
VITE_PLUGIN_THEME_CLIENT_ENTRY + '/client.ts',
);
// 业务 import 时使用的相对路径片段(按 normalizePath 规范化),用于宽匹配
export const CLIENT_PUBLIC_PATH_FRAGMENT = 'theme-plugin/client/client';
export const commentRE = /\\\\?n|\n|\\\\?r|\/\*[\s\S]+?\*\//g;
const cssLangs = `\\.(css|less|sass|scss|styl|stylus|postcss)($|\\?)`;
export const colorRE =
/#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})|rgba?\((.*),\s*(.*),\s*(.*)(?:,\s*(.*(?:.*)?))?\)/gi;
export const cssVariableString = `const css = "`;
export const cssBlockRE = /[^}]*\{[^{]*\}/g;
export const cssLangRE = new RegExp(cssLangs);
export const ruleRE = /(\w+-)*\w+:/;
export const cssValueRE = /(\s?[a-z0-9]+\s)*/;
export const safeEmptyRE = /\s?/;
export const importSafeRE = /(\s*!important)?/;
export const linkID = '__VITE_PLUGIN_THEME-ANTD_DARK_THEME_LINK__';

View File

@ -0,0 +1,185 @@
import type { Plugin, ResolvedConfig } from 'vite';
import path from 'path';
import fs from 'fs-extra';
import { debug as Debug } from 'debug';
import chalk from 'chalk';
import { extractVariable, minifyCSS, createFileHash, formatCss } from './utils';
import { VITE_CLIENT_ENTRY, cssLangRE, cssVariableString, CLIENT_PUBLIC_ABSOLUTE_PATH } from './constants';
import { injectClientPlugin } from './injectClientPlugin';
export * from './client/colorUtils';
export { antdDarkThemePlugin } from './antdDarkThemePlugin';
export type ResolveSelector = (selector: string) => string;
export type InjectTo = 'head' | 'body' | 'body-prepend';
export interface ViteThemeOptions {
colorVariables: string[];
wrapperCssSelector?: string;
resolveSelector?: ResolveSelector;
customerExtractVariable?: (code: string) => string;
fileName?: string;
injectTo?: InjectTo;
verbose?: boolean;
}
const debug = Debug('vite-plugin-theme');
export function viteThemePlugin(opt: ViteThemeOptions): Plugin[] {
let isServer = false;
let config: ResolvedConfig;
let clientPath = '';
const styleMap = new Map<string, string>();
const extCssSet = new Set<string>();
const emptyPlugin: Plugin = { name: 'vite:theme' };
const options: ViteThemeOptions = Object.assign(
{
colorVariables: [],
wrapperCssSelector: '',
fileName: 'app-theme-style',
injectTo: 'body',
verbose: true,
},
opt,
);
debug('plugin options:', options);
const {
colorVariables,
wrapperCssSelector,
resolveSelector,
customerExtractVariable,
fileName,
verbose,
} = options;
if (!colorVariables || colorVariables.length === 0) {
console.error('colorVariables is not empty!');
return [emptyPlugin];
}
const resolveSelectorFn =
resolveSelector || ((s: string) => `${wrapperCssSelector} ${s}`);
const cssOutputName = `${fileName}.${createFileHash()}.css`;
let needSourcemap = false;
return [
injectClientPlugin('colorPlugin', {
colorPluginCssOutputName: cssOutputName,
colorPluginOptions: options,
}),
{
...emptyPlugin,
enforce: 'post',
configResolved(resolvedConfig) {
config = resolvedConfig;
isServer = resolvedConfig.command === 'serve';
// dev 模式直接通过 /@fs/<absolute> 让浏览器加载内置 client.ts
clientPath = JSON.stringify(
isServer
? '/@fs/' + CLIENT_PUBLIC_ABSOLUTE_PATH
: path.posix.join(config.base, CLIENT_PUBLIC_ABSOLUTE_PATH),
);
needSourcemap = !!resolvedConfig.build.sourcemap;
debug('plugin config base:', resolvedConfig.base);
},
async transform(code, id) {
if (!cssLangRE.test(id)) return null;
const getResult = (content: string) => ({
map: needSourcemap ? this.getCombinedSourcemap() : null,
code: content,
});
const clientCode = isServer
? await getClientStyleString(code)
: code.replace('export default', '').replace('"', '');
const extractCssCodeTemplate =
typeof customerExtractVariable === 'function'
? customerExtractVariable(clientCode)
: extractVariable(clientCode, colorVariables, resolveSelectorFn);
debug('extractCssCodeTemplate:', id, extractCssCodeTemplate?.slice(0, 100));
if (!extractCssCodeTemplate) return null;
if (isServer) {
const retCode = [
`import { addCssToQueue } from ${clientPath}`,
`const themeCssId = ${JSON.stringify(id)}`,
`const themeCssStr = ${JSON.stringify(formatCss(extractCssCodeTemplate))}`,
`addCssToQueue(themeCssId, themeCssStr)`,
code,
];
return getResult(retCode.join('\n'));
} else {
if (!styleMap.has(id)) {
extCssSet.add(extractCssCodeTemplate);
}
styleMap.set(id, extractCssCodeTemplate);
}
return null;
},
async writeBundle() {
const {
root,
build: { outDir, assetsDir, minify },
} = config;
let extCssString = '';
for (const css of extCssSet) {
extCssString += css;
}
if (minify) {
extCssString = await minifyCSS(extCssString, config);
}
const cssOutputPath = path.resolve(root, outDir, assetsDir, cssOutputName);
fs.ensureDirSync(path.dirname(cssOutputPath));
fs.writeFileSync(cssOutputPath, extCssString);
},
closeBundle() {
if (verbose && !isServer) {
const {
build: { outDir, assetsDir },
} = config;
console.log(
chalk.cyan('\n✨ [vite-plugin-theme]') +
` - extract css code file is successfully:`,
);
try {
const { size } = fs.statSync(path.join(outDir, assetsDir, cssOutputName));
console.log(
chalk.dim(outDir + '/') +
chalk.magentaBright(`${assetsDir}/${cssOutputName}`) +
`\t\t${chalk.dim((size / 1024).toFixed(2) + 'kb')}` +
'\n',
);
} catch {}
}
},
},
];
}
async function getClientStyleString(code: string) {
if (!code.includes(VITE_CLIENT_ENTRY)) return code;
code = code.replace(/\\n/g, '');
const cssPrefix = cssVariableString;
const cssPrefixLen = cssPrefix.length;
const cssPrefixIndex = code.indexOf(cssPrefix);
const len = cssPrefixIndex + cssPrefixLen;
const cssLastIndex = code.indexOf('\n', len + 1);
if (cssPrefixIndex !== -1) {
code = code.slice(len, cssLastIndex);
}
return code;
}

View File

@ -0,0 +1,94 @@
import path from 'path';
import { normalizePath } from 'vite';
import type { Plugin, ResolvedConfig } from 'vite';
import type { ViteThemeOptions } from './index';
import { CLIENT_PUBLIC_ABSOLUTE_PATH, CLIENT_PUBLIC_PATH_FRAGMENT } from './constants';
type PluginType = 'colorPlugin' | 'antdDarkPlugin';
/**
* transform hook client.ts 6
*
* vite 8 `define` dev .ts
* transform-based @rys-fe/vite-plugin-theme
*
* plugin colorPlugin + antdDarkPlugin transform
*
*/
export function injectClientPlugin(
type: PluginType,
{
colorPluginOptions,
colorPluginCssOutputName,
antdDarkCssOutputName,
antdDarkExtractCss,
antdDarkLoadLink,
}: {
colorPluginOptions?: ViteThemeOptions;
antdDarkCssOutputName?: string;
colorPluginCssOutputName?: string;
antdDarkExtractCss?: boolean;
antdDarkLoadLink?: boolean;
},
): Plugin {
let config: ResolvedConfig;
let isServer = false;
return {
name: `vite:inject-vite-plugin-theme-client(${type})`,
enforce: 'pre',
configResolved(resolvedConfig) {
config = resolvedConfig;
isServer = resolvedConfig.command === 'serve';
},
transform(code, id) {
const nid = normalizePath(id);
const isClientFile =
nid === CLIENT_PUBLIC_ABSOLUTE_PATH ||
nid.endsWith(CLIENT_PUBLIC_PATH_FRAGMENT + '.ts') ||
nid.endsWith(CLIENT_PUBLIC_PATH_FRAGMENT + '.js') ||
nid.includes(CLIENT_PUBLIC_PATH_FRAGMENT.replace(/\//gi, '_'));
if (!isClientFile) return;
const assetsDir = config.build?.assetsDir ?? 'assets';
const base = config.base ?? '/';
const getOutputFile = (name?: string) =>
JSON.stringify(`${base}${assetsDir}/${name}`);
// __PROD__: 所有 plugin 实例都注入
code = code.replace(/\b__PROD__\b/g, JSON.stringify(!isServer));
if (type === 'colorPlugin') {
code = code
.replace(/\b__COLOR_PLUGIN_OUTPUT_FILE_NAME__\b/g, getOutputFile(colorPluginCssOutputName))
.replace(/\b__COLOR_PLUGIN_OPTIONS__\b/g, JSON.stringify(colorPluginOptions ?? {}));
}
if (type === 'antdDarkPlugin') {
code = code.replace(
/\b__ANTD_DARK_PLUGIN_OUTPUT_FILE_NAME__\b/g,
getOutputFile(antdDarkCssOutputName),
);
if (typeof antdDarkExtractCss === 'boolean') {
code = code.replace(
/\b__ANTD_DARK_PLUGIN_EXTRACT_CSS__\b/g,
JSON.stringify(antdDarkExtractCss),
);
}
if (typeof antdDarkLoadLink === 'boolean') {
code = code.replace(
/\b__ANTD_DARK_PLUGIN_LOAD_LINK__\b/g,
JSON.stringify(antdDarkLoadLink),
);
}
}
return { code, map: null };
},
};
}
// 防止 path 被 tree-shake
export const _PATH = path.posix;

View File

@ -0,0 +1,147 @@
import path from 'path';
import fs from 'fs';
import type { Alias, ResolvedConfig } from 'vite';
import { normalizePath } from 'vite';
import less from 'less';
export type ResolveFn = (
id: string,
importer?: string,
aliasOnly?: boolean,
) => Promise<string | undefined>;
type CssUrlReplacer = (url: string, importer?: string) => string | Promise<string>;
export const externalRE = /^(https?:)?\/\//;
export const isExternalUrl = (url: string) => externalRE.test(url);
export const dataUrlRE = /^\s*data:/i;
export const isDataUrl = (url: string) => dataUrlRE.test(url);
const cssUrlRE = /url\(\s*('[^']+'|"[^"]+"|[^'")]+)\s*\)/;
let ViteLessManager: any;
function createViteLessPlugin(
rootFile: string,
alias: Alias[],
resolvers: { less: ResolveFn },
): Less.Plugin {
if (!ViteLessManager) {
ViteLessManager = class ViteManager extends (less as any).FileManager {
resolvers: any;
rootFile: string;
alias: Alias[];
constructor(rootFile: string, resolvers: { less: ResolveFn }, alias: Alias[]) {
super();
this.rootFile = rootFile;
this.resolvers = resolvers;
this.alias = alias;
}
supports() {
return true;
}
supportsSync() {
return false;
}
async loadFile(filename: string, dir: string, opts: any, env: any) {
const resolved = await this.resolvers.less(filename, path.join(dir, '*'));
if (resolved) {
const result = await rebaseUrls(resolved, this.rootFile, this.alias);
let contents: string;
if (result && 'contents' in result) {
contents = result.contents as string;
} else {
contents = fs.readFileSync(resolved, 'utf-8');
}
return {
filename: path.resolve(resolved),
contents,
};
} else {
return super.loadFile(filename, dir, opts, env);
}
}
};
}
return {
install(_: any, pluginManager: any) {
pluginManager.addFileManager(new ViteLessManager(rootFile, resolvers, alias));
},
minVersion: [3, 0, 0],
};
}
export function lessPlugin(id: string, config: ResolvedConfig) {
const resolvers = createCSSResolvers(config);
return createViteLessPlugin(id, (config.resolve as any).alias as Alias[], resolvers);
}
// vite 8 中 ResolvedConfig.createResolver 已经被环境化(依赖 config.environments
// 直接调用会抛 "Cannot read properties of undefined (reading 'environments')"。
// antd dark theme 处理的是 less 源码,业务 less 中的 alias import 已由上游 vite 流水线处理过;
// 这里只用 less 默认 FileManager相对路径少数 alias import 失败时由 antdDark transform 的
// try/catch 捕获并跳过,不阻塞 build。
function createCSSResolvers(_config: ResolvedConfig): { less: ResolveFn } {
return {
less: (async (_id: string) => undefined) as ResolveFn,
};
}
async function rebaseUrls(file: string, rootFile: string, alias: Alias[]): Promise<any> {
file = path.resolve(file);
const fileDir = path.dirname(file);
const rootDir = path.dirname(rootFile);
if (fileDir === rootDir) return { file };
const content = fs.readFileSync(file, 'utf-8');
if (!cssUrlRE.test(content)) return { file };
const rebased = await rewriteCssUrls(content, (url) => {
if (url.startsWith('/')) return url;
for (const { find } of alias) {
const matches = typeof find === 'string' ? url.startsWith(find) : (find as RegExp).test(url);
if (matches) return url;
}
const absolute = path.resolve(fileDir, url);
const relative = path.relative(rootDir, absolute);
return normalizePath(relative);
});
return { file, contents: rebased };
}
function rewriteCssUrls(css: string, replacer: CssUrlReplacer): Promise<string> {
return asyncReplace(css, cssUrlRE, async (match) => {
const [matched, rawUrl] = match;
return await doUrlReplace(rawUrl, matched, replacer);
});
}
export async function asyncReplace(
input: string,
re: RegExp,
replacer: (match: RegExpExecArray) => string | Promise<string>,
) {
let match: RegExpExecArray | null;
let remaining = input;
let rewritten = '';
while ((match = re.exec(remaining))) {
rewritten += remaining.slice(0, match.index);
rewritten += await replacer(match);
remaining = remaining.slice(match.index + match[0].length);
}
rewritten += remaining;
return rewritten;
}
async function doUrlReplace(rawUrl: string, matched: string, replacer: CssUrlReplacer) {
let wrap = '';
const first = rawUrl[0];
if (first === `"` || first === `'`) {
wrap = first;
rawUrl = rawUrl.slice(1, -1);
}
if (isExternalUrl(rawUrl) || isDataUrl(rawUrl) || rawUrl.startsWith('#')) {
return matched;
}
return `url(${wrap}${await replacer(rawUrl)}${wrap})`;
}

View File

@ -0,0 +1,129 @@
import type { ResolvedConfig } from 'vite';
import { createHash } from 'crypto';
import CleanCSS from 'clean-css';
import {
commentRE,
cssBlockRE,
ruleRE,
cssValueRE,
safeEmptyRE,
importSafeRE,
} from './constants';
export type ResolveSelector = (selector: string) => string;
export function getVariablesReg(colors: string[]) {
return new RegExp(
colors
.map(
(i) =>
`(${i
.replace(/\s/g, ' ?')
.replace(/\(/g, `\\(`)
.replace(/\)/g, `\\)`)
.replace(/0?\./g, `0?\\.`)})`,
)
.join('|'),
);
}
export function combineRegs(decorator = '', joinString = '', ...args: any[]) {
const regString = args
.map((item) => {
const str = item.toString();
return `(${str.slice(1, str.length - 1)})`;
})
.join(joinString);
return new RegExp(regString, decorator);
}
export function formatCss(s: string) {
s = s.replace(/\s*([{}:;,])\s*/g, '$1');
s = s.replace(/;\s*;/g, ';');
s = s.replace(/,[\s.#\d]*{/g, '{');
s = s.replace(/([^\s])\{([^\s])/g, '$1 {\n\t$2');
s = s.replace(/([^\s])\}([^\n]*)/g, '$1\n}\n$2');
s = s.replace(/([^\s]);([^\s}])/g, '$1;\n\t$2');
return s;
}
export function createFileHash() {
return createHash('sha256').update(Date.now().toString()).digest('hex').substr(0, 8);
}
/**
* Compress the generated css.
* vite 8 config.build.cleanCssOptions使 CleanCSS
*/
export async function minifyCSS(css: string, config: ResolvedConfig) {
const res = new CleanCSS({ rebase: false }).minify(css);
if (res.errors && res.errors.length) {
console.error(`error when minifying css:\n${res.errors}`);
throw res.errors[0];
}
if (res.warnings && res.warnings.length) {
config.logger.warn(`warnings when minifying css:\n${res.warnings}`);
}
return res.styles;
}
// Used to extract relevant color configuration in css
export function extractVariable(
code: string,
colorVariables: string[],
resolveSelector?: ResolveSelector,
colorRE?: RegExp,
): string {
colorVariables = Array.from(new Set(colorVariables));
code = code.replace(commentRE, '');
const cssBlocks = code.match(cssBlockRE);
if (!cssBlocks || cssBlocks.length === 0) {
return '';
}
let allExtractedVariable = '';
const variableReg = getVariablesReg(colorVariables);
for (let index = 0; index < cssBlocks.length; index++) {
const cssBlock = cssBlocks[index];
if (!variableReg.test(cssBlock) || !cssBlock) continue;
const cssSelector = cssBlock.match(/[^{]*/)?.[0] ?? '';
if (!cssSelector) continue;
if (/^@.*keyframes/.test(cssSelector)) {
allExtractedVariable += `${cssSelector}{${extractVariable(
cssBlock.replace(/[^{]*\{/, '').replace(/}$/, ''),
colorVariables,
resolveSelector,
colorRE,
)}}`;
continue;
}
const colorReg = combineRegs(
'g',
'',
ruleRE,
cssValueRE,
safeEmptyRE,
variableReg,
importSafeRE,
);
const colorReplaceTemplates = cssBlock.match(colorRE || colorReg);
if (!colorReplaceTemplates) continue;
allExtractedVariable += `${
resolveSelector ? resolveSelector(cssSelector) : cssSelector
} {${colorReplaceTemplates.join(';')}}`;
}
return allExtractedVariable;
}

101
build/vite/plugin/theme.ts Normal file
View File

@ -0,0 +1,101 @@
/**
* Vite plugin for website theme color switching
* https://github.com/anncwb/vite-plugin-theme
*/
import type { PluginOption } from 'vite';
import path from 'path';
// 项目内置版本(适配 vite 8原 @rys-fe/vite-plugin-theme 不再适配 vite 8已搬入 ./theme-plugin
import { viteThemePlugin, antdDarkThemePlugin, mixLighten, mixDarken, tinycolor } from './theme-plugin';
import { getThemeColors, generateColors } from '../../config/themeConfig';
import { generateModifyVars } from '../../generate/generateModifyVars';
export function configThemePlugin(isBuild: boolean): PluginOption[] {
const colors = generateColors({
mixDarken,
mixLighten,
tinycolor,
});
// update-begin-修复编译后主题色切换不生效黑屏的问题-----------------------
// https://github.com/vbenjs/vue-vben-admin/issues/1445
// 抽取出viteThemePlugin插件下方会根据不同环境设置enforce
const vite_theme_plugin = viteThemePlugin({
resolveSelector: (s) => {
s = s.trim();
switch (s) {
case '.ant-steps-item-process .ant-steps-item-icon > .ant-steps-icon':
return '.ant-steps-item-icon > .ant-steps-icon';
case '.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled)':
case '.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover':
case '.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):active':
return s;
case '.ant-steps-item-icon > .ant-steps-icon':
return s;
case '.ant-select-item-option-selected:not(.ant-select-item-option-disabled)':
return s;
default:
if (s.indexOf('.ant-btn') >= -1) {
// 按钮被重新定制过需要过滤掉class防止覆盖
return s;
}
}
return s.startsWith('[data-theme') ? s : `[data-theme] ${s}`;
},
colorVariables: [...getThemeColors(), ...colors],
});
vite_theme_plugin.forEach(function (item) {
//对vite:theme插件特殊配置
if ('vite:theme' === item.name) {
// 打包时去除enforce: "post"vite 2.6.x适配否则生成app-theme-style为空因为async transform(code, id) {的code没有正确获取
if (isBuild) {
delete item.enforce;
}
}
});
// update-end-修复编译后主题色切换不生效黑屏的问题-----------------------
const plugin = [
vite_theme_plugin,
antdDarkThemePlugin({
preloadFiles: [
// path.resolve(process.cwd(), 'node_modules/ant-design-vue/dist/reset.css'),
//path.resolve(process.cwd(), 'node_modules/ant-design-vue/dist/antd.dark.less'),
path.resolve(process.cwd(), 'src/design/index.less'),
],
filter: (id) => (isBuild ? !id.endsWith('antd.less') : true),
// extractCss: false,
darkModifyVars: {
...generateModifyVars(true),
'text-color': '#c9d1d9',
'primary-1': 'rgb(255 255 255 / 8%)',
'text-color-base': '#c9d1d9',
'component-background': '#151515',
'heading-color': 'rgb(255 255 255 / 65%)',
// black: '#0e1117',
// #8b949e
'text-color-secondary': '#8b949e',
'border-color-base': '#303030',
'header-light-bottom-border-color': '#303030',
// 'border-color-split': '#30363d',
'item-active-bg': '#111b26',
'app-content-background': '#1e1e1e',
'tree-node-selected-bg': '#11263c',
'alert-success-border-color': '#274916',
'alert-success-bg-color': '#162312',
'alert-success-icon-color': '#49aa19',
'alert-info-border-color': '#153450',
'alert-info-bg-color': '#111b26',
'alert-info-icon-color': '#177ddc',
'alert-warning-border-color': '#594214',
'alert-warning-bg-color': '#2b2111',
'alert-warning-icon-color': '#d89614',
'alert-error-border-color': '#58181c',
'alert-error-bg-color': '#2a1215',
'alert-error-icon-color': '#a61d24',
},
}),
];
return plugin as unknown as PluginOption[];
}

View File

@ -0,0 +1,17 @@
/**
* Package file volume analysis
*/
import visualizer from 'rollup-plugin-visualizer';
import { isReportMode } from '../../utils';
export function configVisualizerConfig() {
if (isReportMode()) {
return visualizer({
filename: './node_modules/.cache/visualizer/stats.html',
open: true,
gzipSize: true,
brotliSize: true,
}) as Plugin;
}
return [];
}

34
build/vite/proxy.ts Normal file
View File

@ -0,0 +1,34 @@
/**
* Used to parse the .env.development proxy configuration
*/
import type { ProxyOptions } from 'vite';
type ProxyItem = [string, string];
type ProxyList = ProxyItem[];
type ProxyTargetList = Record<string, ProxyOptions>;
const httpsRE = /^https:\/\//;
/**
* Generate proxy
* @param list
*/
export function createProxy(list: ProxyList = []) {
const ret: ProxyTargetList = {};
for (const [prefix, target] of list) {
const isHttps = httpsRE.test(target);
// https://github.com/http-party/node-http-proxy#options
ret[prefix] = {
target: target,
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''),
// https is require secure=false
...(isHttps ? { secure: false } : {}),
};
}
return ret;
}

32
commitlint.config.js Normal file
View File

@ -0,0 +1,32 @@
module.exports = {
ignores: [(commit) => commit.includes('init')],
extends: ['@commitlint/config-conventional'],
rules: {
'body-leading-blank': [2, 'always'],
'footer-leading-blank': [1, 'always'],
'header-max-length': [2, 'always', 108],
'subject-empty': [2, 'never'],
'type-empty': [2, 'never'],
'type-enum': [
2,
'always',
[
'feat',
'fix',
'perf',
'style',
'docs',
'test',
'refactor',
'build',
'ci',
'chore',
'revert',
'wip',
'workflow',
'types',
'release',
],
],
},
};

9
deploy/.env.example Normal file
View File

@ -0,0 +1,9 @@
# ============================================
# 前端部署配置 — 复制为 .env 后修改
# ============================================
APP_NAME=test
FRONTEND_PORT=80
# Docker 外部网络(共享后端及 MySQL/Redis 的网络名,与后端保持一致)
EXTERNAL_NETWORK=public-services_default

3
deploy/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# 实际配置不入 git
.env
.docker/

23
deploy/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# ============================================
# 前端镜像 — 宿主机构建 distDocker 打包 Nginx
# 启动时通过 BACKEND_PROXY 环境变量指定后端地址
# ============================================
FROM nginx:alpine
LABEL maintainer="ghb-base deploy"
# 安装 envsubstgettext 包提供)
RUN apk add --no-cache gettext
COPY deploy/nginx/default.conf /etc/nginx/templates/default.conf.template
COPY dist /usr/share/nginx/html
EXPOSE 80
# envsubst 会将模板中 ${BACKEND_PROXY} 替换为实际值
# 单体模式: http://ghb-backend:8081/ghb
# Cloud 模式: http://ghb-gateway:9999/ghb
ENV BACKEND_PROXY=http://ghb-backend:8081/ghb
CMD ["/bin/sh", "-c", "envsubst '${BACKEND_PROXY}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"]

63
deploy/README.md Normal file
View File

@ -0,0 +1,63 @@
# 前端 Docker 部署
## 前置条件
- 服务器需安装 **Node.js 18+****pnpm**macOS 执行 `npm install -g pnpm`
- 后端已部署在同一台服务器上
## 首次部署
```bash
cd deploy
cp .env.example .env
vim .env # 改端口(默认 80 常被占用,建议 3100
bash deploy.sh rebuild
```
## 日常操作
| 命令 | 作用 | 场景 |
| ------------------------------- | -------------------------- | ------------------ |
| `bash deploy.sh start` | 停旧容器 → 启动(不编译) | 改了 nginx 配置 |
| `bash deploy.sh build [分支]` | 宿主机构建 dist → 编译镜像 | 代码变了,先编译 |
| `bash deploy.sh rebuild [分支]` | 拉取分支代码 + 编译 + 启动 | 代码变了,一步到位 |
| `bash deploy.sh stop` | 停止 | - |
| `bash deploy.sh logs` | 查看日志 | 排查问题 |
| `bash deploy.sh status` | 容器状态 | - |
> 分支参数默认 `master`,指定其他分支:`bash deploy.sh rebuild test`
## 构建原理
宿主机构建 dist + Docker 打包 Nginx因 Mac Docker Desktop 内存限制Docker 内编译会 OOM
```
宿主 pnpm build → dist/
Docker (nginx:alpine)
├─ nginx 配置 → /etc/nginx/conf.d/
└─ dist/ → /usr/share/nginx/html/
```
## Nginx 代理规则
| 路径 | 转发目标 | 说明 |
| ------------- | ------------------------------- | ------------------------- |
| `/ghb-boot/*` | `http://ghb-backend:8081/ghb/*` | API 代理到后端容器 |
| `/*` | 本地静态文件 | 前端路由回退到 index.html |
前后端通过 `public-services_default` 外部网络互通,无需额外配置。
## 目录结构
```
deploy/
├── deploy.sh # 部署脚本(与后端命令一致)
├── .env.example # 配置模板
├── .env # 实际配置(不入 git
├── Dockerfile # 精简镜像nginx + dist
├── docker-compose.yml # Docker 编排
├── nginx/
│ └── default.conf # Nginx 配置(含 API 代理)
└── README.md
```

117
deploy/deploy.sh Executable file
View File

@ -0,0 +1,117 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
cd "$SCRIPT_DIR"
# 检查 .env
if [ ! -f .env ]; then
echo "❌ 未找到 .env请先执行cp .env.example .env && vim .env"
exit 1
fi
CMD="${1:-help}"
BRANCH="${2:-master}"
case "$CMD" in
start)
IMAGE_NAME="$(grep APP_NAME .env | cut -d= -f2 | tr -d '"' | tr -d "'" | xargs)-frontend:latest"
IMAGE_NAME="${IMAGE_NAME:-ghb-frontend:latest}"
if ! docker image inspect "$IMAGE_NAME" > /dev/null 2>&1; then
echo "❌ 镜像 $IMAGE_NAME 不存在,请先执行: bash deploy.sh build"
exit 1
fi
echo ">>> 停掉旧容器..."
docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true
echo ""
echo ">>> 启动..."
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
echo ""
echo "✅ 已启动"
echo " 地址: http://localhost:$(grep FRONTEND_PORT .env | cut -d= -f2 | tr -d '"' | tr -d "'" | xargs)"
echo " 日志: bash deploy.sh logs"
;;
build)
echo ">>> 宿主机构建 dist..."
cd "$PROJECT_DIR"
if ! command -v pnpm &> /dev/null; then
echo "安装 pnpm..."
npm install -g pnpm
fi
pnpm install
pnpm build
echo ""
echo ">>> 编译 Docker 镜像..."
cd "$SCRIPT_DIR"
docker compose -f "$COMPOSE_FILE" build frontend
echo ""
echo "✅ 编译完成,执行 bash deploy.sh start 启动"
;;
rebuild)
echo ">>> 切到分支 ${BRANCH} 并拉取最新代码..."
cd "$PROJECT_DIR"
git fetch origin
git checkout "${BRANCH}"
git pull origin "${BRANCH}"
echo ""
echo ">>> 宿主机构建 dist..."
if ! command -v pnpm &> /dev/null; then
echo "安装 pnpm..."
npm install -g pnpm
fi
pnpm install
pnpm build
echo ""
echo ">>> 重新编译 + 启动..."
cd "$SCRIPT_DIR"
docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true
docker compose -f "$COMPOSE_FILE" build frontend
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
echo ""
echo "✅ 重新部署完成(分支: ${BRANCH}"
echo " 前端地址: http://localhost:$(grep FRONTEND_PORT .env | cut -d= -f2 | tr -d '"' | tr -d "'" | xargs)"
;;
stop)
docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true
echo "✅ 已停止"
;;
logs)
docker compose -f "$COMPOSE_FILE" logs -f --tail=200
;;
status)
docker compose -f "$COMPOSE_FILE" ps -a
;;
*)
echo "用法: bash deploy.sh <命令> [分支]"
echo ""
echo " start 停旧容器 → 启动(日常使用,不重新编译)"
echo " build 编译 dist + 编译镜像(代码变更后用一次)"
echo " rebuild [分支] 拉取分支代码 + 编译 + 启动(一步到位)"
echo " stop 停止"
echo " logs 查看日志"
echo " status 查看容器状态"
echo ""
echo "分支参数默认 master示例"
echo " bash deploy.sh rebuild test"
echo ""
echo "首次部署cp .env.example .env && vim .env && bash deploy.sh rebuild"
echo "日常更新bash deploy.sh start"
echo "代码变更bash deploy.sh rebuild <分支>"
;;
esac

26
deploy/docker-compose.yml Normal file
View File

@ -0,0 +1,26 @@
# ============================================
# 前端 Docker Compose
# ============================================
#
# 前端容器通过 public-services_default 网络连接后端及 MySQL/Redis
# 前提:后端已部署在同一台机器上
#
services:
frontend:
build:
context: ..
dockerfile: deploy/Dockerfile
image: ${APP_NAME:-ghb}-frontend:latest
container_name: ${APP_NAME:-ghb}-frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-80}:80"
networks:
- default
- shared
networks:
shared:
name: ${EXTERNAL_NETWORK:-public-services_default}
external: true

40
deploy/nginx/default.conf Normal file
View File

@ -0,0 +1,40 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 1024;
# 后端 API — 匹配已知接口路径前缀
# 新项目通过 init-project-repos.sh 会将 /test/ 替换为 /项目前缀/
location ~ ^/test/(sys|oauth2|jmreport|online|generic|drag|actuator|ws|websocket)/ {
proxy_pass ${BACKEND_PROXY};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_read_timeout 120s;
# WebSocket 支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 前端 SPA — 所有非 API 非文件请求回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源长期缓存Vite 生成的带 hash 文件)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

24
electron-builder.yaml Normal file
View File

@ -0,0 +1,24 @@
appId: 'com.jeecg.boot3'
# 产品名称
productName: 'jeecgboot'
files:
# 仅包含 dist 目录下所有文件
- 'dist/**/*'
# 特别排除 node_modules 目录
- '!node_modules'
directories:
# 输出目录
output: 'dist-electron'
win:
# win exe 程序图标
icon: 'electron/icons/app.ico'
artifactName: 'jeecgboot-setup-${version}.exe'
# 安装包配置
nsis:
oneClick: false
# 是否允许用户选择安装目录
allowToChangeInstallationDirectory: true
# 是否创建桌面快捷方式
createDesktopShortcut: true
# 安装程序的图标
installerIcon: 'electron/icons/installer.ico'

36
electron.md Normal file
View File

@ -0,0 +1,36 @@
# Electron桌面应用打包
- 1.安装依赖很慢得10分钟左右
- 2.electron桌面应用打包文档
https://help.jeecg.com/ui/setup/electron-build
- 3.临时注释掉electron功能
注释代码build/vite/plugin/electron.ts
修改build/vite/plugin/index.ts搜索`electron plugin`注释相关逻辑代码
修改package.json删除相关依赖
```yaml
{
"main": "dist/electron/main.js",
"scripts": {
"electron:dev": "cross-env VITE_GLOB_RUN_PLATFORM=electron npm run dev",
"electron:build-all": "npm run electron:build-web && npm run electron:build-app",
"electron:build-web": "cross-env VITE_GLOB_RUN_PLATFORM=electron NODE_ENV=production NODE_OPTIONS=--max-old-space-size=8192 vite build --mode prod_electron && cross-env VITE_GLOB_RUN_PLATFORM=electron esno ./build/script/postBuild.ts && esno ./build/script/copyChat.ts",
"electron:build-app": "esno ./electron/script/buildBefore.ts && electron-builder && esno ./electron/script/buildAfter.ts",
"electron:install": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js",
},
"devDependencies": {
"electron": "35.1.4",
"electron-builder": "^26.0.12",
"vite-plugin-electron": "^0.29.0",
},
}
```
> 提示:在执行`pnpm install`后如果Electron安装失败可以尝试运行`npm run electron:install`进行安装
# Electron桌面通知示例和代码位置
1. 代码位置electron/utils/tray.ts
2. 发送系统通知调用sendDesktopNotice
3. 开始托盘图标闪动调用startBlink
4. 停止托盘图标闪动调用stopBlink

18
electron/env.ts Normal file
View File

@ -0,0 +1,18 @@
// 不能直接使用 process.env会报错
export const $ps = process;
export const isDev = !!$ps.env.VITE_DEV_SERVER_URL;
export const $env = getEnv();
function getEnv() {
if (isDev) {
return $ps.env;
}
// 非开发环境,从 JSON 文件中获取环境变量
const env = require('./env.json');
return {
...$ps.env,
...env,
};
}

BIN
electron/icons/app.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
electron/icons/mac/dock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

43
electron/ipc/index.ts Normal file
View File

@ -0,0 +1,43 @@
import { Tray, ipcMain, BrowserWindow, app, Notification } from 'electron';
import type { NotificationConstructorOptions, IpcMainInvokeEvent } from 'electron';
import { openInBrowser } from '../utils';
import { omit } from 'lodash-es';
ipcMain.on('open-in-browser', (event: IpcMainInvokeEvent, url: string) => openInBrowser(url));
// 处理任务栏闪烁
ipcMain.on('notify-flash', (event: IpcMainInvokeEvent, count: number = 0) => {
const win = BrowserWindow.getAllWindows()[0];
if (!win) return;
if (win.isFocused()) return;
if (process.platform === 'win32') {
// windows
win.flashFrame(true);
} else if (process.platform === 'darwin') {
// Mac
if (app.dock) {
app.dock.bounce('informational');
// 设置角标(未读消息)
if (count > 0) {
app.dock.setBadge(count.toString());
} else {
app.dock.setBadge('');
}
}
}
});
// 通知 (点击通知打开指定页面)
ipcMain.on('notify-with-path', (event: IpcMainInvokeEvent, options: NotificationConstructorOptions & { path: string }) => {
const win = BrowserWindow.getAllWindows()[0];
if (!win) return;
if (win.isFocused()) return;
const notification = new Notification({
...omit(options, 'path'),
});
notification.on('click', () => {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
// win.webContents.send('navigate-to', options.path);
});
notification.show();
});

62
electron/main.ts Normal file
View File

@ -0,0 +1,62 @@
import { app, BrowserWindow, Menu } from 'electron';
import { isDev } from './env';
import { createMainWindow, createIndexWindow } from './utils/window';
import { getAppInfo } from './utils';
import './ipc';
// 隐藏所有菜单
Menu.setApplicationMenu(null);
let mainWindow: BrowserWindow | null = null;
function main() {
mainWindow = createMainWindow();
// 代码逻辑说明: 【JHHB-13】桌面应用消息通知
mainWindow.on('focus', () => {
// 清除任务栏闪烁
if (process.platform === 'win32') {
mainWindow!.flashFrame(false);
}
});
return mainWindow;
}
// 非开发环境,只允许一个实例运行
if (!isDev) {
// 是否取得了单一实例锁
const gotTheLock = app.requestSingleInstanceLock();
if (gotTheLock) {
app.on('second-instance', () => {
// 开启一个新的窗口
createIndexWindow();
});
} else {
// 没有取得单一实例锁,则退出应用
app.exit(0);
}
}
// 生命周期管理
app.whenReady().then(() => {
// 获取应用信息
const $appInfo = getAppInfo();
if ($appInfo?.productName && $appInfo?.appId) {
app.setName($appInfo.productName);
app.setAppUserModelId($appInfo.appId);
}
main();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
main();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

18
electron/paths.ts Normal file
View File

@ -0,0 +1,18 @@
import path from 'path'
import {isDev} from "./env";
export const _PATHS = getPaths()
function getPaths() {
const _root = __dirname;
const publicRoot = path.join(_root, isDev ? '../../public' : '..');
const preloadRoot = path.join(_root, 'preload')
return {
electronRoot: _root,
publicRoot,
preloadRoot,
appIcon: path.join(_root, `icons/app.ico`).replace(/[\\/]dist[\\/]/, '/'),
}
}

20
electron/preload/index.ts Normal file
View File

@ -0,0 +1,20 @@
import { contextBridge, ipcRenderer } from 'electron';
import { ElectronEnum } from '../../src/enums/jeecgEnum';
contextBridge.exposeInMainWorld(ElectronEnum.ELECTRON_API, {
openInBrowser: (url: string) => ipcRenderer.send('open-in-browser', url),
// 发送消息通知
sendNotification: (title: string, body: string, path: string) => {
ipcRenderer.send('notify-with-path', { title, body, path });
},
// 绑定路由跳转
onNavigate: (cb: (path: string) => void) => {
ipcRenderer.on('navigate-to', (_, path) => cb(path));
},
// 任务栏闪
sendNotifyFlash: () => ipcRenderer.send('notify-flash'),
// 托盘闪动
trayFlash: () => ipcRenderer.send('tray-flash'),
// 托盘停止闪动
trayFlashStop: () => ipcRenderer.send('tray-flash-stop'),
});

View File

@ -0,0 +1 @@
console.log('build elctron is done.');

View File

@ -0,0 +1,27 @@
import path from 'path';
import fs from 'fs';
const root = path.join(__dirname, '../../');
const electronDistRoot = path.join(root, 'dist/electron');
let yamlName = 'electron-builder.yaml';
const sourcePath = fs.readFileSync(path.join(root, yamlName), 'utf-8');
try {
// 通过正则表达式匹配 appId 和 productName
const appIdMatch = sourcePath.match(/appId:\s*['"]([^'"]+)['"]/);
const productNameMatch = sourcePath.match(/productName:\s*['"]([^'"]+)['"]/);
if (appIdMatch && productNameMatch) {
const fileContent = `${appIdMatch[0]}\n${productNameMatch[0]}`;
yamlName = 'env.yaml';
const targetPath = path.join(electronDistRoot, yamlName);
fs.writeFileSync(targetPath, fileContent, 'utf-8');
console.log(`✨ write dist ${yamlName} successfully.`);
} else {
throw new Error('appId or productName not found');
}
} catch (e) {
console.error(e);
console.error(`请检查 ${yamlName} 是否存在,或者内容是否正确`);
process.exit(1);
}

31
electron/utils/index.ts Normal file
View File

@ -0,0 +1,31 @@
import fs from 'fs';
import path from 'path'
import {shell, dialog} from 'electron'
import {_PATHS} from "../paths";
import {isDev} from "../env";
// 通过浏览器打开链接
export function openInBrowser(url: string) {
return shell.openExternal(url);
}
export function getAppInfo(): any {
try {
const yamlPath = isDev ? path.join(_PATHS.publicRoot, '../electron-builder.yaml') : path.join(_PATHS.electronRoot, 'env.yaml');
const yamlContent = fs.readFileSync(yamlPath, 'utf-8');
// 通过正则表达式匹配 appId 和 productName
const appIdMatch = yamlContent.match(/appId:\s*['"]([^'"]+)['"]/);
const productNameMatch = yamlContent.match(/productName:\s*['"]([^'"]+)['"]/);
const appId = appIdMatch ? appIdMatch[1] : '';
const productName = productNameMatch ? productNameMatch[1] : '';
return {appId, productName}
} catch (e) {
dialog.showMessageBoxSync(null, {
type: 'error',
title: '错误',
message: '应用启动失败,请从官网下载最新版本安装包后重新安装!',
});
process.exit(-1);
}
}

200
electron/utils/tray.ts Normal file
View File

@ -0,0 +1,200 @@
// tray = 系统托盘
import path from 'path';
import { Tray, Menu, app, dialog, nativeImage, BrowserWindow, Notification, ipcMain } from 'electron';
import type { IpcMainInvokeEvent } from 'electron';
import {_PATHS} from '../paths';
import {$env, isDev} from '../env';
const TrayIcons = {
// 代码逻辑说明: 【JHHB-13】桌面应用消息通知
normal: nativeImage.createFromPath(
process.platform === 'win32'
? path.join(_PATHS.publicRoot, 'logo.png')
: path.join(_PATHS.electronRoot, './icons/mac/tray-icon.png').replace(/[\\/]dist[\\/]/, '/')
),
empty: nativeImage.createEmpty(),
};
// 创建托盘图标
export function createTray(win: BrowserWindow) {
const tray = new Tray(TrayIcons.normal);
const TrayUtils = useTray(tray, win);
tray.setToolTip($env.VITE_GLOB_APP_TITLE! + (isDev ? ' (开发环境)' : ''));
// 左键托盘图标显示主窗口
tray.on('click', () => TrayUtils.showMainWindow());
// 右键托盘图标显示托盘菜单
tray.on('right-click', () => showTrayContextMenu());
function showTrayContextMenu() {
const trayContextMenu = getTrayMenus(win, TrayUtils);
// 弹出托盘菜单,不使用 setContextMenu 方法是因为要实时更新菜单内容
tray.popUpContextMenu(trayContextMenu);
}
}
export function useTray(tray: Tray, win: BrowserWindow) {
let isBlinking = false;
let blinkTimer: NodeJS.Timeout | null = null;
function showMainWindow() {
win.show();
}
// 开始闪动
function startBlink() {
isBlinking = true;
tray.setImage(TrayIcons.empty);
blinkTimer = setTimeout(() => {
tray.setImage(TrayIcons.normal);
setTimeout(() => {
if (isBlinking) {
startBlink();
}
}, 500);
}, 500);
}
// 结束闪动
function stopBlink() {
isBlinking = false;
if (blinkTimer) {
clearTimeout(blinkTimer);
blinkTimer = null;
}
tray.setImage(TrayIcons.normal);
}
ipcMain.on('tray-flash', (event: IpcMainInvokeEvent) => {
// 仅在 Windows 系统中闪烁
if (process.platform === 'win32') {
startBlink();
}
});
ipcMain.on('tray-flash-stop', (event: IpcMainInvokeEvent) => {
// 仅在 Windows 系统中停止闪烁
if (process.platform === 'win32') {
stopBlink();
}
});
win.on('focus', () => {
stopBlink();
});
// 发送桌面通知
function sendDesktopNotice() {
// 判断是否支持桌面通知
if (!Notification.isSupported()) {
// todo 实际开发中不需要提示,直接返回或者换一种提示方式
dialog.showMessageBoxSync(win, {
type: 'error',
title: '错误',
message: '当前系统不支持桌面通知',
});
return;
}
const ins = new Notification({
title: '通知标题',
body: '通知内容第一行\n通知内容第二行',
// icon: TrayIcons.normal.resize({width: 32, height: 32}),
});
ins.on('click', () => {
dialog.showMessageBoxSync(win, {
type: 'info',
title: '提示',
message: '通知被点击',
});
});
ins.show();
}
return {
showMainWindow,
startBlink,
stopBlink,
isBlinking: () => isBlinking,
sendDesktopNotice,
};
}
const MenuIcon = {
exit: nativeImage
.createFromDataURL(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAA7EAAAOxAGVKw4bAAACJ0lEQVR4nH1TzWvUQBRP7fpxsWqVXsSLiAevRWhhN28msRJo981kay4WRBCF/QdEFJpbaUHw4kFBQTwUKX4gKh48KPiBBcGLJ1F0uzPZ7ibWXf0DIjObielS+mDIm/fxm9/85sWyBixN06E0CIaV3wB2XhC8puOWNZSG4Y7B+k2mi7Kl9l2n9rHnzvbWJoLRYn7r5jTViQjwzM8ynlC+AFyVgN2NU8G+Rnn6QETx3FfP223A/jeHfWqCsAUJ7Hlryh9Te0nYqiDsz9rE6VHVIABvNwEf/ADYk4OsZPeVFbwiCHtcZBVR9k4CJhJmDuUxwEVJ8H4fINOkC9Vjbeq/UTR1IgPturX3f93Z35+B7ddxgJL6dih/skF9zE9KCJ//5bDLpii1+npIuzolKTubC5gBxzarJo6vWWjrUP+etFlF+ds9lRFOXalN+NPEmxvRDS3KH34v8+PFIgNmTh0EahH+InGCwzoQEbYcuTMnlR8aYbaxGHFvRNiznssP6sA65UsxrdU1+hYnFhlpAGAkdvzlPLFu88mY8pcrVjCsxcqGapC2eYW249/tUH4xS4QaVQLeigi/YWJqPl4DlNRSrAwzSaoXIspeWUYrI9qXINglgT1qAt5JPG+kkNN5BSAJuyoJfhAVdmST4PlPBFASNs6rIgnspqC8HlF+SQAuRQTfKpYiEy6fwuIdP42P71T+t0l/TBKcE8AXm4DXBfB6w50+apgUhf4HZ5j+Z5+zNTAAAAAASUVORK5CYII='
)
.resize({
width: 16,
height: 16,
}),
};
// 设置托盘菜单
function getTrayMenus(win: BrowserWindow, TrayUtils: ReturnType<typeof useTray>) {
const {startBlink, stopBlink, sendDesktopNotice} = TrayUtils;
const isBlinking = TrayUtils.isBlinking();
return Menu.buildFromTemplate([
...(isDev
? [
{
label: '开发工具',
submenu: [
{
label: '以下菜单仅显示在开发环境',
sublabel: '当前为开发环境',
enabled: false,
},
{type: 'separator'},
{
label: '切换 DevTools',
click: () => win.webContents.toggleDevTools(),
},
{
label: `托盘图标${isBlinking ? '停止' : '开始'}闪烁`,
sublabel: '模拟新消息提醒',
click: () => (isBlinking ? stopBlink() : startBlink()),
},
{
label: '发送桌面通知示例',
click: () => sendDesktopNotice(),
},
],
},
{type: 'separator'},
]
: ([] as any)),
{
label: '显示主窗口',
// 文件图标
icon: TrayIcons.normal.resize({width: 16, height: 16}),
click: () => win.show(),
},
{type: 'separator'},
{
label: '退出',
// base64图标
icon: MenuIcon.exit,
click: () => {
// 弹出是否确认退出提示框
const choice = dialog.showMessageBoxSync(win, {
type: 'question',
title: '提示',
message: '确定要退出应用吗?',
buttons: ['退出', '取消'],
defaultId: 1,
cancelId: 1,
noLink: true,
});
// 用户选择了退出,直接 exit
if (choice === 0) {
// global.isQuitting = true;
app.exit(0);
}
},
},
]);
}

100
electron/utils/window.ts Normal file
View File

@ -0,0 +1,100 @@
import type {BrowserWindowConstructorOptions} from 'electron';
import {app, BrowserWindow, dialog} from 'electron';
import path from 'path';
import {_PATHS} from '../paths';
import {$env, isDev} from '../env';
import {createTray} from './tray';
// 获取公共窗口选项
export function getBrowserWindowOptions(options?: BrowserWindowConstructorOptions): BrowserWindowConstructorOptions {
return {
width: 1200,
height: 800,
webPreferences: {
preload: path.join(_PATHS.preloadRoot, 'index.js'),
nodeIntegration: false,
contextIsolation: true,
},
// 应用图标
icon: isDev ? _PATHS.appIcon : void 0,
...options,
}
}
// 创建窗口
export function createBrowserWindow(options?: BrowserWindowConstructorOptions) {
const win = new BrowserWindow(getBrowserWindowOptions(options));
// 代码逻辑说明: 【JHHB-13】桌面应用消息通知
if (process.platform === 'darwin') { // 仅 macOS 生效
if (app.dock) {
app.dock.setIcon(path.join(_PATHS.electronRoot, './icons/mac/dock.png').replace(/[\\/]dist[\\/]/, '/'));
}
}
// 设置窗口打开处理器
win.webContents.setWindowOpenHandler(() => {
return {
action: 'allow',
// 覆写新窗口的选项用于调整默认尺寸和加载preload脚本等
overrideBrowserWindowOptions: getBrowserWindowOptions(),
}
});
// 当 beforeunload 阻止窗口关闭时触发
win.webContents.on('will-prevent-unload', () => {
const choice = dialog.showMessageBoxSync(win, {
type: 'question',
title: '确认关闭吗?',
message: '系统可能不会保存您所做的更改。',
buttons: ['关闭', '取消'],
defaultId: 1,
cancelId: 1,
noLink: true,
});
// 用户选择了关闭,直接销毁窗口
if (choice === 0) {
win.destroy();
}
});
return win;
}
// 创建主窗口、系统托盘
export function createMainWindow() {
const win = createIndexWindow()
// 设置系统托盘图标
createTray(win);
// 主窗口尝试关闭时,默认不直接退出应用,而是隐藏到托盘
win.on('close', (event) => {
event.preventDefault();
win.hide();
});
return win;
}
// 创建索引窗口
export function createIndexWindow() {
const win = createBrowserWindow({
width: 1600,
height: 1000,
title: $env.VITE_GLOB_APP_TITLE!,
});
// 开发环境加载Vite服务生产加载打包文件
if (isDev) {
let serverUrl = $env.VITE_DEV_SERVER_URL! as string;
// 【JHHB-936】由于wps预览不能使用localhost访问所以把localhost替换为127.0.0.1
serverUrl = serverUrl.replace('localhost', '127.0.0.1');
win.loadURL(serverUrl)
// 开发环境下,自动打开调试工具
// win.webContents.openDevTools()
} else {
win.loadFile(path.join(_PATHS.publicRoot, 'index.html'));
}
return win;
}

183
index.html Normal file
View File

@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="zh_CN" id="htmlRoot">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="renderer" content="webkit" />
<meta
name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<title><%= title %></title>
<link rel="icon" href="<%= basePublicPath %>/logo.png" />
<!-- 全局配置 -->
<script>
window._CONFIG = {};
</script>
</head>
<body>
<script>
(() => {
var htmlRoot = document.getElementById('htmlRoot');
var theme = window.localStorage.getItem('__APP__DARK__MODE__');
if (htmlRoot && theme) {
htmlRoot.setAttribute('data-theme', theme);
theme = htmlRoot = null;
}
})();
</script>
<div id="app">
<style>
html[data-theme='dark'] .app-loading {
background-color: #2c344a;
}
html[data-theme='dark'] .app-loading .app-loading-title {
color: rgba(255, 255, 255, 0.85);
}
.app-loading {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
background-color: #f4f7f9;
}
.app-loading .app-loading-wrap {
position: absolute;
top: 50%;
left: 50%;
display: flex;
-webkit-transform: translate3d(-50%, -50%, 0);
transform: translate3d(-50%, -50%, 0);
justify-content: center;
align-items: center;
flex-direction: column;
}
.app-loading .dots {
display: flex;
padding: 98px;
justify-content: center;
align-items: center;
}
.app-loading .app-loading-title {
display: flex;
margin-top: 30px;
font-size: 30px;
color: rgba(0, 0, 0, 0.85);
justify-content: center;
align-items: center;
}
.app-loading .app-loading-logo {
display: block;
width: 90px;
margin: 0 auto;
margin-bottom: 20px;
}
.dot {
position: relative;
display: inline-block;
width: 48px;
height: 48px;
margin-top: 30px;
font-size: 32px;
transform: rotate(45deg);
box-sizing: border-box;
animation: antRotate 1.2s infinite linear;
}
.dot i {
position: absolute;
display: block;
width: 20px;
height: 20px;
background-color: #0065cc;
border-radius: 100%;
opacity: 0.3;
transform: scale(0.75);
animation: antSpinMove 1s infinite linear alternate;
transform-origin: 50% 50%;
}
.dot i:nth-child(1) {
top: 0;
left: 0;
}
.dot i:nth-child(2) {
top: 0;
right: 0;
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.dot i:nth-child(3) {
right: 0;
bottom: 0;
-webkit-animation-delay: 0.8s;
animation-delay: 0.8s;
}
.dot i:nth-child(4) {
bottom: 0;
left: 0;
-webkit-animation-delay: 1.2s;
animation-delay: 1.2s;
}
@keyframes antRotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
}
}
@-webkit-keyframes antRotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
}
}
@keyframes antSpinMove {
to {
opacity: 1;
}
}
@-webkit-keyframes antSpinMove {
to {
opacity: 1;
}
}
</style>
<div class="app-loading">
<div class="app-loading-wrap">
<img src="<%= basePublicPath %>/resource/img/logo.png" class="app-loading-logo" alt="Logo" />
<div class="app-loading-dots">
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
</div>
<div class="app-loading-title"><%= title %></div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
<!-- 百度统计 -->
<script>
window.addEventListener('load', function() {
var _hmt = _hmt || [];
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?0febd9e3cacb3f627ddac64d52caac39";
hm.async = true;
hm.defer = true;
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
});
</script>
</body>
</html>

36
jest.config.mjs Normal file
View File

@ -0,0 +1,36 @@
export default {
preset: 'ts-jest',
roots: ['<rootDir>/tests/'],
clearMocks: true,
moduleDirectories: ['node_modules', 'src'],
moduleFileExtensions: ['js', 'ts', 'vue', 'tsx', 'jsx', 'json', 'node'],
modulePaths: ['<rootDir>/src', '<rootDir>/node_modules'],
testMatch: [
'**/tests/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[tj]s?(x)',
'(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts)$',
],
testPathIgnorePatterns: [
'<rootDir>/tests/server/',
'<rootDir>/tests/__mocks__/',
'/node_modules/',
],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
transformIgnorePatterns: ['<rootDir>/tests/__mocks__/', '/node_modules/'],
// A map from regular expressions to module names that allow to stub out resources with a single module
moduleNameMapper: {
'\\.(vs|fs|vert|frag|glsl|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/tests/__mocks__/fileMock.ts',
'\\.(sass|s?css|less)$': '<rootDir>/tests/__mocks__/styleMock.ts',
'\\?worker$': '<rootDir>/tests/__mocks__/workerMock.ts',
'^/@/(.*)$': '<rootDir>/src/$1',
},
testEnvironment: 'jsdom',
verbose: true,
collectCoverage: false,
coverageDirectory: 'coverage',
collectCoverageFrom: ['src/**/*.{js,ts,vue}'],
coveragePathIgnorePatterns: ['^.+\\.d\\.ts$'],
};

View File

@ -0,0 +1,18 @@
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer';
const modules = import.meta.glob('./**/*.ts', { eager: true });
const mockModules: any[] = [];
Object.keys(modules).forEach((key) => {
if (key.includes('/_')) {
return;
}
mockModules.push(...(modules as Recordable)[key].default);
});
/**
* Used in a production environment. Need to manually import all modules
*/
export function setupProdMockServer() {
createProdMockServer(mockModules);
}

63
mock/_util.ts Normal file
View File

@ -0,0 +1,63 @@
// Interface data format used to return a unified format
export function resultSuccess<T = Recordable>(result: T, { message = 'ok' } = {}) {
return {
code: 0,
result,
message,
type: 'success',
};
}
export function resultPageSuccess<T = any>(
pageNo: number,
pageSize: number,
list: T[],
{ message = 'ok' } = {}
) {
const pageData = pagination(pageNo, pageSize, list);
return {
...resultSuccess({
records: pageData,
total: list.length,
}),
message,
};
}
export function resultError(message = 'Request failed', { code = -1, result = null } = {}) {
return {
code,
result,
message,
type: 'error',
};
}
export function pagination<T = any>(pageNo: number, pageSize: number, array: T[]): T[] {
const offset = (pageNo - 1) * Number(pageSize);
const ret =
offset + Number(pageSize) >= array.length
? array.slice(offset, array.length)
: array.slice(offset, offset + Number(pageSize));
return ret;
}
export interface requestParams {
method: string;
body: any;
headers?: { authorization?: string };
query: any;
}
/**
* @description request数据中获取token
*
*/
export function getRequestToken({ headers }: requestParams): string | undefined {
return headers?.authorization;
}
//TODO 接口父路径(写死不够灵活)
export const baseUrl = '/jeecgboot/mock';

70
mock/demo/account.ts Normal file
View File

@ -0,0 +1,70 @@
import { MockMethod } from 'vite-plugin-mock';
import { resultSuccess, resultError, baseUrl } from '../_util';
import { ResultEnum } from '../../src/enums/httpEnum';
const userInfo = {
name: 'Jeecg',
userid: '00000001',
email: 'test@gmail.com',
signature: '海纳百川,有容乃大',
introduction: '微笑着,努力着,欣赏着',
title: '交互专家',
group: '某某某事业群某某平台部某某技术部UED',
tags: [
{
key: '0',
label: '很有想法的',
},
{
key: '1',
label: '专注设计',
},
{
key: '2',
label: '辣~',
},
{
key: '3',
label: '大长腿',
},
{
key: '4',
label: '川妹子',
},
{
key: '5',
label: '海纳百川',
},
],
notifyCount: 12,
unreadCount: 11,
country: 'China',
address: 'Xiamen City 77',
phone: '0592-268888888',
};
export default [
{
url: `${baseUrl}/account/getAccountInfo`,
timeout: 1000,
method: 'get',
response: () => {
return resultSuccess(userInfo);
},
},
{
url: `${baseUrl}/user/sessionTimeout`,
method: 'post',
statusCode: 401,
response: () => {
return resultError();
},
},
{
url: '/basic-api/user/tokenExpired',
method: 'post',
statusCode: 200,
response: () => {
return resultError('Token Expired!', { code: ResultEnum.TIMEOUT as number });
},
},
] as MockMethod[];

37
mock/demo/select-demo.ts Normal file
View File

@ -0,0 +1,37 @@
import { MockMethod } from 'vite-plugin-mock';
import { resultSuccess, baseUrl } from '../_util';
const demoList = (keyword, count = 20) => {
const result = {
list: [] as any[],
};
for (let index = 0; index < count; index++) {
//根据搜索关键词做一下匹配
let name = `选项${index}`;
if(keyword && name.indexOf(keyword)!=-1){
result.list.push({
name: `选项${index}`,
id: `${index}`,
});
}else if(!keyword){
result.list.push({
name: `选项${index}`,
id: `${index}`,
});
}
}
return result;
};
export default [
{
url: `${baseUrl}/select/getDemoOptions`,
timeout: 1000,
method: 'get',
response: ({ query }) => {
const { keyword,count} = query;
console.log("查询条件:", keyword);
return resultSuccess(demoList(keyword,count));
},
},
] as MockMethod[];

298
mock/demo/system.ts Normal file
View File

@ -0,0 +1,298 @@
import { MockMethod } from 'vite-plugin-mock';
import { resultError, resultPageSuccess, resultSuccess, baseUrl } from '../_util';
const accountList = (() => {
const result: any[] = [];
for (let index = 0; index < 20; index++) {
result.push({
id: `${index}`,
account: '@first',
email: '@email',
nickname: '@cname()',
role: '@first',
createTime: '@datetime',
remark: '@cword(10,20)',
'status|1': ['0', '1'],
});
}
return result;
})();
const userList = (() => {
const result: any[] = [];
for (let index = 0; index < 20; index++) {
result.push({
id: `${index}`,
username: '@first',
email: '@email',
realname: '@cname()',
createTime: '@datetime',
remark: '@cword(10,20)',
avatar: 'https://q1.qlogo.cn/g?b=qq&nk=190848757&s=640'
});
}
return result;
})();
const roleList = (() => {
const result: any[] = [];
for (let index = 0; index < 4; index++) {
result.push({
id: index + 1,
orderNo: `${index + 1}`,
roleName: ['超级管理员', '管理员', '文章管理员', '普通用户'][index],
roleValue: '@first',
createTime: '@datetime',
remark: '@cword(10,20)',
menu: [['0', '1', '2'], ['0', '1'], ['0', '2'], ['2']][index],
'status|1': ['0', '1'],
});
}
return result;
})();
const newRoleList = (() => {
const result: any[] = [];
for (let index = 0; index < 4; index++) {
result.push({
id: index + 1,
orderNo: `${index + 1}`,
roleName: ['超级管理员', '管理员', '文章管理员', '普通用户'][index],
roleCode: '@first',
createTime: '@datetime',
remark: '@cword(10,20)'
});
}
return result;
})();
const testList = (() => {
const result: any[] = [];
for (let index = 0; index < 4; index++) {
result.push({
id: index + 1,
orderNo: `${index + 1}`,
testName: ['数据1', '数据2', '数据3', '数据4'][index],
testValue: '@first',
createTime: '@datetime'
});
}
return result;
})();
const tableDemoList = (() => {
const result: any[] = [];
for (let index = 0; index < 4; index++) {
result.push({
id: index + 1,
orderCode: '2008200' + `${index + 1}`,
orderMoney: '@natural(1000,3000)',
ctype: '@natural(1,2)',
content: '@cword(10,20)',
orderDate: '@datetime'
});
}
return result;
})();
const deptList = (() => {
const result: any[] = [];
for (let index = 0; index < 3; index++) {
result.push({
id: `${index}`,
deptName: ['华东分部', '华南分部', '西北分部'][index],
orderNo: index + 1,
createTime: '@datetime',
remark: '@cword(10,20)',
'status|1': ['0', '0', '1'],
children: (() => {
const children: any[] = [];
for (let j = 0; j < 4; j++) {
children.push({
id: `${index}-${j}`,
deptName: ['研发部', '市场部', '商务部', '财务部'][j],
orderNo: j + 1,
createTime: '@datetime',
remark: '@cword(10,20)',
'status|1': ['0', '1'],
parentDept: `${index}`,
children: undefined,
});
}
return children;
})(),
});
}
return result;
})();
const menuList = (() => {
const result: any[] = [];
for (let index = 0; index < 3; index++) {
result.push({
id: `${index}`,
icon: ['ion:layers-outline', 'ion:git-compare-outline', 'ion:tv-outline'][index],
component: 'LAYOUT',
type: '0',
menuName: ['Dashboard', '权限管理', '功能'][index],
permission: '',
orderNo: index + 1,
createTime: '@datetime',
'status|1': ['0', '0', '1'],
children: (() => {
const children: any[] = [];
for (let j = 0; j < 4; j++) {
children.push({
id: `${index}-${j}`,
type: '1',
menuName: ['菜单1', '菜单2', '菜单3', '菜单4'][j],
icon: 'ion:document',
permission: ['menu1:view', 'menu2:add', 'menu3:update', 'menu4:del'][index],
component: [
'/dashboard/welcome/index',
'/dashboard/Analysis/index',
'/dashboard/workbench/index',
'/dashboard/test/index',
][j],
orderNo: j + 1,
createTime: '@datetime',
'status|1': ['0', '1'],
parentMenu: `${index}`,
children: (() => {
const children: any[] = [];
for (let k = 0; k < 4; k++) {
children.push({
id: `${index}-${j}-${k}`,
type: '2',
menuName: '按钮' + (j + 1) + '-' + (k + 1),
icon: '',
permission:
['menu1:view', 'menu2:add', 'menu3:update', 'menu4:del'][index] +
':btn' +
(k + 1),
component: [
'/dashboard/welcome/index',
'/dashboard/Analysis/index',
'/dashboard/workbench/index',
'/dashboard/test/index',
][j],
orderNo: j + 1,
createTime: '@datetime',
'status|1': ['0', '1'],
parentMenu: `${index}-${j}`,
children: undefined,
});
}
return children;
})(),
});
}
return children;
})(),
});
}
return result;
})();
export default [
{
url: `${baseUrl}/system/getAccountList`,
timeout: 100,
method: 'get',
response: ({ query }) => {
const { page = 1, pageSize = 20 } = query;
return resultPageSuccess(page, pageSize, accountList);
},
},
{
url: `${baseUrl}/sys/user/list`,
timeout: 100,
method: 'get',
response: ({ query }) => {
const { page = 1, pageSize = 20 } = query;
return resultPageSuccess(page, pageSize, userList);
},
},
{
url: `${baseUrl}/system/getRoleListByPage`,
timeout: 100,
method: 'get',
response: ({ query }) => {
const { page = 1, pageSize = 20 } = query;
return resultPageSuccess(page, pageSize, roleList);
},
},
{
url: `${baseUrl}/sys/role/list`,
timeout: 100,
method: 'get',
response: ({ query }) => {
const { page = 1, pageSize = 20 } = query;
return resultPageSuccess(page, pageSize, newRoleList);
},
},
{
url: `${baseUrl}/system/getTestListByPage`,
timeout: 100,
method: 'get',
response: ({ query }) => {
const { page = 1, pageSize = 20 } = query;
return resultPageSuccess(page, pageSize, testList);
},
},
{
url: `${baseUrl}/system/getDemoTableListByPage`,
timeout: 100,
method: 'get',
response: ({ query }) => {
const { page = 1, pageSize = 20 } = query;
return resultPageSuccess(page, pageSize, tableDemoList);
},
},
{
url: `${baseUrl}/system/setRoleStatus`,
timeout: 500,
method: 'post',
response: ({ query }) => {
const { id, status } = query;
return resultSuccess({ id, status });
},
},
{
url: `${baseUrl}/system/getAllRoleList`,
timeout: 100,
method: 'get',
response: () => {
return resultSuccess(roleList);
},
},
{
url: `${baseUrl}/system/getDeptList`,
timeout: 100,
method: 'get',
response: () => {
return resultSuccess(deptList);
},
},
{
url: `${baseUrl}/system/getMenuList`,
timeout: 100,
method: 'get',
response: () => {
return resultSuccess(menuList);
},
},
{
url: `${baseUrl}/system/accountExist`,
timeout: 500,
method: 'post',
response: ({ body }) => {
const { account } = body || {};
if (account && account.indexOf('admin') !== -1) {
return resultError('该字段不能包含admin');
} else {
return resultSuccess(`${account} can use`);
}
},
},
] as MockMethod[];

56
mock/demo/table-demo.ts Normal file
View File

@ -0,0 +1,56 @@
import { MockMethod } from 'vite-plugin-mock';
import { Random } from 'mockjs';
import { resultPageSuccess, baseUrl } from '../_util';
function getRandomPics(count = 10): string[] {
const arr: string[] = [];
for (let i = 0; i < count; i++) {
arr.push(Random.image('800x600', Random.color(), Random.color(), Random.title()));
}
return arr;
}
const demoList = (() => {
const result: any[] = [];
for (let index = 0; index < 200; index++) {
result.push({
id: `${index}`,
beginTime: '@datetime',
endTime: '@datetime',
address: '@city()',
name: '@cname()',
name1: '@cname()',
name2: '@cname()',
name3: '@cname()',
name4: '@cname()',
name5: '@cname()',
name6: '@cname()',
name7: '@cname()',
name8: '@cname()',
avatar: Random.image('400x400', Random.color(), Random.color(), Random.first()),
imgArr: getRandomPics(Math.ceil(Math.random() * 3) + 1),
imgs: getRandomPics(Math.ceil(Math.random() * 3) + 1),
age: Math.ceil(Math.random() * 30) + 1,
score: Math.ceil(Math.random() * 80) + 1,
date: `@date('yyyy-MM-dd')`,
time: `@time('HH:mm')`,
'no|100000-10000000': 100000,
'status|1': ['normal', 'enable', 'disable'],
});
}
return result;
})();
export default [
{
url: `${baseUrl}/table/getDemoList`,
timeout: 100,
method: 'get',
response: ({ query }) => {
const { page = 1, pageSize = 20 } = query;
// 代码逻辑说明: 【issues/6943】mock翻页之后数据id和图片没自动刷新
const pageNo = +(query.pageNo ?? page);
return resultPageSuccess(pageNo, +pageSize, demoList);
},
},
] as MockMethod[];

38
mock/demo/tree-demo.ts Normal file
View File

@ -0,0 +1,38 @@
import { MockMethod } from 'vite-plugin-mock';
import { resultSuccess, baseUrl } from '../_util';
const demoTreeList = (keyword) => {
const result = {
list: [] as Recordable[],
};
for (let index = 0; index < 5; index++) {
const children: Recordable[] = [];
for (let j = 0; j < 3; j++) {
children.push({
title: `${keyword ?? ''}选项${index}-${j}`,
value: `${index}-${j}`,
key: `${index}-${j}`,
});
}
result.list.push({
title: `${keyword ?? ''}选项${index}`,
value: `${index}`,
key: `${index}`,
children,
});
}
return result;
};
export default [
{
url: `${baseUrl}/tree/getDemoOptions`,
timeout: 1000,
method: 'get',
response: ({ query }) => {
const { keyword } = query;
console.log("查询条件:", keyword);
return resultSuccess(demoTreeList(keyword));
},
},
] as MockMethod[];

271
mock/sys/menu.ts Normal file
View File

@ -0,0 +1,271 @@
import { resultSuccess, resultError, getRequestToken, requestParams,baseUrl} from '../_util';
import { MockMethod } from 'vite-plugin-mock';
import { createFakeUserList } from './user';
import { PageEnum } from '/@/enums/pageEnum';
// single
const dashboardRoute = {
path: '/dashboard',
name: 'Dashboard',
component: 'LAYOUT',
redirect: PageEnum.BASE_HOME,
meta: {
title: 'routes.dashboard.dashboard',
hideChildrenInMenu: true,
icon: 'bx:bx-home',
},
children: [
{
path: 'analysis',
name: 'Analysis',
component: '/dashboard/Analysis/index',
meta: {
hideMenu: true,
hideBreadcrumb: true,
title: 'routes.dashboard.analysis',
currentActiveMenu: '/dashboard',
icon: 'bx:bx-home',
},
},
{
path: 'workbench',
name: 'Workbench',
component: '/dashboard/workbench/index',
meta: {
hideMenu: true,
hideBreadcrumb: true,
title: 'routes.dashboard.workbench',
currentActiveMenu: '/dashboard',
icon: 'bx:bx-home',
},
},
],
};
const backRoute = {
path: 'back',
name: 'PermissionBackDemo',
meta: {
title: 'routes.demo.permission.back',
},
children: [
{
path: 'page',
name: 'BackAuthPage',
component: '/demo/permission/back/index',
meta: {
title: 'routes.demo.permission.backPage',
},
},
{
path: 'btn',
name: 'BackAuthBtn',
component: '/demo/permission/back/Btn',
meta: {
title: 'routes.demo.permission.backBtn',
},
},
],
};
const authRoute = {
path: '/permission',
name: 'Permission',
component: 'LAYOUT',
redirect: '/permission/front/page',
meta: {
icon: 'carbon:user-role',
title: 'routes.demo.permission.permission',
},
children: [backRoute],
};
const levelRoute = {
path: '/level',
name: 'Level',
component: 'LAYOUT',
redirect: '/level/menu1/menu1-1',
meta: {
icon: 'carbon:user-role',
title: 'routes.demo.level.level',
},
children: [
{
path: 'menu1',
name: 'Menu1Demo',
meta: {
title: 'Menu1',
},
children: [
{
path: 'menu1-1',
name: 'Menu11Demo',
meta: {
title: 'Menu1-1',
},
children: [
{
path: 'menu1-1-1',
name: 'Menu111Demo',
component: '/demo/level/Menu111',
meta: {
title: 'Menu111',
},
},
],
},
{
path: 'menu1-2',
name: 'Menu12Demo',
component: '/demo/level/Menu12',
meta: {
title: 'Menu1-2',
},
},
],
},
{
path: 'menu2',
name: 'Menu2Demo',
component: '/demo/level/Menu2',
meta: {
title: 'Menu2',
},
},
],
};
const sysRoute = {
path: '/system',
name: 'System',
component: 'LAYOUT',
redirect: '/system/account',
meta: {
icon: 'ion:settings-outline',
title: 'routes.demo.system.moduleName',
},
children: [
{
path: 'account',
name: 'AccountManagement',
meta: {
title: 'routes.demo.system.account',
ignoreKeepAlive: true,
},
component: '/demo/system/account/index',
},
{
path: 'account_detail/:id',
name: 'AccountDetail',
meta: {
hideMenu: true,
title: 'routes.demo.system.account_detail',
ignoreKeepAlive: true,
showMenu: false,
currentActiveMenu: '/system/account',
},
component: '/demo/system/account/AccountDetail',
},
{
path: 'role',
name: 'RoleManagement',
meta: {
title: 'routes.demo.system.role',
ignoreKeepAlive: true,
},
component: '/demo/system/role/index',
},
{
path: 'menu',
name: 'MenuManagement',
meta: {
title: 'routes.demo.system.menu',
ignoreKeepAlive: true,
},
component: '/demo/system/menu/index',
},
{
path: 'dept',
name: 'DeptManagement',
meta: {
title: 'routes.demo.system.dept',
ignoreKeepAlive: true,
},
component: '/demo/system/dept/index',
},
{
path: 'changePassword',
name: 'ChangePassword',
meta: {
title: 'routes.demo.system.password',
ignoreKeepAlive: true,
},
component: '/demo/system/password/index',
},
],
};
const linkRoute = {
path: '/link',
name: 'Link',
component: 'LAYOUT',
meta: {
icon: 'ion:tv-outline',
title: 'routes.demo.iframe.frame',
},
children: [
{
path: 'doc',
name: 'Doc',
meta: {
title: 'routes.demo.iframe.doc',
frameSrc: 'https://vvbin.cn/doc-next/',
},
},
{
path: 'https://vvbin.cn/doc-next/',
name: 'DocExternal',
component: 'LAYOUT',
meta: {
title: 'routes.demo.iframe.docExternal',
},
},
],
};
export default [
{
url: `${baseUrl}/sys/permission/getUserPermissionByToken`,
timeout: 1000,
method: 'get',
response: (request: requestParams) => {
const token = getRequestToken(request);
if (!token) {
return resultError('Invalid token!');
}
const checkUser = createFakeUserList().find((item) => item.token === token);
if (!checkUser) {
return resultError('Invalid user token!');
}
const id = checkUser.userId;
let menu: Object[];
switch (id) {
case '1':
dashboardRoute.redirect = dashboardRoute.path + '/' + dashboardRoute.children[0].path;
menu = [dashboardRoute, authRoute, levelRoute, sysRoute, linkRoute];
break;
case '2':
dashboardRoute.redirect = dashboardRoute.path + '/' + dashboardRoute.children[1].path;
menu = [dashboardRoute, authRoute, levelRoute, linkRoute];
break;
default:
menu = [];
}
return resultSuccess(menu);
},
},
] as MockMethod[];

125
mock/sys/user.ts Normal file
View File

@ -0,0 +1,125 @@
import { MockMethod } from 'vite-plugin-mock';
import { resultError, resultSuccess, getRequestToken, requestParams, baseUrl } from '../_util';
import { PageEnum } from '/@/enums/pageEnum';
export function createFakeUserList() {
return [
{
userId: '1',
username: 'admin',
realname: '管理员',
avatar: 'https://q1.qlogo.cn/g?b=qq&nk=190848757&s=640',
desc: 'manager',
password: '123456',
token: 'fakeToken1',
homePath: PageEnum.BASE_HOME,
roles: [
{
roleName: 'Super Admin',
value: 'super',
},
],
},
{
userId: '2',
username: 'jeecg',
password: '123456',
realname: '测试用户',
avatar: 'https://q1.qlogo.cn/g?b=qq&nk=339449197&s=640',
desc: 'tester',
token: 'fakeToken2',
homePath: PageEnum.BASE_HOME,
roles: [
{
roleName: 'Tester',
value: 'test',
},
],
},
];
}
const fakeCodeList: any = {
'1': ['1000', '3000', '5000'],
'2': ['2000', '4000', '6000'],
};
export default [
// mock user login
{
url: `${baseUrl}/sys/login`,
timeout: 200,
method: 'post',
response: ({ body }) => {
const { username, password } = body;
const checkUser = createFakeUserList().find(
(item) => item.username === username && password === item.password
);
if (!checkUser) {
return resultError('Incorrect account or password');
}
const { userId, username: _username, token, realname, desc, roles } = checkUser;
return resultSuccess({
roles,
userId,
username: _username,
token,
realname,
desc,
});
},
},
{
url: `${baseUrl}/sys/user/getUserInfo`,
method: 'get',
response: (request: requestParams) => {
const token = getRequestToken(request);
if (!token) return resultError('Invalid token');
const checkUser = createFakeUserList().find((item) => item.token === token);
if (!checkUser) {
return resultError('The corresponding user information was not obtained!');
}
return resultSuccess(checkUser);
},
},
{
url: `${baseUrl}/sys/permission/getPermCode`,
timeout: 200,
method: 'get',
response: (request: requestParams) => {
const token = getRequestToken(request);
if (!token) return resultError('Invalid token');
const checkUser = createFakeUserList().find((item) => item.token === token);
if (!checkUser) {
return resultError('Invalid token!');
}
const codeList = fakeCodeList[checkUser.userId];
return resultSuccess(codeList);
},
},
{
url: `${baseUrl}/sys/logout`,
timeout: 200,
method: 'get',
response: (request: requestParams) => {
const token = getRequestToken(request);
if (!token) return resultError('Invalid token');
const checkUser = createFakeUserList().find((item) => item.token === token);
if (!checkUser) {
return resultError('Invalid token!');
}
return resultSuccess(undefined, { message: 'Token has been destroyed' });
},
},
{
url: `${baseUrl}/sys/randomImage/1629428467008`,
timeout: 200,
method: 'get',
response: (request: requestParams) => {
const result =
'data:image/jpg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAjAGkDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3h/ME5lErCKNSHi8rO44yCp65/P060gmdbbIaKecxl0VDsEmOmMk8cgZ96dcypFGpkEm0uOUzxznJI6DjnPH54ryTWNW1+P436T4ftNev4LTU7Brhw9tbCSHiZxGrNCcKCijkMeuSSOAD1KJ7hrTZNK0d1Iz7egGVPRSV6HGRkE4z1xV5SWQEqVJGSpxke3FeYL4n1PSfi5pXhK7vxrFlf27XVveyQRrcQHa4KFo1CMhMLdFUjfy2F59NjZm3Bl2kMRxnGO3JA7Y/HI7UAVrjfbWt3NJLNKmCwVAoaNcc4PHTk8+n5rKs1tDGtsdw5X94GkOT90ls5xnr14PYDNeUfDzxvq3xCuNZvG8SNp1zBKHtdFgtYpFjhAADuWXfKpJwwRkIwcFd6geg+Dm12Tw9bN4hkX+0Y5bmKcLGVV9s7KjLkA7dijBPLAgnJ5oWgGrb36zBtqyPh9oKwuvG4DncByCeeexPsLHm7jH5amSNxnzFI2gY479/bNVrxYZpRD57Q3JRkikRFLxlgfmUspGQEbrkccg8V5l8PfEOt6h4p8YW+r+JJf7M8OXZhjWWC2jjMQaZSZWWNSMLGDkFQD1BHFAHq7usMcksjnYoLHI+6AOen0pqllZfNkUMWZVUdG5yOvOcD+dcclt4j1/XlvtM8T3dn4cIc7kgt2a4PGwwBoTiLqfMZm34+VQpVz0Ou2t/eWfk2GrXGmTNxHcW9ukxV8EAOrqwKZIJ+6fl+8M0AXjHcb5GWdcN9xWjyF6ehGe/5j05jmdri3cxPJGI3YOAOXABGARnGTjkcjpwenmvwt8Y61czeIdM8b6g0esaW3mTR3SwQJFAB99dijcOpZySoBjI+8TXR+EtK16SNdR1rWL64WUNPaWtxbxQGBXbKCVY0VjKq/f525dgBxuKA6i4ZfsSEyq0R275Wm8v5f7wZe/TjgHNWqiiSWNEV5vNwp3MygFj68cAdeMen4y0wKk6Mtysp3yQsvktEoyPmI+YgnGB9O57dPIfGML3H7SnhaGO+msXfS2C3EIjLqcXPTzFZeenIPXjnFeyiIJIzx4XzH3SZBO75ccc8dF/L3zXHah4D0K58US+ILuLUPtcMeI5l1G5WT5i+VjYSDYCW2hVIHLDaMg0AX9K8FaVZ+IJPEVxPc6rrTAxLf3rqzRJgKURUVUToeQoPzNk8mt0qRJF5uTMVdBNGuAucHoScdByeOPcAxRw3MdzOpuC3nZkQiEBY8EcMf4sggfRT0qYQSJBJGJWlGwKodirZxjlxzzxzjIOfYAA8Tn+HehfErRoPGvhDUP7J1uQCaWK3YmJLpQWZT0aN95X5xxgBgp3ZPb/AAi8U6r4q8BRajriv9qW5khFy6hBcrkEOoCqAAW2cZ5Q85yBdtPAfhSTSv7Og0mWxiWH7PcQ288lq86Y2/vjEy+cCM8sW6t6mukt9PttNsobLTbC0gtY34gjURIgzuJVVGM55xxz3oAnZf8ASI38vcQrLu3fdBwenccfy96+abbwjqHiu8+K1lpt/NFJBqYlW1MwSO5KzzECRmBJwA2ASBuKljxkfSF9ai7jeESzRtJGy5R3UAdCcoVIOGOCCD36qMc9oHgHw/oGrSalpMd1a3DuWnRb6Z1lYr/y2VnYOw3sQT03Z96AMn4W+PP+Et0xrLVZmi8TaerRX9pJH5Rba2BIE9egbGMMSCACtdxOHuYmXy3CCVRjIHmLkbs5HTrx3A9DXJN8LvCraj/adpa3SajtZBqC6rdGaMhdgw3mZOB8uMjgY9q39X0Sx1myuLLUknWwbLSLDdyQrKGUhgxRlOPvZUnac5OT0AseQfEHT7u71CP4keHrbTZJtFSGWQ/Zmk+3gMytMByDGm0BXwrbVdwwVYmPqXhvxDZeKdEj1bRL6AWM42NvT9/DLtVQkmWOXBx16jbjIINGl+GdPs9I/seG6nSAfIY7e/n3qqEBVSQuZI1A25RWABOOhO7P8NeA/DejRveaBpb2IuW2k/bJmDxjIDlJNyk4LYyOjHBGaAOwjeQogYxtIuBNtbhTjPH6cHsalqlbWMFrcSNuVnkkeZAwG5c/eI79wOO2O+SbtABSMiuMMoYZBwRnkHIP50UUALRRRQAUUUUAFFFFACBVUsVUAscsQOpxjn8AKWiigBFVUQIihVUYAAwAKY8EUkqSOgZ0BCk9uQf5qD+FFFAElFFFAH//2Q==';
return resultSuccess(result);
},
},
] as MockMethod[];

0
npm Normal file
View File

201
package.json Normal file
View File

@ -0,0 +1,201 @@
{
"name": "test-frontend",
"version": "3.9.2",
"author": {
"name": "北京国炬信息技术有限公司",
"email": "jeecgos@163.com",
"url": "https://www.jeecg.com"
},
"scripts": {
"pinstall": "pnpm install",
"clean:cache": "rimraf node_modules/.cache/ && rimraf node_modules/.vite",
"dev": "vite",
"build": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=8192 vite build && esno ./build/script/postBuild.ts && esno ./build/script/copyChat.ts",
"build:docker": "cross-env NODE_ENV=docker NODE_OPTIONS=--max-old-space-size=8192 vite build --mode docker && esno ./build/script/postBuild.ts && esno ./build/script/copyChat.ts",
"build:dockercloud": "cross-env NODE_ENV=dockercloud NODE_OPTIONS=--max-old-space-size=8192 vite build --mode dockercloud && esno ./build/script/postBuild.ts && esno ./build/script/copyChat.ts",
"build:report": "pnpm clean:cache && cross-env REPORT=true npm run build",
"preview": "npm run build && vite preview",
"reinstall": "rimraf pnpm-lock.yaml && rimraf yarn.lock && rimraf package.lock.json && rimraf node_modules && npm run install",
"clean:lib": "rimraf node_modules",
"gen:icon": "esno ./build/generate/icon/index.ts",
"lint": "eslint --ext .vue,.ts,.tsx src/",
"stylelint": "stylelint \"src/**/*.{vue,less,css}\"",
"batch:prettier": "prettier --write \"src/**/*.{js,json,tsx,css,less,scss,vue,html,md}\"",
"upgrade:log": "conventional-changelog -p angular -i CHANGELOG.md -s",
"husky:install": "husky install"
},
"dependencies": {
"@jeecg/aiflow": "3.9.2-beta",
"@logicflow/core": "^2.1.2",
"@logicflow/extension": "^2.1.4",
"@logicflow/vue-node-registry": "^1.1.3",
"@iconify/iconify": "^3.1.1",
"@ant-design/colors": "^7.2.1",
"@ant-design/icons-vue": "^7.0.1",
"@vue/shared": "^3.5.22",
"@vueuse/core": "^10.11.1",
"@tinymce/tinymce-vue": "4.0.7",
"@zxcvbn-ts/core": "^3.0.4",
"ant-design-vue": "^4.2.6",
"axios": "^1.12.2",
"@vant/area-data": "^1.5.2",
"clipboard": "^2.0.11",
"codemirror": "^5.65.20",
"cron-parser": "^4.9.0",
"cropperjs": "^1.6.2",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.18",
"dom-align": "^1.12.4",
"echarts": "^5.6.0",
"emoji-mart-vue-fast": "^15.0.5",
"enquire.js": "^2.1.6",
"intro.js": "^7.2.0",
"lodash-es": "^4.17.21",
"lodash.get": "^4.4.2",
"markdown-it": "^14.1.0",
"markdown-it-link-attributes": "^4.0.1",
"event-source-polyfill": "^1.0.31",
"highlight.js": "^11.11.1",
"@traptitech/markdown-it-katex": "^3.6.0",
"md5": "^2.3.0",
"mockjs": "^1.1.0",
"nprogress": "^0.2.0",
"path-to-regexp": "^6.3.0",
"pinia": "2.1.7",
"print-js": "^1.6.0",
"pinyin-pro": "^3.27.0",
"qs": "^6.14.0",
"qrcode": "^1.5.4",
"resize-observer-polyfill": "^1.5.1",
"showdown": "^2.1.0",
"sortablejs": "^1.15.6",
"swagger-ui-dist": "^5.29.3",
"tinymce": "6.6.2",
"vditor": "^3.11.2",
"vue": "^3.5.22",
"vue-cropper": "^0.6.5",
"vue-cropperjs": "^5.0.0",
"vue-i18n": "^9.14.5",
"vue-infinite-scroll": "^2.0.2",
"vue-print-nb-jeecg": "^1.0.13",
"vue-router": "^4.5.1",
"vue-types": "^5.1.3",
"vuedraggable": "^4.1.0",
"vxe-table": "4.13.31",
"vxe-pc-ui": "4.6.12",
"vxe-table-plugin-antd": "4.0.8",
"xe-utils": "3.5.26",
"xss": "^1.0.15",
"vue-grid-layout-v3": "^3.1.2",
"lunar-javascript": "^1.7.5",
"perfect-scrollbar": "^1.5.6",
"vue-color": "^3.3.3"
},
"devDependencies": {
"@commitlint/cli": "^18.6.1",
"@commitlint/config-conventional": "^18.6.3",
"@iconify/json": "^2.2.394",
"@purge-icons/generated": "^0.10.0",
"unplugin-icons": "^0.22.0",
"@types/codemirror": "^5.60.16",
"@types/crypto-js": "^4.2.2",
"@types/fs-extra": "^11.0.4",
"@types/inquirer": "^9.0.9",
"@types/intro.js": "^5.1.5",
"@types/jest": "^29.5.14",
"@types/lodash-es": "^4.17.12",
"@types/mockjs": "^1.0.10",
"@types/node": "^20.19.20",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.5",
"@types/qs": "^6.14.0",
"@types/pinyin": "^2.10.2",
"@types/showdown": "^2.0.6",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/compiler-sfc": "^3.5.22",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.21",
"commitizen": "^4.3.1",
"conventional-changelog-cli": "^4.1.0",
"cross-env": "^7.0.3",
"cz-git": "^1.12.0",
"czg": "^1.12.0",
"dotenv": "^16.6.1",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.2",
"eslint-define-config": "^2.1.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^9.33.0",
"esno": "^4.8.0",
"fs-extra": "^11.3.2",
"http-server": "^14.1.1",
"husky": "^8.0.3",
"inquirer": "^9.3.8",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"less": "^4.4.2",
"lint-staged": "15.2.2",
"npm-run-all": "^4.1.5",
"picocolors": "^1.1.1",
"postcss": "^8.5.6",
"postcss-html": "^1.8.0",
"postcss-less": "^6.0.0",
"prettier": "^3.6.2",
"pretty-quick": "^4.2.2",
"rimraf": "^5.0.10",
"rollup": "4.52.5",
"rollup-plugin-visualizer": "5.14.0",
"stylelint": "^16.25.0",
"stylelint-config-prettier": "^9.0.5",
"stylelint-config-recommended": "^14.0.1",
"stylelint-config-recommended-vue": "^1.6.1",
"stylelint-config-standard": "^36.0.1",
"stylelint-order": "^6.0.4",
"ts-jest": "^29.4.4",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"unplugin-vue-components": "~0.24.1",
"vite": "^7.3.3",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.2",
"vite-plugin-mkcert": "^1.17.9",
"vite-plugin-mock": "^2.9.8",
"vite-plugin-optimize-persist": "^0.1.2",
"vite-plugin-package-config": "^0.1.1",
"vite-plugin-purge-icons": "^0.10.0",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-pwa": "^1.1.0",
"workbox-window": "^7.3.0",
"vite-plugin-qiankun": "^1.0.15",
"vite-plugin-vue-setup-extend-plus": "^0.1.0",
"unocss": "^66.6.8",
"vue-eslint-parser": "^9.4.3",
"vue-tsc": "^1.8.27",
"dingtalk-jsapi": "^3.2.0",
"big.js": "^6.2.2"
},
"repository": {
"type": "git",
"url": "git+https://github.com/jeecgboot/JeecgBoot.git"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/jeecgboot/JeecgBoot/issues"
},
"homepage": "https://www.jeecg.com",
"engines": {
"node": "^18 || >=20"
},
"packageManager": "pnpm@9.15.4",
"lint-staged": {
"*.{vue,ts,tsx}": [
"eslint --fix"
],
"*.{js,json,scss,css,less,html,md}": "prettier --write"
}
}

15376
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More