hookehuyr

fix(checkin): 修复 mockData 语法错误并完善地图活动功能

主要修改:
- 修复 mockData.js 的语法错误(移除孤立的 return 语句)
- 修复 generateApiFromOpenAPI.js 的 brace-style 代码风格问题
- 实现 CheckinMap 页面与 map_activity API 的集成
- 添加完整的 API 规范文档
- 更新 API 代码生成脚本

技术细节:
- 清理 mockData.js 中遗留的孤立代码片段
- 移除未使用的函数参数以消除警告
- 修复 ESLint brace-style 规则错误(9处)
- 完善 map_activity API 接口定义
- 添加 API Mock 数据支持开发环境测试

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
# 打卡
## OpenAPI Specification
```yaml
openapi: 3.0.1
info:
title: ''
version: 1.0.0
paths:
/srv/:
post:
summary: 打卡
deprecated: false
description: ''
tags:
- 老来赛/地图-新版多活动
parameters:
- name: f
in: query
description: ''
required: true
example: walk
schema:
type: string
- name: a
in: query
description: ''
required: true
example: map_activity
schema:
type: string
- name: t
in: query
description: ''
required: true
example: checkin
schema:
type: string
requestBody:
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
activity_id:
description: 活动ID
example: ''
type: string
detail_id:
description: 打卡点ID
example: '828360'
type: string
openid:
example: oAHBN10P-hn-vF1cTY4tQeStQFmU
type: string
examples: {}
responses:
'200':
description: ''
content:
application/json:
schema:
type: object
properties:
code:
type: integer
msg:
type: string
x-apifox-orders:
- code
- msg
required:
- code
- msg
headers: {}
x-apifox-name: 成功
x-apifox-ordering: 0
security: []
x-apifox-folder: 老来赛/地图-新版多活动
x-apifox-status: integrating
x-run-in-apifox: https://app.apifox.com/web/project/1753326/apis/api-417072141-run
components:
schemas: {}
responses: {}
securitySchemes: {}
servers: []
security: []
```
# 地图活动详情
## OpenAPI Specification
```yaml
openapi: 3.0.1
info:
title: ''
version: 1.0.0
paths:
/srv/:
get:
summary: 地图活动详情
deprecated: false
description: ''
tags:
- 老来赛/地图-新版多活动
parameters:
- name: f
in: query
description: ''
required: true
example: walk
schema:
type: string
- name: a
in: query
description: ''
required: true
example: map_activity
schema:
type: string
- name: t
in: query
description: ''
required: true
example: detail
schema:
type: string
- name: id
in: query
description: 活动ID
required: false
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
type: object
properties:
code:
type: integer
msg:
type: string
data:
type: object
properties:
url:
type: string
title: 地图网址
id:
type: string
title: 活动ID
cover:
type: string
title: 封面图
tittle:
type: string
title: 标题
begin_date:
type: string
title: 开始时间
end_date:
type: string
title: 结束时间
is_ended:
type: boolean
title: 活动是否已经结束
is_begin:
type: boolean
title: 活动是否开始
first_checkin_points:
type: integer
title: 首次打卡获得积分
required_checkin_count:
type: integer
title: 需要打卡几次,才能完成活动
complete_points:
type: integer
title: 完成活动获得多少积分
discount_title:
type: string
title: 打卡点底部优惠标题
x-apifox-orders:
- id
- cover
- tittle
- begin_date
- end_date
- is_ended
- is_begin
- url
- first_checkin_points
- required_checkin_count
- complete_points
- discount_title
required:
- url
- end_date
- begin_date
- id
- cover
- tittle
- first_checkin_points
- required_checkin_count
- complete_points
- discount_title
x-apifox-orders:
- code
- msg
- data
required:
- code
- msg
- data
headers: {}
x-apifox-name: 成功
x-apifox-ordering: 0
security: []
x-apifox-folder: 老来赛/地图-新版多活动
x-apifox-status: integrating
x-run-in-apifox: https://app.apifox.com/web/project/1753326/apis/api-417075691-run
components:
schemas: {}
responses: {}
securitySchemes: {}
servers: []
security: []
```
# 是否已经打卡
## OpenAPI Specification
```yaml
openapi: 3.0.1
info:
title: ''
version: 1.0.0
paths:
/srv/:
get:
summary: 是否已经打卡
deprecated: false
description: ''
tags:
- 老来赛/地图-新版多活动
parameters:
- name: f
in: query
description: ''
required: true
example: walk
schema:
type: string
- name: a
in: query
description: ''
required: true
example: map_activity
schema:
type: string
- name: t
in: query
description: ''
required: true
example: is_checked
schema:
type: string
- name: detail_id
in: query
description: 打卡点ID
required: false
example: '828359'
schema:
type: string
- name: openid
in: query
description: ''
required: false
example: oAHBN10P-hn-vF1cTY4tQeStQFmU
schema:
type: string
- name: activity_id
in: query
description: 活动ID
required: false
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
type: object
properties:
code:
type: integer
msg:
type: string
data:
type: object
properties:
is_checked:
type: boolean
title: 是否已经打卡
x-apifox-orders:
- is_checked
required:
- is_checked
x-apifox-orders:
- code
- msg
- data
required:
- code
- msg
- data
headers: {}
x-apifox-name: 成功
x-apifox-ordering: 0
security: []
x-apifox-folder: 老来赛/地图-新版多活动
x-apifox-status: integrating
x-run-in-apifox: https://app.apifox.com/web/project/1753326/apis/api-417072140-run
components:
schemas: {}
responses: {}
securitySchemes: {}
servers: []
security: []
```
# 地图活动列表
## OpenAPI Specification
```yaml
openapi: 3.0.1
info:
title: ''
version: 1.0.0
paths:
/srv/:
get:
summary: 地图活动列表
deprecated: false
description: ''
tags:
- 老来赛/地图-新版多活动
parameters:
- name: f
in: query
description: ''
required: true
example: walk
schema:
type: string
- name: a
in: query
description: ''
required: true
example: map_activity
schema:
type: string
- name: t
in: query
description: ''
required: true
example: list
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
type: object
properties:
code:
type: integer
msg:
type: string
data:
type: array
items:
type: object
properties:
url:
type: string
title: 地图网址
id:
type: string
title: 活动ID
cover:
type: string
title: 封面图
tittle:
type: string
title: 标题
begin_date:
type: string
title: 开始时间
end_date:
type: string
title: 结束时间
x-apifox-orders:
- id
- cover
- tittle
- begin_date
- end_date
- url
required:
- url
- end_date
- begin_date
- id
- cover
- tittle
x-apifox-orders:
- code
- msg
- data
required:
- code
- msg
- data
headers: {}
x-apifox-name: 成功
x-apifox-ordering: 0
security: []
x-apifox-folder: 老来赛/地图-新版多活动
x-apifox-status: integrating
x-run-in-apifox: https://app.apifox.com/web/project/1753326/apis/api-417072114-run
components:
schemas: {}
responses: {}
securitySchemes: {}
servers: []
security: []
```
# 获取海报
## OpenAPI Specification
```yaml
openapi: 3.0.1
info:
title: ''
version: 1.0.0
paths:
/srv/:
get:
summary: 获取海报
deprecated: false
description: ''
tags:
- 老来赛/地图-新版多活动
parameters:
- name: f
in: query
description: ''
required: true
example: walk
schema:
type: string
- name: a
in: query
description: ''
required: true
example: map_activity
schema:
type: string
- name: t
in: query
description: ''
required: true
example: poster
schema:
type: string
- name: activity_id
in: query
description: 活动ID
required: false
example:
- ''
schema:
type: string
- name: detail_id
in: query
description: 关卡ID
required: false
example: ''
schema:
type: string
- name: env_version
in: query
description: 小程序版本。正式版为 "release",体验版为 "trial"。默认是正式版
required: false
example: trial
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
type: object
properties:
code:
type: integer
msg:
type: string
data:
type: object
properties:
details:
type: array
items:
type: object
properties:
id:
type: integer
title: 关卡ID
name:
type: string
title: 关卡名称
background_url:
type: string
title: 关卡背景图
main_slogan:
type: string
sub_slogan:
type: string
is_checked:
type: boolean
title: 是否已经打卡
x-apifox-orders:
- id
- name
- is_checked
- background_url
- main_slogan
- sub_slogan
required:
- id
- name
- background_url
- main_slogan
- sub_slogan
- is_checked
title: 关卡列表
family:
type: object
properties:
id:
type: integer
title: 家庭ID
name:
type: string
title: 家庭名称
avatar_url:
type: string
title: 家庭头像
x-apifox-orders:
- id
- name
- avatar_url
required:
- id
- name
- avatar_url
title: 用户的当前家庭
show_detail_index:
type: integer
title: 当前应该显示第几个关卡
description: 从 0 开始计数
end_date:
type: string
title: 活动截止时间
qrcode_url:
type: string
title: 小程序码
title:
type: string
title: 海报标题
begin_date:
type: string
title: 活动开始日期
x-apifox-orders:
- title
- begin_date
- end_date
- details
- show_detail_index
- family
- qrcode_url
required:
- title
- details
- family
- show_detail_index
- end_date
- qrcode_url
- begin_date
x-apifox-orders:
- code
- msg
- data
required:
- code
- msg
- data
headers: {}
x-apifox-name: 成功
x-apifox-ordering: 0
security: []
x-apifox-folder: 老来赛/地图-新版多活动
x-apifox-status: integrating
x-run-in-apifox: https://app.apifox.com/web/project/1753326/apis/api-417072142-run
components:
schemas: {}
responses: {}
securitySchemes: {}
servers: []
security: []
```
# 上传海报背景
## OpenAPI Specification
```yaml
openapi: 3.0.1
info:
title: ''
version: 1.0.0
paths:
/srv/:
post:
summary: 上传海报背景
deprecated: false
description: ''
tags:
- 老来赛/地图-新版多活动
parameters:
- name: f
in: query
description: ''
required: true
example: walk
schema:
type: string
- name: a
in: query
description: ''
required: true
example: map_activity
schema:
type: string
- name: t
in: query
description: ''
required: true
example: save_poster_background
schema:
type: string
requestBody:
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
activity_id:
description: 活动ID
example: ''
type: string
detail_id:
description: 打卡点ID
example: '828359'
type: string
poster_background_url:
description: 关卡海报背景
example: >-
https://cdn.ipadbiz.cn/space_34093/t0122259914a77e9a57_FiYxd1DK70vLJ53Po8g2Y6JmqMeQ.jpg
type: string
examples: {}
responses:
'200':
description: ''
content:
application/json:
schema:
type: object
properties:
code:
type: integer
msg:
type: string
x-apifox-orders:
- code
- msg
required:
- code
- msg
headers: {}
x-apifox-name: 成功
x-apifox-ordering: 0
security: []
x-apifox-folder: 老来赛/地图-新版多活动
x-apifox-status: developing
x-run-in-apifox: https://app.apifox.com/web/project/1753326/apis/api-417072143-run
components:
schemas: {}
responses: {}
securitySchemes: {}
servers: []
security: []
```
......@@ -18,14 +18,14 @@
"build:rn": "taro build --type rn",
"build:qq": "taro build --type qq",
"build:quickapp": "taro build --type quickapp",
"dev:weapp": "npm run build:weapp -- --watch",
"dev:swan": "npm run build:swan -- --watch",
"dev:alipay": "npm run build:alipay -- --watch",
"dev:tt": "npm run build:tt -- --watch",
"dev:h5": "npm run build:h5 -- --watch",
"dev:rn": "npm run build:rn -- --watch",
"dev:qq": "npm run build:qq -- --watch",
"dev:quickapp": "npm run build:quickapp -- --watch",
"dev:weapp": "NODE_ENV=development npm run build:weapp -- --watch",
"dev:swan": "NODE_ENV=development npm run build:swan -- --watch",
"dev:alipay": "NODE_ENV=development npm run build:alipay -- --watch",
"dev:tt": "NODE_ENV=development npm run build:tt -- --watch",
"dev:h5": "NODE_ENV=development npm run build:h5 -- --watch",
"dev:rn": "NODE_ENV=development npm run build:rn -- --watch",
"dev:qq": "NODE_ENV=development npm run build:qq -- --watch",
"dev:quickapp": "NODE_ENV=development npm run build:quickapp -- --watch",
"postinstall": "weapp-tw patch",
"prepare": "husky install",
"test": "vitest",
......@@ -33,7 +33,8 @@
"test:coverage": "vitest --coverage",
"test:run": "vitest run",
"lint": "eslint ./src --ext .vue,.js",
"format": "prettier --write \"src/**/*.{js,vue,less}\""
"format": "prettier --write \"src/**/*.{js,vue,less}\"",
"api:generate": "node scripts/generateApiFromOpenAPI.js"
},
"browserslist": [
"last 3 versions",
......
# API 文档生成指南
本项目的 API 文档采用手动维护的方式。
## 📝 工作流程
### 1. 维护 OpenAPI 文档
`docs/api-specs/` 目录中维护 OpenAPI 文档。
#### 目录结构
```
docs/api-specs/
├── user/ # 用户模块
│ ├── login.md
│ ├── login_status.md
│ └── ...
├── favorite/ # 收藏模块
│ ├── add.md
│ ├── del.md
│ └── list.md
└── ...
```
#### 文档格式
每个 `.md` 文件包含:
- 接口描述(Markdown 格式)
- OpenAPI 3.0.1 规范(YAML 格式)
**示例**`docs/api-specs/user/login.md`):
\`\`\`markdown
# 登录并绑定 OpenID
## 接口信息
- **方法**: POST
- **路径**: /srv/?a=user&t=login
- **标签**: user
## OpenAPI 规范
\`\`\`yaml
openapi: 3.0.0
info:
title: 登录并绑定 OpenID
description: 使用手机号和验证码登录,并绑定到 OpenID
version: 1.0.0
paths:
/srv/?:
post:
summary: 登录并绑定 OpenID
description: 使用手机号和验证码登录,并绑定到 OpenID
requestBody:
content:
application/x-www-form-urlencoded:
schema:
type: object
required:
- f
- a
- t
properties:
f:
type: string
description: 业务模块标识
example: manulife
a:
type: string
description: 模块名(user)
example: user
t:
type: string
description: 接口类型(login)
example: login
phone:
type: string
description: 手机号
example: '13800138000'
code:
type: string
description: 验证码
example: '123456'
openid:
type: string
description: 微信 OpenID
example: 'oXXXX-XXXXXXXXXXXXXXXXXXX'
responses:
'200':
description: 成功
content:
application/json:
schema:
type: object
properties:
code:
type: number
description: 状态码(0=失败,1=成功)
msg:
type: string
description: 消息
data:
type: object
description: 用户信息
properties:
id:
type: number
description: 用户 ID
avatar:
type: string
description: 头像 URL
name:
type: string
description: 姓名
\`\`\`
\`\`\`
### 2. 生成 API 代码
运行生成脚本:
```bash
node scripts/generateApiFromOpenAPI.js
```
#### 生成内容
脚本会:
1. 扫描 `docs/api-specs/` 目录
2. 解析每个 `.md` 文件中的 OpenAPI 规范
3. 生成对应的 JavaScript API 文件到 `src/api/` 目录
#### 输出示例
\`\`\`
=== OpenAPI 转 API 文档生成器 ===
输入目录: /Users/huyirui/program/itomix/git/manulife-weapp/docs/api-specs
输出目录: /Users/huyirui/program/itomix/git/manulife-weapp/src/api
💾 备份当前 OpenAPI 文档...
找到 9 个模块: event, favorite, feedback, file, get_file_list, get_product, news, user, wechat
处理模块: user
找到 5 个 API 文档
✓ get_profile: 获取个人信息
✓ login: 登录并绑定openid
✓ login_status: 查询登录状态
✓ logout: 退出登录并解绑openid
✓ update_profile: 更新个人资料
📝 生成文件: /Users/huyirui/program/itomix/git/manulife-weapp/src/api/user.js
✅ API 文档生成完成!
\`\`\`
### 3. 使用生成的 API
在组件中导入并使用:
\`\`\`javascript
import { loginAPI, getUserProfileAPI } from '@/api/user';
// 登录
const result = await loginAPI({
phone: '13800138000',
code: '123456',
openid: 'oXXXX-XXXXXXXXXXXXXXXXXXX'
});
if (result.code === 1) {
console.log('登录成功', result.data);
}
\`\`\`
## 🔧 高级功能
### API 变更检测
脚本会自动检测 API 变更:
-**新增接口** - 检测到新的 API 文档
- ⚠️ **修改接口** - 检测到 API 规范变更
-**删除接口** - 检测到删除的 API 文档
#### 变更报告示例
\`\`\`
🔍 开始检测 API 变更...
📦 新增模块: user
包含 2 个新增接口:
• POST /srv/?a=user&t=login - 登录并绑定 OpenID
• GET /srv/?a=user&t=get_profile - 获取个人信息
📦 对比范围: 9 个旧接口 → 11 个新接口
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 新增接口 (2):
+ login - 登录并绑定 OpenID
+ get_profile - 获取个人信息
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
总计: 2 新增, 0 修改, 0 删除
✅ 未检测到破坏性变更
\`\`\`
## 📚 最佳实践
### 1. 文档命名
- 使用语义化文件名(如 `login.md`, `get_list.md`
- 避免使用通用名称(如 `api.md`, `endpoint.md`
### 2. 参数定义
- **必填参数**:在 `required` 数组中列出
- **可选参数**:不在 `required`
- **描述**:为每个参数添加清晰的 `description`
- **示例**:为每个参数添加 `example`
### 3. 响应结构
- 统一使用 `{ code, msg, data }` 格式
- `code`: 状态码(0=失败,1=成功)
- `msg`: 消息说明
- `data`: 数据内容
### 4. 文档分组
- 按业务模块分组(user, favorite, product 等)
- 每个模块一个目录
- 相关接口放在同一目录
## 🛠️ 故障排除
### 问题:YAML 解析失败
**错误信息**
\`\`\`
✗ login.md: 解析失败 - YAML 代码块格式错误
\`\`\`
**解决方案**
- 检查 YAML 代码块是否正确包裹在 `\`\`\`yaml``\`\`\`` 之间
- 检查 YAML 缩进是否正确(使用空格,不要使用 Tab)
- 使用在线 YAML 验证器验证格式
### 问题:未找到 YAML 代码块
**错误信息**
\`\`\`
⚠️ login.md: 未找到 YAML 代码块
\`\`\`
**解决方案**
- 确保文档中包含 `\`\`\`yaml` 代码块
- 检查代码块格式是否正确
### 问题:生成的 API 代码为空
**可能原因**
1. OpenAPI 文档格式不正确
2. `paths``requestBody` 定义缺失
**解决方案**
- 检查 OpenAPI 文档结构是否完整
- 参考本文档中的示例格式
## 📖 参考资料
- [OpenAPI 3.0 规范](https://swagger.io/specification/)
- [YAML 语法指南](https://yaml.org/spec/1.2/spec.html)
- [项目 API 文档目录](../docs/api-specs/)
# OpenAPI 转 API 文档生成器 - 快速开始
## 🎯 一分钟快速上手
### 1️⃣ 创建 OpenAPI 文档
`docs/api-specs/` 目录下创建模块和接口文档:
```bash
# 创建新模块
mkdir -p docs/api-specs/product
# 创建接口文档
touch docs/api-specs/product/getList.md
```
### 2️⃣ 编写 OpenAPI 规范
编辑 `getList.md`
```markdown
# 获取商品列表
## OpenAPI Specification
\```yaml
openapi: 3.0.1
info:
title: ''
version: 1.0.0
paths:
/srv/:
get:
summary: 获取商品列表
tags:
- 商品
parameters:
- name: a
in: query
example: product_list
- name: f
in: query
example: behalo
responses:
'200':
description: 成功
\```
```
### 3️⃣ 生成 API 文件
```bash
pnpm api:generate
```
### 4️⃣ 使用生成的 API
```javascript
import { getListAPI } from '@/api/product';
const result = await getListAPI({ page: 1, pageSize: 10 });
```
## ✅ 验证结果
运行测试脚本验证生成的文件:
```bash
node scripts/test-generate.js
```
## 📂 文件结构
```
manulife-weapp/
├── docs/
│ ├── api-specs/ # API 规范文档源目录
│ │ └── user/ # 模块目录
│ │ └── getUserInfo.md
│ ├── OPENAPI_TO_API_GUIDE.md # 详细使用指南
│ └── API_USAGE_EXAMPLES.md # API 使用示例
├── scripts/
│ ├── generateApiFromOpenAPI.js # 生成器核心脚本
│ └── test-generate.js # 测试脚本
├── src/
│ └── api/ # 生成的 API 文件目录
│ ├── user.js # 自动生成
│ ├── wx/
│ └── index.js
└── package.json # 包含 api:generate 命令
```
## 🔄 工作流程
```mermaid
graph LR
A[编写 OpenAPI 文档] --> B[运行 pnpm api:generate]
B --> C[生成 API 文件]
C --> D[在项目中使用]
D --> E[需要修改接口]
E --> A
```
## 🎨 常见场景
### 场景 1: 批量生成多个接口
```bash
docs/api-specs/
├── user/
│ ├── getUserInfo.md
│ ├── updateProfile.md
│ └── changePassword.md
└── order/
├── getList.md
└── getDetail.md
```
运行 `pnpm api:generate` 后生成:
```
src/api/
├── user.js # 包含 3 个接口
└── order.js # 包含 2 个接口
```
### 场景 2: 更新已有接口
1. 修改 `docs/api-specs/user/getUserInfo.md`
2. 运行 `pnpm api:generate`
3. `src/api/user.js` 自动更新
### 场景 3: 添加新模块
1. 创建 `docs/api-specs/payment/`
2. 添加接口文档
3. 运行生成命令
4. 自动生成 `src/api/payment.js`
## ⚙️ 配置和自定义
### 修改输出目录
编辑 `scripts/generateApiFromOpenAPI.js`
```javascript
const outputDir = path.resolve(__dirname, '../src/api');
// 改为你想要的目录
const outputDir = path.resolve(__dirname, '../src/apis');
```
### 修改命名规则
编辑 `toCamelCase()``toPascalCase()` 函数。
### 修改生成模板
编辑 `generateApiFileContent()` 函数。
## 🐛 调试技巧
### 启用详细日志
在脚本中添加更多 console.log:
```javascript
console.log('解析的 API 信息:', JSON.stringify(apiInfo, null, 2));
```
### 单独测试某个模块
修改脚本中的模块过滤逻辑。
### 查看生成的中间数据
添加调试输出查看 YAML 解析结果。
## 📞 获取帮助
- 详细指南:[OpenAPI 转 API 文档生成器指南](./OPENAPI_TO_API_GUIDE.md)
- 使用示例:[API 使用示例](./API_USAGE_EXAMPLES.md)
- 项目架构:[CLAUDE.md](../CLAUDE.md)
## 🎉 开始使用
现在你已经准备好了!开始创建你的第一个 OpenAPI 文档吧。
```bash
# 1. 创建模块目录
mkdir -p docs/api-specs/your-module
# 2. 创建接口文档(参考 docs/api-specs/user/getUserInfo.md)
# 3. 生成 API
pnpm api:generate
# 4. 查看生成的文件
cat src/api/your-module.js
# 5. 开始使用
```
祝你编码愉快!🚀
/**
* API 对比工具
*
* 功能:
* 1. 对比两个 OpenAPI 文档的差异
* 2. 检测破坏性变更
* 3. 生成详细的变更报告
*
* 使用方式:
* node scripts/apiDiff.js <oldPath> <newPath>
*/
const fs = require('fs')
const path = require('path')
const yaml = require('js-yaml')
/**
* 从 Markdown 文件中提取 YAML
*/
function extractYAMLFromMarkdown(content) {
const yamlRegex = /```yaml\s*\n([\s\S]*?)\n```/
const match = content.match(yamlRegex)
return match ? match[1] : null
}
/**
* 解析 OpenAPI 文档(支持 .md 和目录)
*/
function parseOpenAPIPath(filePath) {
const stat = fs.statSync(filePath)
if (stat.isFile()) {
// 单个文件
if (filePath.endsWith('.md')) {
const content = fs.readFileSync(filePath, 'utf8')
const yamlContent = extractYAMLFromMarkdown(content)
if (!yamlContent) {
throw new Error(`文件 ${filePath} 中未找到 YAML 代码块`)
}
return [yaml.load(yamlContent)]
} else if (filePath.endsWith('.js')) {
// TODO: 支持对比生成的 JS 文件(需要解析 AST)
throw new Error('暂不支持对比生成的 JS 文件,请对比 OpenAPI 文档')
} else {
throw new Error(`不支持的文件类型: ${filePath}`)
}
} else if (stat.isDirectory()) {
// 目录,读取所有 .md 文件
const files = fs.readdirSync(filePath).filter(f => f.endsWith('.md'))
const docs = []
files.forEach(file => {
const fullPath = path.join(filePath, file)
const content = fs.readFileSync(fullPath, 'utf8')
const yamlContent = extractYAMLFromMarkdown(content)
if (yamlContent) {
const doc = yaml.load(yamlContent)
// 保存文件名用于标识
doc._fileName = path.basename(file, '.md')
docs.push(doc)
}
})
return docs
}
}
/**
* 从 OpenAPI 文档提取 API 信息
*/
function extractAPIInfo(openapiDoc) {
const path = Object.keys(openapiDoc.paths)[0]
const method = Object.keys(openapiDoc.paths[path])[0]
const apiInfo = openapiDoc.paths[path][method]
// 提取参数
const queryParams = (apiInfo.parameters || [])
.filter(p => p.in === 'query' && p.name !== 'a' && p.name !== 'f')
.map(p => ({
name: p.name,
type: p.schema?.type || 'any',
required: p.required || false,
description: p.description || '',
}))
// 提取 body 参数
const bodyParams = []
if (apiInfo.requestBody && apiInfo.requestBody.content) {
const content =
apiInfo.requestBody.content['application/x-www-form-urlencoded'] ||
apiInfo.requestBody.content['application/json']
if (content && content.schema && content.schema.properties) {
Object.entries(content.schema.properties).forEach(([key, value]) => {
if (key !== 'a' && key !== 'f') {
bodyParams.push({
name: key,
type: value.type || 'any',
required: content.schema.required?.includes(key) || false,
description: value.description || '',
})
}
})
}
}
// 提取响应结构
const responseSchema = apiInfo.responses?.['200']?.content?.['application/json']?.schema
return {
name: openapiDoc._fileName || 'unknown',
path,
method: method.toUpperCase(),
queryParams: new Set(queryParams.map(p => p.name)),
bodyParams: new Set(bodyParams.map(p => p.name)),
requiredQueryParams: new Set(queryParams.filter(p => p.required).map(p => p.name)),
requiredBodyParams: new Set(bodyParams.filter(p => p.required).map(p => p.name)),
allQueryParams: queryParams,
allBodyParams: bodyParams,
responseSchema,
summary: apiInfo.summary || '',
}
}
/**
* 对比两个 API 信息
*/
function compareAPI(oldAPI, newAPI) {
const changes = {
breaking: [],
nonBreaking: [],
}
// 检查 HTTP 方法变更
if (oldAPI.method !== newAPI.method) {
changes.breaking.push(`HTTP 方法变更: ${oldAPI.method}${newAPI.method}`)
}
// 检查 GET 参数变更
oldAPI.allQueryParams.forEach(oldParam => {
const newParam = newAPI.allQueryParams.find(p => p.name === oldParam.name)
if (!newParam) {
// 参数被删除
if (oldAPI.requiredQueryParams.has(oldParam.name)) {
changes.breaking.push(`删除必填 query 参数: ${oldParam.name}`)
} else {
changes.nonBreaking.push(`删除可选 query 参数: ${oldParam.name}`)
}
} else {
// 参数类型变更
if (oldParam.type !== newParam.type) {
changes.breaking.push(
`query 参数类型变更: ${oldParam.name} (${oldParam.type}${newParam.type})`
)
}
// 可选 → 必填
if (!oldParam.required && newParam.required) {
changes.breaking.push(`query 参数变为必填: ${newParam.name}`)
}
// 必填 → 可选
if (oldParam.required && !newParam.required) {
changes.nonBreaking.push(`query 参数变为可选: ${newParam.name}`)
}
}
})
// 检查新增 GET 参数
newAPI.allQueryParams.forEach(newParam => {
const oldParam = oldAPI.allQueryParams.find(p => p.name === newParam.name)
if (!oldParam) {
if (newParam.required) {
changes.breaking.push(`新增必填 query 参数: ${newParam.name}`)
} else {
changes.nonBreaking.push(`新增可选 query 参数: ${newParam.name}`)
}
}
})
// 检查 POST body 参数变更
oldAPI.allBodyParams.forEach(oldParam => {
const newParam = newAPI.allBodyParams.find(p => p.name === oldParam.name)
if (!newParam) {
// 参数被删除
if (oldAPI.requiredBodyParams.has(oldParam.name)) {
changes.breaking.push(`删除必填 body 参数: ${oldParam.name}`)
} else {
changes.nonBreaking.push(`删除可选 body 参数: ${oldParam.name}`)
}
} else {
// 参数类型变更
if (oldParam.type !== newParam.type) {
changes.breaking.push(
`body 参数类型变更: ${oldParam.name} (${oldParam.type}${newParam.type})`
)
}
// 可选 → 必填
if (!oldParam.required && newParam.required) {
changes.breaking.push(`body 参数变为必填: ${newParam.name}`)
}
// 必填 → 可选
if (oldParam.required && !newParam.required) {
changes.nonBreaking.push(`body 参数变为可选: ${newParam.name}`)
}
}
})
// 检查新增 body 参数
newAPI.allBodyParams.forEach(newParam => {
const oldParam = oldAPI.allBodyParams.find(p => p.name === newParam.name)
if (!oldParam) {
if (newParam.required) {
changes.breaking.push(`新增必填 body 参数: ${newParam.name}`)
} else {
changes.nonBreaking.push(`新增可选 body 参数: ${newParam.name}`)
}
}
})
return changes
}
/**
* 生成变更报告
*/
function generateReport(oldDocs, newDocs, format = 'text') {
const oldAPIs = oldDocs.map(extractAPIInfo)
const newAPIs = newDocs.map(extractAPIInfo)
const oldAPIsMap = new Map(oldAPIs.map(api => [api.name, api]))
const newAPIsMap = new Map(newAPIs.map(api => [api.name, api]))
const addedAPIs = []
const removedAPIs = []
const modifiedAPIs = []
// 检测新增接口
newAPIs.forEach(api => {
if (!oldAPIsMap.has(api.name)) {
addedAPIs.push(api)
}
})
// 检测删除接口
oldAPIs.forEach(api => {
if (!newAPIsMap.has(api.name)) {
removedAPIs.push(api)
}
})
// 检测修改接口
newAPIs.forEach(api => {
const oldAPI = oldAPIsMap.get(api.name)
if (oldAPI) {
const changes = compareAPI(oldAPI, api)
if (changes.breaking.length > 0 || changes.nonBreaking.length > 0) {
modifiedAPIs.push({
name: api.name,
summary: api.summary,
changes,
})
}
}
})
// 统计
const totalBreaking = modifiedAPIs.reduce((sum, api) => sum + api.changes.breaking.length, 0)
// 生成文本报告
if (format === 'text') {
const lines = []
lines.push('=== API 变更检测报告 ===\n')
lines.push(`📦 对比范围: ${oldAPIs.length} 个旧接口 → ${newAPIs.length} 个新接口`)
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n')
if (addedAPIs.length > 0) {
lines.push(`✅ 新增接口 (${addedAPIs.length}):`)
addedAPIs.forEach(api => {
lines.push(` + ${api.name} - ${api.summary}`)
})
lines.push('')
}
if (modifiedAPIs.length > 0) {
lines.push(`⚠️ 修改接口 (${modifiedAPIs.length}):`)
modifiedAPIs.forEach(api => {
lines.push(` ↪ ${api.name} - ${api.summary}`)
api.changes.breaking.forEach(change => {
lines.push(` ✗ [破坏性] ${change}`)
})
api.changes.nonBreaking.forEach(change => {
lines.push(` ✓ [非破坏性] ${change}`)
})
})
lines.push('')
}
if (removedAPIs.length > 0) {
lines.push(`❌ 删除接口 (${removedAPIs.length}):`)
removedAPIs.forEach(api => {
lines.push(` - ${api.name} - ${api.summary}`)
})
lines.push('')
}
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
lines.push(
`总计: ${addedAPIs.length} 新增, ${modifiedAPIs.length} 修改, ${removedAPIs.length} 删除`
)
if (totalBreaking > 0) {
lines.push(`⚠️ 检测到 ${totalBreaking} 个破坏性变更,请仔细检查业务逻辑!`)
} else if (addedAPIs.length > 0 || modifiedAPIs.length > 0 || removedAPIs.length > 0) {
lines.push('✅ 未检测到破坏性变更')
} else {
lines.push('✅ 无接口变更')
}
return lines.join('\n')
}
// 生成 JSON 报告
if (format === 'json') {
return JSON.stringify(
{
summary: {
added: addedAPIs.length,
modified: modifiedAPIs.length,
removed: removedAPIs.length,
breakingChanges: totalBreaking,
},
added: addedAPIs.map(api => ({
name: api.name,
summary: api.summary,
method: api.method,
path: api.path,
})),
modified: modifiedAPIs.map(api => ({
name: api.name,
summary: api.summary,
breakingChanges: api.changes.breaking,
nonBreakingChanges: api.changes.nonBreaking,
})),
removed: removedAPIs.map(api => ({
name: api.name,
summary: api.summary,
method: api.method,
path: api.path,
})),
},
null,
2
)
}
}
/**
* 主函数
*/
function main() {
const args = process.argv.slice(2)
if (args.length < 2) {
console.error('用法: node scripts/apiDiff.js <oldPath> <newPath>')
console.error('示例:')
console.error(' node scripts/apiDiff.js docs/api-specs/user/ docs/api-specs/user-new/')
console.error(
' node scripts/apiDiff.js docs/api-specs/user/api1.md docs/api-specs/user/api1-new.md'
)
process.exit(1)
}
const [oldPath, newPath] = args
if (!fs.existsSync(oldPath)) {
console.error(`❌ 旧路径不存在: ${oldPath}`)
process.exit(1)
}
if (!fs.existsSync(newPath)) {
console.error(`❌ 新路径不存在: ${newPath}`)
process.exit(1)
}
try {
const oldDocs = parseOpenAPIPath(oldPath)
const newDocs = parseOpenAPIPath(newPath)
const format = process.env.API_DIFF_FORMAT || 'text'
const report = generateReport(oldDocs, newDocs, format)
console.log(report)
// 如果有破坏性变更,返回退出码 1
const oldAPIs = oldDocs.map(extractAPIInfo)
const newAPIs = newDocs.map(extractAPIInfo)
const oldAPIsMap = new Map(oldAPIs.map(api => [api.name, api]))
const newAPIsMap = new Map(newAPIs.map(api => [api.name, api]))
let totalBreaking = 0
newAPIs.forEach(api => {
const oldAPI = oldAPIsMap.get(api.name)
if (oldAPI) {
const changes = compareAPI(oldAPI, api)
totalBreaking += changes.breaking.length
}
})
// 严格模式:任何变更都返回 1
const strictMode = process.env.API_DIFF_STRICT === 'true'
const hasChanges =
oldAPIs.length !== newAPIs.length ||
newAPIs.some(api => !oldAPIsMap.has(api.name)) ||
oldAPIs.some(api => !newAPIsMap.has(api.name))
if (totalBreaking > 0 || (strictMode && hasChanges)) {
process.exit(1)
} else {
process.exit(0)
}
} catch (error) {
console.error(`❌ 对比失败: ${error.message}`)
process.exit(1)
}
}
// 如果直接运行此脚本
if (require.main === module) {
main()
}
module.exports = {
compareAPI,
generateReport,
parseOpenAPIPath,
extractAPIInfo,
}
#!/bin/bash
###############################################################################
# CHANGELOG 漏记检查脚本
#
# 功能:
# 1. 扫描最近 N 天的 git 提交记录
# 2. 对比 CHANGELOG.md 中的记录
# 3. 生成漏记报告
#
# 使用:
# ./scripts/check-changelog.sh [days]
#
# 示例:
# ./scripts/check-changelog.sh 7 # 检查最近 7 天
# ./scripts/check-changelog.sh 30 # 检查最近 30 天
# ./scripts/check-changelog.sh # 检查所有提交
#
###############################################################################
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 默认参数
DAYS=${1:-7} # 默认检查最近 7 天
CHANGELOG_FILE="docs/CHANGELOG.md"
echo -e "${BLUE}======================================${NC}"
echo -e "${BLUE} CHANGELOG 漏记检查工具${NC}"
echo -e "${BLUE}======================================${NC}"
echo ""
echo -e "检查范围: 最近 ${DAYS} 天"
echo ""
# 检查 CHANGELOG 文件是否存在
if [ ! -f "$CHANGELOG_FILE" ]; then
echo -e "${RED}错误: CHANGELOG 文件不存在: $CHANGELOG_FILE${NC}"
exit 1
fi
# 1. 获取 git 提交记录
echo -e "${BLUE}[1/4] 正在获取 git 提交记录...${NC}"
if [ "$DAYS" = "0" ]; then
# 检查所有提交
GIT_LOG=$(git log --all --pretty=format:"%h|%ad|%s" --date=short)
else
# 检查最近 N 天的提交
GIT_LOG=$(git log --since="$DAYS days ago" --pretty=format:"%h|%ad|%s" --date=short)
fi
TOTAL_COMMITS=$(echo "$GIT_LOG" | wc -l | tr -d ' ')
echo -e " 找到 ${GREEN}$TOTAL_COMMITS${NC} 个提交"
# 2. 解析 CHANGELOG 中的记录
echo -e "${BLUE}[2/4] 正在解析 CHANGELOG 记录...${NC}"
# 提取 CHANGELOG 中的日期和描述
CHANGELOG_ENTRIES=$(grep "^## \[" "$CHANGELOG_FILE" | sed 's/^## \[//' | sed 's/\].*//' | sort -u)
TOTAL_CHANGELOG=$(echo "$CHANGELOG_ENTRIES" | wc -l | tr -d ' ')
echo -e " 找到 ${GREEN}$TOTAL_CHANGELOG${NC} 条记录"
# 3. 对比分析
echo -e "${BLUE}[3/4] 正在对比分析...${NC}"
# 统计每个日期的提交数量
COMMITS_BY_DATE=$(echo "$GIT_LOG" | awk -F'|' '{print $2}' | sort | uniq -c | sort -rn)
echo ""
echo -e "${YELLOW}📊 每日提交统计:${NC}"
echo "$COMMITS_BY_DATE" | head -20
# 检查哪些日期有提交但 CHANGELOG 没有记录
echo ""
echo -e "${YELLOW}🔍 可能漏记的日期:${NC}"
MISSING_DATES=0
while IFS='|' read -r count date; do
date=$(echo "$date" | awk '{print $2}')
# 检查 CHANGELOG 中是否有这个日期的记录
if ! echo "$CHANGELOG_ENTRIES" | grep -q "$date"; then
echo -e " ${RED}${NC} $date - ${RED}$count 个提交未记录${NC}"
MISSING_DATES=$((MISSING_DATES + 1))
fi
done <<< "$COMMITS_BY_DATE"
if [ $MISSING_DATES -eq 0 ]; then
echo -e " ${GREEN}${NC} 所有提交都已记录"
fi
# 4. 生成详细报告
echo ""
echo -e "${BLUE}[4/4] 生成详细报告...${NC}"
# 临时文件
TEMP_REPORT=$(mktemp)
# 输出报告头
cat > "$TEMP_REPORT" << 'EOF'
# CHANGELOG 漏记详细报告
## 检查日期
- 检查范围: 最近 {DAYS} 天
- 生成时间: {TIMESTAMP}
## 统计摘要
- Git 提交总数: {TOTAL_COMMITS}
- CHANGELOG 记录数: {TOTAL_CHANGELOG}
- 可能漏记的提交: {MISSING_COMMITS}
## 漏记详情
EOF
# 替换模板变量
sed -i.bak "s/{DAYS}/$DAYS/g" "$TEMP_REPORT"
sed -i.bak "s/{TIMESTAMP}/$(date '+%Y-%m-%d %H:%M:%S')/g" "$TEMP_REPORT"
sed -i.bak "s/{TOTAL_COMMITS}/$TOTAL_COMMITS/g" "$TEMP_REPORT"
sed -i.bak "s/{TOTAL_CHANGELOG}/$TOTAL_CHANGELOG/g" "$TEMP_REPORT"
# 计算可能漏记的提交数
MISSING_COMMITS=0
while IFS='|' read -r count date; do
date=$(echo "$date" | awk '{print $2}')
if ! echo "$CHANGELOG_ENTRIES" | grep -q "$date"; then
MISSING_COMMITS=$((MISSING_COMMITS + count))
fi
done <<< "$COMMITS_BY_DATE"
sed -i.bak "s/{MISSING_COMMITS}/$MISSING_COMMITS/g" "$TEMP_REPORT"
# 如果有漏记,列出详细提交
if [ $MISSING_COMMITS -gt 0 ]; then
echo "" >> "$TEMP_REPORT"
echo "### 未记录的提交详情" >> "$TEMP_REPORT"
echo "" >> "$TEMP_REPORT"
while IFS='|' read -r count date; do
date_only=$(echo "$date" | awk '{print $2}')
if ! echo "$CHANGELOG_ENTRIES" | grep -q "$date_only"; then
echo "**$date_only** ($count 个提交):" >> "$TEMP_REPORT"
echo "$GIT_LOG" | grep "$date_only" | awk -F'|' '{print "- " $3}' >> "$TEMP_REPORT"
echo "" >> "$TEMP_REPORT"
fi
done <<< "$COMMITS_BY_DATE"
else
echo "" >> "$TEMP_REPORT"
echo "### ✅ 完整性检查" >> "$TEMP_REPORT"
echo "" >> "$TEMP_REPORT"
echo "所有提交都已在 CHANGELOG 中记录!" >> "$TEMP_REPORT"
fi
# 删除备份文件
rm -f "$TEMP_REPORT.bak"
# 输出报告
echo ""
echo -e "${BLUE}======================================${NC}"
echo -e "${BLUE} 检查完成${NC}"
echo -e "${BLUE}======================================${NC}"
echo ""
cat "$TEMP_REPORT"
# 保存报告
REPORT_FILE="docs/changelog-check-report-$(date +%Y%m%d).md"
mv "$TEMP_REPORT" "$REPORT_FILE"
echo ""
echo -e "${GREEN}✓ 详细报告已保存到: $REPORT_FILE${NC}"
# 5. 给出建议
echo ""
echo -e "${YELLOW}💡 建议:${NC}"
if [ $MISSING_COMMITS -gt 0 ]; then
echo -e " ${YELLOW}1.${NC} 查看 $REPORT_FILE 了解漏记详情"
echo -e " ${YELLOW}2.${NC} 更新 CHANGELOG.md 补充漏记的记录"
echo -e " ${YELLOW}3.${NC} 使用标准格式添加记录(参考文档顶部模板)"
else
echo -e " ${GREEN}${NC} CHANGELOG 记录完整,继续保持!"
fi
echo ""
echo -e "${BLUE}======================================${NC}"
echo ""
# 返回退出码
if [ $MISSING_COMMITS -gt 0 ]; then
exit 1 # 有漏记,返回非零退出码
else
exit 0 # 无漏记,返回零
fi
/**
* 从 OpenAPI 文档自动生成 API 接口文件
*
* 功能:
* 1. 扫描 docs/api-specs 目录
* 2. 解析每个 .md 文件中的 OpenAPI YAML 规范
* 3. 提取 API 信息并生成对应的 JavaScript API 文件
* 4. 保存到 src/api/ 目录
*
* 目录结构:
* docs/api-specs/
* ├── module1/
* │ ├── api1.md
* │ └── api2.md
* └── module2/
* └── api3.md
*
* 生成到:
* src/api/
* ├── module1.js
* └── module2.js
*/
const fs = require('fs')
const path = require('path')
const yaml = require('js-yaml')
const { generateReport, parseOpenAPIPath } = require('./apiDiff')
/**
* 提取 Markdown 文件中的 YAML 代码块
* @param {string} content - Markdown 文件内容
* @returns {string|null} - YAML 字符串或 null
*/
function extractYAMLFromMarkdown(content) {
const yamlRegex = /```yaml\s*\n([\s\S]*?)\n```/
const match = content.match(yamlRegex)
return match ? match[1] : null
}
/**
* 将字符串转换为驼峰命名
* @param {string} str - 输入字符串
* @returns {string} - 驼峰命名字符串
*/
function toCamelCase(str) {
return str
.replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ''))
.replace(/^(.)/, c => c.toLowerCase())
}
/**
* 将字符串转换为帕斯卡命名(首字母大写)
* @param {string} str - 输入字符串
* @returns {string} - 帕斯卡命名字符串
*/
function toPascalCase(str) {
const camelCase = toCamelCase(str)
return camelCase.charAt(0).toUpperCase() + camelCase.slice(1)
}
/**
* 简单的英文到中文翻译
* @param {string} text - 英文文本
* @returns {string} - 中文文本
*/
function translateToChinese(text) {
if (!text) {
return ''
}
// 常见技术术语翻译字典(优先级从高到低)
const dictionary = {
// 短语和完整句子
'mini program authorization': '小程序授权',
'login flow design': '登录流程设计',
// 短语
'mini program': '小程序',
'mini-program': '小程序',
wechat: '微信',
weixin: '微信',
openid: 'OpenID',
// 动词和常用词
authorize: '授权',
authorization: '授权',
login: '登录',
logout: '登出',
register: '注册',
call: '调用',
return: '返回',
using: '使用',
'bound to': '绑定到',
'according to': '根据',
// 名词
user: '用户',
code: '授权码',
token: '令牌',
session: '会话',
request: '请求',
response: '响应',
interface: '接口',
api: '接口',
account: '账号',
openid: 'OpenID',
// 描述性词汇
first: '首先',
then: '然后',
if: '如果',
else: '否则',
need: '需要',
should: '应该',
must: '必须',
// 状态
success: '成功',
fail: '失败',
error: '错误',
empty: '空',
'non-empty': '非空',
// 属性
avatar: '头像',
name: '姓名',
id: 'ID',
info: '信息',
data: '数据',
flow: '流程',
design: '设计',
// 其他
internal: '内部',
automatically: '自动',
specify: '指定',
testing: '测试',
'used for': '用于',
'bound with': '绑定',
}
let translated = text
// 按照字典进行替换(优先匹配长词)
const sortedKeys = Object.keys(dictionary).sort((a, b) => b.length - a.length)
sortedKeys.forEach(key => {
const regex = new RegExp(key, 'gi')
translated = translated.replace(regex, dictionary[key])
})
return translated
}
/**
* 格式化描述文本(翻译+格式化)
* @param {string} description - 原始描述
* @returns {string} - 格式化后的描述
*/
function formatDescription(description) {
if (!description) {
return ''
}
// 移除 markdown 格式符号(如 # 标题)
let formatted = description
.replace(/^#+\s*/gm, '') // 移除标题符号
.replace(/\*\*(.*?)\*\*/g, '$1') // 移除加粗
.replace(/\*(.*?)\*/g, '$1') // 移除斜体
.replace(/`([^`]+)`/g, '$1') // 移除行内代码
.trim()
// 先进行整句翻译(常见句式)
formatted = translateSentences(formatted)
return formatted
}
/**
* 翻译常见句式
* @param {string} text - 文本
* @returns {string} - 翻译后的文本
*/
function translateSentences(text) {
if (!text) {
return ''
}
// 常见句式翻译(按优先级排序,长的先匹配)
const sentences = {
// 完整句子
'# 登录流程设计': '# 登录流程设计',
'# Login Flow Design': '# 登录流程设计',
// 常见句式
'Authorize mini program first': '先进行小程序授权',
'If user is empty, call login API': '如果返回 user 为空,则需要调用登录接口',
'If user is not empty, no need to call login API': '如果返回 user 非空,则不需要调用登录接口',
'the authorization API will automatically login using the account bound to openid':
'授权接口内部按照 openid 绑定的账号,自动登录',
'Specify an openid for testing': '指定一个 openid 用来测试',
'User information bound to openid': 'openid 绑定的用户信息',
'0=fail, 1=success': '0=失败,1=成功',
}
let translated = text
// 按长度排序(长句优先)
const sortedKeys = Object.keys(sentences).sort((a, b) => b.length - a.length)
sortedKeys.forEach(key => {
const regex = new RegExp(escapeRegExp(key), 'gi')
translated = translated.replace(regex, sentences[key])
})
// 最后进行单词级别的补充翻译
translated = translateWords(translated)
return translated
}
/**
* 转义正则表达式特殊字符
* @param {string} string - 字符串
* @returns {string} - 转义后的字符串
*/
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* 单词级别的翻译(补充)
* @param {string} text - 文本
* @returns {string} - 翻译后的文本
*/
function translateWords(text) {
const dictionary = {
'mini program': '小程序',
wechat: '微信',
openid: 'OpenID',
user: '用户',
authorization: '授权',
login: '登录',
avatar: '头像',
name: '姓名',
}
let translated = text
Object.entries(dictionary).forEach(([key, value]) => {
const regex = new RegExp(key, 'gi')
translated = translated.replace(regex, value)
})
return translated
}
/**
* 解析对象属性,生成字段描述
* @param {object} properties - 属性对象
* @param {number} indent - 缩进级别
* @returns {string} - 字段描述字符串
*/
function parseProperties(properties, indent = 0) {
if (!properties) {
return ''
}
const lines = []
const prefix = ' '.repeat(indent)
Object.entries(properties).forEach(([key, value]) => {
const type = value.type || 'any'
const desc = value.description || value.title || ''
const required = value.required ? '' : ' (可选)'
// 基本类型
if (type !== 'object' && type !== 'array') {
lines.push(`${prefix}${key}: ${type}${required} - ${desc}`)
// 对象类型
} else if (type === 'object' && value.properties) {
lines.push(`${prefix}${key}: {`)
lines.push(`${prefix} // ${desc}`)
lines.push(parseProperties(value.properties, indent + 2))
lines.push(prefix + '}')
// 数组类型
} else if (type === 'array' && value.items) {
const itemType = value.items.type || 'any'
if (itemType === 'object' && value.items.properties) {
lines.push(`${prefix}${key}: Array<{`)
lines.push(`${prefix} // ${desc}`)
lines.push(parseProperties(value.items.properties, indent + 2))
lines.push(prefix + '}>')
} else {
lines.push(`${prefix}${key}: Array<${itemType}>${required} - ${desc}`)
}
}
})
return lines.join('\n')
}
/**
* 从 requestBody 中提取参数
* @param {object} requestBody - requestBody 对象
* @returns {Array} - 参数数组
*/
function extractRequestParams(requestBody) {
if (!requestBody || !requestBody.content) {
return []
}
// 获取内容类型(可能是 application/x-www-form-urlencoded 或 application/json)
const content =
requestBody.content['application/x-www-form-urlencoded'] ||
requestBody.content['application/json']
if (!content || !content.schema || !content.schema.properties) {
return []
}
const params = []
Object.entries(content.schema.properties).forEach(([key, value]) => {
params.push({
name: key,
type: value.type || 'any',
description: value.description || '',
example: value.example || '',
required: content.schema.required?.includes(key) || false,
})
})
return params
}
/**
* 生成 JSDoc 参数注释
* @param {Array} parameters - parameters 数组(GET 请求)
* @param {Array} bodyParams - requestBody 参数数组(POST 请求)
* @param {string} method - HTTP 方法
* @returns {string} - JSDoc 参数注释
*/
function generateParamJSDoc(parameters, bodyParams, method) {
const lines = [' * @param {Object} params 请求参数']
// POST 请求使用 body 参数
if (method === 'POST' && bodyParams && bodyParams.length > 0) {
// 过滤掉 a、f 和 t 参数(这些参数已硬编码到 URL 中)
const filteredParams = bodyParams.filter(
p => p.name !== 'a' && p.name !== 'f' && p.name !== 't'
)
filteredParams.forEach(param => {
const type = param.type || 'any'
const desc = param.description || ''
const required = param.required ? '' : ' (可选)'
lines.push(` * @param {${type}} params.${param.name}${required} ${desc}`)
})
// GET 请求使用 query 参数
} else if (method === 'GET' && parameters && parameters.length > 0) {
// 只保留 query 参数,过滤 header 参数和 a、f、t 参数
const queryParams = parameters.filter(
p => p.in === 'query' && p.name !== 'a' && p.name !== 'f' && p.name !== 't'
)
queryParams.forEach(param => {
const type = param.schema?.type || 'any'
const desc = param.description || ''
const required = param.required ? '' : ' (可选)'
lines.push(` * @param {${type}} params.${param.name}${required} ${desc}`)
})
}
return lines.join('\n')
}
/**
* 递归生成属性字段的 JSDoc 注释
* @param {object} properties - 属性对象
* @param {number} indent - 缩进级别(空格数)
* @returns {string} - JSDoc 注释
*/
function generatePropertiesJSDoc(properties, indent = 0) {
const lines = []
const prefix = ' '.repeat(indent)
Object.entries(properties).forEach(([key, value]) => {
const type = value.type || 'any'
const desc = value.description || value.title || ''
// 处理嵌套对象
if (type === 'object' && value.properties) {
lines.push(`${prefix}${key}: {\n`)
// 递归处理嵌套对象的属性
lines.push(generatePropertiesJSDoc(value.properties, indent + 2))
lines.push(`${prefix}};\n`)
// 处理数组(元素是对象)
} else if (type === 'array' && value.items && value.items.properties) {
lines.push(`${prefix}${key}: Array<{\n`)
// 递归处理数组元素的属性
lines.push(generatePropertiesJSDoc(value.items.properties, indent + 2))
lines.push(`${prefix}}>;\n`)
// 处理简单数组
} else if (type === 'array' && value.items) {
const itemType = value.items.type || 'any'
lines.push(`${prefix}${key}: Array<${itemType}>; // ${desc}\n`)
// 处理基本类型
} else {
lines.push(`${prefix}${key}: ${type}; // ${desc}\n`)
}
})
return lines.join('')
}
/**
* 生成 JSDoc 返回值注释
* @param {object} responseSchema - 响应 schema
* @returns {string} - JSDoc 返回值注释
*/
function generateReturnJSDoc(responseSchema) {
if (!responseSchema || !responseSchema.properties) {
return ' * @returns {Promise<{code:number,data:any,msg:string}>} 标准返回'
}
const { code, msg, data } = responseSchema.properties
let returnDesc = ' * @returns {Promise<{\n'
returnDesc += ' * code: number; // 状态码\n'
returnDesc += ' * msg: string; // 消息\n'
if (data) {
const dataType = data.type || 'any'
const dataDesc = data.description || data.title || ''
// 处理对象类型的 data
if (dataType === 'object' && data.properties) {
returnDesc += ' * data: {\n'
// 使用递归函数处理 data 的所有属性
returnDesc += generatePropertiesJSDoc(data.properties, 4)
returnDesc += ' * };\n'
// 处理数组类型的 data(元素是对象)
} else if (dataType === 'array' && data.items && data.items.properties) {
returnDesc += ' * data: Array<{\n'
returnDesc += generatePropertiesJSDoc(data.items.properties, 4)
returnDesc += ' * }>;\n'
// 处理简单数组类型
} else if (dataType === 'array' && data.items) {
const itemType = data.items.type || 'any'
returnDesc += ` * data: Array<${itemType}>;\n`
// 其他类型
} else {
returnDesc += ` * data: ${dataType};\n`
}
} else {
returnDesc += ' * data: any;\n'
}
returnDesc += ' * }>}'
return returnDesc
}
/**
* 解析 OpenAPI 文档并提取 API 信息
* @param {object} openapiDoc - 解析后的 OpenAPI 对象
* @param {string} fileName - 文件名(用作 API 名称)
* @returns {object} - 提取的 API 信息
*/
function parseOpenAPIDocument(openapiDoc, fileName) {
try {
const path = Object.keys(openapiDoc.paths)[0]
const method = Object.keys(openapiDoc.paths[path])[0]
const apiInfo = openapiDoc.paths[path][method]
// 提取 query 参数
const parameters = apiInfo.parameters || []
const queryParams = {}
let actionValue = ''
let typeValue = '' // t 参数
// 提取 body 参数(用于 POST 请求)
const requestBody = apiInfo.requestBody
const bodyParams = extractRequestParams(requestBody)
// 对于 POST 请求,从 requestBody 中提取 action 和 type
if (requestBody && bodyParams.length > 0) {
const actionParam = bodyParams.find(p => p.name === 'a')
if (actionParam) {
actionValue = actionParam.example || ''
}
const typeParam = bodyParams.find(p => p.name === 't')
if (typeParam) {
typeValue = typeParam.example || ''
}
}
// 对于 GET 请求,从 query 参数中提取 action 和 type
if (parameters.length > 0) {
parameters.forEach(param => {
if (param.in === 'query') {
queryParams[param.name] = param.example || param.schema?.default || ''
// 提取 action 参数(通常是 'a' 参数)
if (param.name === 'a' && !actionValue) {
actionValue = param.example || ''
}
// 提取 type 参数('t' 参数)
if (param.name === 't' && !typeValue) {
typeValue = param.example || ''
}
}
})
}
// 提取响应结构
const responseSchema = apiInfo.responses?.['200']?.content?.['application/json']?.schema
return {
summary: apiInfo.summary || fileName,
description: apiInfo.description || '',
method: method.toUpperCase(),
action: actionValue,
type: typeValue, // 保存 t 参数
queryParams,
parameters, // 保存完整的参数信息用于生成 JSDoc(GET 请求)
bodyParams, // 保存 requestBody 参数用于生成 JSDoc(POST 请求)
responseSchema, // 保存响应结构用于生成 JSDoc
fileName,
}
} catch (error) {
console.error(`解析 OpenAPI 文档失败: ${error.message}`)
return null
}
}
/**
* 生成 API 文件内容
* @param {string} moduleName - 模块名称
* @param {Array} apis - API 信息数组
* @returns {string} - 生成的文件内容
*/
function generateApiFileContent(moduleName, apis) {
const imports = "import { fn, fetch } from '@/api/fn';\n\n"
const apiConstants = []
const apiFunctions = []
apis.forEach(api => {
// 生成常量名(帕斯卡命名)
const constantName = toPascalCase(api.fileName)
// 生成函数名(驼峰命名 + API 后缀)
const functionName = toCamelCase(api.fileName) + 'API'
// 构建 URL,包含 a 和 t 参数
let url = '/srv/?'
const params = []
if (api.action) {
params.push(`a=${api.action}`)
}
if (api.type) {
params.push(`t=${api.type}`)
}
url += params.join('&')
// 添加常量定义
apiConstants.push(` ${constantName}: '${url}',`)
// 生成详细的 JSDoc 注释
const paramJSDoc = generateParamJSDoc(api.parameters, api.bodyParams, api.method)
const returnJSDoc = generateReturnJSDoc(api.responseSchema)
// 添加函数定义
const fetchMethod = api.method === 'GET' ? 'fetch.get' : 'fetch.post'
// 格式化描述
const formattedDesc = formatDescription(api.description)
// 生成 JSDoc 注释(包含描述)
const comment = `/**
* @description ${api.summary}
* @remark ${formattedDesc}
${paramJSDoc}
${returnJSDoc}
*/`
apiFunctions.push(
`${comment}\nexport const ${functionName} = (params) => fn(${fetchMethod}(Api.${constantName}, params));`
)
})
return `${imports}const Api = {\n${apiConstants.join('\n')}\n}\n\n${apiFunctions.join('\n\n')}\n`
}
/**
* 扫描目录并处理所有 OpenAPI 文档
* @param {string} openAPIDir - OpenAPI 文档目录
* @param {string} outputDir - 输出目录
*/
function scanAndGenerate(openAPIDir, outputDir) {
if (!fs.existsSync(openAPIDir)) {
console.error(`OpenAPI 目录不存在: ${openAPIDir}`)
return
}
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
// 扫描第一级目录(模块)
const modules = fs
.readdirSync(openAPIDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
console.log(`找到 ${modules.length} 个模块: ${modules.join(', ')}`)
modules.forEach(moduleName => {
const moduleDir = path.join(openAPIDir, moduleName)
const apiFiles = fs
.readdirSync(moduleDir)
.filter(file => file.endsWith('.md') && file !== 'CLAUDE.md')
if (apiFiles.length === 0) {
console.log(`模块 ${moduleName} 中没有找到 .md 文件`)
return
}
console.log(`\n处理模块: ${moduleName}`)
console.log(`找到 ${apiFiles.length} 个 API 文档`)
const apis = []
apiFiles.forEach(fileName => {
const filePath = path.join(moduleDir, fileName)
const content = fs.readFileSync(filePath, 'utf8')
const yamlContent = extractYAMLFromMarkdown(content)
if (!yamlContent) {
console.warn(` ⚠️ ${fileName}: 未找到 YAML 代码块`)
return
}
try {
const openapiDoc = yaml.load(yamlContent)
const apiName = path.basename(fileName, '.md')
const apiInfo = parseOpenAPIDocument(openapiDoc, apiName)
if (apiInfo) {
apis.push(apiInfo)
console.log(` ✓ ${apiName}: ${apiInfo.summary}`)
}
} catch (error) {
console.error(` ✗ ${fileName}: 解析失败 - ${error.message}`)
}
})
// 生成并保存 API 文件
if (apis.length > 0) {
const fileContent = generateApiFileContent(moduleName, apis)
const outputPath = path.join(outputDir, `${moduleName}.js`)
fs.writeFileSync(outputPath, fileContent, 'utf8')
console.log(` 📝 生成文件: ${outputPath}`)
}
})
console.log('\n✅ API 文档生成完成!')
// 对比新旧 API
console.log('\n🔍 开始检测 API 变更...\n')
compareAPIChanges(openAPIDir)
}
/**
* 备份 OpenAPI 文档目录
* @param {string} sourceDir - 源目录
* @returns {string} - 备份目录路径
*/
function backupOpenAPIDir(sourceDir) {
const backupBaseDir = path.resolve(__dirname, '../.tmp')
const backupDir = path.join(backupBaseDir, 'api-specs-backup')
// 创建备份目录
if (!fs.existsSync(backupBaseDir)) {
fs.mkdirSync(backupBaseDir, { recursive: true })
}
// 删除旧备份
if (fs.existsSync(backupDir)) {
fs.rmSync(backupDir, { recursive: true, force: true })
}
// 复制目录
copyDirectory(sourceDir, backupDir)
return backupDir
}
/**
* 递归复制目录
* @param {string} src - 源路径
* @param {string} dest - 目标路径
*/
function copyDirectory(src, dest) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true })
}
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
copyDirectory(srcPath, destPath)
} else {
fs.copyFileSync(srcPath, destPath)
}
}
}
/**
* 对比新旧 API 变更
* @param {string} openAPIDir - OpenAPI 文档目录
*/
function compareAPIChanges(openAPIDir) {
const backupDir = path.resolve(__dirname, '../.tmp/api-specs-backup')
const tempDir = path.resolve(__dirname, '../.tmp/api-specs-temp')
// 检查是否存在临时备份(上一次的版本)
if (!fs.existsSync(tempDir)) {
console.log('ℹ️ 首次运行,已建立基线。下次运行将检测 API 变更。')
// 将当前备份移动到临时目录,作为下次对比的基线
if (fs.existsSync(backupDir)) {
fs.renameSync(backupDir, tempDir)
}
return
}
// 扫描模块
const modules = fs
.readdirSync(openAPIDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
let hasChanges = false
const moduleReports = []
modules.forEach(moduleName => {
const moduleDir = path.join(openAPIDir, moduleName)
const tempModuleDir = path.join(tempDir, moduleName)
// 如果临时备份中不存在该模块,说明是新增模块
if (!fs.existsSync(tempModuleDir)) {
console.log(`\n📦 新增模块: ${moduleName}`)
// 解析新增模块的接口
try {
const newDocs = parseOpenAPIPath(moduleDir)
if (newDocs && newDocs.length > 0) {
// 显示新增接口信息
console.log(` 包含 ${newDocs.length} 个新增接口:`)
newDocs.forEach(doc => {
const path = Object.keys(doc.paths || {})[0] || ''
const method = Object.keys(doc.paths?.[path] || {})[0] || ''
const apiInfo = doc.paths?.[path]?.[method]
const summary = apiInfo?.summary || doc.info?.title || '未命名接口'
console.log(` • ${method?.toUpperCase()} ${path} - ${summary}`)
})
}
} catch (error) {
console.error(` ⚠️ 解析模块失败: ${error.message}`)
}
hasChanges = true
return
}
// 读取当前和临时备份的文档
const currentFiles = fs
.readdirSync(moduleDir)
.filter(f => f.endsWith('.md') && f !== 'CLAUDE.md')
const tempFiles = fs
.readdirSync(tempModuleDir)
.filter(f => f.endsWith('.md') && f !== 'CLAUDE.md')
// 检查是否有文件变更
const hasNewFiles = currentFiles.some(f => !tempFiles.includes(f))
const hasRemovedFiles = tempFiles.some(f => !currentFiles.includes(f))
const hasModifiedFiles = currentFiles.some(f => {
if (!tempFiles.includes(f)) {
return false
}
const currentContent = fs.readFileSync(path.join(moduleDir, f), 'utf8')
const tempContent = fs.readFileSync(path.join(tempModuleDir, f), 'utf8')
return currentContent !== tempContent
})
if (hasNewFiles || hasRemovedFiles || hasModifiedFiles) {
hasChanges = true
moduleReports.push({ moduleName, moduleDir, tempModuleDir })
}
})
// 检查删除的模块
const tempModules = fs.existsSync(tempDir)
? fs
.readdirSync(tempDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
: []
const deletedModules = tempModules.filter(m => !modules.includes(m))
if (deletedModules.length > 0) {
hasChanges = true
console.log(`\n❌ 删除模块: ${deletedModules.join(', ')}`)
}
if (!hasChanges) {
console.log('✅ 未检测到 API 变更')
// 更新基线
if (fs.existsSync(backupDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
fs.renameSync(backupDir, tempDir)
}
return
}
// 逐个模块对比
console.log('')
moduleReports.forEach(({ moduleName, moduleDir, tempModuleDir }) => {
try {
const oldDocs = parseOpenAPIPath(tempModuleDir)
const newDocs = parseOpenAPIPath(moduleDir)
const report = generateReport(oldDocs, newDocs, 'text')
console.log(report)
console.log('')
} catch (error) {
console.error(`⚠️ 模块 ${moduleName} 对比失败: ${error.message}`)
}
})
// 更新基线:将当前备份作为下次对比的基准
console.log('📝 更新 API 基线...')
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
}
if (fs.existsSync(backupDir)) {
fs.renameSync(backupDir, tempDir)
}
}
// 执行生成
const openAPIDir = path.resolve(__dirname, '../docs/api-specs')
const outputDir = path.resolve(__dirname, '../src/api')
console.log('=== OpenAPI 转 API 文档生成器 ===\n')
console.log(`输入目录: ${openAPIDir}`)
console.log(`输出目录: ${outputDir}\n`)
// 备份当前的 OpenAPI 文档(用于下次对比)
if (fs.existsSync(openAPIDir)) {
console.log('💾 备份当前 OpenAPI 文档...')
backupOpenAPIDir(openAPIDir)
console.log('')
}
scanAndGenerate(openAPIDir, outputDir)
/**
* 测试生成的 API 文件
*/
const path = require('path')
const fs = require('fs')
// 测试导入生成的 API
const userApiPath = path.resolve(__dirname, '../src/api/user.js')
console.log('=== 测试生成的 API 文件 ===\n')
if (fs.existsSync(userApiPath)) {
const content = fs.readFileSync(userApiPath, 'utf8')
console.log('✅ API 文件生成成功\n')
console.log('文件内容:')
console.log('─'.repeat(60))
console.log(content)
console.log('─'.repeat(60))
// 验证关键部分
const checks = [
{ name: '导入 fn 和 fetch', pattern: /import \{ fn, fetch \} from '@\/api\/fn'/ },
{ name: 'Api 常量定义', pattern: /const Api = \{/ },
{ name: '导出函数', pattern: /export const getUserInfoAPI/ },
{ name: 'JSDoc 注释', pattern: /\/\*\*[\s\S]*?\*\// },
{ name: '正确的 action', pattern: /a=user_info/ },
]
console.log('\n验证结果:')
checks.forEach(check => {
const passed = check.pattern.test(content)
console.log(`${passed ? '✅' : '❌'} ${check.name}`)
})
console.log('\n✅ 所有验证通过!')
} else {
console.log('❌ API 文件不存在,请先运行 pnpm api:generate')
}
import { fn, fetch } from '@/api/fn'
const Api = {
Checkin: '/srv/?a=map_activity&t=checkin',
Detail: '/srv/?a=map_activity&t=detail',
IsChecked: '/srv/?a=map_activity&t=is_checked',
List: '/srv/?a=map_activity&t=list',
Poster: '/srv/?a=map_activity&t=poster',
SavePosterBackground: '/srv/?a=map_activity&t=save_poster_background',
}
/**
* @description 打卡
* @remark
* @param {Object} params 请求参数
* @param {string} params.activity_id (可选) 活动ID
* @param {string} params.detail_id (可选) 打卡点ID
* @param {string} params.openid (可选)
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: any;
* }>}
*/
export const checkinAPI = params => fn(fetch.post(Api.Checkin, params))
/**
* @description 地图活动详情
* @remark
* @param {Object} params 请求参数
* @param {string} params.id (可选) 活动ID
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: {
url: string; // 地图网址
id: string; // 活动ID
cover: string; // 封面图
tittle: string; // 标题
begin_date: string; // 开始时间
end_date: string; // 结束时间
is_ended: boolean; // 活动是否已经结束
is_begin: boolean; // 活动是否开始
first_checkin_points: integer; // 首次打卡获得积分
required_checkin_count: integer; // 需要打卡几次,才能完成活动
complete_points: integer; // 完成活动获得多少积分
discount_title: string; // 打卡点底部优惠标题
* };
* }>}
*/
export const detailAPI = params => fn(fetch.get(Api.Detail, params))
/**
* @description 是否已经打卡
* @remark
* @param {Object} params 请求参数
* @param {string} params.detail_id (可选) 打卡点ID
* @param {string} params.openid (可选)
* @param {string} params.activity_id (可选) 活动ID
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: {
is_checked: boolean; // 是否已经打卡
* };
* }>}
*/
export const isCheckedAPI = params => fn(fetch.get(Api.IsChecked, params))
/**
* @description 地图活动列表
* @remark
* @param {Object} params 请求参数
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: Array<{
url: string; // 地图网址
id: string; // 活动ID
cover: string; // 封面图
tittle: string; // 标题
begin_date: string; // 开始时间
end_date: string; // 结束时间
* }>;
* }>}
*/
export const listAPI = params => fn(fetch.get(Api.List, params))
/**
* @description 获取海报
* @remark
* @param {Object} params 请求参数
* @param {string} params.activity_id (可选) 活动ID
* @param {string} params.detail_id (可选) 关卡ID
* @param {string} params.env_version (可选) 小程序版本。正式版为 "release",体验版为 "trial"。默认是正式版
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: {
details: Array<{
id: integer; // 关卡ID
name: string; // 关卡名称
background_url: string; // 关卡背景图
main_slogan: string; //
sub_slogan: string; //
is_checked: boolean; // 是否已经打卡
}>;
family: {
id: integer; // 家庭ID
name: string; // 家庭名称
avatar_url: string; // 家庭头像
};
show_detail_index: integer; // 从 0 开始计数
end_date: string; // 活动截止时间
qrcode_url: string; // 小程序码
title: string; // 海报标题
begin_date: string; // 活动开始日期
* };
* }>}
*/
export const posterAPI = params => fn(fetch.get(Api.Poster, params))
/**
* @description 上传海报背景
* @remark
* @param {Object} params 请求参数
* @param {string} params.activity_id (可选) 活动ID
* @param {string} params.detail_id (可选) 打卡点ID
* @param {string} params.poster_background_url (可选) 关卡海报背景
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: any;
* }>}
*/
export const savePosterBackgroundAPI = params => fn(fetch.post(Api.SavePosterBackground, params))
......@@ -37,54 +37,68 @@
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import BottomNav from '@/components/BottomNav.vue'
import { listAPI } from '@/api/map_activity'
import { mockMapActivityListAPI } from '@/utils/mockData'
import { useLoad } from '@tarojs/taro'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
/**
* 便民地图列表数据
*/
const mapList = ref([])
const loading = ref(false)
/**
* 格式化 API 数据为页面所需格式
* @param {Array} list - API 返回的活动列表
* @returns {Array} 格式化后的活动列表
*/
const formatMapList = list => {
return list.map(item => ({
id: item.id,
title: item.tittle, // API 返回的是 tittle,映射为 title
cover: item.cover,
timeRange: `${item.begin_date}~${item.end_date}`,
activityId: item.id, // 使用 id 作为 activityId
}))
}
/**
* Mock 便民地图数据
* 获取地图活动列表
*/
const mapList = ref([
{
id: 1,
title: '重阳登高打卡',
cover: 'https://picsum.photos/400/300?random=1',
timeRange: '2025.09.06~2025.10.31',
activityId: 'chongyang_2024',
},
{
id: 2,
title: '公园晨跑打卡',
cover: 'https://picsum.photos/400/300?random=2',
timeRange: '2025.09.01~2025.12.31',
activityId: 'morning_run_2024',
},
{
id: 3,
title: '社区健身打卡',
cover: 'https://picsum.photos/400/300?random=3',
timeRange: '2025.08.01~2025.12.31',
activityId: 'community_fitness',
},
{
id: 4,
title: '周末徒步打卡',
cover: 'https://picsum.photos/400/300?random=4',
timeRange: '2025.09.15~2025.11.30',
activityId: 'weekend_hike',
},
{
id: 5,
title: '秋日赏菊打卡',
cover: 'https://picsum.photos/400/300?random=5',
timeRange: '2025.10.15~2025.11.15',
activityId: 'autumn_chrysanthemum',
},
{
id: 6,
title: '古镇文化打卡',
cover: 'https://picsum.photos/400/300?random=6',
timeRange: '2025.10.01~2025.10.31',
activityId: 'ancient_town',
},
])
const fetchMapList = async () => {
if (loading.value) {
return
}
loading.value = true
try {
const params = {}
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA ? await mockMapActivityListAPI(params) : await listAPI(params)
if (res.code === 1 && res.data) {
mapList.value = formatMapList(res.data)
} else {
Taro.showToast({
title: res.msg || '获取活动列表失败',
icon: 'none',
})
}
} catch (err) {
console.error('获取地图活动列表失败:', err)
Taro.showToast({
title: '网络异常,请重试',
icon: 'none',
})
} finally {
loading.value = false
}
}
/**
* 处理卡片点击事件
......@@ -107,6 +121,11 @@ const handleEnter = item => {
url: `/pages/ActivitiesCover/index?activityId=${item.activityId}&title=${encodeURIComponent(item.title)}`,
})
}
// 页面加载时获取列表
useLoad(() => {
fetchMapList()
})
</script>
<style lang="less">
......
/**
* @Description: Mock 数据生成工具 - 用于测试地图活动功能
* @Date: 2026-02-09
*
* 支持的 API Mock:
* - listAPI: 地图活动列表
* - detailAPI: 地图活动详情
* - isCheckedAPI: 是否已打卡
* - posterAPI: 获取海报
*/
// ============================================================================
// 工具函数
// ============================================================================
/**
* 随机图片 URL 生成器
* @param {number} width - 图片宽度
* @param {number} height - 图片高度
* @param {number} seed - 随机种子
* @returns {string} 图片 URL
*/
function randomImage(width = 400, height = 300, seed = 1) {
return `https://picsum.photos/${width}/${height}?random=${seed}`
}
/**
* 模拟网络延迟
* @param {number} min - 最小延迟(ms)
* @param {number} max - 最大延迟(ms)
* @returns {Promise}
*/
function mockDelay(min = 100, max = 300) {
const delay = Math.random() * (max - min) + min
return new Promise(resolve => setTimeout(resolve, delay))
}
// ============================================================================
// 1. 地图活动列表 Mock (listAPI)
// ============================================================================
const ACTIVITY_NAMES = [
'重阳登高打卡',
'公园晨跑打卡',
'社区健身打卡',
'周末徒步打卡',
'秋日赏菊打卡',
'古镇文化打卡',
'健康骑行活动',
'亲子运动会',
'户外拓展训练',
'城市定向挑战',
]
/**
* 生成地图活动列表项
* @param {number} id - 活动 ID
* @returns {Object} 活动对象
*/
function generateMapActivityItem(id) {
const activityName = ACTIVITY_NAMES[Math.floor(Math.random() * ACTIVITY_NAMES.length)]
const now = new Date()
const startDate = new Date(now.getTime() + Math.random() * 30 * 24 * 60 * 60 * 1000)
const endDate = new Date(startDate.getTime() + (30 + Math.random() * 60) * 24 * 60 * 60 * 1000)
// 格式化日期为 YYYY.MM.DD
const formatDate = date => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}.${month}.${day}`
}
return {
id: String(id),
tittle: activityName,
cover: randomImage(400, 300, id),
begin_date: formatDate(startDate),
end_date: formatDate(endDate),
url: '',
}
}
/**
* Mock: listAPI (地图活动列表)
* @param {Object} params - 请求参数
* @returns {Promise<{code: number, msg: string, data: Array}>}
*/
export async function mockMapActivityListAPI() {
await mockDelay()
const list = []
const total = 6
for (let i = 0; i < total; i++) {
list.push(generateMapActivityItem(i + 1))
}
console.log(`[Mock] listAPI - 地图活动列表,共${list.length}条`)
return {
code: 1,
msg: 'success',
data: list,
}
}
// ============================================================================
// 2. 地图活动详情 Mock (detailAPI)
// ============================================================================
/**
* Mock: detailAPI (地图活动详情)
* @param {Object} params - 请求参数
* @param {string} params.id - 活动 ID
* @returns {Promise<{code: number, msg: string, data: Object}>}
*/
export async function mockMapActivityDetailAPI(params) {
await mockDelay()
const { id } = params
const item = generateMapActivityItem(parseInt(id) || 1)
console.log(`[Mock] detailAPI - 活动详情,ID:${id}`)
return {
code: 1,
msg: 'success',
data: {
...item,
url: 'https://example.com/map',
is_ended: false,
is_begin: true,
first_checkin_points: 10,
required_checkin_count: 5,
complete_points: 50,
discount_title: '打卡点优惠信息',
},
}
}
// ============================================================================
// 3. 是否已打卡 Mock (isCheckedAPI)
// ============================================================================
/**
* Mock: isCheckedAPI (是否已打卡)
* @param {Object} params - 请求参数
* @param {string} params.detail_id - 打卡点 ID
* @returns {Promise<{code: number, msg: string, data: Object}>}
*/
export async function mockIsCheckedAPI(params) {
await mockDelay()
const { detail_id } = params
const isChecked = parseInt(detail_id) % 3 === 0
console.log(`[Mock] isCheckedAPI - 打卡点${detail_id},${isChecked ? '已打卡' : '未打卡'}`)
return {
code: 1,
msg: 'success',
data: {
is_checked: isChecked,
},
}
}
// ============================================================================
// 4. 获取海报 Mock (posterAPI)
// ============================================================================
/**
* Mock: posterAPI (获取海报)
* @param {Object} params - 请求参数
* @param {string} params.activity_id - 活动 ID
* @returns {Promise<{code: number, msg: string, data: Object}>}
*/
export async function mockPosterAPI(params) {
await mockDelay()
const { activity_id } = params
console.log(`[Mock] posterAPI - 获取海报,活动ID:${activity_id}`)
return {
code: 1,
msg: 'success',
data: {
details: [
{
id: 1,
name: '起点打卡',
background_url: randomImage(750, 1200, 20),
main_slogan: '开启健康之旅',
sub_slogan: '坚持就是胜利',
is_checked: true,
},
{
id: 2,
name: '山顶打卡',
background_url: randomImage(750, 1200, 21),
main_slogan: '登高望远',
sub_slogan: '风景这边独好',
is_checked: false,
},
],
family: {
id: 123,
name: '快乐家庭',
avatar_url: randomImage(200, 200, 30),
},
show_detail_index: 0,
end_date: '2025.10.31',
qrcode_url: 'https://example.com/qrcode.jpg',
title: '重阳登高打卡',
begin_date: '2025.09.06',
},
}
}
// ============================================================================
// 导出统一 Mock API 调用器
// ============================================================================
/**
* Mock API 调用器
* @param {string} apiName - API 名称
* @param {Object} params - 请求参数
* @returns {Promise}
*/
export async function mockAPI(apiName, params) {
switch (apiName) {
case 'listAPI':
return await mockMapActivityListAPI(params)
case 'detailAPI':
return await mockMapActivityDetailAPI(params)
case 'isCheckedAPI':
return await mockIsCheckedAPI(params)
case 'posterAPI':
return await mockPosterAPI(params)
default:
console.warn(`[Mock] 未知的 API: ${apiName}`)
return { code: 0, msg: 'Unknown API', data: null }
}
}
/**
* Mock 地图活动详情数据
* @param {string} activityId - 活动 ID
* @returns {Object} 地图活动详情
*/
export const mockMapActivityDetail = () => {
return {
url: 'https://example.com/map',
id: '1',
cover: randomImage(750, 500, 10),
tittle: '重阳登高打卡',
begin_date: '2025.09.06',
end_date: '2025.10.31',
is_ended: false,
is_begin: true,
first_checkin_points: 10,
required_checkin_count: 5,
complete_points: 50,
discount_title: '打卡点优惠信息',
}
}
/**
* Mock 是否已打卡数据
* @param {string} detailId - 打卡点 ID
* @returns {boolean} 是否已打卡
*/
export const mockIsChecked = detailId => {
// 偶尔返回已打卡
return parseInt(detailId) % 3 === 0
}
/**
* Mock 海报数据
* @param {string} activityId - 活动 ID
* @returns {Object} 海报数据
*/
export const mockPoster = activityId => {
return {
details: [
{
id: 1,
name: '起点打卡',
background_url: randomImage(750, 1200, 20),
main_slogan: '开启健康之旅',
sub_slogan: '坚持就是胜利',
is_checked: true,
},
{
id: 2,
name: '山顶打卡',
background_url: randomImage(750, 1200, 21),
main_slogan: '登高望远',
sub_slogan: '风景这边独好',
is_checked: false,
},
],
family: {
id: 123,
name: '快乐家庭',
avatar_url: randomImage(200, 200, 30),
},
show_detail_index: 0,
end_date: '2025.10.31',
qrcode_url: 'https://example.com/qrcode.jpg',
title: '重阳登高打卡',
begin_date: '2025.09.06',
}
}