This commit is contained in:
admin
2026-04-11 20:46:55 +08:00
parent e6c2d76238
commit e04405d0bc
70 changed files with 10438 additions and 332 deletions

View File

@@ -0,0 +1,129 @@
---
name: better-icons
description: 'Use when working with icons in any project. Provides CLI for searching 200+ icon libraries (Iconify) and retrieving SVGs. Commands: `better-icons search <query>` to find icons, `better-icons get <id>` to get SVG. Also available as MCP server for AI agents.'
---
# Better Icons
Search and retrieve icons from 200+ libraries via Iconify.
## Installation
Before using any `better-icons` commands, ensure the tool is available in the environment.
**Option 1 — Install globally (recommended, matches all examples below):**
```bash
# Using npm
npm install -g better-icons
# Using Bun (faster)
bun add -g better-icons
```
**Option 2 — Run without installing (prefix every command with `npx` or `bunx`):**
```bash
# Using npx (npm)
npx better-icons search arrow --limit 10
npx better-icons get lucide:home > icon.svg
# Using bunx (Bun — faster)
bunx better-icons search arrow --limit 10
bunx better-icons get lucide:home > icon.svg
```
> **For AI agents:** Prefer the global install so that `better-icons` is on `$PATH` and the commands below work as-is. Run the install step once during environment setup, then use the commands without `npx`/`bunx`.
## CLI
```bash
# Search icons
better-icons search <query> [--prefix <prefix>] [--limit <n>] [--json]
# Search and download all found icons as SVG files
better-icons search <query> -d [dir] [--color <color>] [--size <px>]
# Get icon SVG (outputs to stdout)
better-icons get <icon-id> [--color <color>] [--size <px>] [--json]
# Setup MCP server for AI agents
better-icons setup [-a cursor,claude-code] [-s global|project]
```
## Examples
```bash
better-icons search arrow --limit 10
better-icons search home --json | jq '.icons[0]'
better-icons get lucide:home > icon.svg
better-icons get mdi:home --color '#333' --json
# Batch download all search results
better-icons search arrow -d # saves to ./icons/
better-icons search check -d ./my-icons # saves to ./my-icons/
better-icons search star -d -c '#000' -s 24 --limit 64
```
## Icon ID Format
`prefix:name` - e.g., `lucide:home`, `mdi:arrow-right`, `heroicons:check`
## Popular Collections
`lucide`, `mdi`, `heroicons`, `tabler`, `ph`, `ri`, `solar`, `iconamoon`
---
## MCP Tools (for AI agents)
| Tool | Description |
|------|-------------|
| `search_icons` | Search across all libraries |
| `get_icon` | Get single icon SVG |
| `get_icons` | Batch retrieve multiple icons |
| `list_collections` | Browse available icon sets |
| `recommend_icons` | Smart recommendations for use cases |
| `find_similar_icons` | Find variations across collections |
| `sync_icon` | Add icon to project file |
| `scan_project_icons` | List icons in project |
## TypeScript Interfaces
```typescript
interface SearchIcons {
query: string
limit?: number // 1-999, default 32
prefix?: string // e.g., 'mdi', 'lucide'
category?: string // e.g., 'General', 'Emoji'
}
interface GetIcon {
icon_id: string // 'prefix:name' format
color?: string // e.g., '#ff0000', 'currentColor'
size?: number // pixels
}
interface GetIcons {
icon_ids: string[] // max 20
color?: string
size?: number
}
interface RecommendIcons {
use_case: string // e.g., 'navigation menu'
style?: 'solid' | 'outline' | 'any'
limit?: number // default 10
}
interface SyncIcon {
icons_file: string // absolute path
framework: 'react' | 'vue' | 'svelte' | 'solid' | 'svg'
icon_id: string
component_name?: string
}
```
## API
All icons from `https://api.iconify.design`

View File

@@ -0,0 +1,193 @@
---
name: brainstorming
description: 在任何创造性工作前执行此步骤——包括创建功能、构建组件、添加功能或修改行为。在实施前探究用户意图、需求与设计方案。通过自然协作对话,将创意转化为完整的设计方案与规格说明。当用户提及"新功能"、"构建"、"创建组件"、"添加功能"、"实现"或开始任何开发工作时触发本技能。本技能会自动调用 project-context 技能获取用户画像和流程路径推荐。
license: MIT
metadata:
version: "3.0.0"
---
# 头脑风暴技能
本技能是任何创造性工作前的必经步骤,通过协作对话将创意转化为完整的设计规格说明。
---
## 核心原则
### 设计优先
在用户批准设计文档前,请勿:
- 调用任何实施技能
- 编写任何代码
- 搭建项目框架
- 采取任何实施行动
### 会话状态管理
使用统一的会话状态文件跟踪进度:
- **路径**`design/session-state.md`
- **模板**`references/session-state-template.md`
**操作时机**
- 技能激活时 → **创建状态文件**如果状态文件存在则将旧文件重命名为session-state-YYYYMMDDHHMMSS.md
- 获得用户确认后 → **更新状态文件**
- 每轮对话开始时 → 读取状态文件恢复上下文
- **每个阶段完成后 → 更新状态文件**
### 终止条件
本技能的唯一终止方式是调用 `writing-plan` 技能,并传递规格文档路径。
---
## 工作流程
### 阶段 1探索背景
**目标**:了解当前项目状态,评估需求范围。
**执行动作**
1. 调用 `project-context` 技能获取用户画像
2. 读取项目上下文(如有):`design/context/project-context.md`
3. 检查项目文件结构
4. 评估需求是否涉及多个独立子系统
5. **主动更新用户画像**(如果识别到新的偏好或技术背景)
**范围检查**
- 若请求涉及多个独立子系统,协助用户分解为子项目
- 每个子项目独立进行头脑风暴
**完成标准**:已了解项目技术栈和现有结构,已确认需求范围,用户画像已更新(如有变化)
---
### 阶段 2确认需求
**目标**:逐条理解目标、约束条件和成功标准。
**执行规则**
- 每条消息只提一个问题
- 优先使用选择题形式
**核心问题清单**(按顺序询问):
| 序号 | 问题类型 | 示例问题 |
| ---- | -------- | -------------------------------- |
| 1 | 目标定位 | "这个功能的主要用户是谁?" |
| 2 | 约束条件 | "有没有时间或技术栈的限制?" |
| 3 | 成功标准 | "如何判断这个功能是否成功交付?" |
| 4 | 边界情况 | "有没有需要特别处理的边界情况?" |
**完成标准**:目标定位明确、约束条件清晰、成功标准可验证
---
### 阶段 3方案选择
**目标**:提供 2-3 种不同方案及其权衡分析。
**执行动作**
1. 提出 2-3 种不同方案
2. 说明每种方案的优缺点
3. 给出推荐方案及理由
**完成标准**:用户已选择一个方案
> 参考:`references/tech-selection-guide.md`
---
### 阶段 4设计文档
**目标**:将验证通过的设计写入规格文档。
**执行动作**
1. 创建规格文档:`design/specs/YYYY-MM-DD-<topic>-design.md`
2. 包含所有已确认的内容
3. 确保无 TODO、TBD 或占位符
**文档结构**
| 章节 | 内容要求 |
| -------- | -------------------- |
| 概述 | 功能目标、背景上下文 |
| 目标定位 | 主要用户和使用场景 |
| 约束条件 | 技术、时间、资源限制 |
| 成功标准 | 可验证的交付标准 |
| 架构设计 | 系统结构和组件划分 |
| 数据流 | 数据如何流转 |
| 错误处理 | 异常情况处理策略 |
| 测试策略 | 如何验证功能正确性 |
**完成标准**:文档已写入指定路径,无占位符内容
> 参考:`references/spec-template.md`
---
### 阶段 5交接
**目标**:获得用户批准,调用 writing-plan 技能。
**执行动作**
1. 呈现规格文档路径给用户
2. 请用户审阅并确认
3. 若用户批准,调用 writing-plan 技能
**标准话术**
> "规格文档已编写并提交至 `<path>`。请审阅,确认后我将开始制定实施计划。"
**调用格式**
```
调用 writing-plan 技能,规格文档路径:<path>
```
**终止**:此阶段完成后,本技能结束。
---
## 参考指南
| 参考文档 | 用途 |
| ---------------------------------------- | ---------------- |
| `references/session-state-template.md` | 会话状态文件模板 |
| `references/spec-template.md` | 规格文档模板 |
| `references/tech-selection-guide.md` | 技术选型指导 |
| `references/risk-assessment-template.md` | 风险评估模板 |
---
## 常见问题
### 用户坚持跳过设计直接实施?
回复:
> "我理解您希望快速推进。设计文档可以很简短,但需要记录并获得您的批准,这能确保我们对目标达成共识,避免后续返工。"
### 项目范围过大怎么办?
处理方式:
1. 标记为多子系统项目
2. 协助用户分解为独立子项目
3. 对第一个子项目开始头脑风暴
4. 每个子项目独立进行规格→规划→实施循环
### 对话中断后如何恢复?
处理方式:
1. 读取会话状态文件 `design/session-state.md`
2. 根据"下一步"继续执行

View File

@@ -0,0 +1,56 @@
# 风险评估模板
本文档提供设计阶段的风险识别和评估框架。
---
## 风险类别
| 类别 | 示例风险 |
|------|----------|
| 技术风险 | 技术方案未验证、依赖项不稳定、性能瓶颈 |
| 范围风险 | 需求蔓延、功能边界模糊、依赖外部系统 |
| 资源风险 | 人力不足、时间紧迫、技能缺口 |
| 集成风险 | 第三方 API 变更、数据迁移复杂、兼容性问题 |
| 安全风险 | 数据泄露、认证漏洞、权限控制缺陷 |
---
## 风险评估矩阵
```
影响程度
低 中 高
┌────────┬────────┬────────┐
高 │ 监控 │ 缓解 │ 优先 │
发 ├────────┼────────┼────────┤
生 中 │ 接受 │ 监控 │ 缓解 │
概 ├────────┼────────┼────────┤
率 低 │ 接受 │ 接受 │ 监控 │
└────────┴────────┴────────┘
```
---
## 风险记录模板
在规格文档中记录风险评估:
```markdown
## 风险评估
| 风险描述 | 类别 | 影响 | 概率 | 缓解策略 |
|----------|------|------|------|----------|
| [风险] | [类别] | 高/中/低 | 高/中/低 | [应对方案] |
```
---
## 缓解策略示例
| 策略类型 | 适用场景 | 示例 |
|----------|----------|------|
| 技术验证 | 技术方案不确定 | 对不确定的技术方案进行原型验证 |
| 增量交付 | 功能复杂度高 | 将大功能拆分为可独立交付的小功能 |
| 备选方案 | 关键依赖风险 | 为关键依赖准备替代方案 |
| 监控预警 | 运行时风险 | 建立关键指标的监控机制 |

View File

@@ -0,0 +1,68 @@
# 会话状态文件模板
本模板用于跟踪多轮对话中的进度,解决注意力丢失问题。
---
## 文件路径
```
design/session-state.md
```
所有技能共用同一状态文件,按阶段更新。
---
## 模板
```markdown
# 会话状态
## 基本信息
- **技能**: [brainstorming / writing-plan / executing-plans]
- **主题**: [功能名称或任务描述]
- **开始时间**: YYYY-MM-DD HH:mm
- **最后更新**: YYYY-MM-DD HH:mm
## 当前状态
- **阶段**: [当前阶段名称]
- **上一步**: [刚完成的内容]
- **下一步**: [待执行的下一步]
## 已确认内容
<!-- 记录用户已确认的关键决策和信息 -->
- YYYY-MM-DD HH:mm-[决策/信息 1]
- YYYY-MM-DD HH:mm-[决策/信息 2]
## 待处理问题
<!-- 需要向用户询问或解决的问题 -->
- [ ] [问题 1]
- [ ] [问题 2]
```
---
## 使用规则
### 创建时机
技能激活时创建,路径为项目根目录下的 `design/session-state.md`
### 更新时机
每次获得用户确认或完成阶段性成果后更新。
### 恢复时机
每轮对话开始时读取,恢复上下文后继续执行。
### 技能切换时
技能切换时更新"技能"字段,保留已确认内容,更新当前状态。

View File

@@ -0,0 +1,82 @@
# 规格文档模板
本文档提供设计规格文档的标准结构。
---
## 文档路径
```
design/specs/YYYY-MM-DD-<topic>-design.md
```
---
## 模板
```markdown
# [功能名称] 设计规格
## 概述
[功能目标和背景1-2 段]
## 目标定位
- **主要用户**[用户群体]
- **使用场景**[典型使用场景]
## 约束条件
- **技术限制**[技术栈、框架限制]
- **时间限制**[交付时间要求]
- **资源限制**[人力、预算限制]
## 成功标准
- [ ] [可验证的交付标准 1]
- [ ] [可验证的交付标准 2]
## 架构设计
### 系统结构
[系统整体架构描述]
### 组件划分
| 组件 | 职责 | 依赖 |
|------|------|------|
| [组件名] | [职责描述] | [依赖组件] |
## 数据流
[数据如何流转,可用文字或简单图示]
## 错误处理
| 错误类型 | 处理方式 |
|----------|----------|
| [错误类型] | [处理策略] |
## 测试策略
- **单元测试**[测试范围]
- **集成测试**[测试范围]
- **验收标准**[通过条件]
## 决策记录
| 决策 | 理由 | 影响 |
|------|------|------|
| [决策内容] | [为什么] | [影响范围] |
```
---
## 质量要求
- 无 TODO、TBD 或占位符
- 所有章节内容完整
- 成功标准可验证
- 决策有明确理由

View File

@@ -0,0 +1,59 @@
# 技术选型指导
本文档提供技术选型的评估框架。
---
## 评估维度
| 维度 | 评估问题 |
|------|----------|
| 团队熟悉度 | 团队是否已有相关经验?学习成本如何? |
| 社区生态 | 文档是否完善?遇到问题能否找到解决方案? |
| 长期维护 | 项目是否持续维护?向后兼容性如何? |
| 性能特性 | 是否满足性能需求?有无已知问题? |
| 集成难度 | 与现有系统的兼容性如何?迁移成本多大? |
---
## 选型流程
1. 明确技术约束条件(团队技能、现有技术栈、预算/时间限制)
2. 列出候选方案(通常 2-3 个)
3. 按评估维度对比分析
4. 给出推荐方案及理由
5. 在规格文档中记录选型决策
---
## 决策记录模板
```markdown
## 技术选型决策
### 候选方案对比
| 方案 | 团队熟悉度 | 社区生态 | 长期维护 | 性能特性 | 集成难度 |
|------|------------|----------|----------|----------|----------|
| 方案A | 高 | 高 | 高 | 中 | 低 |
| 方案B | 中 | 高 | 高 | 高 | 中 |
### 选择理由
[为什么选择这个方案]
### 权衡考量
[牺牲了什么,换取了什么]
```
---
## 常见陷阱
| 陷阱 | 应对策略 |
|------|----------|
| 追逐流行 | 评估实际需求匹配度 |
| 忽视学习成本 | 预留学习缓冲期 |
| 缺乏维护支持 | 检查项目活跃度指标 |
| 过度设计 | 遵循 YAGNI 原则 |

View File

@@ -0,0 +1,257 @@
---
name: code-commentator
description: 代码注释补充与规范化专家,自动识别代码文件类型,为函数、类、方法、属性等代码元素添加专业双语注释。当用户提及"添加注释"、"补充注释"、"规范化注释"、"代码注释"、"annotate code"、"add comments"或需要为代码添加文档注释时使用本技能。本技能也可被其他技能(如 executing-plans在代码完成后自动调用。
license: MIT
metadata:
version: "1.2.0"
---
# 代码注释补充与规范化技能
本技能提供代码注释的系统性补充与规范化处理能力,自动识别代码文件类型,应用相应的注释规范,确保生成的注释准确、完整且符合双语要求。
## 触发条件
当检测到以下情况时,主动激活此技能:
**用户直接调用**
- 用户提及添加或补充注释:"添加注释"、"补充注释"、"增加注释"
- 用户提及规范化注释:"规范化注释"、"统一注释风格"、"注释规范"
- 用户提及代码注释需求:"给代码加注释"、"代码缺少注释"、"注释不完整"
- 用户提及特定注释类型:"函数注释"、"类注释"、"JSDoc"、"docstring"
- 英文触发词:"add comments"、"annotate code"、"code documentation"、"JSDoc"
**其他技能自动调用**
- `executing-plans` 技能在任务完成后自动调用(如果代码需要注释)
- 在测试通过后、代码提交前的自动化流程中
---
## 被其他技能调用
当被 `executing-plans` 或其他技能调用时:
**接收参数**
- `target_files`:需要添加注释的文件路径列表(必需)
- `comment_priority`注释优先级可选high/medium/low
- `focus_areas`:重点注释区域(可选:如"公共API"、"核心逻辑"
**执行模式**
- 跳过用户确认,直接执行
- 使用默认的双语注释格式
- 自动识别代码复杂度,智能添加注释
- 完成后返回处理结果摘要
---
## 绝对禁止事项 ⚠️
**严禁修改任何代码内容**。本技能只能添加或修改注释,不得:
- ❌ 修改代码逻辑
- ❌ 修改变量名、函数名、类名等标识符
- ❌ 修改代码结构(如添加、删除、重排代码行)
- ❌ 修改代码格式(如缩进、换行、空格)
**唯一允许的操作**:为代码添加注释或修改现有注释。
---
## 双语注释要求
所有注释必须同时包含**中英文双语说明**,格式为:
- 英文描述在前
- 空行分隔
- 中文描述在后
---
## 注释格式参考
详细的多语言注释格式示例已迁移到 references 目录:
| 参考文件 | 内容 |
| ------------------------------------------------------- | ------------------------------ |
| [comment-formats.md](references/comment-formats.md) | 各种编程语言的注释格式代码示例 |
| [writing-style.md](references/writing-style.md) | 语气与口吻规范、语言语法要求 |
| [quality-checklist.md](references/quality-checklist.md) | 注释质量验证清单 |
**重要**:添加注释前,先阅读 [comment-formats.md](references/comment-formats.md) 确保使用正确的语言格式。
---
## 核心注释原则
### 必须添加多行注释的元素
以下代码元素**必须**添加多行文档注释:
- ✅ 函数/方法
- ✅ 类/结构体/接口
- ✅ 公共属性/字段
- ✅ 枚举及其成员
- ✅ 类型定义
- ✅ 模块/命名空间
### 需要评估是否添加注释
仅在代码逻辑不够直观时才添加单行注释:
- ⚠️ 复杂的条件分支逻辑
- ⚠️ 复杂的循环结构
- ⚠️ 复杂的表达式
- ⚠️ 重要的变量声明(用途不明显时)
### 不需要添加注释的情况
代码本身已足够清晰,无需添加注释:
- ❌ 简单的条件判断(如 `if (isValid)`
- ❌ 简单的循环(如 `for (const item of items)`
- ❌ 变量名已清晰表达用途的声明(如 `const userName = user.name`
- ❌ 标准的 getter/setter 方法
- ❌ 一目了然的赋值语句
### 判断标准
**添加注释前,先问自己**
1. 不看注释,能否快速理解这段代码的意图?
2. 代码命名是否足够清晰?
3. 注释是否提供了代码本身无法表达的信息?
如果以上问题的答案表明代码已足够清晰,则不需要添加注释。
---
## 注释插入规则
| 元素类型 | 插入位置 | 要求 |
| ------------- | ------------------ | ------------------ |
| 函数/方法注释 | 紧贴在函数声明上方 | 不留空行 |
| 类注释 | 紧贴在类声明上方 | 不留空行 |
| 属性注释 | 紧贴在属性声明上方 | 不留空行 |
| 单行注释 | 放在代码行上方 | 与代码保持相同缩进 |
---
## 函数/方法注释要点
函数/方法注释必须包含:
1. **功能描述**:清晰描述函数的功能和用途
2. **入参说明**:每个参数的类型和用途
3. **出参说明**:返回值的类型和含义
4. **异常说明**:可能抛出的异常及其条件(如有)
5. **使用示例**:至少一个基本使用示例
### 联合类型参数
当参数为联合类型时,**必须**详细说明每个类型:
```typescript
@param input - The input data to process. Can be:
- `string`: A string value to parse /
- `Buffer`: A buffer containing raw data /
- `ReadableStream`: A stream to read data from /
```
### 多种传参方式
支持多种传参方式时,提供**不同场景的使用示例**
```typescript
@example
// Create user with name only
const user = createUser('John');
// Create user with options object
const user = createUser({ name: 'John', age: 25, email: 'john@example.com' });
```
---
## 执行流程
### 文件扫描流程
1. 接收用户指定的文件或目录
2. 递归扫描所有代码文件
3. 按语言类型分组处理
4. 生成处理计划
### 单文件处理流程
1. 读取文件内容
2. 解析代码结构AST 或正则匹配)
3. 识别需要注释的代码元素
4. 检查现有注释的完整性
5. 生成符合规范的注释
6. 将注释插入到正确位置
7. 写入更新后的文件
### 进度报告格式
处理过程中实时报告进度:
```markdown
## 注释处理进度
### 已完成 (3/10)
- ✅ src/utils/helper.ts (添加了 5 个函数注释)
- ✅ src/models/user.ts (添加了 2 个类注释, 8 个属性注释)
- ✅ src/api/client.ts (添加了 3 个函数注释)
### 处理中
- 🔄 src/services/auth.ts
### 待处理 (6/10)
- ⏳ src/config/index.ts
- ...
```
---
## 快速参考
### 注释格式速查
```typescript
/**
* English description of the function.
*
* 中文描述函数的功能。
*
* @param paramName - English description / 中文描述
* @returns English description / 中文描述
* @throws {ErrorType} English description / 中文描述
* @example
* const result = functionName('value');
*/
function functionName(paramName: string): Result {}
```
### 注意事项速记
1. **只添加注释**:严禁修改任何代码内容
2. **保持一致性**:同一文件内注释风格必须一致
3. **准确性优先**:注释内容必须准确反映代码功能
4. **避免过度注释**:如果代码本身足够清晰,则不需要添加注释
5. **简洁明了**:注释应有实际价值
6. **示例可运行**:所有使用示例必须是可运行的代码
---
## 参考文档
- 📄 [comment-formats.md](references/comment-formats.md) - 详细格式示例
- 📄 [writing-style.md](references/writing-style.md) - 写作风格指南
- 📄 [quality-checklist.md](references/quality-checklist.md) - 质量验证清单

View File

@@ -0,0 +1,409 @@
# 注释格式参考指南
本文档提供各种编程语言的注释格式详细示例,供在添加注释时参考。
## 目录
- [编程语言识别](#编程语言识别)
- [多行注释格式](#多行注释格式)
- [单行注释格式](#单行注释格式)
- [注释文本格式](#注释文本格式)
---
## 编程语言识别
根据文件扩展名识别编程语言,选择对应的注释风格:
| 语言 | 文件扩展名 | 多行注释风格 | 单行注释风格 |
| --------------------- | -------------------------------------------- | ------------------------------ | ------------ |
| JavaScript/TypeScript | `.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs` | `/** ... */` | `// ...` |
| Python | `.py` | `""" ... """``''' ... '''` | `# ...` |
| Java | `.java` | `/** ... */` | `// ...` |
| C/C++ | `.c`, `.cpp`, `.h`, `.hpp` | `/** ... */` | `// ...` |
| Go | `.go` | `/** ... */` | `// ...` |
| Rust | `.rs` | `/// ...` (文档注释) | `// ...` |
| PHP | `.php` | `/** ... */` | `// ...` |
| Ruby | `.rb` | `=begin ... =end` | `# ...` |
| Swift | `.swift` | `/** ... */` | `// ...` |
| Kotlin | `.kt`, `.kts` | `/** ... */` | `// ...` |
| C# | `.cs` | `/** ... */` | `// ...` |
| Scala | `.scala` | `/** ... */` | `// ...` |
| Lua | `.lua` | `--[[ ... --]]` | `-- ...` |
| Shell | `.sh`, `.bash` | `: ' ... '` | `# ...` |
---
## 多行注释格式
### JavaScript/TypeScript 格式
```typescript
/**
* English description of the function.
*
* 中文描述函数的功能。
*
* @param paramName - English description / 中文描述
* @returns English description / 中文描述
* @throws {ErrorType} English description / 中文描述
* @example
* // Basic usage
* const result = functionName('value');
*
* // With optional parameter
* const result = functionName('value', { option: true });
*/
function functionName(paramName: string, options?: Options): Result {
// implementation
}
```
### Python 格式
```python
"""
English description of the function.
中文描述函数的功能。
Args:
param_name (str): English description / 中文描述
Returns:
Result: English description / 中文描述
Raises:
ErrorType: English description / 中文描述
Example:
>>> # Basic usage
>>> result = function_name('value')
>>>
>>> # With optional parameter
>>> result = function_name('value', option=True)
"""
def function_name(param_name: str, options: Optional[Options] = None) -> Result:
# implementation
```
### Java 格式
```java
/**
* English description of the method.
*
* 中文描述方法的功能。
*
* @param paramName English description / 中文描述
* @return English description / 中文描述
* @throws ExceptionType English description / 中文描述
* @example
* // Basic usage
* Result result = methodName("value");
*
* // With optional parameter
* Result result = methodName("value", options);
*/
public Result methodName(String paramName, Options options) {
// implementation
}
```
### Go 格式
```go
// FunctionName English description of the function.
//
// 中文描述函数的功能。
//
// Parameters:
// - paramName: English description / 中文描述
//
// Returns:
// - result: English description / 中文描述
//
// Example:
//
// // Basic usage
// result := FunctionName("value")
//
// // With optional parameter
// result := FunctionName("value", options)
func FunctionName(paramName string, options *Options) *Result {
// implementation
}
```
### Rust 格式
````rust
/// English description of the function.
///
/// 中文描述函数的功能.
///
/// # Arguments
///
/// * `param_name` - English description / 中文描述
///
/// # Returns
///
/// English description / 中文描述
///
/// # Errors
///
/// Returns an error if...
///
/// # Examples
///
/// ```
/// // Basic usage
/// let result = function_name("value");
///
/// // With optional parameter
/// let result = function_name("value", Some(options));
/// ```
pub fn function_name(param_name: &str, options: Option<Options>) -> Result<Result, Error> {
// implementation
}
````
### PHP 格式
```php
/**
* English description of the function.
*
* 中文描述函数的功能。
*
* @param string $paramName English description / 中文描述
* @param array|null $options English description / 中文描述
* @return Result English description / 中文描述
* @throws ExceptionType English description / 中文描述
* @example
* // Basic usage
* $result = functionName('value');
*
* // With optional parameter
* $result = functionName('value', ['option' => true]);
*/
function functionName(string $paramName, ?array $options = null): Result {
// implementation
}
```
### Ruby 格式
```ruby
=begin
English description of the method.
中文描述方法的功能。
Parameters:
- param_name: English description / 中文描述
- options: English description / 中文描述
Returns:
- result: English description / 中文描述
Example:
# Basic usage
result = method_name('value')
# With optional parameter
result = method_name('value', option: true)
=end
def method_name(param_name, options = {})
# implementation
end
```
### Swift 格式
````swift
/// English description of the function.
///
/// 中文描述函数的功能.
///
/// - Parameters:
/// - paramName: English description / 中文描述
/// - options: English description / 中文描述
/// - Returns: English description / 中文描述
/// - Throws: English description / 中文描述
///
/// ## Example
/// ```swift
/// // Basic usage
/// let result = functionName("value")
///
/// // With optional parameter
/// let result = functionName("value", option: true)
/// ```
func functionName(paramName: String, options: Options? = nil) throws -> Result {
// implementation
}
````
### Kotlin 格式
```kotlin
/**
* English description of the function.
*
* 中文描述函数的功能。
*
* @param paramName English description / 中文描述
* @param options English description / 中文描述
* @return English description / 中文描述
* @throws ExceptionType English description / 中文描述
*
* @example
* // Basic usage
* val result = functionName("value")
*
* // With optional parameter
* val result = functionName("value", Options())
*/
fun functionName(paramName: String, options: Options? = null): Result {
// implementation
}
```
---
## 单行注释格式
### 短描述格式
```typescript
// english description / 中文描述
if (condition) {
}
// english description / 中文描述
const variable = value;
```
### 长描述格式
```typescript
// This is a longer english description that explains
// the purpose of the following code block.
//
// 这是较长的中文描述,解释以下代码块的用途。
if (complexCondition) {
// english description / 中文描述
doSomething();
}
```
### Python 单行注释
```python
# english description / 中文描述
if condition:
pass
# This is a longer english description that explains
# the purpose of the following code block.
#
# 这是较长的中文描述,解释以下代码块的用途。
for item in items:
# english description / 中文描述
process(item)
```
---
## 特殊注释场景
### 联合类型参数处理
当参数类型为联合类型时,使用无序列表详细说明每个类型:
```typescript
/**
* Processes the input data.
*
* 处理输入数据。
*
* @param input - The input data to process. Can be:
* - `string`: A string value to parse / 要解析的字符串值
* - `Buffer`: A buffer containing raw data / 包含原始数据的缓冲区
* - `ReadableStream`: A stream to read data from / 要读取数据的流
* @returns The processed result / 处理后的结果
* @example
* // Process a string
* const result = process('hello');
*
* // Process a buffer
* const result = process(Buffer.from('hello'));
*/
function process(input: string | Buffer | ReadableStream): Result {}
```
### 支持多种传参方式的函数
对于支持多种传参类型或传参个数的函数,需提供不同场景的使用示例:
```typescript
/**
* Creates a new user instance.
*
* 创建新的用户实例。
*
* @param nameOrOptions - Can be:
* - `string`: The user's name / 用户名称
* - `UserOptions`: Full user configuration object / 完整的用户配置对象
* @param age - User's age (required when first param is string) / 用户年龄(第一个参数为字符串时必填)
* @returns The created user instance / 创建的用户实例
* @example
* // Create user with name only
* const user = createUser('John');
*
* // Create user with name and age
* const user = createUser('John', 25);
*
* // Create user with options object
* const user = createUser({ name: 'John', age: 25, email: 'john@example.com' });
*/
function createUser(nameOrOptions: string | UserOptions, age?: number): User {}
```
---
## 注释文本格式
### 换行规则
注释文本应在100个字符左右处换行长链接或表格等特殊内容除外
```typescript
/**
* This is a very long description that needs to be wrapped at approximately
* 100 characters to maintain readability and consistency across the codebase.
*
* 这是一个很长的描述需要在约100个字符处换行以保持代码库的可读性和一致性。
*/
```
### 中英文分隔
双语注释中,英文描述在前,中文描述在后,中间用空行分隔:
```typescript
/**
* English description comes first.
*
* 中文描述紧随其后。
*/
```
### 参数描述格式
参数描述使用斜杠分隔中英文:
```typescript
@param paramName - English description / 中文描述
```

View File

@@ -0,0 +1,230 @@
# 注释质量检查清单
本文档提供注释添加后的质量验证清单,用于确保注释符合规范要求。
## 目录
- [注释完整性检查](#注释完整性检查)
- [注释内容检查](#注释内容检查)
- [注释格式检查](#注释格式检查)
- [代码完整性检查](#代码完整性检查)
---
## 注释完整性检查
检查是否所有必需的代码元素都已添加注释:
### 函数/方法
- [ ] 所有公共函数都有多行注释
- [ ] 所有私有函数都有注释(如果复杂)
- [ ] 异步函数标记清晰
### 类/结构体
- [ ] 所有公共类都有多行注释
- [ ] 抽象类标记清晰
- [ ] 泛型类型参数有说明
### 属性/字段
- [ ] 所有公共属性都有注释
- [ ] 静态属性有说明
- [ ] 常量有用途说明
### 枚举
- [ ] 枚举本身有注释
- [ ] 所有枚举成员都有注释
- [ ] 每个成员的取值含义清晰
### 类型定义
- [ ] 接口/类型有注释
- [ ] 所有属性有说明
- [ ] 泛型参数有说明
---
## 注释内容检查
### 功能描述
- [ ] 功能描述清晰准确
- [ ] 不重复代码已表达的信息
- [ ] 描述了"做什么"而非"怎么做"
### 参数说明
- [ ] 每个参数都有说明
- [ ] 参数类型标注正确
- [ ] 联合类型参数有详细说明每个类型
- [ ] 可选参数标注清晰
- [ ] 参数的约束条件有说明
### 返回值说明
- [ ] 返回值类型正确
- [ ] 返回值的含义清晰
- [ ] 特殊情况null、undefined有说明
### 异常说明
- [ ] 所有可能抛出的异常都有说明
- [ ] 异常发生条件清晰
- [ ] 异常处理建议(如果有)
### 使用示例
- [ ] 至少包含一个基本使用示例
- [ ] 示例代码可运行
- [ ] 复杂函数有多个示例
- [ ] 示例注释使用目标语言
---
## 注释格式检查
### 语言特定格式
- [ ] 符合语言特定的注释风格JSDoc、Rustdoc 等)
- [ ] 标签使用正确(@param@returns 等)
- [ ] 标签顺序合理
### 双语格式
- [ ] 所有注释都是中英双语
- [ ] 英文描述在前,中文描述在后
- [ ] 中英文之间有空行分隔
- [ ] 参数描述使用斜杠分隔(`/`
### 换行格式
- [ ] 注释文本在100字符左右换行
- [ ] 列表项缩进一致
- [ ] 代码块缩进正确
### 标点符号
- [ ] 中文内容使用中文标点
- [ ] 英文内容使用英文标点
- [ ] 引号使用正确
- [ ] 无多余空格
---
## 代码完整性检查
**这是最关键的检查项**,确保注释添加过程中没有意外修改代码:
### 代码逻辑
- [ ] 未修改任何代码逻辑
- [ ] 未添加新代码逻辑
- [ ] 未删除代码逻辑
### 标识符
- [ ] 未修改任何变量名
- [ ] 未修改任何函数名
- [ ] 未修改任何类名
- [ ] 未修改任何属性名
### 代码结构
- [ ] 未添加新代码行
- [ ] 未删除代码行
- [ ] 未重排代码行
- [ ] 未修改代码顺序
### 代码格式
- [ ] 未修改缩进
- [ ] 未修改换行
- [ ] 未修改空格
- [ ] 原有代码格式完全保留
---
## 特殊场景检查
### 复杂条件分支
如果为复杂条件添加了单行注释:
- [ ] 注释清晰解释条件逻辑
- [ ] 注释说明为什么需要这个条件
### 复杂循环
如果为复杂循环添加了单行注释:
- [ ] 注释清晰解释循环目的
- [ ] 注释说明边界条件
### 复杂表达式
如果为复杂表达式添加了单行注释:
- [ ] 注释清晰解释表达式含义
- [ ] 注释说明计算逻辑
---
## 验证流程
### 1. 完整性验证
逐项检查上述完整性检查清单。
### 2. 内容审查
快速浏览注释内容,确保:
- 功能描述准确
- 参数说明完整
- 示例代码可运行
### 3. 格式验证
检查所有注释是否符合格式要求:
- 双语格式
- 换行规则
- 标点符号
### 4. 代码对比
将修改后的文件与原文件对比,确保:
- 只有注释被添加或修改
- 所有代码逻辑完全一致
### 5. 测试验证(如可能)
如果项目有测试,运行测试确保代码功能未受影响。
---
## 常见问题
### 问题:注释与代码不一致
**原因**:代码在添加注释后被修改
**解决**:回退代码修改,只保留注释
### 问题:过多不必要的注释
**原因**:为简单代码添加了注释
**解决**:删除明显多余的注释
### 问题:双语格式不统一
**原因**:部分注释遗漏中文或英文
**解决**:补全所有双语注释
### 问题:示例代码无法运行
**原因**:示例与实际函数签名不符
**解决**:修正示例代码或函数签名

View File

@@ -0,0 +1,186 @@
# 注释写作风格指南
本指南规范注释的写作风格和语言规范,确保注释专业且一致。
## 目录
- [语气标准](#语气标准)
- [语言与语法规范](#语言与语法规范)
- [示例改进](#示例改进)
---
## 语气标准
### 视角与时态
- **称呼**:以"您"称呼读者
- **语态**:使用主动语态
- **时态**:使用现在时(例如"函数返回..."而非"函数将返回..."
### 语气要求
- **专业**:避免口语化和非正式表达
- **友好**:保持帮助性的语气,但不冗长
- **直接**:开门见山,避免绕圈子
### 清晰度要求
- **词汇**:使用简单词汇,避免过度使用专业术语
- **俚语**:避免俚语和口语表达
- **营销用语**:避免宣传性和夸张性语言
### 要求层级
明确区分不同层级的指令:
| 层级 | 用词 | 说明 |
| -------- | ------------------ | ------------------------------ |
| 强制要求 | **必须**、**严禁** | 必须遵守的规则,违反会导致错误 |
| 强烈建议 | **推荐**、**建议** | 推荐的最佳实践 |
| 可选建议 | **可以**、**考虑** | 可根据情况选择的建议 |
**避免使用**"应当"、"宜"等模糊表述。
### 措辞选择
| 避免 | 推荐 |
| -------- | -------- |
| 请 | (省略) |
| 允许你 | 可以 |
| 允许您 | 您可以 |
| 函数认为 | 函数检查 |
| 数组包含 | 数组有 |
**可以使用缩写**:不要、它是、它有、它会等。
---
## 语言与语法规范
### 缩写使用
| 避免 | 推荐 |
| ------ | --------- |
| e.g. | 例如 |
| i.e. | 即 |
| etc. | 等 |
| vs. | 与...对比 |
| et al. | 等人 |
### 标点符号
- **序列逗号**:在列表中使用逗号分隔(如"a、b、c"
- **中文引号**:使用 `""` 而非 `""`
- **英文引号**:使用 `""` 而非 ''
### 日期格式
使用明确格式,例如:
- ✅ "2026年1月22日"
- ✅ "January 22, 2026"
- ❌ "2026/1/22"
- ❌ "1/22/26"
### 简洁性
| 避免 | 推荐 |
| ---------------- | ---- |
| 允许你/您 | 可以 |
| 让我们来看 | 看 |
| 接下来我们将 | 我们 |
| 这个函数的作用是 | 函数 |
### 动词选择
使用精确、具体的动词:
| 模糊动词 | 精确动词 |
| -------- | ---------------------- |
| 处理 | 解析、转换、计算、验证 |
| 获取 | 读取、拉取、提取、请求 |
| 设置 | 配置、赋值、初始化 |
---
## 示例改进
### 改进前(不推荐)
```typescript
/**
* 这个函数可以帮助我们对用户输入的数据进行处理。
* 它会返回一个处理后的结果。
*
* @param input - 输入的数据
* @returns 返回处理后的结果
*/
function processData(input: any) {}
/**
* 这个类是用来管理用户的。
* 它可以帮助我们进行用户认证。
*/
class UserManager {}
```
### 改进后(推荐)
```typescript
/**
* Processes user input data.
*
* 处理用户输入数据。
*
* @param input - User input data to process / 要处理的用户输入数据
* @returns Processed result / 处理后的结果
*/
function processData(input: Data): Result {}
/**
* Manages user authentication and session handling.
*
* 管理用户认证和会话处理。
*
* @example
* const manager = new UserManager();
* const session = await manager.login('user@example.com', 'password');
*/
class UserManager {}
```
### 关键改进点
1. **直接描述功能**:开头直接说明函数/类的功能,而非"这个函数是..."
2. **使用动词开头**:函数描述以动词开头(如 Process、Calculate、Validate
3. **避免冗余**:删除"可以帮助我们"、"这个"等冗余表述
4. **保持简洁**:每行控制在合理长度内
---
## 注释原则
### 不要过度注释
注释是为了增强可读性,而非为注释而注释。过多注释反而会影响代码可读性。
**添加注释前,先问自己**
1. 不看注释,能否快速理解这段代码的意图?
2. 代码命名是否足够清晰?
3. 注释是否提供了代码本身无法表达的信息?
如果以上问题的答案表明代码已足够清晰,则不需要添加注释。
### 简洁明了
- 避免冗余注释
- 注释应有实际价值
- 提供代码本身无法表达的信息
### 示例要求
- 所有使用示例必须是可以实际运行的代码
- 示例中使用有意义的名称
- 避免使用"foo"、"bar"等无意义占位符

View File

@@ -0,0 +1,93 @@
---
name: doc-todo-log-loop
description: 基于日志记录驱动的轻量级项目开发和管理方案。如果用户在项目章程提及,应使用此技能。
author: github/cafe3310
license: Apache-2.0
---
# Skill: doc-todo-log-loop
## 1. 概述 (Overview)
本 Skill 定义了一种人机协作的开发工作流,其核心是文档驱动、日志记录的迭代循环。它旨在通过清晰的步骤和产出物,确保开发过程的规范性、可追溯性和高质量,同时允许用户自由控制开发节奏。
此工作流特别适用于需要设计、过程记录和阶段性确认的软件功能开发或问题修复任务。
## 2. 核心工作循环 (Core Workflow Loop)
本工作流由用户(项目负责人)和 Gemini开发助理交替执行遵循以下六个核心步骤
### 步骤 1: 背景描述 -> 文档撰写 (User -> Gemini)
- **触发**: 用户提出一个高阶的功能目标或问题背景。
- **Gemini 的行动**:
1. 与用户充分沟通,理解背景、目标、约束。
2. 撰写一份正式的需求描述文档。
3. 文档应遵循项目章程定义的命名约定。如无特别约定,使用 `YYYY-MM-DD-HH-mm-需求-{简述}.md`
4. 如果 agent 有 plan mode 中已经被接受的 plan 文件,也要留在文档目录下,用文件的创建时间作为文件名上的时间。
### 步骤 2: 需求描述 -> TODO 拆分 (User -> Gemini)
- **触发**: 用户基于设计文档,或直接提出具体的功能需求。
- **Gemini 的行动**:
1. 将高阶需求拆解为一系列具体的、可执行的待办事项。
2. 将这些待办事项结构化地更新到项目章程定义的 `TODO.md` 文件中。如无特别约定,放在项目根目录下。
3. 每个待办事项应尽可能清晰、原子化。
### 步骤 3: 任务指派 (User -> Gemini)
- **触发**: 用户从 `TODO.md` 中选择一个或一组待办事项,并明确指示 Gemini 开始执行。
- **Gemini 的行动**:
1. 确认任务指令。
2. **绝不能**在收到用户明确指令前,提前进行开发。
### 步骤 4: 开发与确认 (Gemini -> User)
- **触发**: Gemini 接收到开发指令。
- **Gemini 的行动**:
1. 执行具体的开发任务,如修改代码、创建文件、安装依赖等。
2. 每完成一个有意义的、原子性的操作后,都应停下并等待用户确认。
3. 用户作为审查者和测试者,对 Gemini 的操作结果进行确认。如果发现问题应立即指出Gemini 负责修正。
### 步骤 5: 开发日志记录 (Gemini -> User)
- **触发**: 在一个阶段性功能或一个完整的 TODO 事项 **经用户确认** 完成后。
- **Gemini 的行动**:
1. 撰写一份新的开发日志。
2. 开发日志应遵循项目章程中提及的命名和目录约定。如无特别约定,放在项目日志目录下并在命名中统一命名 `YYYY-MM-DD-HH-mm-开发日志-{标题}.md`
3. 日志内容应总结本次开发的工作,包括:
- 实现了哪些功能点。
- 对代码或项目结构做了哪些主要修改。
- 遇到了哪些问题,以及是如何修正的(包括用户和 Gemini 双方)。
- 对后续步骤的建议或说明。
### 步骤 6: 版本控制 (User)
- **触发**: Gemini 完成开发日志的撰写。
- **用户的行动**:
1. 审查最终的代码变更和开发日志。
2. 执行 `git add``git commit` 等操作将本次开发的产出物提交到版本控制系统中。Gemini 不负责此步骤。
## 3. 文档命名与分类规范 (Document Naming & Classification)
本 Skill 默认实施统一的文档命名格式:
`YYYY-MM-DD-HH-mm-{类别}-{标题}.md`
### 文档类别定义:
如果用户要求Gemini 可以随时写文档。命名时,按建议的下列分类进行归类:
1. `开发日志` (最重要): 对过去一段时间开发过程、决策、遇到的问题及解决方式的忠实记录。
2. `需求` (最重要): 忠实记录用户想要实现的功能或达到的目标。仅包含「需求」本身,不含实现细节。
3. `设计`: 对即将实施的任务进行的提前分析。可使用包括 `系统设计``架构设计``交互设计``需求设计` 的子类别。
4. `规范`: 定义未来广泛适用的规则、流程或标准。可使用包括 `架构规范``代码规范``流程规范` 的子类别。
5. `文档`: 对已完成的技术实体(如接口、模块、工具)的使用说明和描述。可用 `接口文档``模块文档` 等子类别。
6. `调研`: 对外部技术、互联网资料或其他现有文档的研究与对比分析。
7. `参考`: 从外部摘录的资料、API 说明书或原始规范。与「调研」的区别在于其侧重于原样引用而非主动分析。
## 4. Gemini 的主动性与约束 (Constraints & Proactivity)
为了提升协作效率Gemini 在此 Skill 中被赋予了有限的主动性。
- **可以主动**:
- 在完成一个步骤(如步骤 4 或 5主动查阅 `TODO.md` 和最新的开发日志。
- 基于查阅结果,向用户 **建议** 下一步可以执行的任务。例如:“开发日志已记录完毕。根据 `TODO.md`,下一个待办事项...。需要现在开始吗?”
- **禁止主动**:
- **绝对禁止** 在未获得用户明确指令的情况下,提前开始任何待办事项的开发工作。
此约束旨在确保用户始终对开发节奏拥有完全的控制权。

View File

@@ -0,0 +1,271 @@
---
name: executing-plans
description: 当有书面实施计划需要在会话中执行时使用。加载计划,审阅后执行所有任务,完成后报告。当用户提及"执行计划"、"实施计划"、"运行计划"、"开始执行"或计划文档已准备好实施时触发。
license: MIT
metadata:
version: "3.0.0"
---
# 执行计划技能
本技能用于执行书面实施计划,严格遵循文档步骤。
---
## 核心原则
### 执行优先
在计划完成前,请勿:
- 跳过审阅步骤
- 跳过验证步骤
- 在主分支上操作(除非获得明确批准)
- 猜测或假设
### 会话状态管理
使用统一的会话状态文件跟踪进度:
- **路径**`design/session-state.md`
- **模板**`references/session-state-template.md`(共享)
**操作时机**
- 技能激活时 → **立即创建/更新状态文件**
- 完成任务后 → **立即更新状态文件**
- 每轮对话开始时 → 读取状态文件恢复上下文
- **每个 **阶段/任务** 完成后 → 立即更新状态文件**
### 终止条件
本技能的唯一终止方式是完成所有任务并生成完成报告。
---
## 任务状态
| 状态 | 说明 |
| ------ | ------------------------ |
| 已完成 | 任务已完成,所有验证通过 |
| 进行中 | 任务正在执行中 |
| 待执行 | 任务尚未开始 |
| 阻塞 | 任务因问题被阻塞 |
---
## 工作流程
### 阶段 1加载并审阅计划
**目标**:加载计划文档,进行批判性审阅。
**执行动作**
1. 运行 `git branch --show-current` 确认当前分支
2. 如果在 main/master 分支,停止并询问是否继续
3. 读取计划文档
4. 批判性审阅计划
5. **验证测试覆盖**
**审阅检查清单**
- [ ] 目标是否清晰且可实现?
- [ ] 所有文件路径是否精确且正确?
- [ ] 代码片段是否完整(非占位符)?
- [ ] 测试命令对项目是否准确?
- [ ] 每个步骤是否足够细粒度2-5分钟
- [ ] 每个任务是否都包含测试步骤?
- [ ] 测试文件路径是否明确且完整?
- [ ] 测试用例是否覆盖主要功能?
**测试覆盖验证**
⚠️ **强制检查项**
- 检查每个任务是否定义了测试文件路径
- 验证测试文件路径不为空、不是占位符
- 确认测试用例覆盖了核心功能
- 如果发现测试缺失,立即停止并要求补充测试计划
**决策点**
- 若发现疑虑 → 记录问题,与用户讨论后再继续
- 若测试覆盖不足 → 要求返回 writing-plan 补充测试
- 若无疑虑 → 创建任务列表,进入阶段 2
**完成标准**:计划已审阅,测试覆盖已验证,任务列表已创建
---
### 阶段 2执行任务
**目标**:按计划执行每个任务。
**执行规则**
对于计划中的每个任务:
1. 在任务列表中将任务标记为"进行中"
2. **验证测试文件存在**(如果不存在,先创建测试文件)
3. 完全按照书面执行每个步骤
4. 按照 TDD 流程执行测试,测试必须通过,测试失败必须修复
5. 运行指定的验证
6. **检查是否需要添加代码注释**
7. 在任务列表中将任务标记为"已完成"
8. 更新会话状态文件
9. 如果计划指定,则提交
**添加代码注释调用格式**
```
调用 code-commentator 技能,目标文件:[文件路径]
```
**步骤执行规则**
- 修改前先读取现有文件
- 使用计划中的确切代码
- 按计划中指定的方式执行命令
- 检查实际输出是否与预期输出匹配
**验证要求**
每个任务在进入下一个之前必须通过其验证:
- 测试必须通过(不仅仅是运行)
- 构建必须成功
- Lint 必须通过
- 代码注释已添加(如需要)
**错误处理**
错误发生时:
1. 记录错误到会话状态文件
2. 停止并寻求帮助
**完成标准**:所有任务已执行,所有测试已通过,代码注释已添加,验证已通过
---
### 阶段 3完成与报告
**目标**:生成完成报告,更新项目上下文。
**执行动作**
1. 运行最终验证套件
2. 更新会话状态文件标记完成
3. 生成完成报告
4. **使用 project-context 技能更新项目上下文**
5. **使用 project-context 技能更新用户画像**(如果识别到新的偏好)
**完成报告格式**
```markdown
## 实施完成
**计划**[计划文件路径]
**状态**:✅ 所有任务已完成
### 摘要
[已构建内容的简要描述]
### 变更文件
- 新建:[新文件列表]
- 修改:[修改文件列表]
### 验证结果
- 测试:✅ 通过
- 构建:✅ 成功
- Lint✅ 干净
```
**完成标准**:报告已生成,项目上下文已更新,用户画像已更新(如有变化)
---
## 何时停止并寻求帮助
**在以下情况下立即停止执行**
| 情况 | 行动 |
| ---------------- | ---------------------- |
| 缺少依赖项 | 请求用户安装或提供 |
| 测试反复失败 | 调试并在卡住时寻求指导 |
| 指令不明确 | 请求澄清,不要猜测 |
| 计划存在关键缺陷 | 返回阶段一进行计划修订 |
| 出现意外错误 | 记录错误并寻求帮助 |
**停止协议**
1. 记录阻碍到会话状态文件
2. 解释尝试了什么
3. 提出具体问题
4. 等待指导
---
## 多子系统处理
当计划包含多个子系统时:
1. 读取纲领文件,识别子系统数量
2. 验证依赖关系,按执行顺序排列
3. 按依赖顺序执行每个子系统(阶段一 → 阶段二 → 阶段三)
4. 共享组件在第一个使用它的子系统中实现
---
## 被其他技能调用
当被 writing-plan 技能调用时:
**接收参数**
- `plan_path`:计划文档路径(必需)
- `subsystem_paths`:子系统计划路径列表(多子系统时必需)
- `outline_path`:纲领文件路径(如适用)
**跳过触发条件检查**,直接创建会话状态文件并进入阶段一。
---
## 参考指南
| 参考文档 | 用途 |
| ------------------------------------------ | ---------------- |
| `references/execution-state-template.md` | 执行状态记录模板 |
| `references/completion-report-template.md` | 完成报告模板 |
---
## 常见问题
### 对话中断后如何恢复?
处理方式:
1. 读取会话状态文件 `design/session-state.md`
2. 验证已完成任务的代码和测试
3. 向用户报告恢复选项
### 计划需要修改?
处理方式:
1. 返回阶段一进行计划审阅
2. 与用户讨论修改方案
3. 修改后重新执行
### 遇到阻碍?
处理方式:
1. 记录问题到会话状态文件
2. 停止并询问用户
3. 不要强行突破阻碍

View File

@@ -0,0 +1,61 @@
# 完成报告模板
执行完成后的报告格式。
---
## 报告模板
```markdown
## 实施完成
**计划**[计划文件路径]
**状态**:✅ 所有任务已完成
### 摘要
[已构建内容的简要描述]
### 变更文件
- 新建:[新文件列表]
- 修改:[修改文件列表]
### 验证结果
- 测试:✅ 通过
- 构建:✅ 成功
- Lint✅ 干净
### 执行统计
- 总任务数:[数量]
- 遇到问题:[数量]
### 备注
[任何观察或建议]
```
---
## 多子系统报告
```markdown
## 多子系统实施完成
**纲领**[纲领文件路径]
**状态**:✅ 所有子系统已完成
### 子系统摘要
| 子系统 | 状态 | 计划文档 |
|--------|------|----------|
| 子系统A | ✅ 完成 | plans/subsystem-a.md |
| 子系统B | ✅ 完成 | plans/subsystem-b.md |
### 整体验证
- 集成测试:✅ 通过
- 端到端测试:✅ 通过
```

View File

@@ -0,0 +1,55 @@
# 执行状态记录模板
本模板用于记录执行过程中的状态信息。
---
## 使用方式
在会话状态文件 `design/session-state.md` 中记录执行相关内容。
---
## 执行状态记录格式
在会话状态文件中追加以下内容:
```markdown
## 执行状态
### 计划信息
- **计划文档**[计划文件路径]
- **纲领文件**[纲领文件路径](如适用)
- **开始时间**YYYY-MM-DD HH:mm
### 任务进度
| 任务 | 状态 | 完成时间 | 备注 |
|------|------|----------|------|
| 任务1[名称] | 已完成 | HH:mm | - |
| 任务2[名称] | 进行中 | - | 步骤2/4 |
| 任务3[名称] | 待执行 | - | - |
### 遇到的问题
| 问题 | 解决方案 | 解决时间 |
|------|----------|----------|
| [问题描述] | [如何解决] | HH:mm |
```
---
## 多子系统记录
对于多子系统项目,在会话状态文件中追加:
```markdown
## 子系统进度
| 子系统 | 状态 | 计划文档 |
|--------|------|----------|
| 子系统A | 已完成 | plans/subsystem-a.md |
| 子系统B | 进行中 | plans/subsystem-b.md |
| 子系统C | 待执行 | plans/subsystem-c.md |
```

View File

@@ -0,0 +1,182 @@
---
name: "flutter-animating-apps"
description: "Implements animated effects, transitions, and motion in a Flutter app. Use when adding visual feedback, shared element transitions, or physics-based animations."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:16:34 GMT"
---
# Implementing Flutter Animations
## Contents
- [Core Concepts](#core-concepts)
- [Animation Strategies](#animation-strategies)
- [Workflows](#workflows)
- [Implementing Implicit Animations](#implementing-implicit-animations)
- [Implementing Explicit Animations](#implementing-explicit-animations)
- [Implementing Hero Transitions](#implementing-hero-transitions)
- [Implementing Physics-Based Animations](#implementing-physics-based-animations)
- [Examples](#examples)
## Core Concepts
Manage Flutter animations using the core typed `Animation` system. Do not manually calculate frames; rely on the framework's ticker and interpolation classes.
* **`Animation<T>`**: Treat this as an abstract representation of a value that changes over time. It holds state (completed, dismissed) and notifies listeners, but knows nothing about the UI.
* **`AnimationController`**: Instantiate this to drive the animation. It generates values (typically 0.0 to 1.0) tied to the screen refresh rate. Always provide a `vsync` (usually via `SingleTickerProviderStateMixin`) to prevent offscreen resource consumption. Always `dispose()` controllers to prevent memory leaks.
* **`Tween<T>`**: Define a stateless mapping from an input range (usually 0.0-1.0) to an output type (e.g., `Color`, `Offset`, `double`). Chain tweens with curves using `.animate()`.
* **`Curve`**: Apply non-linear timing (e.g., `Curves.easeIn`, `Curves.bounceOut`) to an animation using a `CurvedAnimation` or `CurveTween`.
## Animation Strategies
Apply conditional logic to select the correct animation approach:
* **If animating simple property changes (size, color, opacity) without playback control:** Use **Implicit Animations** (e.g., `AnimatedContainer`, `AnimatedOpacity`, `TweenAnimationBuilder`).
* **If requiring playback control (play, pause, reverse, loop) or coordinating multiple properties:** Use **Explicit Animations** (e.g., `AnimationController` with `AnimatedBuilder` or `AnimatedWidget`).
* **If animating elements between two distinct routes:** Use **Hero Animations** (Shared Element Transitions).
* **If modeling real-world motion (e.g., snapping back after a drag):** Use **Physics-Based Animations** (e.g., `SpringSimulation`).
* **If animating a sequence of overlapping or delayed motions:** Use **Staggered Animations** (multiple `Tween`s driven by a single `AnimationController` using `Interval` curves).
## Workflows
### Implementing Implicit Animations
Use this workflow for "fire-and-forget" state-driven animations.
- [ ] **Task Progress:**
- [ ] Identify the target properties to animate (e.g., width, color).
- [ ] Replace the static widget (e.g., `Container`) with its animated counterpart (e.g., `AnimatedContainer`).
- [ ] Define the `duration` property.
- [ ] (Optional) Define the `curve` property for non-linear motion.
- [ ] Trigger the animation by updating the properties inside a `setState()` call.
- [ ] Run validator -> review UI for jank -> adjust duration/curve if necessary.
### Implementing Explicit Animations
Use this workflow when you need granular control over the animation lifecycle.
- [ ] **Task Progress:**
- [ ] Add `SingleTickerProviderStateMixin` (or `TickerProviderStateMixin` for multiple controllers) to the `State` class.
- [ ] Initialize an `AnimationController` in `initState()`, providing `vsync: this` and a `duration`.
- [ ] Define a `Tween` and chain it to the controller using `.animate()`.
- [ ] Wrap the target UI in an `AnimatedBuilder` (preferred for complex trees) or subclass `AnimatedWidget`.
- [ ] Pass the `Animation` object to the `AnimatedBuilder`'s `animation` property.
- [ ] Control playback using `controller.forward()`, `controller.reverse()`, or `controller.repeat()`.
- [ ] Call `controller.dispose()` in the `dispose()` method.
- [ ] Run validator -> check for memory leaks -> ensure `dispose()` is called.
### Implementing Hero Transitions
Use this workflow to fly a widget between two routes.
- [ ] **Task Progress:**
- [ ] Wrap the source widget in a `Hero` widget.
- [ ] Assign a unique, data-driven `tag` to the source `Hero`.
- [ ] Wrap the destination widget in a `Hero` widget.
- [ ] Assign the *exact same* `tag` to the destination `Hero`.
- [ ] Ensure the widget trees inside both `Hero` widgets are visually similar to prevent jarring jumps.
- [ ] Trigger the transition by pushing the destination route via `Navigator`.
### Implementing Physics-Based Animations
Use this workflow for gesture-driven, natural motion.
- [ ] **Task Progress:**
- [ ] Set up an `AnimationController` (do not set a fixed duration).
- [ ] Capture gesture velocity using a `GestureDetector` (e.g., `onPanEnd` providing `DragEndDetails`).
- [ ] Convert the pixel velocity to the coordinate space of the animating property.
- [ ] Instantiate a `SpringSimulation` with mass, stiffness, damping, and the calculated velocity.
- [ ] Drive the controller using `controller.animateWith(simulation)`.
## Examples
<details>
<summary><b>Example: Explicit Animation (Staggered with AnimatedBuilder)</b></summary>
```dart
class StaggeredAnimationDemo extends StatefulWidget {
@override
State<StaggeredAnimationDemo> createState() => _StaggeredAnimationDemoState();
}
class _StaggeredAnimationDemoState extends State<StaggeredAnimationDemo> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _widthAnimation;
late Animation<Color?> _colorAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
);
// Staggered width animation (0.0 to 0.5 interval)
_widthAnimation = Tween<double>(begin: 50.0, end: 200.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeIn),
),
);
// Staggered color animation (0.5 to 1.0 interval)
_colorAnimation = ColorTween(begin: Colors.blue, end: Colors.red).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeOut),
),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose(); // CRITICAL: Prevent memory leaks
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
width: _widthAnimation.value,
height: 50.0,
color: _colorAnimation.value,
);
},
);
}
}
```
</details>
<details>
<summary><b>Example: Custom Page Route Transition</b></summary>
```dart
Route createCustomRoute(Widget destination) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => destination,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(0.0, 1.0); // Start from bottom
const end = Offset.zero;
const curve = Curves.easeOut;
final tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
final offsetAnimation = animation.drive(tween);
return SlideTransition(
position: offsetAnimation,
child: child,
);
},
);
}
// Usage: Navigator.of(context).push(createCustomRoute(const NextPage()));
```
</details>

View File

@@ -0,0 +1,163 @@
---
name: "flutter-architecting-apps"
description: "Architects a Flutter application using the recommended layered approach (UI, Logic, Data). Use when structuring a new project or refactoring for scalability."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:13:42 GMT"
---
# Architecting Flutter Applications
## Contents
- [Core Architectural Principles](#core-architectural-principles)
- [Structuring the Layers](#structuring-the-layers)
- [Implementing the Data Layer](#implementing-the-data-layer)
- [Feature Implementation Workflow](#feature-implementation-workflow)
- [Examples](#examples)
## Core Architectural Principles
Design Flutter applications to scale by strictly adhering to the following principles:
* **Enforce Separation of Concerns:** Decouple UI rendering from business logic and data fetching. Organize the codebase into distinct layers (UI, Logic, Data) and further separate by feature within those layers.
* **Maintain a Single Source of Truth (SSOT):** Centralize application state and data in the Data layer. Ensure the SSOT is the only component authorized to mutate its respective data.
* **Implement Unidirectional Data Flow (UDF):** Flow state downwards from the Data layer to the UI layer. Flow events upwards from the UI layer to the Data layer.
* **Treat UI as a Function of State:** Drive the UI entirely via immutable state objects. Rebuild widgets reactively when the underlying state changes.
## Structuring the Layers
Separate the application into 2 to 3 distinct layers depending on complexity. Restrict communication so that a layer only interacts with the layer directly adjacent to it.
### 1. UI Layer (Presentation)
* **Views (Widgets):** Build reusable, lean widgets. Strip all business and data-fetching logic from the widget tree. Restrict widget logic to UI-specific concerns (e.g., animations, routing, layout constraints).
* **ViewModels:** Manage the UI state. Consume domain models from the Data/Logic layers and transform them into presentation-friendly formats. Expose state to the Views and handle user interaction events.
### 2. Logic Layer (Domain) - *Conditional*
* **If the application requires complex client-side business logic:** Implement a Logic layer containing Use Cases or Interactors. Use this layer to orchestrate interactions between multiple repositories before passing data to the UI layer.
* **If the application is a standard CRUD app:** Omit this layer. Allow ViewModels to interact directly with Repositories.
### 3. Data Layer (Model)
* **Responsibilities:** Act as the SSOT for all application data. Handle business data, external API consumption, event processing, and data synchronization.
* **Components:** Divide the Data layer strictly into **Repositories** and **Services**.
## Implementing the Data Layer
### Services
* **Role:** Wrap external APIs (HTTP servers, local databases, platform plugins).
* **Implementation:** Write Services as stateless Dart classes. Do not store application state here.
* **Mapping:** Create exactly one Service class per external data source.
### Repositories
* **Role:** Act as the SSOT for domain data.
* **Implementation:** Consume raw data from Services. Handle caching, offline synchronization, and retry logic.
* **Transformation:** Transform raw API/Service data into clean Domain Models formatted for consumption by ViewModels.
## Feature Implementation Workflow
Follow this sequential workflow when adding a new feature to the application.
**Task Progress:**
- [ ] **Step 1: Define Domain Models.** Create immutable Dart classes representing the core data structures required by the feature.
- [ ] **Step 2: Implement Services.** Create stateless Service classes to handle raw data fetching (e.g., HTTP GET/POST).
- [ ] **Step 3: Implement Repositories.** Create Repository classes that consume the Services, handle caching, and return Domain Models.
- [ ] **Step 4: Implement ViewModels.** Create ViewModels that consume the Repositories. Expose immutable state and define methods (commands) for user actions.
- [ ] **Step 5: Implement Views.** Create Flutter Widgets that bind to the ViewModel state and trigger ViewModel methods on user interaction.
- [ ] **Step 6: Run Validator.** Execute unit tests for Services, Repositories, and ViewModels. Execute widget tests for Views.
* *Feedback Loop:* Review test failures -> Fix logic/mocking errors -> Re-run tests until passing.
## Examples
### Data Layer: Service and Repository
```dart
// 1. Service (Stateless API Wrapper)
class UserApiService {
final HttpClient _client;
UserApiService(this._client);
Future<Map<String, dynamic>> fetchUserRaw(String userId) async {
final response = await _client.get('/users/$userId');
return response.data;
}
}
// 2. Domain Model (Immutable)
class User {
final String id;
final String name;
const User({required this.id, required this.name});
}
// 3. Repository (SSOT & Data Transformer)
class UserRepository {
final UserApiService _apiService;
User? _cachedUser;
UserRepository(this._apiService);
Future<User> getUser(String userId) async {
if (_cachedUser != null && _cachedUser!.id == userId) {
return _cachedUser!;
}
final rawData = await _apiService.fetchUserRaw(userId);
final user = User(id: rawData['id'], name: rawData['name']);
_cachedUser = user; // Cache data
return user;
}
}
```
### UI Layer: ViewModel and View
```dart
// 4. ViewModel (State Management)
class UserViewModel extends ChangeNotifier {
final UserRepository _userRepository;
User? user;
bool isLoading = false;
String? error;
UserViewModel(this._userRepository);
Future<void> loadUser(String userId) async {
isLoading = true;
error = null;
notifyListeners();
try {
user = await _userRepository.getUser(userId);
} catch (e) {
error = e.toString();
} finally {
isLoading = false;
notifyListeners();
}
}
}
// 5. View (Lean UI)
class UserProfileView extends StatelessWidget {
final UserViewModel viewModel;
const UserProfileView({Key? key, required this.viewModel}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
if (viewModel.isLoading) return const CircularProgressIndicator();
if (viewModel.error != null) return Text('Error: ${viewModel.error}');
if (viewModel.user == null) return const Text('No user data.');
return Text('Hello, ${viewModel.user!.name}');
},
);
}
}
```

View File

@@ -0,0 +1,122 @@
---
name: "flutter-building-forms"
description: "Builds Flutter forms with validation and user input handling. Use when creating login screens, data entry forms, or any multi-field user input."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:15:24 GMT"
---
# Building Validated Forms
## Contents
- [Form Architecture](#form-architecture)
- [Field Validation](#field-validation)
- [Workflow: Implementing a Validated Form](#workflow-implementing-a-validated-form)
- [Examples](#examples)
## Form Architecture
Implement forms using a `Form` widget to group and validate multiple input fields together.
- **Use a StatefulWidget:** Always host your `Form` inside a `StatefulWidget`.
- **Persist the GlobalKey:** Instantiate a `GlobalKey<FormState>` exactly once as a final variable within the `State` class. Do not generate a new `GlobalKey` inside the `build` method; doing so is resource-expensive and destroys the form's state on every rebuild.
- **Bind the Key:** Pass the `GlobalKey<FormState>` to the `key` property of the `Form` widget. This uniquely identifies the form and provides access to the `FormState` for validation and submission.
- **Alternative Access:** If dealing with highly complex widget trees where passing the key is impractical, use `Form.of(context)` to access the `FormState` from a descendant widget.
## Field Validation
Use `TextFormField` to render Material Design text inputs with built-in validation support. `TextFormField` is a convenience widget that automatically wraps a standard `TextField` inside a `FormField`.
- **Implement the Validator:** Provide a `validator()` callback function to each `TextFormField`.
- **Return Error Messages:** If the user's input is invalid, return a `String` containing the specific error message. The `Form` will automatically rebuild to display this text below the field.
- **Return Null for Success:** If the input passes validation, you must return `null`.
## Workflow: Implementing a Validated Form
Follow this sequential workflow to implement and validate a form. Copy the checklist to track your progress.
**Task Progress:**
- [ ] 1. Create a `StatefulWidget` and its corresponding `State` class.
- [ ] 2. Instantiate `final _formKey = GlobalKey<FormState>();` in the `State` class.
- [ ] 3. Return a `Form` widget in the `build` method and assign `key: _formKey`.
- [ ] 4. Add `TextFormField` widgets as descendants of the `Form`.
- [ ] 5. Write a `validator` function for each `TextFormField` (return `String` on error, `null` on success).
- [ ] 6. Add a submit button (e.g., `ElevatedButton`).
- [ ] 7. Implement the validation check in the button's `onPressed` callback using `_formKey.currentState!.validate()`.
### Validation Decision Logic
When the user triggers the submit action, execute the following conditional logic:
1. Call `_formKey.currentState!.validate()`.
2. **If `true` (Valid):** All validators returned `null`. Proceed with form submission (e.g., save data, make API call) and display a success indicator (e.g., a `SnackBar`).
3. **If `false` (Invalid):** One or more validators returned an error string. The `FormState` automatically rebuilds the UI to display the error messages.
4. **Feedback Loop:** Run validator -> review errors -> fix. The user must adjust their input and resubmit until `validate()` returns `true`.
## Examples
### Complete Validated Form Implementation
Use the following pattern to implement a robust, validated form.
```dart
import 'package:flutter/material.dart';
class UserRegistrationForm extends StatefulWidget {
const UserRegistrationForm({super.key});
@override
State<UserRegistrationForm> createState() => _UserRegistrationFormState();
}
class _UserRegistrationFormState extends State<UserRegistrationForm> {
// 1. Persist the GlobalKey in the State class
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
// 2. Bind the key to the Form
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 3. Add TextFormFields with validators
TextFormField(
decoration: const InputDecoration(
labelText: 'Username',
hintText: 'Enter your username',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a username'; // Error state
}
if (value.length < 4) {
return 'Username must be at least 4 characters'; // Error state
}
return null; // Valid state
},
),
const SizedBox(height: 16),
// 4. Add the submit button
ElevatedButton(
onPressed: () {
// 5. Trigger validation logic
if (_formKey.currentState!.validate()) {
// Form is valid: Process data
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
} else {
// Form is invalid: Errors are automatically displayed
debugPrint('Form validation failed.');
}
},
child: const Text('Submit'),
),
],
),
);
}
}
```

View File

@@ -0,0 +1,128 @@
---
name: "flutter-building-layouts"
description: "Builds Flutter layouts using the constraint system and layout widgets. Use when creating or refining the UI structure of a Flutter application."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:14:15 GMT"
---
# Architecting Flutter Layouts
## Contents
- [Core Layout Principles](#core-layout-principles)
- [Structural Widgets](#structural-widgets)
- [Adaptive and Responsive Design](#adaptive-and-responsive-design)
- [Workflow: Implementing a Complex Layout](#workflow-implementing-a-complex-layout)
- [Examples](#examples)
## Core Layout Principles
Master the fundamental Flutter layout rule: **Constraints go down. Sizes go up. Parent sets position.**
* **Pass Constraints Down:** Always pass constraints (minimum/maximum width and height) from the parent Widget to its children. A Widget cannot choose its own size independently of its parent's constraints.
* **Pass Sizes Up:** Calculate the child Widget's desired size within the given constraints and pass this size back up to the parent.
* **Set Position via Parent:** Define the `x` and `y` coordinates of a child Widget exclusively within the parent Widget. Children do not know their own position on the screen.
* **Avoid Unbounded Constraints:** Never pass unbounded constraints (e.g., `double.infinity`) in the cross-axis of a flex box (`Row` or `Column`) or within scrollable regions (`ListView`). This causes render exceptions.
## Structural Widgets
Select the appropriate structural Widget based on the required spatial arrangement.
* **Use `Row` and `Column`:** Implement `Row` for horizontal linear layouts and `Column` for vertical linear layouts. Control child alignment using `mainAxisAlignment` and `crossAxisAlignment`.
* **Use `Expanded` and `Flexible`:** Wrap children of `Row` or `Column` in `Expanded` to force them to fill available space, or `Flexible` to allow them to size themselves up to the available space.
* **Use `Container`:** Wrap Widgets in a `Container` when you need to apply padding, margins, borders, or background colors.
* **Use `Stack`:** Implement `Stack` when Widgets must overlap on the Z-axis. Use `Positioned` to anchor children to specific edges of the `Stack`.
* **Use `SizedBox`:** Enforce strict, tight constraints on a child Widget by wrapping it in a `SizedBox` with explicit `width` and `height` values.
## Adaptive and Responsive Design
Apply conditional logic to handle varying screen sizes and form factors.
* **If fitting UI into available space (Responsive):** Use `LayoutBuilder`, `Expanded`, and `Flexible` to dynamically adjust the size and placement of elements based on the parent's constraints.
* **If adjusting UI usability for a specific form factor (Adaptive):** Use conditional rendering to swap entire layout structures. For example, render a bottom navigation bar on mobile, but a side navigation rail on tablets/desktop.
## Workflow: Implementing a Complex Layout
Follow this sequential workflow to architect and implement robust Flutter layouts.
### Task Progress
- [ ] **Phase 1: Visual Deconstruction**
- [ ] Break down the target UI into a hierarchy of rows, columns, and grids.
- [ ] Identify overlapping elements (requiring `Stack`).
- [ ] Identify scrolling regions (requiring `ListView` or `SingleChildScrollView`).
- [ ] **Phase 2: Constraint Planning**
- [ ] Determine which Widgets require tight constraints (fixed size) vs. loose constraints (flexible size).
- [ ] Identify potential unbounded constraint risks (e.g., a `ListView` inside a `Column`).
- [ ] **Phase 3: Implementation**
- [ ] Build the layout from the outside in, starting with the `Scaffold` and primary structural Widgets.
- [ ] Extract deeply nested layout sections into separate, stateless Widgets to maintain readability.
- [ ] **Phase 4: Validation and Feedback Loop**
- [ ] Run the application on target devices/simulators.
- [ ] **Run validator -> review errors -> fix:** Open the Flutter Inspector. Enable "Debug Paint" to visualize render boxes.
- [ ] Check for yellow/black striped overflow warnings.
- [ ] If overflow occurs: Wrap the overflowing Widget in `Expanded` (if inside a flex box) or wrap the parent in a scrollable Widget.
## Examples
### Example: Resolving Unbounded Constraints in Flex Boxes
**Anti-pattern:** Placing a `ListView` directly inside a `Column` causes an unbounded height exception because the `Column` provides infinite vertical space to the `ListView`.
```dart
// BAD: Throws unbounded height exception
Column(
children: [
Text('Header'),
ListView(
children: [/* items */],
),
],
)
```
**Implementation:** Wrap the `ListView` in an `Expanded` Widget to bound its height to the remaining space in the `Column`.
```dart
// GOOD: ListView is constrained to remaining space
Column(
children: [
Text('Header'),
Expanded(
child: ListView(
children: [/* items */],
),
),
],
)
```
### Example: Responsive Layout with LayoutBuilder
Implement `LayoutBuilder` to conditionally render different structural Widgets based on available width.
```dart
Widget buildAdaptiveLayout(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Conditional logic based on screen width
if (constraints.maxWidth > 600) {
// Tablet/Desktop: Side-by-side layout
return Row(
children: [
SizedBox(width: 250, child: SidebarWidget()),
Expanded(child: MainContentWidget()),
],
);
} else {
// Mobile: Stacked layout with navigation
return Column(
children: [
Expanded(child: MainContentWidget()),
BottomNavigationBarWidget(),
],
);
}
},
);
}
```

View File

@@ -0,0 +1,206 @@
---
name: "flutter-building-plugins"
description: "Builds Flutter plugins that provide native interop for other apps to use. Use when creating reusable packages that bridge Flutter with platform-specific functionality."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:21:35 GMT"
---
# Developing Flutter Plugins
## Contents
- [Architecture & Design Patterns](#architecture--design-patterns)
- [Workflow: Creating a New Plugin](#workflow-creating-a-new-plugin)
- [Workflow: Implementing Android Platform Code](#workflow-implementing-android-platform-code)
- [Workflow: Implementing Windows Platform Code](#workflow-implementing-windows-platform-code)
- [Workflow: Adding Platforms to an Existing Plugin](#workflow-adding-platforms-to-an-existing-plugin)
- [Examples](#examples)
## Architecture & Design Patterns
### Federated Plugins
Implement federated plugins to split a plugin's API across multiple packages, allowing independent teams to build platform-specific implementations. Structure federated plugins into three distinct components:
1. **App-facing interface:** The primary package users depend on. It exports the public API.
2. **Platform interface:** The package defining the common interface that all platform implementations must implement.
3. **Platform implementations:** Independent packages containing platform-specific code (e.g., `my_plugin_android`, `my_plugin_windows`).
### FFI vs. Standard Plugins
Choose the correct plugin template based on your native interoperability requirements:
* **Standard Plugins (`--template=plugin`):** Use for accessing platform-specific APIs (e.g., Android SDK, iOS frameworks) via Method Channels.
* **FFI Plugins (`--template=plugin_ffi`):** Use for accessing C/C++ native libraries, configuring Google Play services on Android, or using static linking on iOS/macOS.
* *Constraint:* FFI plugin packages support bundling native code and method channel registration code, but *not* method channels themselves. If you require both method channels and FFI, use the standard non-FFI plugin template.
## Workflow: Creating a New Plugin
Follow this workflow to initialize a new plugin package.
**Task Progress:**
- [ ] Determine if the plugin requires FFI or standard Method Channels.
- [ ] Execute the appropriate `flutter create` command.
- [ ] Verify the generated directory structure.
**Conditional Initialization:**
* **If creating a STANDARD plugin:**
Run the following command, specifying your supported platforms, organization, and preferred languages (defaults are Swift and Kotlin):
```bash
flutter create --template=plugin \
--platforms=android,ios,web,linux,macos,windows \
--org com.example.organization \
-i objc -a java \
my_plugin
```
* **If creating an FFI plugin:**
Run the following command to generate a project with Dart code in `lib` (using `dart:ffi`) and native source code in `src` (with a `CMakeLists.txt`):
```bash
flutter create --template=plugin_ffi my_ffi_plugin
```
## Workflow: Implementing Android Platform Code
Always edit Android platform code using Android Studio to ensure proper code completion and Gradle synchronization.
**Task Progress:**
- [ ] Run initial build to generate necessary Gradle files.
- [ ] Open the Android module in Android Studio.
- [ ] Implement `FlutterPlugin` and lifecycle-aware interfaces.
- [ ] Refactor legacy `registerWith` logic.
- [ ] Run validator -> review errors -> fix.
1. **Generate Build Files:**
Build the code at least once before editing to resolve dependencies.
```bash
cd example
flutter build apk --config-only
```
2. **Open in IDE:**
Launch Android Studio and open the `example/android/build.gradle` or `example/android/build.gradle.kts` file.
3. **Locate Source:**
Navigate to your plugin's source code at `java/<organization-path>/<PluginName>`.
4. **Implement V2 Embedding:**
* Implement the `FlutterPlugin` interface.
* Ensure your plugin class has a public constructor.
* Extract shared initialization logic from the legacy `registerWith()` method and the new `onAttachedToEngine()` method into a single private method. Both entry points must call this private method to maintain backward compatibility without duplicating logic.
5. **Implement Lifecycle Interfaces:**
* **If your plugin requires an `Activity` reference:** Implement the `ActivityAware` interface and handle the `onAttachedToActivity`, `onDetachedFromActivityForConfigChanges`, `onReattachedToActivityForConfigChanges`, and `onDetachedFromActivity` callbacks.
* **If your plugin runs in a background `Service`:** Implement the `ServiceAware` interface.
6. **Update Example App:**
Ensure the example app's `MainActivity.java` extends the v2 embedding `io.flutter.embedding.android.FlutterActivity`.
7. **Document API:**
Document all non-overridden public members in your Android implementation.
## Workflow: Implementing Windows Platform Code
Always edit Windows platform code using Visual Studio.
**Task Progress:**
- [ ] Run initial build to generate the Visual Studio solution.
- [ ] Open the solution in Visual Studio.
- [ ] Implement C++ logic.
- [ ] Rebuild the solution.
1. **Generate Build Files:**
```bash
cd example
flutter build windows
```
2. **Open in IDE:**
Launch Visual Studio and open the `example/build/windows/hello_example.sln` file.
3. **Locate Source:**
Navigate to `hello_plugin/Source Files` and `hello_plugin/Header Files` in the Solution Explorer.
4. **Rebuild:**
After making changes to the C++ plugin code, you *must* rebuild the solution in Visual Studio before running the app, or the outdated plugin binary will be used.
## Workflow: Adding Platforms to an Existing Plugin
Use this workflow to retrofit an existing plugin with support for additional platforms.
**Task Progress:**
- [ ] Run the platform addition command.
- [ ] Update iOS/macOS podspecs (if applicable).
- [ ] Implement the platform-specific code.
1. **Run Create Command:**
Navigate to the root directory of your existing plugin and run:
```bash
flutter create --template=plugin --platforms=web,macos .
```
2. **Update Podspecs:**
If adding iOS or macOS support, open the generated `.podspec` file and configure the required dependencies and deployment targets.
## Examples
### Android V2 Embedding Implementation
High-fidelity example of an Android plugin implementing `FlutterPlugin` and `ActivityAware` while maintaining legacy compatibility.
```java
package com.example.myplugin;
import androidx.annotation.NonNull;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.embedding.engine.plugins.activity.ActivityAware;
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import io.flutter.plugin.common.PluginRegistry.Registrar;
/** MyPlugin */
public class MyPlugin implements FlutterPlugin, MethodCallHandler, ActivityAware {
private MethodChannel channel;
// Public constructor required for v2 embedding
public MyPlugin() {}
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
setupChannel(flutterPluginBinding.getBinaryMessenger());
}
// Legacy v1 embedding support
public static void registerWith(Registrar registrar) {
MyPlugin plugin = new MyPlugin();
plugin.setupChannel(registrar.messenger());
}
// Shared initialization logic
private void setupChannel(BinaryMessenger messenger) {
channel = new MethodChannel(messenger, "my_plugin");
channel.setMethodCallHandler(this);
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
if (call.method.equals("getPlatformVersion")) {
result.success("Android " + android.os.Build.VERSION.RELEASE);
} else {
result.notImplemented();
}
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
channel.setMethodCallHandler(null);
}
@Override
public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) {
// Handle Activity attachment
}
@Override
public void onDetachedFromActivityForConfigChanges() {
// Handle config changes
}
@Override
public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) {
// Handle reattachment
}
@Override
public void onDetachedFromActivity() {
// Clean up Activity references
}
}
```

View File

@@ -0,0 +1,168 @@
---
name: "flutter-caching-data"
description: "Implements caching strategies for Flutter apps to improve performance and offline support. Use when retaining app data locally to reduce network requests or speed up startup."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:19:54 GMT"
---
# Implementing Flutter Caching and Offline-First Architectures
## Contents
- [Selecting a Caching Strategy](#selecting-a-caching-strategy)
- [Implementing Offline-First Data Synchronization](#implementing-offline-first-data-synchronization)
- [Managing File System and SQLite Persistence](#managing-file-system-and-sqlite-persistence)
- [Optimizing UI, Scroll, and Image Caching](#optimizing-ui-scroll-and-image-caching)
- [Caching the FlutterEngine (Android)](#caching-the-flutterengine-android)
- [Workflows](#workflows)
## Selecting a Caching Strategy
Apply the appropriate caching mechanism based on the data lifecycle and size requirements.
* **If storing small, non-critical UI states or preferences:** Use `shared_preferences`.
* **If storing large, structured datasets:** Use on-device databases (SQLite via `sqflite`, Drift, Hive CE, or Isar).
* **If storing binary data or large media:** Use file system caching via `path_provider`.
* **If retaining user session state (navigation, scroll positions):** Implement Flutter's built-in state restoration to sync the Element tree with the engine.
* **If optimizing Android initialization:** Pre-warm and cache the `FlutterEngine`.
## Implementing Offline-First Data Synchronization
Design repositories as the single source of truth, combining local databases and remote API clients.
### Read Operations (Stream Approach)
Yield local data immediately for fast UI rendering, then fetch remote data, update the local cache, and yield the fresh data.
```dart
Stream<UserProfile> getUserProfile() async* {
// 1. Yield local cache first
final localProfile = await _databaseService.fetchUserProfile();
if (localProfile != null) yield localProfile;
// 2. Fetch remote, update cache, yield fresh data
try {
final remoteProfile = await _apiClientService.getUserProfile();
await _databaseService.updateUserProfile(remoteProfile);
yield remoteProfile;
} catch (e) {
// Handle network failure; UI already has local data
}
}
```
### Write Operations
Determine the write strategy based on data criticality:
* **If strict server synchronization is required (Online-only):** Attempt the API call first. Only update the local database if the API call succeeds.
* **If offline availability is prioritized (Offline-first):** Write to the local database immediately. Attempt the API call. If the API call fails, flag the local record for background synchronization.
### Background Synchronization
Add a `synchronized` boolean flag to your data models. Run a periodic background task (e.g., via `workmanager` or a `Timer`) to push unsynchronized local changes to the server.
## Managing File System and SQLite Persistence
### File System Caching
Use `path_provider` to locate the correct directory.
* Use `getApplicationDocumentsDirectory()` for persistent data.
* Use `getTemporaryDirectory()` for cache data the OS can clear.
```dart
Future<File> get _localFile async {
final directory = await getApplicationDocumentsDirectory();
return File('${directory.path}/cache.txt');
}
```
### SQLite Persistence
Use `sqflite` for relational data caching. Always use `whereArgs` to prevent SQL injection.
```dart
Future<void> updateCachedRecord(Record record) async {
final db = await database;
await db.update(
'records',
record.toMap(),
where: 'id = ?',
whereArgs: [record.id], // NEVER use string interpolation here
);
}
```
## Optimizing UI, Scroll, and Image Caching
### Image Caching
Image I/O and decompression are expensive.
* Use the `cached_network_image` package to handle file-system caching of remote images.
* **Custom ImageProviders:** If implementing a custom `ImageProvider`, override `createStream()` and `resolveStreamForKey()` instead of the deprecated `resolve()` method.
* **Cache Sizing:** The `ImageCache.maxByteSize` no longer automatically expands for large images. If loading images larger than the default cache size, manually increase `ImageCache.maxByteSize` or subclass `ImageCache` to implement custom eviction logic.
### Scroll Caching
When configuring caching for scrollable widgets (`ListView`, `GridView`, `Viewport`), use the `scrollCacheExtent` property with a `ScrollCacheExtent` object. Do not use the deprecated `cacheExtent` and `cacheExtentStyle` properties.
```dart
// Correct implementation
ListView(
scrollCacheExtent: const ScrollCacheExtent.pixels(500.0),
children: // ...
)
Viewport(
scrollCacheExtent: const ScrollCacheExtent.viewport(0.5),
slivers: // ...
)
```
### Widget Caching
* Avoid overriding `operator ==` on `Widget` objects. It causes O(N²) behavior during rebuilds.
* **Exception:** You may override `operator ==` *only* on leaf widgets (no children) where comparing properties is significantly faster than rebuilding, and the properties rarely change.
* Prefer using `const` constructors to allow the framework to short-circuit rebuilds automatically.
## Caching the FlutterEngine (Android)
To eliminate the non-trivial warm-up time of a `FlutterEngine` when adding Flutter to an existing Android app, pre-warm and cache the engine.
1. Instantiate and pre-warm the engine in the `Application` class.
2. Store it in the `FlutterEngineCache`.
3. Retrieve it using `withCachedEngine` in the `FlutterActivity` or `FlutterFragment`.
```kotlin
// 1. Pre-warm in Application class
val flutterEngine = FlutterEngine(this)
flutterEngine.navigationChannel.setInitialRoute("/cached_route")
flutterEngine.dartExecutor.executeDartEntrypoint(DartEntrypoint.createDefault())
// 2. Cache the engine
FlutterEngineCache.getInstance().put("my_engine_id", flutterEngine)
// 3. Use in Activity/Fragment
startActivity(
FlutterActivity.withCachedEngine("my_engine_id").build(this)
)
```
*Note: You cannot set an initial route via the Activity/Fragment builder when using a cached engine. Set the initial route on the engine's navigation channel before executing the Dart entrypoint.*
## Workflows
### Workflow: Implementing an Offline-First Repository
Follow these steps to implement a robust offline-first data layer.
- [ ] **Task Progress:**
- [ ] Define the data model with a `synchronized` boolean flag (default `false`).
- [ ] Implement the local `DatabaseService` (SQLite/Hive) with CRUD operations.
- [ ] Implement the remote `ApiClientService` for network requests.
- [ ] Create the `Repository` class combining both services.
- [ ] Implement the read method returning a `Stream<T>` (yield local, fetch remote, update local, yield remote).
- [ ] Implement the write method (write local, attempt remote, update `synchronized` flag).
- [ ] Implement a background sync function to process records where `synchronized == false`.
- [ ] Run validator -> review errors -> fix (Test offline behavior by disabling network).
### Workflow: Pre-warming the Android FlutterEngine
Follow these steps to cache the FlutterEngine for seamless Android integration.
- [ ] **Task Progress:**
- [ ] Locate the Android `Application` class (create one if it doesn't exist and register in `AndroidManifest.xml`).
- [ ] Instantiate a new `FlutterEngine`.
- [ ] (Optional) Set the initial route via `navigationChannel.setInitialRoute()`.
- [ ] Execute the Dart entrypoint via `dartExecutor.executeDartEntrypoint()`.
- [ ] Store the engine in `FlutterEngineCache.getInstance().put()`.
- [ ] Update the target `FlutterActivity` or `FlutterFragment` to use `.withCachedEngine("id")`.
- [ ] Run validator -> review errors -> fix (Verify no blank screen appears during transition).

View File

@@ -0,0 +1,197 @@
---
name: "flutter-embedding-native-views"
description: "Embeds native Android, iOS, or macOS views into a Flutter app. Use when integrating complex native components like maps or web views."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:20:28 GMT"
---
# Integrating Platform Views and Web Content
## Contents
- [Platform Views Architecture](#platform-views-architecture)
- [Web Embedding Architecture](#web-embedding-architecture)
- [Workflow: Implementing Android Platform Views](#workflow-implementing-android-platform-views)
- [Workflow: Implementing iOS Platform Views](#workflow-implementing-ios-platform-views)
- [Workflow: Embedding Flutter in Web Applications](#workflow-embedding-flutter-in-web-applications)
- [Examples](#examples)
## Platform Views Architecture
Platform Views allow embedding native views (Android, iOS, macOS) directly into a Flutter application, enabling the application of transforms, clips, and opacity from Dart.
### Android Implementations (API 23+)
Choose the appropriate implementation based on your performance and fidelity requirements:
* **Hybrid Composition:** Renders Flutter content into a texture and uses `SurfaceFlinger` to compose both.
* *Pros:* Best performance and fidelity for Android views.
* *Cons:* Lowers overall application FPS. Certain Flutter widget transformations will not work.
* **Texture Layer (Texture Layer Hybrid Composition):** Renders Platform Views into a texture. Flutter draws them via the texture and renders its own content directly into a Surface.
* *Pros:* Best performance for Flutter rendering. All transformations work correctly.
* *Cons:* Quick scrolling (e.g., WebViews) can be janky. `SurfaceView` is problematic (breaks accessibility). Text magnifiers break unless Flutter is rendered into a `TextureView`.
### iOS & macOS Implementations
* **iOS:** Uses Hybrid Composition exclusively. The native `UIView` is appended to the view hierarchy.
* *Limitations:* `ShaderMask` and `ColorFiltered` widgets are not supported. `BackdropFilter` has composition limitations.
* **macOS:** Uses Hybrid Composition (`NSView`).
* *Limitations:* Not fully functional in current releases (e.g., gesture support is unavailable).
### Performance Mitigation
Mitigate performance drops during complex Dart animations by rendering a screenshot of the native view as a placeholder texture while the animation runs.
## Web Embedding Architecture
Embed Flutter into existing web applications (Vanilla JS, React, Angular, etc.) using either Full Page mode or Embedded (Multi-view) mode.
* **Full Page Mode:** Flutter takes over the entire browser window. Use an `iframe` if you need to constrain the Flutter app without modifying the Flutter bootstrap process.
* **Embedded Mode (Multi-view):** Render Flutter into specific HTML elements (`div`s). Requires `multiViewEnabled: true` during engine initialization.
* Manage views from JavaScript using `app.addView()` and `app.removeView()`.
* In Dart, replace `runApp` with `runWidget`.
* Manage the dynamic list of views using `WidgetsBinding.instance.platformDispatcher.views` and render them using `ViewCollection` and `View` widgets.
## Workflow: Implementing Android Platform Views
Follow this sequential workflow to implement a Platform View on Android.
**Task Progress:**
- [ ] 1. Determine the composition mode (Hybrid vs. Texture Layer).
- [ ] 2. Implement the Dart widget.
- [ ] 3. Implement the native Android View and Factory.
- [ ] 4. Register the Platform View in the Android host.
- [ ] 5. Run validator -> review rendering -> fix manual invalidation issues.
### 1. Dart Implementation
If using **Hybrid Composition**, use `PlatformViewLink`, `AndroidViewSurface`, and `PlatformViewsService.initSurfaceAndroidView`.
If using **Texture Layer**, use the `AndroidView` widget.
### 2. Native Implementation
Create a class implementing `io.flutter.plugin.platform.PlatformView` that returns your native `android.view.View`.
Create a factory extending `PlatformViewFactory` to instantiate your view.
### 3. Registration
Register the factory in your `MainActivity.kt` (or plugin) using `flutterEngine.platformViewsController.registry.registerViewFactory`.
*Note: If your native view uses `SurfaceView` or `SurfaceTexture`, manually call `invalidate` on the View or its parent when content changes, as they do not invalidate themselves automatically.*
## Workflow: Implementing iOS Platform Views
Follow this sequential workflow to implement a Platform View on iOS.
**Task Progress:**
- [ ] 1. Implement the Dart widget using `UiKitView`.
- [ ] 2. Implement the native iOS View (`FlutterPlatformView`) and Factory (`FlutterPlatformViewFactory`).
- [ ] 3. Register the Platform View in `AppDelegate.swift` or the plugin registrar.
- [ ] 4. Run validator -> review composition limitations -> fix unsupported filters.
## Workflow: Embedding Flutter in Web Applications
Follow this sequential workflow to embed Flutter into an existing web DOM.
**Task Progress:**
- [ ] 1. Update `flutter_bootstrap.js` to enable multi-view.
- [ ] 2. Update `main.dart` to use `runWidget` and `ViewCollection`.
- [ ] 3. Implement JavaScript logic to add/remove host elements.
- [ ] 4. Run validator -> review view constraints -> fix CSS conflicts.
### 1. JavaScript Configuration
In `flutter_bootstrap.js`, initialize the engine with `multiViewEnabled: true`.
Use the returned `app` object to add views: `app.addView({ hostElement: document.getElementById('my-div') })`.
### 2. Dart Configuration
Replace `runApp()` with `runWidget()`.
Create a root widget that listens to `WidgetsBindingObserver.didChangeMetrics`.
Map over `WidgetsBinding.instance.platformDispatcher.views` to create a `View` widget for each attached `FlutterView`, and wrap them all in a `ViewCollection`.
## Examples
### Example: Android Texture Layer (Dart)
```dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class NativeAndroidView extends StatelessWidget {
@override
Widget build(BuildContext context) {
const String viewType = 'my_native_view';
final Map<String, dynamic> creationParams = <String, dynamic>{};
return AndroidView(
viewType: viewType,
layoutDirection: TextDirection.ltr,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
);
}
}
```
### Example: Web Multi-View Initialization (JavaScript)
```javascript
_flutter.loader.load({
onEntrypointLoaded: async function(engineInitializer) {
let engine = await engineInitializer.initializeEngine({
multiViewEnabled: true,
});
let app = await engine.runApp();
// Add a view to a specific DOM element
let viewId = app.addView({
hostElement: document.querySelector('#flutter-host-container'),
initialData: { customData: 'Hello from JS' }
});
}
});
```
### Example: Web Multi-View Root Widget (Dart)
```dart
import 'dart:ui' show FlutterView;
import 'package:flutter/widgets.dart';
void main() {
runWidget(MultiViewApp(viewBuilder: (context) => const MyEmbeddedWidget()));
}
class MultiViewApp extends StatefulWidget {
final WidgetBuilder viewBuilder;
const MultiViewApp({super.key, required this.viewBuilder});
@override
State<MultiViewApp> createState() => _MultiViewAppState();
}
class _MultiViewAppState extends State<MultiViewApp> with WidgetsBindingObserver {
Map<Object, Widget> _views = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_updateViews();
}
@override
void didChangeMetrics() => _updateViews();
void _updateViews() {
final newViews = <Object, Widget>{};
for (final FlutterView view in WidgetsBinding.instance.platformDispatcher.views) {
newViews[view.viewId] = _views[view.viewId] ?? View(
view: view,
child: Builder(builder: widget.viewBuilder),
);
}
setState(() => _views = newViews);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewCollection(views: _views.values.toList(growable: false));
}
}
```

View File

@@ -0,0 +1,179 @@
---
name: "flutter-handling-concurrency"
description: "Executes long-running tasks in background isolates to keep the UI responsive. Use when performing heavy computations or parsing large datasets."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:23:14 GMT"
---
# Managing Dart Concurrency and Isolates
## Contents
- [Core Concepts](#core-concepts)
- [Decision Matrix: Async vs. Isolates](#decision-matrix-async-vs-isolates)
- [Workflows](#workflows)
- [Implementing Standard Asynchronous UI](#implementing-standard-asynchronous-ui)
- [Offloading Short-Lived Heavy Computation](#offloading-short-lived-heavy-computation)
- [Establishing Long-Lived Worker Isolates](#establishing-long-lived-worker-isolates)
- [Examples](#examples)
## Core Concepts
Dart utilizes a single-threaded execution model driven by an Event Loop (comparable to the iOS main loop). By default, all Flutter application code runs on the Main Isolate.
* **Asynchronous Operations (`async`/`await`):** Use for non-blocking I/O tasks (network requests, file access). The Event Loop continues processing other events while waiting for the `Future` to complete.
* **Isolates:** Dart's implementation of lightweight threads. Isolates possess their own isolated memory and do not share state. They communicate exclusively via message passing.
* **Main Isolate:** The default thread where UI rendering and event handling occur. Blocking this isolate causes UI freezing (jank).
* **Worker Isolate:** A spawned isolate used to offload CPU-bound tasks (e.g., decoding large JSON blobs) to prevent Main Isolate blockage.
## Decision Matrix: Async vs. Isolates
Apply the following conditional logic to determine the correct concurrency approach:
* **If** the task is I/O bound (e.g., HTTP request, database read) -> **Use `async`/`await`** on the Main Isolate.
* **If** the task is CPU-bound but executes quickly (< 16ms) -> **Use `async`/`await`** on the Main Isolate.
* **If** the task is CPU-bound, takes significant time, and runs once (e.g., parsing a massive JSON payload) -> **Use `Isolate.run()`**.
* **If** the task requires continuous or repeated background processing with multiple messages passed over time -> **Use `Isolate.spawn()` with `ReceivePort` and `SendPort`**.
## Workflows
### Implementing Standard Asynchronous UI
Use this workflow to fetch and display non-blocking asynchronous data.
**Task Progress:**
- [ ] Mark the data-fetching function with the `async` keyword.
- [ ] Return a `Future<T>` from the function.
- [ ] Use the `await` keyword to yield execution until the operation completes.
- [ ] Wrap the UI component in a `FutureBuilder<T>` (or `StreamBuilder` for streams).
- [ ] Handle `ConnectionState.waiting`, `hasError`, and `hasData` states within the builder.
- [ ] Run validator -> review UI for loading indicators -> fix missing states.
### Offloading Short-Lived Heavy Computation
Use this workflow for one-off, CPU-intensive tasks using Dart 2.19+.
**Task Progress:**
- [ ] Identify the CPU-bound operation blocking the Main Isolate.
- [ ] Extract the computation into a standalone callback function.
- [ ] Ensure the callback function signature accepts exactly one required, unnamed argument (as per specific architectural constraints).
- [ ] Invoke `Isolate.run()` passing the callback.
- [ ] `await` the result of `Isolate.run()` in the Main Isolate.
- [ ] Assign the returned value to the application state.
### Establishing Long-Lived Worker Isolates
Use this workflow for persistent background processes requiring continuous bidirectional communication.
**Task Progress:**
- [ ] Instantiate a `ReceivePort` on the Main Isolate to listen for messages.
- [ ] Spawn the worker isolate using `Isolate.spawn()`, passing the `ReceivePort.sendPort` as the initial message.
- [ ] In the worker isolate, instantiate its own `ReceivePort`.
- [ ] Send the worker's `SendPort` back to the Main Isolate via the initial port.
- [ ] Store the worker's `SendPort` in the Main Isolate for future message dispatching.
- [ ] Implement listeners on both `ReceivePort` instances to handle incoming messages.
- [ ] Run validator -> review memory leaks -> ensure ports are closed when the isolate is no longer needed.
## Examples
### Example 1: Asynchronous UI with FutureBuilder
```dart
// 1. Define the async operation
Future<String> fetchUserData() async {
await Future.delayed(const Duration(seconds: 2)); // Simulate network I/O
return "User Data Loaded";
}
// 2. Consume in the UI
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: fetchUserData(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return Text('Result: ${snapshot.data}');
}
},
);
}
```
### Example 2: Short-Lived Isolate (`Isolate.run`)
```dart
import 'dart:isolate';
import 'dart:convert';
// 1. Define the heavy computation callback
// Note: Adhering to the strict single-argument signature requirement.
List<dynamic> decodeHeavyJson(String jsonString) {
return jsonDecode(jsonString) as List<dynamic>;
}
// 2. Offload to a worker isolate
Future<List<dynamic>> processDataInBackground(String rawJson) async {
// Isolate.run spawns the isolate, runs the computation, returns the value, and exits.
final result = await Isolate.run(() => decodeHeavyJson(rawJson));
return result;
}
```
### Example 3: Long-Lived Isolate (`ReceivePort` / `SendPort`)
```dart
import 'dart:isolate';
class WorkerManager {
late SendPort _workerSendPort;
final ReceivePort _mainReceivePort = ReceivePort();
Isolate? _isolate;
Future<void> initialize() async {
// 1. Spawn isolate and pass the Main Isolate's SendPort
_isolate = await Isolate.spawn(_workerEntry, _mainReceivePort.sendPort);
// 2. Listen for messages from the Worker Isolate
_mainReceivePort.listen((message) {
if (message is SendPort) {
// First message is the Worker's SendPort
_workerSendPort = message;
_startCommunication();
} else {
// Subsequent messages are data payloads
print('Main Isolate received: $message');
}
});
}
void _startCommunication() {
// Send data to the worker
_workerSendPort.send("Process this data");
}
// 3. Worker Isolate Entry Point
static void _workerEntry(SendPort mainSendPort) {
final workerReceivePort = ReceivePort();
// Send the Worker's SendPort back to the Main Isolate
mainSendPort.send(workerReceivePort.sendPort);
// Listen for incoming tasks
workerReceivePort.listen((message) {
print('Worker Isolate received: $message');
// Perform work and send result back
final result = "Processed: $message";
mainSendPort.send(result);
});
}
void dispose() {
_mainReceivePort.close();
_isolate?.kill();
}
}
```

View File

@@ -0,0 +1,181 @@
---
name: "flutter-handling-http-and-json"
description: "Executes HTTP requests and handles JSON serialization in a Flutter app. Use when integrating with REST APIs or parsing structured data from external sources."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:18:44 GMT"
---
# Handling HTTP and JSON
## Contents
- [Core Guidelines](#core-guidelines)
- [Workflow: Executing HTTP Operations](#workflow-executing-http-operations)
- [Workflow: Implementing JSON Serialization](#workflow-implementing-json-serialization)
- [Workflow: Parsing Large JSON in the Background](#workflow-parsing-large-json-in-the-background)
- [Examples](#examples)
## Core Guidelines
- **Enforce HTTPS:** iOS and Android disable cleartext (HTTP) connections by default. Always use HTTPS endpoints. If HTTP is strictly required for debugging, configure `network_security_config.xml` (Android) and `NSAppTransportSecurity` (iOS).
- **Construct URIs Safely:** Always use `Uri.https(authority, unencodedPath, [queryParameters])` to safely build URLs. This handles encoding and formatting reliably, preventing string concatenation errors.
- **Handle Status Codes:** Always validate the `http.Response.statusCode`. Treat `200` (OK) and `201` (Created) as success. Throw explicit exceptions for other codes (do not return `null`).
- **Prevent UI Jank:** Move expensive JSON parsing operations (taking >16ms) to a background isolate using the `compute()` function.
- **Structured AI Output:** When integrating LLMs, enforce reliable JSON output by specifying a strict JSON schema in the system prompt and setting the response MIME type to `application/json`.
## Workflow: Executing HTTP Operations
Use this workflow to implement network requests using the `http` package.
**Task Progress:**
- [ ] Add the `http` package to `pubspec.yaml`.
- [ ] Configure platform permissions (Internet permission in `AndroidManifest.xml` and macOS `.entitlements`).
- [ ] Construct the target `Uri`.
- [ ] Execute the HTTP method.
- [ ] Validate the response and parse the JSON payload.
**Conditional Implementation:**
- **If fetching data (GET):** Use `http.get(uri)`.
- **If sending new data (POST):** Use `http.post(uri, headers: {...}, body: jsonEncode(data))`. Ensure `Content-Type` is `application/json; charset=UTF-8`.
- **If updating data (PUT):** Use `http.put(uri, headers: {...}, body: jsonEncode(data))`.
- **If deleting data (DELETE):** Use `http.delete(uri, headers: {...})`.
**Feedback Loop: Validation & Error Handling**
1. Run the HTTP request.
2. Check `response.statusCode`.
3. If `200` or `201`, call `jsonDecode(response.body)` and map to a Dart object.
4. If any other code, throw an `Exception('Failed to load/update/delete resource')`.
5. Review errors -> fix endpoint, headers, or payload structure.
## Workflow: Implementing JSON Serialization
Choose the serialization strategy based on project complexity.
**Conditional Implementation:**
- **If building a small prototype or simple models:** Use manual serialization with `dart:convert`.
- **If building a production app with complex/nested models:** Use code generation with `json_serializable`.
### Manual Serialization Setup
**Task Progress:**
- [ ] Import `dart:convert`.
- [ ] Define the Model class with `final` properties.
- [ ] Implement a `factory Model.fromJson(Map<String, dynamic> json)` constructor.
- [ ] Implement a `Map<String, dynamic> toJson()` method.
### Code Generation Setup (`json_serializable`)
**Task Progress:**
- [ ] Add dependencies: `flutter pub add json_annotation` and `flutter pub add -d build_runner json_serializable`.
- [ ] Import `json_annotation.dart` in the model file.
- [ ] Add the `part 'model_name.g.dart';` directive.
- [ ] Annotate the class with `@JsonSerializable()`. Use `explicitToJson: true` if the class contains nested models.
- [ ] Define the `fromJson` factory and `toJson` method delegating to the generated functions.
- [ ] Run the generator: `dart run build_runner build --delete-conflicting-outputs`.
## Workflow: Parsing Large JSON in the Background
Use this workflow to prevent frame drops when parsing large JSON payloads (e.g., lists of 1000+ items).
**Task Progress:**
- [ ] Create a top-level or static function that takes a `String` (the response body) and returns the parsed Dart object (e.g., `List<Model>`).
- [ ] Inside the function, call `jsonDecode` and map the results to the Model class.
- [ ] In the HTTP fetch method, pass the top-level parsing function and the `response.body` to Flutter's `compute()` function.
## Examples
### Example 1: HTTP GET with Manual Serialization
```dart
import 'dart:convert';
import 'package:http/http.dart' as http;
class Album {
final int id;
final String title;
const Album({required this.id, required this.title});
factory Album.fromJson(Map<String, dynamic> json) {
return Album(
id: json['id'] as int,
title: json['title'] as String,
);
}
}
Future<Album> fetchAlbum() async {
final uri = Uri.https('jsonplaceholder.typicode.com', '/albums/1');
final response = await http.get(uri);
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
} else {
throw Exception('Failed to load album');
}
}
```
### Example 2: HTTP POST Request
```dart
Future<Album> createAlbum(String title) async {
final uri = Uri.https('jsonplaceholder.typicode.com', '/albums');
final response = await http.post(
uri,
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{'title': title}),
);
if (response.statusCode == 201) {
return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
} else {
throw Exception('Failed to create album.');
}
}
```
### Example 3: Background Parsing with `compute`
```dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
// 1. Top-level function for parsing
List<Photo> parsePhotos(String responseBody) {
final parsed = (jsonDecode(responseBody) as List<Object?>)
.cast<Map<String, Object?>>();
return parsed.map<Photo>(Photo.fromJson).toList();
}
// 2. Fetch function using compute
Future<List<Photo>> fetchPhotos(http.Client client) async {
final uri = Uri.https('jsonplaceholder.typicode.com', '/photos');
final response = await client.get(uri);
if (response.statusCode == 200) {
// Run parsePhotos in a separate isolate
return compute(parsePhotos, response.body);
} else {
throw Exception('Failed to load photos');
}
}
```
### Example 4: Code Generation (`json_serializable`)
```dart
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable(explicitToJson: true)
class User {
final String name;
@JsonKey(name: 'registration_date_millis')
final int registrationDateMillis;
User(this.name, this.registrationDateMillis);
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
```

View File

@@ -0,0 +1,205 @@
---
name: "flutter-implementing-navigation-and-routing"
description: "Handles routing, navigation, and deep linking in a Flutter application. Use when moving between screens or setting up URL-based navigation."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:30:17 GMT"
---
# Implementing Navigation and Routing in Flutter
## Contents
- [Core Concepts](#core-concepts)
- [Implementing Imperative Navigation](#implementing-imperative-navigation)
- [Implementing Declarative Navigation](#implementing-declarative-navigation)
- [Implementing Nested Navigation](#implementing-nested-navigation)
- [Workflows](#workflows)
- [Examples](#examples)
## Core Concepts
- **Routes:** In Flutter, screens and pages are referred to as *routes*. A route is simply a widget. This is equivalent to an `Activity` in Android or a `ViewController` in iOS.
- **Navigator vs. Router:**
- Use `Navigator` (Imperative) for small applications without complex deep linking requirements. It manages a stack of `Route` objects.
- Use `Router` (Declarative) for applications with advanced navigation, web URL synchronization, and specific deep linking requirements.
- **Deep Linking:** Allows an app to open directly to a specific location based on a URL. Supported on iOS, Android, and Web. Web requires no additional setup.
- **Named Routes:** Avoid using named routes (`MaterialApp.routes` and `Navigator.pushNamed`) for most applications. They have rigid deep linking behavior and do not support the browser forward button. Use a routing package like `go_router` instead.
## Implementing Imperative Navigation
Use the `Navigator` widget to push and pop routes using platform-specific transition animations (`MaterialPageRoute` or `CupertinoPageRoute`).
### Pushing and Popping
- Navigate to a new route using `Navigator.push(context, route)`.
- Return to the previous route using `Navigator.pop(context)`.
- Use `Navigator.pushReplacement()` to replace the current route, or `Navigator.pushAndRemoveUntil()` to clear the stack based on a condition.
### Passing and Returning Data
- **Sending Data:** Pass data directly into the constructor of the destination widget. Alternatively, pass data via the `settings: RouteSettings(arguments: data)` parameter of the `PageRoute` and extract it using `ModalRoute.of(context)!.settings.arguments`.
- **Returning Data:** Pass the return value to the `pop` method: `Navigator.pop(context, resultData)`. Await the result on the pushing side: `final result = await Navigator.push(...)`.
## Implementing Declarative Navigation
For apps requiring deep linking, web URL support, or complex routing, implement the `Router` API via a declarative routing package like `go_router`.
- Switch from `MaterialApp` to `MaterialApp.router`.
- Define a router configuration that parses route paths and configures the `Navigator` automatically.
- Navigate using package-specific APIs (e.g., `context.go('/path')`).
- **Page-backed vs. Pageless Routes:** Declarative routes are *page-backed* (deep-linkable). Imperative pushes (e.g., dialogs, bottom sheets) are *pageless*. Removing a page-backed route automatically removes all subsequent pageless routes.
## Implementing Nested Navigation
Implement nested navigation to manage a sub-flow of screens (e.g., a multi-step setup process or persistent bottom navigation tabs) independently from the top-level global navigator.
- Instantiate a new `Navigator` widget inside the host widget.
- Assign a `GlobalKey<NavigatorState>` to the nested `Navigator` to control it programmatically.
- Implement the `onGenerateRoute` callback within the nested `Navigator` to resolve sub-routes.
- Intercept hardware back button presses using `PopScope` to prevent the top-level navigator from popping the entire nested flow prematurely.
## Workflows
### Workflow: Standard Screen Transition
Copy this checklist to track progress when implementing a basic screen transition:
- [ ] Create the destination widget (Route).
- [ ] Define required data parameters in the destination widget's constructor.
- [ ] Implement `Navigator.push()` in the source widget.
- [ ] Wrap the destination widget in a `MaterialPageRoute` or `CupertinoPageRoute`.
- [ ] Implement `Navigator.pop()` in the destination widget to return.
### Workflow: Implementing Deep-Linkable Routing
Use this conditional workflow when setting up app-wide routing:
- [ ] **If** the app is simple and requires no deep linking:
- [ ] Use standard `MaterialApp` and `Navigator.push()`.
- [ ] **If** the app requires deep linking, web support, or complex flows:
- [ ] Add the `go_router` package.
- [ ] Change `MaterialApp` to `MaterialApp.router`.
- [ ] Define the `GoRouter` configuration with all top-level routes.
- [ ] Replace `Navigator.push()` with `context.go()` or `context.push()`.
### Workflow: Creating a Nested Navigation Flow
Run this workflow when building a multi-step sub-flow (e.g., IoT device setup):
- [ ] Define string constants for the nested route paths.
- [ ] Create a `GlobalKey<NavigatorState>` in the host widget's state.
- [ ] Return a `Navigator` widget in the host's `build` method, passing the key.
- [ ] Implement `onGenerateRoute` in the nested `Navigator` to map string paths to specific step widgets.
- [ ] Wrap the host `Scaffold` in a `PopScope` to handle back-button interceptions (e.g., prompting "Are you sure you want to exit setup?").
- [ ] Use `navigatorKey.currentState!.pushNamed()` to advance steps within the flow.
## Examples
### Example: Passing Data via Constructor (Imperative)
```dart
// 1. Define the data model
class Todo {
final String title;
final String description;
const Todo(this.title, this.description);
}
// 2. Source Screen
class TodosScreen extends StatelessWidget {
final List<Todo> todos;
const TodosScreen({super.key, required this.todos});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Todos')),
body: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(todos[index].title),
onTap: () {
// Push and pass data via constructor
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailScreen(todo: todos[index]),
),
);
},
);
},
),
);
}
}
// 3. Destination Screen
class DetailScreen extends StatelessWidget {
final Todo todo;
const DetailScreen({super.key, required this.todo});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(todo.title)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Text(todo.description),
),
);
}
}
```
### Example: Nested Navigation Flow
```dart
class SetupFlow extends StatefulWidget {
final String initialRoute;
const SetupFlow({super.key, required this.initialRoute});
@override
State<SetupFlow> createState() => _SetupFlowState();
}
class _SetupFlowState extends State<SetupFlow> {
final _navigatorKey = GlobalKey<NavigatorState>();
void _exitSetup() => Navigator.of(context).pop();
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
// Intercept back button to prevent accidental exit
_exitSetup();
},
child: Scaffold(
appBar: AppBar(title: const Text('Setup')),
body: Navigator(
key: _navigatorKey,
initialRoute: widget.initialRoute,
onGenerateRoute: _onGenerateRoute,
),
),
);
}
Route<Widget> _onGenerateRoute(RouteSettings settings) {
Widget page;
switch (settings.name) {
case 'step1':
page = StepOnePage(
onComplete: () => _navigatorKey.currentState!.pushNamed('step2'),
);
break;
case 'step2':
page = StepTwoPage(onComplete: _exitSetup);
break;
default:
throw StateError('Unexpected route name: ${settings.name}!');
}
return MaterialPageRoute(
builder: (context) => page,
settings: settings,
);
}
}
```

View File

@@ -0,0 +1,110 @@
---
name: "flutter-improving-accessibility"
description: "Configures a Flutter app to support assistive technologies like Screen Readers. Use when ensuring an application is usable for people with disabilities."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:17:37 GMT"
---
# Implementing Flutter Accessibility
## Contents
- [UI Design and Styling](#ui-design-and-styling)
- [Accessibility Widgets](#accessibility-widgets)
- [Web Accessibility](#web-accessibility)
- [Adaptive and Responsive Design](#adaptive-and-responsive-design)
- [Workflows](#workflows)
- [Examples](#examples)
## UI Design and Styling
Design layouts to accommodate dynamic scaling and high visibility. Flutter automatically calculates font sizes based on OS-level accessibility settings.
* **Font Scaling:** Ensure layouts provide sufficient room to render all contents when font sizes are increased to their maximum OS settings. Avoid hardcoding fixed heights on text containers.
* **Color Contrast:** Maintain a contrast ratio of at least 4.5:1 for small text and 3.0:1 for large text (18pt+ regular or 14pt+ bold) to meet W3C standards.
* **Tap Targets:** Enforce a minimum tap target size of 48x48 logical pixels to accommodate users with limited dexterity.
## Accessibility Widgets
Utilize Flutter's catalog of accessibility widgets to manipulate the semantics tree exposed to assistive technologies (like TalkBack or VoiceOver).
* **`Semantics`**: Use this to annotate the widget tree with a description of the meaning of the widgets. Assign specific roles using the `SemanticsRole` enum (e.g., button, link, heading) when building custom components.
* **`MergeSemantics`**: Wrap composite widgets to merge the semantics of all descendants into a single selectable node for screen readers.
* **`ExcludeSemantics`**: Use this to drop the semantics of all descendants, hiding redundant or purely decorative sub-widgets from accessibility tools.
## Web Accessibility
Flutter web renders UI on a single canvas, requiring a specialized DOM layer to expose structure to browsers.
* **Enable Semantics:** Web accessibility is disabled by default for performance. Users can enable it via an invisible button (`aria-label="Enable accessibility"`).
* **Programmatic Enablement:** If building a web-first application requiring default accessibility, force the semantics tree generation at startup.
* **Semantic Roles:** Rely on standard widgets (`TabBar`, `MenuAnchor`, `Table`) for automatic ARIA role mapping. For custom components, explicitly assign `SemanticsRole` values to ensure screen readers interpret the elements correctly.
## Adaptive and Responsive Design
Differentiate between adaptive and responsive paradigms to build universal applications.
* **Responsive Design:** Adjust the placement, sizing, and reflowing of design elements to fit the available screen space.
* **Adaptive Design:** Select appropriate layouts (e.g., bottom navigation vs. side panel) and input mechanisms (e.g., touch vs. mouse/keyboard) to make the UI usable within the current device context. Design to the strengths of each form factor.
## Workflows
### Task Progress: Accessibility Implementation
Copy this checklist to track accessibility compliance during UI development:
- [ ] Verify all interactive elements have a minimum tap target of 48x48 pixels.
- [ ] Test layout with maximum OS font size settings to ensure no text clipping or overflow occurs.
- [ ] Validate color contrast ratios (4.5:1 for normal text, 3.0:1 for large text).
- [ ] Wrap custom interactive widgets in `Semantics` and assign the appropriate `SemanticsRole`.
- [ ] Group complex composite widgets using `MergeSemantics` to prevent screen reader fatigue.
- [ ] Hide decorative elements from screen readers using `ExcludeSemantics`.
- [ ] If targeting web, verify ARIA roles are correctly mapped and consider programmatic enablement of the semantics tree.
### Feedback Loop: Accessibility Validation
Run this loop when finalizing a view or component:
1. **Run validator:** Execute accessibility tests or use OS-level screen readers (VoiceOver/TalkBack) to navigate the view.
2. **Review errors:** Identify unannounced interactive elements, trapped focus, or clipped text.
3. **Fix:** Apply `Semantics`, adjust constraints, or modify colors. Repeat until the screen reader provides a clear, logical traversal of the UI.
## Examples
### Programmatic Web Accessibility Enablement
If targeting web and requiring accessibility by default, initialize the semantics binding before running the app.
```dart
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/foundation.dart';
void main() {
if (kIsWeb) {
SemanticsBinding.instance.ensureSemantics();
}
runApp(const MyApp());
}
```
### Custom Component Semantics
If building a custom widget that acts as a list item, explicitly define its semantic role so assistive technologies and web ARIA mappings interpret it correctly.
```dart
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
class CustomListItem extends StatelessWidget {
final String text;
const CustomListItem({super.key, required this.text});
@override
Widget build(BuildContext context) {
return Semantics(
role: SemanticsRole.listItem,
label: text,
child: Padding(
padding: const EdgeInsets.all(12.0), // Ensures > 48px tap target if interactive
child: Text(
text,
style: const TextStyle(fontSize: 16), // Ensure contrast ratio > 4.5:1
),
),
);
}
}
```

View File

@@ -0,0 +1,203 @@
---
name: "flutter-managing-state"
description: "Manages application and ephemeral state in a Flutter app. Use when sharing data between widgets or handling complex UI state transitions."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:18:06 GMT"
---
# Managing State in Flutter
## Contents
- [Core Concepts](#core-concepts)
- [Architecture and Data Flow](#architecture-and-data-flow)
- [Workflow: Selecting a State Management Approach](#workflow-selecting-a-state-management-approach)
- [Workflow: Implementing MVVM with Provider](#workflow-implementing-mvvm-with-provider)
- [Examples](#examples)
## Core Concepts
Flutter's UI is declarative; it is built to reflect the current state of the app (`UI = f(state)`). When state changes, trigger a rebuild of the UI that depends on that state.
Distinguish between two primary types of state to determine your management strategy:
* **Ephemeral State (Local State):** State contained neatly within a single widget (e.g., current page in a `PageView`, current selected tab, animation progress). Manage this using a `StatefulWidget` and `setState()`.
* **App State (Shared State):** State shared across multiple parts of the app and maintained between user sessions (e.g., user preferences, login info, shopping cart contents). Manage this using advanced approaches like `InheritedWidget`, the `provider` package, and the MVVM architecture.
## Architecture and Data Flow
Implement the **Model-View-ViewModel (MVVM)** design pattern combined with **Unidirectional Data Flow (UDF)** for scalable app state management.
* **Unidirectional Data Flow (UDF):** Enforce a strict flow where state flows *down* from the data layer, through the logic layer, to the UI layer. Events from user interactions flow *up* from the UI layer, to the logic layer, to the data layer.
* **Single Source of Truth (SSOT):** Ensure data changes always happen in the data layer (Repositories). The SSOT class must be the only class capable of modifying its respective data.
* **Model (Data Layer):** Handle low-level tasks like HTTP requests, data caching, and system resources using Repository classes.
* **ViewModel (Logic Layer):** Manage the UI state. Convert app data from the Model into UI State. Extend `ChangeNotifier` and call `notifyListeners()` to trigger UI rebuilds when data changes.
* **View (UI Layer):** Display the state provided by the ViewModel. Keep views lean; they should contain minimal logic (only routing, animations, or simple UI conditionals).
## Workflow: Selecting a State Management Approach
Evaluate the scope of the state to determine the correct implementation strategy.
* **If managing Ephemeral State (single widget scope):**
1. Subclass `StatefulWidget` and `State`.
2. Store mutable state as private fields within the `State` class.
3. Mutate state exclusively inside a `setState()` callback to mark the widget as dirty and schedule a rebuild.
* **If managing App State (shared across widgets):**
1. Implement the MVVM pattern.
2. Use the `provider` package (a wrapper around `InheritedWidget`) to inject state into the widget tree.
3. Use `ChangeNotifier` to emit state updates.
## Workflow: Implementing MVVM with Provider
Follow this sequential workflow to implement app-level state management using MVVM and `provider`.
**Task Progress:**
- [ ] 1. Define the Model (Repository).
- [ ] 2. Create the ViewModel (`ChangeNotifier`).
- [ ] 3. Inject the ViewModel into the Widget Tree.
- [ ] 4. Consume the State in the View.
- [ ] 5. Validate the implementation.
### 1. Define the Model (Repository)
Create a repository class to act as the Single Source of Truth (SSOT) for the specific data domain. Handle all external API calls or database queries here.
### 2. Create the ViewModel (`ChangeNotifier`)
Create a ViewModel class that extends `ChangeNotifier`.
* Pass the Repository into the ViewModel via dependency injection.
* Define properties for the UI state (e.g., `isLoading`, `data`, `errorMessage`).
* Implement methods to handle UI events. Inside these methods, mutate the state and call `notifyListeners()` to trigger UI rebuilds.
### 3. Inject the ViewModel into the Widget Tree
Use `ChangeNotifierProvider` from the `provider` package to provide the ViewModel to the widget subtree that requires it. Place the provider as low in the widget tree as possible to avoid polluting the scope.
### 4. Consume the State in the View
Access the ViewModel in your `StatelessWidget` or `StatefulWidget`.
* Use `Consumer<MyViewModel>` to rebuild specific parts of the UI when `notifyListeners()` is called.
* Use `context.read<MyViewModel>()` (or `Provider.of<MyViewModel>(context, listen: false)`) inside event handlers (like `onPressed`) to call ViewModel methods without triggering a rebuild of the calling widget.
### 5. Validate the implementation
Run the following feedback loop to ensure data flows correctly:
1. Trigger a user action in the View.
2. Verify the ViewModel receives the event and calls the Repository.
3. Verify the Repository updates the SSOT and returns data.
4. Verify the ViewModel updates its state and calls `notifyListeners()`.
5. Verify the View rebuilds with the new state.
*Run validator -> review errors -> fix missing `notifyListeners()` calls or incorrect `Provider` scopes.*
## Examples
### Ephemeral State Implementation (`setState`)
Use this pattern strictly for local, UI-only state.
```dart
class EphemeralCounter extends StatefulWidget {
const EphemeralCounter({super.key});
@override
State<EphemeralCounter> createState() => _EphemeralCounterState();
}
class _EphemeralCounterState extends State<EphemeralCounter> {
int _counter = 0; // Local state
void _increment() {
setState(() {
_counter++; // Mutate state and schedule rebuild
});
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: _increment,
child: Text('Count: $_counter'),
);
}
}
```
### App State Implementation (MVVM + Provider)
Use this pattern for shared data and complex business logic.
```dart
// 1. Model (Repository)
class CartRepository {
Future<void> saveItemToCart(String item) async {
// Simulate network/database call
await Future.delayed(const Duration(milliseconds: 500));
}
}
// 2. ViewModel (ChangeNotifier)
class CartViewModel extends ChangeNotifier {
final CartRepository repository;
CartViewModel({required this.repository});
final List<String> _items = [];
bool isLoading = false;
String? errorMessage;
List<String> get items => List.unmodifiable(_items);
Future<void> addItem(String item) async {
isLoading = true;
errorMessage = null;
notifyListeners(); // Trigger loading UI
try {
await repository.saveItemToCart(item);
_items.add(item);
} catch (e) {
errorMessage = 'Failed to add item';
} finally {
isLoading = false;
notifyListeners(); // Trigger success/error UI
}
}
}
// 3. Injection & 4. View (UI)
class CartApp extends StatelessWidget {
const CartApp({super.key});
@override
Widget build(BuildContext context) {
// Inject ViewModel
return ChangeNotifierProvider(
create: (_) => CartViewModel(repository: CartRepository()),
child: const CartScreen(),
);
}
}
class CartScreen extends StatelessWidget {
const CartScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Consumer<CartViewModel>(
builder: (context, viewModel, child) {
if (viewModel.isLoading) {
return const CircularProgressIndicator();
}
if (viewModel.errorMessage != null) {
return Text(viewModel.errorMessage!);
}
return ListView.builder(
itemCount: viewModel.items.length,
itemBuilder: (_, index) => Text(viewModel.items[index]),
);
},
),
floatingActionButton: FloatingActionButton(
// Use read() to access methods without listening for rebuilds
onPressed: () => context.read<CartViewModel>().addItem('New Item'),
child: const Icon(Icons.add),
),
);
}
}
```

View File

@@ -0,0 +1,100 @@
---
name: "flutter-reducing-app-size"
description: "Measures and optimizes the size of Flutter application bundles for deployment. Use when minimizing download size or meeting app store package constraints."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:22:44 GMT"
---
# Reducing Flutter App Size
## Contents
- [Core Concepts](#core-concepts)
- [Workflow: Generating Size Analysis Files](#workflow-generating-size-analysis-files)
- [Workflow: Analyzing Size Data in DevTools](#workflow-analyzing-size-data-in-devtools)
- [Workflow: Estimating iOS Download Size](#workflow-estimating-ios-download-size)
- [Workflow: Implementing Size Reduction Strategies](#workflow-implementing-size-reduction-strategies)
- [Examples](#examples)
## Core Concepts
- **Debug vs. Release:** Never use debug builds to measure app size. Debug builds include VM overhead and lack Ahead-Of-Time (AOT) compilation and tree-shaking.
- **Upload vs. Download Size:** The size of an upload package (APK, AAB, IPA) does not represent the end-user download size. App stores filter redundant native library architectures and asset densities based on the target device.
- **AOT Tree-Shaking:** The Dart AOT compiler automatically removes unused or unreachable code in profile and release modes.
- **Size Analysis JSON:** The `--analyze-size` flag generates a `*-code-size-analysis_*.json` file detailing the byte size of packages, libraries, classes, and functions.
## Workflow: Generating Size Analysis Files
Use this workflow to generate the raw data required for size analysis.
**Task Progress:**
- [ ] Determine the target platform (apk, appbundle, ios, linux, macos, windows).
- [ ] Run the Flutter build command with the `--analyze-size` flag.
- [ ] Locate the generated `*-code-size-analysis_*.json` file in the `build/` directory.
**Conditional Logic:**
- **If targeting Android:** Run `flutter build apk --analyze-size` or `flutter build appbundle --analyze-size`.
- **If targeting iOS:** Run `flutter build ios --analyze-size`. *Note: This creates a `.app` file useful for relative content sizing, but not for estimating final App Store download size. Use the [Estimating iOS Download Size](#workflow-estimating-ios-download-size) workflow for accurate iOS metrics.*
- **If targeting Desktop:** Run `flutter build [windows|macos|linux] --analyze-size`.
## Workflow: Analyzing Size Data in DevTools
Use this workflow to visualize and drill down into the Size Analysis JSON.
**Task Progress:**
- [ ] Launch DevTools by running `dart devtools` in the terminal.
- [ ] Select "Open app size tool" from the DevTools landing page.
- [ ] Upload the generated `*-code-size-analysis_*.json` file.
- [ ] Inspect the treemap or tree view to identify large packages, libraries, or assets.
- [ ] **Feedback Loop:**
1. Identify the largest contributors to app size.
2. Determine if the dependency or asset is strictly necessary.
3. Remove, replace, or optimize the identified component.
4. Regenerate the Size Analysis JSON and compare the new build against the old build using the DevTools "Diff" tab.
## Workflow: Estimating iOS Download Size
Use this workflow to get an accurate projection of iOS download and installation sizes across different devices.
**Task Progress:**
- [ ] Configure the app version and build number in `pubspec.yaml`.
- [ ] Generate an Xcode archive by running `flutter build ipa --export-method development`.
- [ ] Open the generated archive (`build/ios/archive/*.xcarchive`) in Xcode.
- [ ] Click **Distribute App** and select **Development**.
- [ ] In the App Thinning configuration, select **All compatible device variants**.
- [ ] Check the option to **Strip Swift symbols**.
- [ ] Sign and export the IPA.
- [ ] Open the exported directory and review the `App Thinning Size Report.txt` file to evaluate projected sizes per device.
## Workflow: Implementing Size Reduction Strategies
Apply these strategies to actively reduce the compiled footprint of the application.
**Task Progress:**
- [ ] **Split Debug Info:** Strip debug symbols from the compiled binary and store them in separate files.
- [ ] **Remove Unused Resources:** Audit the `pubspec.yaml` and `assets/` directory. Delete any images, fonts, or files not actively referenced in the codebase.
- [ ] **Minimize Library Resources:** Review third-party packages. If a package imports massive resource files (e.g., large icon sets or localization files) but only a fraction is used, consider alternative packages or custom implementations.
- [ ] **Compress Media:** Compress all PNG and JPEG assets using tools like `pngquant`, `imageoptim`, or WebP conversion before bundling them into the app.
## Examples
### Generating Size Analysis (Android)
```bash
# Generate the size analysis JSON for an Android App Bundle
flutter build appbundle --analyze-size --target-platform=android-arm64
```
### Splitting Debug Info (Release Build)
```bash
# Build an APK while stripping debug info to reduce binary size
flutter build apk --obfuscate --split-debug-info=build/app/outputs/symbols
```
### Reading the iOS App Thinning Size Report
When reviewing `App Thinning Size Report.txt`, look for the specific target device to understand the true impact on the user:
```text
Variant: Runner-7433FC8E-1DF4-4299-A7E8-E00768671BEB.ipa
Supported variant descriptors: [device: iPhone12,1, os-version: 13.0]
App + On Demand Resources size: 5.4 MB compressed, 13.7 MB uncompressed
App size: 5.4 MB compressed, 13.7 MB uncompressed
```
*Interpretation: The end-user download size (compressed) is 5.4 MB, and the on-device footprint (uncompressed) is 13.7 MB.*

View File

@@ -0,0 +1,103 @@
---
name: "flutter-setting-up-on-windows"
description: "Sets up a Windows environment for Flutter development. Use when configuring a Windows machine to run, build, or deploy Flutter applications for Windows desktop or Android."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:13:13 GMT"
---
# Setting Up Flutter for Windows Development
## Contents
- [Core Requirements](#core-requirements)
- [Workflow: Installing and Configuring the SDK](#workflow-installing-and-configuring-the-sdk)
- [Workflow: Configuring Tooling and IDEs](#workflow-configuring-tooling-and-ides)
- [Workflow: Configuring Target Platforms](#workflow-configuring-target-platforms)
- [Workflow: Building and Packaging for Windows](#workflow-building-and-packaging-for-windows)
- [Workflow: Generating and Installing Certificates](#workflow-generating-and-installing-certificates)
- [Examples](#examples)
## Core Requirements
Configure the Windows environment to support both Flutter framework execution and native C/C++ compilation. Differentiate strictly between **Visual Studio** (required for Windows desktop C++ compilation) and **VS Code** (the recommended Dart/Flutter code editor).
## Workflow: Installing and Configuring the SDK
Follow this sequential workflow to initialize the Flutter SDK on a Windows machine.
- [ ] Download the latest stable Flutter SDK for Windows.
- [ ] Extract the SDK to a directory with standard user privileges (e.g., `C:\src\flutter`). Do not install in protected directories like `C:\Program Files\`.
- [ ] Copy the absolute path to the Flutter SDK's `bin` directory.
- [ ] Open Windows Environment Variables settings and append the `bin` directory path to the system or user `PATH` variable.
- [ ] Open a new terminal session to apply the `PATH` changes.
- [ ] **Feedback Loop:** Run validator -> review errors -> fix.
1. Execute `flutter doctor -v`.
2. Review the output for missing dependencies or path issues.
3. Resolve any flagged errors before proceeding to tooling setup.
## Workflow: Configuring Tooling and IDEs
- [ ] Install **Visual Studio** (not VS Code).
- [ ] Select and install the **Desktop development with C++** workload during the Visual Studio installation process. This is mandatory for compiling Windows desktop applications.
- [ ] Install your preferred code editor (VS Code, Android Studio, or IntelliJ).
- [ ] Install the official Flutter and Dart extensions/plugins within your chosen editor.
## Workflow: Configuring Target Platforms
Apply conditional logic based on the specific platform you are targeting for development.
**If targeting Windows Desktop:**
- [ ] Ensure the Visual Studio C++ workload is fully updated.
- [ ] Restart your IDE so it detects the Windows desktop device.
- [ ] To disable platforms you do not intend to compile for, execute `flutter config --no-enable-<platform>` (e.g., `flutter config --no-enable-windows-desktop`).
**If targeting Android on Windows:**
- [ ] **For physical devices:** Enable Developer Options and USB debugging on the device. Install the specific OEM USB drivers for Windows.
- [ ] **For emulators:** Open the Android Virtual Device (AVD) manager. Under "Emulated Performance" -> "Graphics acceleration", select an option specifying "Hardware" to enable hardware acceleration.
- [ ] Verify the device connection by running `flutter devices`.
## Workflow: Building and Packaging for Windows
To distribute a Windows desktop application, assemble the compiled executable and its required dependencies into a single distributable archive.
- [ ] Execute `flutter build windows` to compile the release build.
- [ ] Navigate to `build\windows\runner\Release\`.
- [ ] Create a new staging directory for the distribution zip.
- [ ] Copy the following assets from the `Release` directory into the staging directory:
- The application executable (`.exe`).
- All generated `.dll` files.
- The entire `data` directory.
- [ ] Copy the required Visual C++ redistributables into the staging directory alongside the executable:
- `msvcp140.dll`
- `vcruntime140.dll`
- `vcruntime140_1.dll`
- [ ] Compress the staging directory into a `.zip` file for distribution.
## Workflow: Generating and Installing Certificates
If you require a self-signed certificate for MSIX packaging or local testing, use OpenSSL.
- [ ] Install OpenSSL and add its `bin` directory to your `PATH` environment variable.
- [ ] Generate a private key: `openssl genrsa -out mykeyname.key 2048`
- [ ] Generate a Certificate Signing Request (CSR): `openssl req -new -key mykeyname.key -out mycsrname.csr`
- [ ] Generate the signed certificate (CRT): `openssl x509 -in mycsrname.csr -out mycrtname.crt -req -signkey mykeyname.key -days 10000`
- [ ] Generate the `.pfx` file: `openssl pkcs12 -export -out CERTIFICATE.pfx -inkey mykeyname.key -in mycrtname.crt`
- [ ] Install the `.pfx` certificate on the local Windows machine. Place it in the Certificate Store under **Trusted Root Certification Authorities** prior to installing the application.
## Examples
### Windows Distribution Directory Structure
When assembling your Windows build for distribution, ensure the directory structure strictly matches the following layout before zipping:
```text
Release_Archive/
│ my_flutter_app.exe
│ flutter_windows.dll
│ msvcp140.dll
│ vcruntime140.dll
│ vcruntime140_1.dll
└───data/
│ app.so
│ icudtl.dat
│ ...
```

View File

@@ -0,0 +1,182 @@
---
name: "flutter-testing-apps"
description: "Implements unit, widget, and integration tests for a Flutter app. Use when ensuring code quality and preventing regressions through automated testing."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:22:10 GMT"
---
# Testing Flutter Applications
## Contents
- [Core Testing Strategies](#core-testing-strategies)
- [Architectural Testing Guidelines](#architectural-testing-guidelines)
- [Plugin Testing Guidelines](#plugin-testing-guidelines)
- [Workflows](#workflows)
- [Examples](#examples)
## Core Testing Strategies
Balance your testing suite across three main categories to optimize for confidence, maintenance cost, dependencies, and execution speed.
### Unit Tests
Use unit tests to verify the correctness of a single function, method, or class under various conditions.
- Mock all external dependencies.
- Do not involve disk I/O, screen rendering, or user actions from outside the test process.
- Execute using the `test` or `flutter_test` package.
### Widget Tests
Use widget tests (component tests) to ensure a single widget's UI looks and interacts as expected.
- Provide the appropriate widget lifecycle context using `WidgetTester`.
- Use `Finder` classes to locate widgets and `Matcher` constants to verify their existence and state.
- Test views and UI interactions without spinning up the full application.
### Integration Tests
Use integration tests (end-to-end or GUI testing) to validate how individual pieces of an app work together and to capture performance metrics on real devices.
- Add the `integration_test` package as a dependency.
- Run on physical devices, OS emulators, or Firebase Test Lab.
- Prioritize integration tests for routing, dependency injection, and critical user flows.
## Architectural Testing Guidelines
Design your application for observability and testability. Ensure all components can be tested both in isolation and together.
- **ViewModels**: Write unit tests for every ViewModel class. Test the UI logic without relying on Flutter libraries or testing frameworks.
- **Repositories & Services**: Write unit tests for every service and repository. Mock the underlying data sources (e.g., HTTP clients, local databases).
- **Views**: Write widget tests for all views. Pass faked or mocked ViewModels and Repositories into the widget tree to isolate the UI.
- **Fakes over Mocks**: Prefer creating `Fake` implementations of your repositories (e.g., `FakeUserRepository`) over using mocking libraries when testing ViewModels and Views to ensure well-defined inputs and outputs.
## Plugin Testing Guidelines
When testing plugins, combine Dart tests with native platform tests to ensure full coverage across the method channel.
- **Dart Tests**: Use Dart unit and widget tests for the Dart-facing API. Mock the platform channel to validate Dart logic.
- **Native Unit Tests**: Implement native unit tests for isolated platform logic.
- Android: Configure JUnit tests in `android/src/test/`.
- iOS/macOS: Configure XCTest tests in `example/ios/RunnerTests/` and `example/macos/RunnerTests/`.
- Linux/Windows: Configure GoogleTest tests in `linux/test/` and `windows/test/`.
- **Native UI Tests**: Use Espresso (Android) or XCUITest (iOS) if the plugin requires native UI interactions.
- **Integration Tests**: Write at least one integration test for each platform channel call to verify Dart-to-Native communication.
- **End-to-End Fallback**: If integration tests cannot cover a flow (e.g., mocking device state), synthesize calls to the method channel entry point using native unit tests, and test the Dart public API using Dart unit tests.
## Workflows
### Workflow: Implementing a Component Test Suite
Copy and track this checklist when implementing tests for a new architectural feature.
- [ ] **Task Progress**
- [ ] Create `Fake` implementations for any new Repositories or Services.
- [ ] Write Unit Tests for the Repository (mocking the API/Database).
- [ ] Write Unit Tests for the ViewModel (injecting the Fake Repositories).
- [ ] Write Widget Tests for the View (injecting the ViewModel and Fake Repositories).
- [ ] Write an Integration Test for the critical path involving this feature.
- [ ] Run validator -> review coverage -> fix missing edge cases.
### Workflow: Running Integration Tests
Follow conditional logic based on the target platform when executing integration tests.
1. **If testing on Mobile (Local)**:
- Connect the Android/iOS device or emulator.
- Run: `flutter test integration_test/app_test.dart`
2. **If testing on Web**:
- Install and launch ChromeDriver: `chromedriver --port=4444`
- Run: `flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart -d chrome`
3. **If testing on Linux (CI System)**:
- Invoke an X server using `xvfb-run` to provide a display environment.
- Run: `xvfb-run flutter test integration_test/app_test.dart -d linux`
4. **If testing via Firebase Test Lab**:
- Build the Android test APKs: `flutter build apk --debug` and `./gradlew app:assembleAndroidTest`
- Upload the App APK and Test APK to the Firebase Console.
## Examples
### Example: ViewModel Unit Test
Demonstrates testing a ViewModel using a Fake Repository.
```dart
import 'package:flutter_test/flutter_test.dart';
void main() {
group('HomeViewModel tests', () {
test('Load bookings successfully', () {
// Inject fake dependencies
final viewModel = HomeViewModel(
bookingRepository: FakeBookingRepository()..createBooking(kBooking),
userRepository: FakeUserRepository(),
);
// Verify state
expect(viewModel.bookings.isNotEmpty, true);
});
});
}
```
### Example: View Widget Test
Demonstrates testing a View by pumping a localized widget tree with fake dependencies.
```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('HomeScreen tests', () {
late HomeViewModel viewModel;
late FakeBookingRepository bookingRepository;
setUp(() {
bookingRepository = FakeBookingRepository()..createBooking(kBooking);
viewModel = HomeViewModel(
bookingRepository: bookingRepository,
userRepository: FakeUserRepository(),
);
});
testWidgets('renders bookings list', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(viewModel: viewModel),
),
);
// Verify UI state
expect(find.byType(ListView), findsOneWidget);
expect(find.text('Booking 1'), findsOneWidget);
});
});
}
```
### Example: Integration Test
Demonstrates a full end-to-end test using the `integration_test` package.
```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('end-to-end test', () {
testWidgets('tap on the floating action button, verify counter', (tester) async {
// Load app widget
await tester.pumpWidget(const MyApp());
// Verify initial state
expect(find.text('0'), findsOneWidget);
// Find and tap the button
final fab = find.byKey(const ValueKey('increment'));
await tester.tap(fab);
// Trigger a frame to allow animations/state to settle
await tester.pumpAndSettle();
// Verify updated state
expect(find.text('1'), findsOneWidget);
});
});
}
```

View File

@@ -0,0 +1,160 @@
---
name: "flutter-theming-apps"
description: "Customizes the visual appearance of a Flutter app using the theming system. Use when defining global styles, colors, or typography for an application."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:14:47 GMT"
---
# Implementing Flutter Theming and Adaptive Design
## Contents
- [Core Theming Concepts](#core-theming-concepts)
- [Material 3 Guidelines](#material-3-guidelines)
- [Component Theme Normalization](#component-theme-normalization)
- [Button Styling](#button-styling)
- [Platform Idioms & Adaptive Design](#platform-idioms--adaptive-design)
- [Workflows](#workflows)
- [Examples](#examples)
## Core Theming Concepts
Flutter applies styling in a strict hierarchy: styles applied to the specific widget -> themes that override the immediate parent theme -> the main app theme.
- Define app-wide themes using the `theme` property of `MaterialApp` with a `ThemeData` instance.
- Override themes for specific widget subtrees by wrapping them in a `Theme` widget and using `Theme.of(context).copyWith(...)`.
- **Do not** use deprecated `ThemeData` properties:
- Replace `accentColor` with `colorScheme.secondary`.
- Replace `accentTextTheme` with `textTheme` (using `colorScheme.onSecondary` for contrast).
- Replace `AppBarTheme.color` with `AppBarTheme.backgroundColor`.
## Material 3 Guidelines
Material 3 is the default theme as of Flutter 3.16.
- **Colors:** Generate color schemes using `ColorScheme.fromSeed(seedColor: Colors.blue)`. This ensures accessible contrast ratios.
- **Elevation:** Material 3 uses `ColorScheme.surfaceTint` to indicate elevation instead of just drop shadows. To revert to M2 shadow behavior, set `surfaceTint: Colors.transparent` and define a `shadowColor`.
- **Typography:** Material 3 updates font sizes, weights, and line heights. If text wrapping breaks legacy layouts, adjust `letterSpacing` on the specific `TextStyle`.
- **Modern Components:**
- Replace `BottomNavigationBar` with `NavigationBar`.
- Replace `Drawer` with `NavigationDrawer`.
- Replace `ToggleButtons` with `SegmentedButton`.
- Use `FilledButton` for a high-emphasis button without the elevation of `ElevatedButton`.
## Component Theme Normalization
Component themes in `ThemeData` have been normalized to use `*ThemeData` classes rather than `*Theme` widgets.
When defining `ThemeData`, strictly use the `*ThemeData` suffix for the following properties:
- `cardTheme`: Use `CardThemeData` (Not `CardTheme`)
- `dialogTheme`: Use `DialogThemeData` (Not `DialogTheme`)
- `tabBarTheme`: Use `TabBarThemeData` (Not `TabBarTheme`)
- `appBarTheme`: Use `AppBarThemeData` (Not `AppBarTheme`)
- `bottomAppBarTheme`: Use `BottomAppBarThemeData` (Not `BottomAppBarTheme`)
- `inputDecorationTheme`: Use `InputDecorationThemeData` (Not `InputDecorationTheme`)
## Button Styling
Legacy button classes (`FlatButton`, `RaisedButton`, `OutlineButton`) are obsolete.
- Use `TextButton`, `ElevatedButton`, and `OutlinedButton`.
- Configure button appearance using a `ButtonStyle` object.
- For simple overrides based on the theme's color scheme, use the static utility method: `TextButton.styleFrom(foregroundColor: Colors.blue)`.
- For state-dependent styling (hovered, focused, pressed, disabled), use `MaterialStateProperty.resolveWith`.
## Platform Idioms & Adaptive Design
When building adaptive apps, respect platform-specific norms to reduce cognitive load and build user trust.
- **Scrollbars:** Desktop users expect omnipresent scrollbars; mobile users expect them only during scrolling. Toggle `thumbVisibility` on the `Scrollbar` widget based on the platform.
- **Selectable Text:** Web and desktop users expect text to be selectable. Wrap text in `SelectableText` or `SelectableText.rich`.
- **Horizontal Button Order:** Windows places confirmation buttons on the left; macOS/Linux place them on the right. Use a `Row` with `TextDirection.rtl` for Windows and `TextDirection.ltr` for others.
- **Context Menus & Tooltips:** Desktop users expect hover and right-click interactions. Implement `Tooltip` for hover states and use context menu packages for right-click actions.
## Workflows
### Workflow: Migrating Legacy Themes to Material 3
Use this workflow when updating an older Flutter codebase.
**Task Progress:**
- [ ] 1. Remove `useMaterial3: false` from `ThemeData` (it is true by default).
- [ ] 2. Replace manual `ColorScheme` definitions with `ColorScheme.fromSeed()`.
- [ ] 3. Run validator -> review errors -> fix: Search for and replace deprecated `accentColor`, `accentColorBrightness`, `accentIconTheme`, and `accentTextTheme`.
- [ ] 4. Run validator -> review errors -> fix: Search for `AppBarTheme(color: ...)` and replace with `backgroundColor`.
- [ ] 5. Update `ThemeData` component properties to use `*ThemeData` classes (e.g., `cardTheme: CardThemeData()`).
- [ ] 6. Replace legacy buttons (`FlatButton` -> `TextButton`, `RaisedButton` -> `ElevatedButton`, `OutlineButton` -> `OutlinedButton`).
- [ ] 7. Replace legacy navigation components (`BottomNavigationBar` -> `NavigationBar`, `Drawer` -> `NavigationDrawer`).
### Workflow: Implementing Adaptive UI Components
Use this workflow when building a widget intended for both mobile and desktop/web.
**Task Progress:**
- [ ] 1. If displaying a list/grid, wrap it in a `Scrollbar` and set `thumbVisibility: DeviceType.isDesktop`.
- [ ] 2. If displaying read-only data, use `SelectableText` instead of `Text`.
- [ ] 3. If implementing a dialog with action buttons, check the platform. If Windows, set `TextDirection.rtl` on the button `Row`.
- [ ] 4. If implementing interactive elements, wrap them in `Tooltip` widgets to support mouse hover states.
## Examples
### Example: Modern Material 3 ThemeData Setup
```dart
MaterialApp(
title: 'Adaptive App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.light,
),
// Use *ThemeData classes for component normalization
appBarTheme: const AppBarThemeData(
backgroundColor: Colors.deepPurple, // Do not use 'color'
elevation: 0,
),
cardTheme: const CardThemeData(
elevation: 2,
),
textTheme: const TextTheme(
bodyMedium: TextStyle(letterSpacing: 0.2),
),
),
home: const MyHomePage(),
);
```
### Example: State-Dependent ButtonStyle
```dart
TextButton(
style: ButtonStyle(
// Default color
foregroundColor: MaterialStateProperty.all<Color>(Colors.blue),
// State-dependent overlay color
overlayColor: MaterialStateProperty.resolveWith<Color?>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return Colors.blue.withOpacity(0.04);
}
if (states.contains(MaterialState.focused) || states.contains(MaterialState.pressed)) {
return Colors.blue.withOpacity(0.12);
}
return null; // Defer to the widget's default.
},
),
),
onPressed: () {},
child: const Text('Adaptive Button'),
)
```
### Example: Adaptive Dialog Button Order
```dart
Row(
// Windows expects confirmation on the left (RTL reverses the standard LTR Row)
textDirection: Platform.isWindows ? TextDirection.rtl : TextDirection.ltr,
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Confirm'),
),
],
)
```

View File

@@ -0,0 +1,200 @@
---
name: "flutter-working-with-databases"
description: "Manages local data persistence using SQLite or other database solutions. Use when a Flutter app needs to store, query, or synchronize large amounts of structured data on the device."
metadata:
model: "models/gemini-3.1-pro-preview"
last_modified: "Thu, 12 Mar 2026 22:19:15 GMT"
---
# Architecting the Data Layer
## Contents
- [Core Architecture](#core-architecture)
- [Services Implementation](#services-implementation)
- [Repository Implementation](#repository-implementation)
- [Caching Strategies](#caching-strategies)
- [Workflows](#workflows)
- [Examples](#examples)
## Core Architecture
Construct the data layer as the Single Source of Truth (SSOT) for all application data. In an MVVM architecture, the data layer represents the Model. Never update application data outside of this layer.
Separate the data layer into two distinct components: **Repositories** and **Services**.
### Repositories
* Act as the SSOT for a specific domain entity.
* Contain business logic for data mutation, polling, caching, and offline synchronization.
* Transform raw data models (API/DB models) into Domain Models (clean data classes containing only what the UI needs).
* Inject Services as private members to prevent the UI layer from bypassing the repository.
### Services
* Act as stateless wrappers around external data sources (HTTP clients, SQLite databases, platform plugins).
* Perform no business logic or data transformation beyond basic JSON serialization.
* Return raw data models or `Result` wrappers to the calling repository.
## Services Implementation
### Database Services (SQLite)
Use databases to persist and query large amounts of structured data locally.
* Add `sqflite` and `path` packages to `pubspec.yaml`.
* Use the `path` package to define the storage location on disk safely across platforms.
* Define table schemas using constants to prevent typos.
* Use `id` as the primary key with `AUTOINCREMENT` to improve query and update times.
* Always use `whereArgs` in SQL queries to prevent SQL injection (e.g., `where: 'id = ?', whereArgs: [id]`).
### API Services
* Wrap HTTP calls (e.g., using the `http` package) in dedicated client classes.
* Return asynchronous response objects (`Future` or `Stream`).
* Handle raw JSON serialization at this level, returning API-specific data models.
## Repository Implementation
### Domain Models
* Define immutable data classes (using `freezed` or `built_value`) for Domain Models.
* Strip out backend-specific fields (like metadata or pagination tokens) that the UI does not need.
### Offline-First Synchronization
Combine local and remote data sources within the repository to provide seamless offline support.
* **If reading data:** Return a `Stream` that immediately yields the cached local data from the Database Service, performs the network request via the API Service, updates the Database Service, and then yields the fresh data.
* **If writing data (Online-only):** Attempt the API Service mutation first. If successful, update the Database Service.
* **If writing data (Offline-first):** Update the Database Service immediately. Attempt the API Service mutation. If the network fails, flag the local database record as `synchronized: false` and queue a background synchronization task.
## Caching Strategies
Select the appropriate caching strategy based on the data payload:
* **Small Key-Value Data:** Use `shared_preferences` for simple app configurations, theme settings, or user preferences.
* **Large Datasets:** Use relational (`sqflite`, `drift`) or non-relational (`hive_ce`, `isar_community`) on-device databases.
* **Images:** Use the `cached_network_image` package to automatically cache remote images to the device's file system.
* **API Responses:** Implement lightweight remote caching within the API Service or Repository using in-memory maps or temporary file storage.
## Workflows
### Workflow: Implementing a New Data Feature
Copy and track this checklist when adding a new data entity to the application.
- [ ] **Task Progress**
- [ ] Define the Domain Model (immutable, UI-focused).
- [ ] Define the API/DB Models (raw data structures).
- [ ] Create or update the Service(s) to handle raw data fetching/storage.
- [ ] Create the Repository interface (abstract class).
- [ ] Implement the Repository, injecting the required Service(s) as private dependencies.
- [ ] Map raw Service models to the Domain Model within the Repository.
- [ ] Expose Repository methods to the View Model.
- [ ] Run validator -> review errors -> fix.
### Workflow: Implementing SQLite Persistence
Follow this sequence to add a new SQLite table and integrate it.
- [ ] **Task Progress**
- [ ] Add `sqflite` and `path` dependencies.
- [ ] Define table name and column constants.
- [ ] Update the `onCreate` or `onUpgrade` method in the Database Service to execute the `CREATE TABLE` statement.
- [ ] Implement `insert`, `query`, `update`, and `delete` methods in the Database Service.
- [ ] Inject the Database Service into the target Repository.
- [ ] Ensure the Repository calls `database.open()` before executing queries.
## Examples
### Offline-First Repository Implementation
This example demonstrates a Repository coordinating between a Database Service and an API Service using a Stream for offline-first reads.
```dart
import 'dart:async';
class TodoRepository {
TodoRepository({
required DatabaseService databaseService,
required ApiClientService apiClientService,
}) : _databaseService = databaseService,
_apiClientService = apiClientService;
final DatabaseService _databaseService;
final ApiClientService _apiClientService;
/// Yields local data immediately, then fetches remote data, updates local, and yields fresh data.
Stream<List<Todo>> observeTodos() async* {
// 1. Yield local cached data first
final localTodos = await _databaseService.getAllTodos();
if (localTodos.isNotEmpty) {
yield localTodos.map((model) => Todo.fromDbModel(model)).toList();
}
try {
// 2. Fetch fresh data from API
final remoteTodos = await _apiClientService.fetchTodos();
// 3. Update local database
await _databaseService.replaceAllTodos(remoteTodos);
// 4. Yield fresh data
yield remoteTodos.map((model) => Todo.fromApiModel(model)).toList();
} on Exception catch (e) {
// Handle network errors (UI will still have local data)
// Log error or yield a specific error state if required
}
}
/// Offline-first write: Save locally, then attempt remote sync.
Future<void> createTodo(Todo todo) async {
final dbModel = todo.toDbModel().copyWith(isSynced: false);
// 1. Save locally immediately
await _databaseService.insertTodo(dbModel);
try {
// 2. Attempt remote sync
final apiModel = await _apiClientService.postTodo(todo.toApiModel());
// 3. Mark as synced locally
await _databaseService.updateTodo(
dbModel.copyWith(id: apiModel.id, isSynced: true)
);
} on Exception catch (_) {
// Leave as isSynced: false for background sync task to pick up later
}
}
}
```
### SQLite Database Service Implementation
Demonstrates safe query construction using `whereArgs`.
```dart
class DatabaseService {
static const String _tableName = 'todos';
static const String _colId = 'id';
static const String _colTask = 'task';
static const String _colIsSynced = 'is_synced';
Database? _database;
Future<void> open() async {
if (_database != null) return;
final dbPath = join(await getDatabasesPath(), 'app_database.db');
_database = await openDatabase(
dbPath,
version: 1,
onCreate: (db, version) {
return db.execute(
'CREATE TABLE $_tableName('
'$_colId INTEGER PRIMARY KEY AUTOINCREMENT, '
'$_colTask TEXT, '
'$_colIsSynced INTEGER)'
);
},
);
}
Future<void> updateTodo(TodoDbModel todo) async {
await _database!.update(
_tableName,
todo.toMap(),
where: '$_colId = ?',
whereArgs: [todo.id], // Prevents SQL injection
);
}
}
```

View File

@@ -0,0 +1,142 @@
---
name: project-context
description: 项目上下文管理技能,用于管理用户画像和项目上下文。本技能由 brainstorming 和 executing-plans 技能调用,不独立触发。在 brainstorming 激活时调用以获取用户画像,在 executing-plans 完成时自动更新项目上下文。
license: MIT
metadata:
version: "3.0.1"
---
# 项目上下文技能
本技能管理用户画像和项目上下文,为其他技能提供上下文信息。
---
## 核心职责
| 职责 | 说明 | 维护方式 |
|------|------|---------|
| 用户画像读取 | 加载用户画像,为 brainstorming 提供用户信息 | Agent 自动 |
| 用户画像更新 | 在对话中识别用户偏好,自动更新画像 | Agent 自动 + 主动触发 |
| 项目上下文管理 | 记录项目当前状态,为未来规划提供背景 | Agent 自动executing-plans 完成时) |
---
## 主动更新机制
### 用户画像主动更新触发点
⚠️ **以下情况必须主动更新用户画像**
**技术偏好识别**
- 用户明确表达对某个框架/库的偏好
- 用户在技术选型中表现出倾向性
- 用户展示出新的技术栈使用经验
**决策倾向识别**
- 用户在多个决策中表现出一致倾向(质量优先/效率优先)
- 用户对风险的态度(保守/激进)
- 用户对重构的态度(小步快跑/大步重构)
**交互风格识别**
- 用户偏好详细解释还是简洁说明
- 用户偏好选择题还是开放式问题
- 用户希望在每个步骤确认还是关键节点确认
**能力评估更新**
- 用户在某个领域展示出超出预期的能力
- 用户在某个领域需要更多指导
### 项目上下文主动更新触发点
⚠️ **以下情况必须主动更新项目上下文**
**功能完成时**
- 每个功能实施完成后,必须记录到项目上下文
- 记录技术决策和理由
- 记录遇到的问题和解决方案
**架构变更时**
- 项目结构发生重大变化
- 引入新的依赖或技术栈
- 修改核心设计模式
**测试状态更新**
- 测试覆盖率变化
- 测试策略调整
- 测试框架变更
---
## 文件位置
| 文件 | 路径 | 用途 |
|------|------|------|
| 用户画像 | `design/context/user-profile.md` | 跨会话用户偏好 |
| 项目上下文 | `design/context/project-context.md` | 项目持续状态记录 |
---
## 用户画像
### 基本信息
| 字段 | 内容 | 更新日期 |
|------|------|---------|
| 用户定位 | [高级开发者/中级开发者/产品经理/其他] | - |
| 技术背景 | [主要使用的语言、框架、技术栈] | - |
| 首次记录 | YYYY-MM-DD | - |
| 最后更新 | YYYY-MM-DD | - |
### 技术偏好
| 类型 | 偏好 | 备注 |
|------|------|------|
| 前端端框架 | [React/Vue/Angular/其他] | - |
| 后端语言 | [Node.js/Python/Go/Rust/其他] | - |
| 代码风格 | [具体规范或风格描述] | - |
### 决策倾向
| 场景 | 倾向 | 表现 |
|------|------|------|
| 常规开发 | [质量优先/效率优先/平衡] | [具体表现] |
| 紧急情况 | [质量优先/效率优先/平衡] | [具体表现] |
| 重构场景 | [质量优先/效率优先/平衡] | [具体表现] |
### 风险偏好
- **技术选型**[保守/适中/激进]
- **重构决策**[小步快跑/中步迭代/大步重构]
- **依赖引入**[最小依赖/适度依赖/愿意尝新]
---
## 交互风格
| 方面 | 偏好 | 说明 |
|------|------|------|
| 详细程度 | [详细/简洁/适中] | - |
| 反馈频率 | [每个步骤/关键节点/完成后] | - |
| 解释深度 | [深入解释/概要说明/无需解释] | - |
| 问题形式 | [选择题/开放式/两者皆可] | - |
---
## 能力评估
| 能力领域 | 自评等级 | 备注 |
|---------|---------|------|
| 前端开发 | [初级/中级/高级] | - |
| 后端开发 | [初级/中级/高级] | - |
| 架构设计 | [初级/中级/高级] | - |
| 性能优化 | [初级/中级/高级] | - |
---
## 更新历史
| 日期 | 更新内容 | 触发场景 |
|------|---------|---------|
| YYYY-MM-DD | [初始创建] | 首次对话 |
| YYYY-MM-DD | [更新内容] | [触发场景] |

View File

@@ -0,0 +1,88 @@
# 项目上下文模板
本文档记录项目的持续状态,为未来功能规划提供项目背景信息。
---
## 基本信息
| 字段 | 内容 |
|------|------|
| **项目名称** | [项目名称] |
| **技术栈** | [主要使用的语言、框架、技术] |
| **创建日期** | YYYY-MM-DD |
| **最后更新** | YYYY-MM-DD |
| **项目阶段** | [初创/开发中/稳定维护/重构中] |
---
## 已完成功能
按功能模块组织,记录已实现并通过测试的功能。
| 功能 | 描述 | 完成日期 | 状态 |
|------|------|---------|------|
| [模块名] | [功能描述] | YYYY-MM-DD | 稳定/待优化 |
---
## 项目结构
### 目录结构
```
/
├── src/ # 源代码目录
├── tests/ # 测试目录
├── docs/ # 文档目录
└── config/ # 配置文件目录
```
### 模块划分
| 模块 | 职责 | 依赖关系 |
|------|------|---------|
| [模块名] | [职责描述] | [依赖的模块] |
---
## 架构决策记录
记录重要的架构决策及其理由,供未来参考。
| 日期 | 决策 | 理由 | 影响 |
|------|------|------|------|
| YYYY-MM-DD | [决策内容] | [决策理由] | [影响范围] |
---
## 依赖管理
### 项目依赖
| 依赖名称 | 版本 | 用途 | 来源 |
|---------|------|------|------|
| [依赖] | x.x.x | [用途] | npm/pypi/cargo/etc |
### 外部服务
| 服务 | 用途 | 配置位置 |
|------|------|---------|
| [服务名] | [用途] | [配置文件] |
---
## 技术债务
| 债务 | 影响 | 优先级 | 备注 |
|------|------|-------|------|
| [债务描述] | [影响说明] | 高/中/低 | [备注] |
---
## 更新历史
| 日期 | 更新内容 | 触发来源 |
|------|---------|---------|
| YYYY-MM-DD | [初始创建] | 项目初始化 |
| YYYY-MM-DD | [更新内容] | executing-plans 完成 |

View File

@@ -0,0 +1,73 @@
# 用户画像模板
本文档记录用户的跨会话偏好和特征,用于指导 Agent 的交互方式。
---
## 基本信息
| 字段 | 内容 | 更新日期 |
|------|------|---------|
| 用户定位 | [高级开发者/中级开发者/产品经理/其他] | - |
| 技术背景 | [主要使用的语言、框架、技术栈] | - |
| 首次记录 | YYYY-MM-DD | - |
| 最后更新 | YYYY-MM-DD | - |
---
## 技术偏好
| 类型 | 偏好 | 备注 |
|------|------|------|------|
| 前端框架 | [React/Vue/Angular/其他] | - |
| 后端语言 | [Node.js/Python/Go/Rust/其他] | - |
| 代码风格 | [具体规范或风格描述] | - |
---
## 决策倾向
| 场景 | 倾向 | 表现 |
|------|------|------|
| 常规开发 | [质量优先/效率优先/平衡] | [具体表现] |
| 紧急情况 | [质量优先/效率优先/平衡] | [具体表现] |
| 重构场景 | [质量优先/效率优先/平衡] | [具体表现] |
---
## 风险偏好
- **技术选型**[保守/适中/激进]
- **重构决策**[小步快跑/中步迭代/大步重构]
- **依赖引入**[最小依赖/适度依赖/愿意尝新]
---
## 交互风格
| 方面 | 偏好 | 说明 |
|------|------|------|------|
| 详细程度 | [详细/简洁/适中] | - |
| 反馈频率 | [每个步骤/关键节点/完成后] | - |
| 解释深度 | [深入解释/概要说明/无需解释] | - |
| 问题形式 | [选择题/开放式/两者皆可] | - |
---
## 能力评估
| 能力领域 | 自评等级 | 备注 |
|---------|---------|------|
| 前端开发 | [初级/中级/高级] | - |
| 后端开发 | [初级/中级/高级] | - |
| 架构设计 | [初级/中级/高级] | - |
| 性能优化 | [初级/中级/高级] | - |
---
## 更新历史
| 日期 | 更新内容 | 触发场景 |
|------|---------|---------|
| YYYY-MM-DD | [初始创建] | 首次对话 |
| YYYY-MM-DD | [更新内容] | [触发场景] |

View File

@@ -0,0 +1,274 @@
---
name: writing-plan
description: 实施计划创建技能,用于多步骤任务。在有规范或需求时,在直接接触代码前使用本技能。创建详细、可执行的实施计划。当用户提及"创建计划"、"实施计划"、"编写计划"、"规划"、"任务分解"或开始复杂的多步骤开发工作且有现有规范时触发本技能。
license: MIT
metadata:
version: "3.0.1"
---
# 编写计划技能
本技能用于创建多步骤开发任务的全面实施计划。在有规范或需求时,在直接接触任何代码之前使用。
---
## 核心原则
### 计划优先
在计划完成前,请勿:
- 调用任何实施技能
- 编写任何代码
- 创建或修改项目文件(计划文档除外)
- 采取任何实施行动
### 会话状态管理
使用统一的会话状态文件跟踪进度:
- **路径**`design/session-state.md`
- **模板**`references/session-state-template.md`(共享)
**操作时机**
- 技能激活时 → **创建/更新状态文件**
- 完成阶段性成果后 → **更新状态文件**
- 每轮对话开始时 → 读取状态文件恢复上下文
- **每个阶段完成后 → 更新状态文件**
### 终止条件
本技能的唯一终止方式是完成计划文档并通知用户准备执行。
---
## 工作流程
### 阶段 1上下文与范围分析
**目标**:理解规格文档,识别子系统。
**执行动作**
1. 接收 brainstorming 传递的规格文档路径
2. 读取规格文档,理解需求
3. 评估是否涉及多个独立子系统
**子系统识别标准**
| 特征 | 说明 |
| ------------ | ------------------------ |
| 独立数据模型 | 子系统有独立的数据结构 |
| 独立接口 | 子系统对外提供独立 API |
| 独立部署 | 子系统可独立部署运行 |
| 清晰边界 | 子系统间通过明确接口通信 |
**处理策略**
- 单一子系统 → 直接进入阶段 2
- 多个子系统 → 先创建纲领文件,再逐个子系统编写计划
**完成标准**:已理解需求,已识别子系统划分
---
### 阶段 2文件结构规划
**目标**:规划将创建或修改的文件及其职责。
**设计原则**
- 单一职责:每个文件应有单一明确职责
- 上下文友好:能在一个上下文窗口中理解的代码
- 聚焦文件:优先选择小而专注的文件
**输出格式**
```markdown
## 文件结构
### 新建文件
- `path/to/new/file.ts` - 职责描述
### 修改文件
- `path/to/existing/file.ts` - 修改内容描述
### 测试文件
- `tests/path/to/test.ts` - 测试职责
```
**代码注释规划**
**在文件结构规划中明确注释需求**
对于每个新建或修改的文件,标注注释需求:
```markdown
### 新建文件
- `src/utils/helper.ts` - 职责描述
- 注释需求:需要为所有公共函数添加文档注释
- 复杂度:中等(包含复杂算法,需要详细注释)
```
**注释需求评估标准**
- **高优先级**:公共 API、核心业务逻辑、复杂算法
- **中优先级**:工具函数、数据处理逻辑
- **低优先级**:简单工具函数、配置文件
**完成标准**:文件结构已规划,测试文件已明确,注释需求已评估,用户已确认
---
### 阶段 3任务定义
**目标**:定义具体的实施任务。
**任务粒度**:每个步骤应对应一个操作(耗时 2-5 分钟)。
**任务结构**
```markdown
### 任务 N[任务名称]
**文件**
- 创建:`path/to/file.ts`
- 测试:`tests/path/to/test.ts`
- [ ] **步骤 1**:编写失败测试
- [ ] **步骤 2**:运行测试验证失败
- [ ] **步骤 3**:编写最小实现
- [ ] **步骤 4**:运行测试验证通过
```
**任务要求**
- 精确文件路径,无模糊引用
- 计划中包含完整代码,非占位符
- 精确命令及预期输出
- **测试优先方法TDD**:每个任务必须先定义测试文件和测试用例
**测试要求**
⚠️ **任务必须包含测试步骤**,禁止以下情况:
- ❌ 只写实现代码,不写测试
- ❌ 测试文件路径为空或使用占位符
- ❌ 测试用例不完整或过于简单
- ❌ 跳过"编写失败测试"步骤
**测试覆盖标准**
- 每个函数/方法至少有一个正向测试用例
- 边界条件必须有对应测试
- 错误处理路径必须有测试覆盖
- 测试文件必须在任务开始前创建
**完成标准**:所有任务已定义,每个任务都包含完整的测试计划,用户已确认
---
### 阶段 4计划保存与确认
**目标**:保存计划文档,获得用户批准。
**执行动作**
1. 保存计划到 `design/plans/YYYY-MM-DD-<feature>.md`
2. 请用户确认计划内容
3. 若用户批准,询问是否开始执行
**标准话术**
> "计划已完成并保存。请确认以下内容:
>
> **计划文档**`design/plans/<filename>.md`
> **任务总数**[数量]
>
> 确认后是否开始执行?"
---
### 阶段 5执行交接
**目标**:调用 executing-plans 技能。
**调用格式**
```
调用 executing-plans 技能,计划文档路径:<path>
```
**终止**:此阶段完成后,本技能结束。
---
## 多子系统处理
当识别到多个子系统时:
### 1. 创建纲领文件
**路径**`design/plans/YYYY-MM-DD-<topic>-outline.md`
**内容**
```markdown
# [项目名称] 实施计划纲领
## 子系统划分
| 子系统 | 范围 | 依赖 | 状态 |
| ------- | ------ | ---- | ------ |
| 子系统A | [范围] | 无 | 待编写 |
| 子系统B | [范围] | A | 待编写 |
## 执行顺序
1. 子系统A无依赖
2. 子系统B依赖A
## 共享组件
| 组件 | 使用子系统 | 定义位置 |
| -------- | ---------- | ------------- |
| [组件名] | A, B | 子系统A计划中 |
```
### 2. 逐个子系统编写计划
每个子系统独立编写计划文档,完成后更新纲领文件状态。
---
## 参考指南
| 参考文档 | 用途 |
| ----------------------------- | ------------ |
| `references/plan-template.md` | 计划模板 |
| `references/patterns.md` | 常见计划模式 |
---
## 常见问题
### 计划需要修改?
处理方式:
1. 在纲领文件中记录变更
2. 更新受影响的计划文档
3. 通知用户变更影响范围
### 对话中断后如何恢复?
处理方式:
1. 读取会话状态文件 `design/session-state.md`
2. 根据"下一步"继续执行

View File

@@ -0,0 +1,114 @@
# 常见计划模式库
预定义的任务模板,可快速复用于常见场景。
---
## 使用方式
当识别到以下常见模式时,可直接引用对应模板。
---
## 模式 1CRUD 模块
**适用场景**:数据增删改查操作
**典型文件结构**
| 文件 | 职责 |
|------|------|
| `src/models/<entity>.ts` | 数据模型定义 |
| `src/repositories/<entity>Repository.ts` | 数据访问层 |
| `src/services/<entity>Service.ts` | 业务逻辑层 |
| `src/controllers/<entity>Controller.ts` | API 控制器 |
| `tests/<entity>.test.ts` | 测试文件 |
**典型任务顺序**
1. 定义数据模型
2. 实现 RepositoryCRUD 操作)
3. 实现 Service业务逻辑
4. 实现 ControllerAPI 端点)
5. 集成测试
---
## 模式 2认证模块
**适用场景**:用户认证授权
**典型文件结构**
| 文件 | 职责 |
|------|------|
| `src/auth/jwtHandler.ts` | JWT 令牌处理 |
| `src/auth/middleware.ts` | 认证中间件 |
| `src/auth/refreshHandler.ts` | 令牌刷新 |
| `src/services/userService.ts` | 用户服务 |
| `tests/auth.test.ts` | 认证测试 |
**典型任务顺序**
1. 实现 JWT 生成和验证
2. 实现认证中间件
3. 实现登录/登出逻辑
4. 实现令牌刷新机制
5. 权限验证
---
## 模式 3API 集成
**适用场景**:第三方服务集成
**典型文件结构**
| 文件 | 职责 |
|------|------|
| `src/clients/<service>Client.ts` | API 客户端 |
| `src/adapters/<service>Adapter.ts` | 数据适配器 |
| `src/services/<service>Service.ts` | 业务封装 |
| `src/mocks/<service>Mock.ts` | 模拟实现 |
| `tests/<service>.test.ts` | 集成测试 |
**典型任务顺序**
1. 定义 API 客户端接口
2. 实现请求/响应处理
3. 实现数据适配层
4. 实现错误处理和重试
5. 创建模拟实现用于测试
---
## 模式 4中间件/插件
**适用场景**:请求处理管道、插件系统
**典型文件结构**
| 文件 | 职责 |
|------|------|
| `src/middleware/<name>.ts` | 中间件实现 |
| `src/types/middleware.ts` | 类型定义 |
| `tests/middleware/<name>.test.ts` | 测试文件 |
**典型任务顺序**
1. 定义中间件接口
2. 实现核心逻辑
3. 实现配置选项
4. 错误处理
5. 集成测试
---
## 模式引用方式
在计划中引用模式:
```markdown
### 任务组:用户管理 CRUD
> 引用模式CRUD 模块
> 实体User
[基于模式模板编写具体任务...]
```

View File

@@ -0,0 +1,107 @@
# 实施计划模板
本文档提供实施计划的标准结构。
---
## 文档路径
```
design/plans/YYYY-MM-DD-<feature>.md
```
---
## 单一子系统计划模板
```markdown
# [功能名称] 实施计划
**目标**[一句话描述此功能构建什么]
**架构**[2-3 句关于方法]
**技术栈**[关键技术/库]
---
## 文件结构
### 新建文件
- `path/to/new/file.ts` - 职责描述
### 修改文件
- `path/to/existing/file.ts` - 修改内容描述
### 测试文件
- `tests/path/to/test.ts` - 测试职责
---
## 任务列表
### 任务 1[任务名称]
**文件**
- 创建:`path/to/file.ts`
- 测试:`tests/path/to/test.ts`
- [ ] **步骤 1**:编写失败测试
- [ ] **步骤 2**:运行测试验证失败
- [ ] **步骤 3**:编写最小实现
- [ ] **步骤 4**:运行测试验证通过
### 任务 2[任务名称]
[继续按相同模式...]
```
---
## 多子系统纲领文件模板
```markdown
# [项目名称] 实施计划纲领
## 子系统划分
| 子系统 | 范围 | 依赖 | 状态 |
|--------|------|------|------|
| 子系统A | [范围] | 无 | 待编写 |
| 子系统B | [范围] | A | 待编写 |
## 执行顺序
1. 子系统A无依赖
2. 子系统B依赖A
## 共享组件
| 组件 | 使用子系统 | 定义位置 |
|------|------------|----------|
| [组件名] | A, B | 子系统A计划中 |
```
---
## 分块指南
| 指标 | 标准 | 原因 |
|------|------|------|
| 行数 | 300-500 行 | 适配上下文窗口 |
| 任务数 | 5-10 个 | 保持逻辑完整性 |
**分块边界**
- 按功能模块分块
- 按实施阶段分块(基础设施、核心功能、集成测试)
- 按子系统分块
---
## 任务要求
- 精确文件路径,无模糊引用
- 计划中包含完整代码,非占位符
- 精确命令及预期输出
- 测试优先方法TDD
- 每个步骤 2-5 分钟可完成

1630
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.1", "axios": "^1.13.1",
@@ -19,7 +21,10 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.1.0",
"sass": "^1.98.0", "sass": "^1.98.0",
"vite": "^7.1.5" "vite": "^7.1.5",
"vitest": "^3.2.4"
} }
} }

View File

@@ -1,40 +1,37 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<defs> xmlns="http://www.w3.org/2000/svg"
<linearGradient id="tile" x1="16" y1="18" x2="48" y2="50" gradientUnits="userSpaceOnUse"> width="100"
<stop stop-color="#E8FBFF"/> height="100"
<stop offset="1" stop-color="#BCEFFA"/> viewBox="0 0 48 48"
</linearGradient> fill="none"
<linearGradient id="needle" x1="34" y1="17" x2="47" y2="31" gradientUnits="userSpaceOnUse"> role="img"
<stop stop-color="#FFF8D9"/> aria-label="Code tools logo"
<stop offset="1" stop-color="#F4FCFF"/> >
</linearGradient> <g fill="#1e293b" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace" font-size="30" font-weight="300">
</defs> <text x="7" y="24" text-anchor="start" dominant-baseline="middle">{</text>
<text x="41" y="24" text-anchor="end" dominant-baseline="middle">}</text>
<g filter="url(#shadow)">
<rect x="15" y="17" width="14" height="12" rx="4" fill="url(#tile)" stroke="#0A8FB5" stroke-width="2"/>
<rect x="15" y="33" width="14" height="16" rx="4" fill="url(#tile)" stroke="#0A8FB5" stroke-width="2"/>
<rect x="33" y="35" width="15" height="14" rx="4" fill="url(#tile)" fill-opacity="0.92" stroke="#0A8FB5" stroke-width="2"/>
</g> </g>
<path d="M35.2 17.4L49.2 22.4L38.2 33.3L35.6 27.2L29.6 24.6L35.2 17.4Z" fill="url(#needle)"/> <g transform="translate(24 24)">
<path d="M35.2 17.4L40.3 27.1L49.2 22.4" stroke="#74DFF2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/> <g transform="rotate(-45) scale(0.75) translate(-12 -12)">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"
fill="#1e293b"
stroke="#1e293b"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</g>
<path d="M18.5 22.2H25.5" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/> <g transform="translate(21 13)">
<path d="M18.5 37.8H25.5" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/> <g transform="scale(0.6666667)">
<path d="M18.5 42.8H24" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/> <circle cx="18" cy="5" r="3" stroke="#f97316" stroke-width="3" fill="none" />
<path d="M36.5 40.8H44.5" stroke="#0C7D9E" stroke-opacity="0.64" stroke-width="2" stroke-linecap="round"/> <circle cx="6" cy="12" r="3" stroke="#f97316" stroke-width="3" fill="none" />
<path d="M36.5 45.6H42.5" stroke="#0C7D9E" stroke-opacity="0.64" stroke-width="2" stroke-linecap="round"/> <circle cx="18" cy="19" r="3" stroke="#f97316" stroke-width="3" fill="none" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" stroke="#f97316" stroke-width="3" stroke-linecap="round" />
<defs> <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" stroke="#f97316" stroke-width="3" stroke-linecap="round" />
<filter id="shadow" x="11" y="14" width="41" height="39" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> </g>
<feFlood flood-opacity="0" result="BackgroundImageFix"/> </g>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1.5"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.016 0 0 0 0 0.282 0 0 0 0 0.38 0 0 0 0.14 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_1" result="shape"/>
</filter>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

146
client/src/App.spec.js Normal file
View File

@@ -0,0 +1,146 @@
import { flushPromises, mount } from '@vue/test-utils';
import { createMemoryHistory, createRouter } from 'vue-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App.vue';
const apiMocks = vi.hoisted(() => ({
fetchCategories: vi.fn(),
fetchHotKeywords: vi.fn(),
fetchOverview: vi.fn(),
fetchToolDetail: vi.fn(),
fetchTools: vi.fn(),
getApiErrorMessage: vi.fn((error) => error?.message || '请求失败'),
launchTool: vi.fn(),
notifyToolInteraction: vi.fn(),
resolveActionUrl: vi.fn((value) => value),
}));
vi.mock('./api', () => ({
fetchCategories: apiMocks.fetchCategories,
fetchHotKeywords: apiMocks.fetchHotKeywords,
fetchOverview: apiMocks.fetchOverview,
fetchToolDetail: apiMocks.fetchToolDetail,
fetchTools: apiMocks.fetchTools,
getApiErrorMessage: apiMocks.getApiErrorMessage,
launchTool: apiMocks.launchTool,
notifyToolInteraction: apiMocks.notifyToolInteraction,
resolveActionUrl: apiMocks.resolveActionUrl,
}));
function createRouterForTest() {
return createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/',
name: 'public-home',
component: App,
},
{
path: '/tools/:slug',
name: 'tool-detail',
component: { template: '<div>detail page</div>' },
},
],
});
}
describe('App', () => {
beforeEach(() => {
apiMocks.fetchCategories.mockResolvedValue([]);
apiMocks.fetchHotKeywords.mockResolvedValue([]);
apiMocks.fetchOverview.mockResolvedValue({
toolTotal: 1,
categoryTotal: 1,
downloadTotal: 0,
openTotal: 0,
});
apiMocks.fetchTools.mockResolvedValue({
list: [
{
id: 'tool_demo',
slug: 'demo-tool',
name: 'Demo Tool',
description: 'Simple tool',
category: { id: 'cat_demo', name: 'Developer Tools' },
tags: ['cli'],
latestVersion: '1.0.0',
updatedAt: '2026-04-11',
accessMode: 'web',
openUrl: 'https://example.com/tool',
downloadReady: true,
downloadCount: 0,
openCount: 3,
},
],
pagination: {
page: 1,
pageSize: 6,
total: 1,
totalPages: 1,
},
});
apiMocks.fetchToolDetail.mockResolvedValue({
id: 'tool_demo',
slug: 'demo-tool',
name: 'Demo Tool',
description: '# Demo Tool',
category: { id: 'cat_demo', name: 'Developer Tools' },
rating: 4.5,
downloadCount: 0,
openCount: 3,
accessMode: 'web',
tags: ['cli'],
features: [],
updatedAt: '2026-04-11',
openUrl: 'https://example.com/tool',
latestVersion: null,
fileSize: null,
downloadReady: true,
});
});
it('navigates detail actions to the slug route and does not render the old detail modal', async () => {
const router = createRouterForTest();
await router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router],
},
});
await flushPromises();
expect(wrapper.find('#detailTitle').exists()).toBe(false);
await wrapper.get('button.btn-small').trigger('click');
await flushPromises();
expect(router.currentRoute.value.fullPath).toBe('/tools/demo-tool');
});
it('keeps the overview modal behavior available', async () => {
const router = createRouterForTest();
await router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router],
},
});
await flushPromises();
expect(wrapper.findAll('.modal-backdrop.open')).toHaveLength(0);
await wrapper.get('button.nav-btn').trigger('click');
await flushPromises();
expect(wrapper.findAll('.modal-backdrop.open')).toHaveLength(1);
expect(wrapper.get('button.nav-btn').attributes('aria-expanded')).toBe('true');
});
});

View File

@@ -4,7 +4,7 @@
<div class="container header"> <div class="container header">
<a class="brand" href="#" aria-label="Tools工具" @click.prevent> <a class="brand" href="#" aria-label="Tools工具" @click.prevent>
<span class="brand-mark" aria-hidden="true"> <span class="brand-mark" aria-hidden="true">
<img src="/favicon.svg" alt="" width="32" height="32" /> <img src="/favicon.svg" alt="" />
</span> </span>
<span>资源导航</span> <span>资源导航</span>
</a> </a>
@@ -18,6 +18,7 @@
:aria-expanded="overviewModalOpen ? 'true' : 'false'" :aria-expanded="overviewModalOpen ? 'true' : 'false'"
@click="openOverviewModal" @click="openOverviewModal"
> >
<AppIcon name="chartLine" :size="16" />
站点概览 站点概览
</button> </button>
</nav> </nav>
@@ -30,10 +31,7 @@
<div class="search-row"> <div class="search-row">
<label class="search-box" for="searchInput"> <label class="search-box" for="searchInput">
<span class="sr-only">搜索工具</span> <span class="sr-only">搜索工具</span>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> <AppIcon name="search" :size="18" />
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.8" />
<path d="M16.5 16.5L21 21" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
<input <input
id="searchInput" id="searchInput"
v-model="filters.query" v-model="filters.query"
@@ -74,7 +72,12 @@
<section id="tools"> <section id="tools">
<div class="tools-layout"> <div class="tools-layout">
<aside class="category-sidebar" aria-label="分类导航"> <aside class="category-sidebar" aria-label="分类导航">
<h2 class="sidebar-title">分类导航</h2> <h2 class="sidebar-title">
<span class="title-with-icon">
<AppIcon name="stack" :size="18" />
分类导航
</span>
</h2>
<p class="sidebar-tip">点击分类可快速筛选工具</p> <p class="sidebar-tip">点击分类可快速筛选工具</p>
<div class="category-sidebar-list"> <div class="category-sidebar-list">
<button <button
@@ -94,7 +97,10 @@
<div class="tools-main"> <div class="tools-main">
<div class="toolbar"> <div class="toolbar">
<p class="result-tip">{{ resultTip }}</p> <p class="result-tip">
<AppIcon name="layoutGrid" :size="16" />
{{ resultTip }}
</p>
<label class="sr-only" for="sortSelect">排序方式</label> <label class="sr-only" for="sortSelect">排序方式</label>
<select id="sortSelect" v-model="filters.sortBy" class="select" @change="onSortChange"> <select id="sortSelect" v-model="filters.sortBy" class="select" @change="onSortChange">
<option value="created">按创建时间排序</option> <option value="created">按创建时间排序</option>
@@ -141,23 +147,39 @@
</div> </div>
<ul class="meta-list"> <ul class="meta-list">
<li>版本<strong>{{ tool.latestVersion || '暂无版本' }}</strong></li> <li>
<li>更新时间<strong>{{ formatDate(tool.updatedAt) }}</strong></li> <span class="meta-label">
<AppIcon name="sparkles" :size="15" />
版本
</span>
<strong>{{ tool.latestVersion || '暂无版本' }}</strong>
</li>
<li>
<span class="meta-label">
<AppIcon name="calendar" :size="15" />
更新时间
</span>
<strong>{{ formatDate(tool.updatedAt) }}</strong>
</li>
</ul> </ul>
<div class="card-foot"> <div class="card-foot">
<span class="download-num"> <span class="download-num">
<AppIcon :name="actionIconName(tool.accessMode)" :size="15" />
{{ toolModeSummary(tool) }} {{ toolModeSummary(tool) }}
</span> </span>
<div class="actions"> <div class="actions">
<button type="button" class="btn-small" @click="openDetailModal(tool.id)">详情</button> <button type="button" class="btn-small" @click="openDetailPage(tool.slug)">
详情
</button>
<button <button
type="button" type="button"
class="btn-small" class="btn-small btn-with-icon"
:class="tool.accessMode === 'web' ? 'btn-open' : 'btn-download'" :class="tool.accessMode === 'web' ? 'btn-open' : 'btn-download'"
:disabled="isLaunchDisabled(tool)" :disabled="isLaunchDisabled(tool)"
@click="triggerLaunch(tool)" @click="triggerLaunch(tool)"
> >
<AppIcon :name="actionIconName(tool.accessMode)" :size="15" />
{{ launchButtonText(tool) }} {{ launchButtonText(tool) }}
</button> </button>
</div> </div>
@@ -189,66 +211,13 @@
</section> </section>
</main> </main>
<div class="modal-backdrop" :class="{ open: detailModalOpen }" role="dialog" aria-modal="true" aria-labelledby="detailTitle" @click.self="closeDetailModal">
<div class="modal">
<div class="modal-head">
<h2 id="detailTitle">工具详情</h2>
<button type="button" class="icon-btn" aria-label="关闭详情" @click="closeDetailModal">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
<template v-if="detailLoading">
<p class="modal-muted">正在加载工具详情...</p>
</template>
<template v-else-if="detailError">
<p class="modal-error">{{ detailError }}</p>
</template>
<template v-else-if="detail">
<div class="markdown markdown-detail" v-html="renderMarkdown(detail.description)"></div>
<ul class="meta-list">
<li>分类<strong>{{ detail.category?.name || '-' }}</strong></li>
<li>评分<strong>{{ Number(detail.rating || 0).toFixed(1) }}</strong></li>
<li>访问方式<strong>{{ detail.accessMode === 'web' ? '网页打开' : '下载安装' }}</strong></li>
<li v-if="detail.accessMode === 'download'">
下载次数<strong>{{ formatNumber(detail.downloadCount) }}</strong>
</li>
<li v-else>
访问次数<strong>{{ formatNumber(detail.openCount) }}</strong>
</li>
<li v-if="detail.accessMode === 'download'">
最新版本<strong>{{ detail.latestVersion || '暂无版本' }}</strong>
</li>
<li v-if="detail.accessMode === 'download' && detail.fileSize !== null">
文件大小<strong>{{ formatFileSize(detail.fileSize) }}</strong>
</li>
<li v-if="detail.accessMode === 'download' && detail.openUrl">
下载地址
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
</li>
<li v-if="detail.accessMode === 'web' && detail.openUrl">
打开地址
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
</li>
<li>更新时间<strong>{{ formatDate(detail.updatedAt) }}</strong></li>
</ul>
<h3>核心能力</h3>
<ul v-if="detail.features?.length" class="feature-list">
<li v-for="(feature, featureIndex) in detail.features" :key="`detail-${featureIndex}`">
<div class="markdown markdown-inline" v-html="renderInlineMarkdown(feature)"></div>
</li>
</ul>
<p v-else class="modal-muted">暂无能力描述</p>
</template>
</div>
</div>
<div class="modal-backdrop" :class="{ open: overviewModalOpen }" role="dialog" aria-modal="true" aria-labelledby="overviewTitle" @click.self="closeOverviewModal"> <div class="modal-backdrop" :class="{ open: overviewModalOpen }" role="dialog" aria-modal="true" aria-labelledby="overviewTitle" @click.self="closeOverviewModal">
<div class="modal"> <div class="modal">
<div class="modal-head"> <div class="modal-head">
<h2 id="overviewTitle">站点概览</h2> <h2 id="overviewTitle" class="title-with-icon">
<AppIcon name="chartLine" :size="18" />
站点概览
</h2>
<button type="button" class="icon-btn" aria-label="关闭站点概览" @click="closeOverviewModal"> <button type="button" class="icon-btn" aria-label="关闭站点概览" @click="closeOverviewModal">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /> <path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
@@ -257,16 +226,55 @@
</div> </div>
<p>展示站当前统计信息与核心能力说明</p> <p>展示站当前统计信息与核心能力说明</p>
<div class="kpi-grid"> <div class="kpi-grid">
<div><strong>{{ formatNumber(overview.toolTotal) }}</strong><span>工具总数</span></div> <div>
<div><strong>{{ formatNumber(overview.categoryTotal) }}</strong><span>分类数量</span></div> <span class="kpi-label">
<div><strong>{{ formatNumber(overview.downloadTotal) }}</strong><span>累计下载</span></div> <AppIcon name="layoutGrid" :size="16" />
<div><strong>{{ formatNumber(overview.openTotal) }}</strong><span>累计访问</span></div> 工具总数
<div><strong>{{ formatNumber(pagination.total) }}</strong><span>当前结果</span></div> </span>
<strong>{{ formatNumber(overview.toolTotal) }}</strong>
</div>
<div>
<span class="kpi-label">
<AppIcon name="stack" :size="16" />
分类数量
</span>
<strong>{{ formatNumber(overview.categoryTotal) }}</strong>
</div>
<div>
<span class="kpi-label">
<AppIcon name="download" :size="16" />
累计下载
</span>
<strong>{{ formatNumber(overview.downloadTotal) }}</strong>
</div>
<div>
<span class="kpi-label">
<AppIcon name="externalLink" :size="16" />
累计访问
</span>
<strong>{{ formatNumber(overview.openTotal) }}</strong>
</div>
<div>
<span class="kpi-label">
<AppIcon name="search" :size="16" />
当前结果
</span>
<strong>{{ formatNumber(pagination.total) }}</strong>
</div>
</div> </div>
<ul class="tips"> <ul class="tips">
<li>浏览分页展示工具卡片</li> <li>
<li>搜索关键词 + 分类 + 排序组合筛选</li> <AppIcon name="layoutGrid" :size="16" />
<li>获取统一通过 launch 接口完成网页打开或下载</li> 浏览分页展示工具卡片
</li>
<li>
<AppIcon name="search" :size="16" />
搜索关键词 + 分类 + 排序组合筛选
</li>
<li>
<AppIcon name="externalLink" :size="16" />
获取统一通过 launch 接口完成网页打开或下载
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -279,21 +287,23 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import AppIcon from './components/AppIcon.vue';
import { import {
fetchCategories, fetchCategories,
fetchHotKeywords, fetchHotKeywords,
fetchOverview, fetchOverview,
fetchToolDetail,
fetchTools, fetchTools,
getApiErrorMessage, getApiErrorMessage,
launchTool, launchTool,
notifyToolInteraction, notifyToolInteraction,
resolveActionUrl, resolveActionUrl,
} from './api'; } from './api';
import { renderInlineMarkdown, renderMarkdown } from './utils/markdown'; import { renderInlineMarkdown } from './utils/markdown';
const CLIENT_VERSION = 'web-1.0.0'; const CLIENT_VERSION = 'web-1.0.0';
const QUERY_DEBOUNCE_MS = 320; const QUERY_DEBOUNCE_MS = 320;
const router = useRouter();
const filters = reactive({ const filters = reactive({
query: '', query: '',
@@ -325,11 +335,6 @@ const loadingTools = ref(false);
const loadingMeta = ref(false); const loadingMeta = ref(false);
const launchingId = ref(''); const launchingId = ref('');
const detailModalOpen = ref(false);
const detailLoading = ref(false);
const detailError = ref('');
const detail = ref(null);
const overviewModalOpen = ref(false); const overviewModalOpen = ref(false);
const isScrolled = ref(false); const isScrolled = ref(false);
@@ -385,23 +390,6 @@ function formatDate(dateText) {
}).format(date); }).format(date);
} }
function formatFileSize(bytes) {
const size = Number(bytes);
if (!Number.isFinite(size) || size <= 0) {
return '-';
}
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unitIndex]}`;
}
function resolveCategoryCount(item) { function resolveCategoryCount(item) {
if (item.id === 'all') { if (item.id === 'all') {
return pagination.total; return pagination.total;
@@ -543,24 +531,11 @@ function changePage(nextPage) {
loadTools(); loadTools();
} }
async function openDetailModal(toolId) { function openDetailPage(slug) {
detailModalOpen.value = true; router.push({
detailLoading.value = true; name: 'tool-detail',
detailError.value = ''; params: { slug },
detail.value = null; });
try {
const data = await fetchToolDetail(toolId);
detail.value = data;
} catch (error) {
detailError.value = getApiErrorMessage(error);
} finally {
detailLoading.value = false;
}
}
function closeDetailModal() {
detailModalOpen.value = false;
} }
function openOverviewModal() { function openOverviewModal() {
@@ -592,6 +567,10 @@ function launchButtonText(tool) {
return '下载'; return '下载';
} }
function actionIconName(accessMode) {
return accessMode === 'download' ? 'download' : 'externalLink';
}
function toolModeSummary(tool) { function toolModeSummary(tool) {
if (tool.accessMode === 'download') { if (tool.accessMode === 'download') {
return `下载 ${formatNumber(tool.downloadCount)}`; return `下载 ${formatNumber(tool.downloadCount)}`;
@@ -662,9 +641,6 @@ function handleKeydown(event) {
if (event.key !== 'Escape') { if (event.key !== 'Escape') {
return; return;
} }
if (detailModalOpen.value) {
closeDetailModal();
}
if (overviewModalOpen.value) { if (overviewModalOpen.value) {
closeOverviewModal(); closeOverviewModal();
} }

View File

@@ -355,7 +355,7 @@ const toolFormRules = {
], ],
description: [ description: [
{ required: true, message: '请输入工具简介', trigger: 'blur' }, { required: true, message: '请输入工具简介', trigger: 'blur' },
{ min: 10, max: 2000, message: '工具简介长度为 10-2000 位', trigger: 'blur' }, { min: 10, message: '工具简介长度至少为 10 位', trigger: 'blur' },
], ],
accessMode: [{ required: true, message: '请选择访问方式', trigger: 'change' }], accessMode: [{ required: true, message: '请选择访问方式', trigger: 'change' }],
openUrl: [ openUrl: [
@@ -1345,4 +1345,3 @@ onMounted(async () => {
} }
}); });
</script> </script>

View File

@@ -94,7 +94,6 @@
border-radius: 0; border-radius: 0;
display: grid; display: grid;
place-items: center; place-items: center;
background: #2f83ed;
color: #fff; color: #fff;
font-size: 16px; font-size: 16px;
} }

View File

@@ -54,12 +54,10 @@
<el-input <el-input
v-model="toolForm.description" v-model="toolForm.description"
type="textarea" type="textarea"
:rows="4" :rows="8"
maxlength="2000" placeholder="这里会作为访客端详情页的用户手册正文,支持 Markdown例如# 快速开始&#10;## 安装&#10;支持 **加粗**、`代码`、[链接](https://example.com)"
show-word-limit
placeholder="支持 Markdown例如## 用途&#10;支持 **加粗**、`代码`、[链接](https://example.com)"
/> />
<div class="el-form-item__description">简介支持 Markdown 渲染</div> <div class="el-form-item__description">该内容会在访客端详情页按 Markdown 手册展示</div>
</el-form-item> </el-form-item>
<el-form-item label="评分" prop="rating"> <el-form-item label="评分" prop="rating">
<el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" /> <el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" />

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import PublicApp from '../App.vue'; import PublicApp from '../App.vue';
import ToolDetailPage from '../pages/ToolDetailPage.vue';
import AdminApp from './AdminApp.vue'; import AdminApp from './AdminApp.vue';
import AdminAuditLogsPage from './pages/AdminAuditLogsPage.vue'; import AdminAuditLogsPage from './pages/AdminAuditLogsPage.vue';
import AdminCategoriesPage from './pages/AdminCategoriesPage.vue'; import AdminCategoriesPage from './pages/AdminCategoriesPage.vue';
@@ -12,6 +13,11 @@ const routes = [
name: 'public-home', name: 'public-home',
component: PublicApp, component: PublicApp,
}, },
{
path: '/tools/:slug',
name: 'tool-detail',
component: ToolDetailPage,
},
{ {
path: '/admin', path: '/admin',
component: AdminApp, component: AdminApp,

View File

@@ -36,6 +36,10 @@ export function fetchToolDetail(id) {
return apiGet(`/tools/${id}`); return apiGet(`/tools/${id}`);
} }
export function fetchToolDetailBySlug(slug) {
return apiGet(`/tools/slug/${slug}`);
}
export function fetchCategories() { export function fetchCategories() {
return apiGet('/categories'); return apiGet('/categories');
} }

View File

@@ -0,0 +1,64 @@
<template>
<svg
v-if="icon"
class="app-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
:width="size"
:height="size"
:aria-hidden="decorative ? 'true' : undefined"
:aria-label="decorative ? undefined : title"
role="img"
>
<title v-if="!decorative && title">{{ title }}</title>
<path :d="icon" :stroke-width="strokeWidth" />
</svg>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
name: {
type: String,
required: true,
},
size: {
type: [Number, String],
default: 18,
},
strokeWidth: {
type: [Number, String],
default: 1.8,
},
decorative: {
type: Boolean,
default: true,
},
title: {
type: String,
default: '',
},
});
const ICON_PATHS = {
arrowLeft: 'M5 12h14M5 12l6 6m-6-6l6-6',
book: 'M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0M3 6v13m9-13v13m9-13v13',
calendar: 'M4 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm12-4v4M8 3v4m-4 4h16m-9 4h1m0 0v3',
chartLine: 'M4 19h16M4 15l4-6l4 2l4-5l4 4',
download: 'M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 11l5 5l5-5m-5-7v12',
externalLink: 'M12 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6m-7 1l9-9m-5 0h5v5',
layoutGrid:
'M4 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1zM4 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z',
search: 'M3 10a7 7 0 1 0 14 0a7 7 0 1 0-14 0m18 11l-6-6',
sparkles: 'M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2-2a2 2 0 0 1-2-2a2 2 0 0 1-2 2m0-12a2 2 0 0 1 2 2a2 2 0 0 1 2-2a2 2 0 0 1-2-2a2 2 0 0 1-2 2M9 18a6 6 0 0 1 6-6a6 6 0 0 1-6-6a6 6 0 0 1-6 6a6 6 0 0 1 6 6',
stack: 'M12 4L4 8l8 4l8-4zm-8 8l8 4l8-4M4 16l8 4l8-4',
star: 'm12 17.75l-6.172 3.245l1.179-6.873l-5-4.867l6.9-1l3.086-6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z',
};
const icon = computed(() => ICON_PATHS[props.name] || '');
</script>

View File

@@ -0,0 +1,180 @@
import { flushPromises, mount } from '@vue/test-utils';
import { createMemoryHistory, createRouter } from 'vue-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ToolDetailPage from './ToolDetailPage.vue';
const apiMocks = vi.hoisted(() => ({
fetchToolDetailBySlug: vi.fn(),
getApiErrorMessage: vi.fn((error) => error?.message || '请求失败'),
launchTool: vi.fn(),
notifyToolInteraction: vi.fn(),
resolveActionUrl: vi.fn((value) => value),
}));
vi.mock('../api', () => ({
fetchToolDetailBySlug: apiMocks.fetchToolDetailBySlug,
getApiErrorMessage: apiMocks.getApiErrorMessage,
launchTool: apiMocks.launchTool,
notifyToolInteraction: apiMocks.notifyToolInteraction,
resolveActionUrl: apiMocks.resolveActionUrl,
}));
function createDetail(overrides = {}) {
return {
id: 'tool_demo',
slug: 'demo-tool',
name: 'Demo Tool',
description: '# Demo Tool\n\n## Install',
category: { id: 'cat_demo', name: 'Developer Tools' },
rating: 4.6,
downloadCount: 12,
openCount: 34,
accessMode: 'web',
tags: ['cli'],
features: ['Fast setup'],
updatedAt: '2026-04-11',
openUrl: 'https://example.com/tool',
latestVersion: null,
fileSize: null,
downloadReady: true,
...overrides,
};
}
async function mountPage(path = '/tools/demo-tool') {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/',
name: 'public-home',
component: { template: '<div>home</div>' },
},
{
path: '/tools/:slug',
name: 'tool-detail',
component: ToolDetailPage,
},
],
});
await router.push(path);
await router.isReady();
const wrapper = mount(ToolDetailPage, {
global: {
plugins: [router],
},
});
return { router, wrapper };
}
describe('ToolDetailPage', () => {
beforeEach(() => {
apiMocks.fetchToolDetailBySlug.mockReset();
apiMocks.getApiErrorMessage.mockClear();
apiMocks.launchTool.mockReset();
apiMocks.notifyToolInteraction.mockReset();
apiMocks.resolveActionUrl.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('loads detail data by slug and renders the page', async () => {
let resolveRequest;
apiMocks.fetchToolDetailBySlug.mockReturnValue(
new Promise((resolve) => {
resolveRequest = resolve;
}),
);
const { wrapper } = await mountPage();
expect(wrapper.text()).toContain('正在加载用户手册');
resolveRequest(createDetail());
await flushPromises();
expect(apiMocks.fetchToolDetailBySlug).toHaveBeenCalledWith('demo-tool');
expect(wrapper.text()).toContain('Demo Tool');
expect(wrapper.text()).toContain('Developer Tools');
expect(wrapper.text()).toContain('打开网页');
});
it('shows a not found state for unknown slugs', async () => {
apiMocks.fetchToolDetailBySlug.mockRejectedValue({
response: { status: 404 },
message: 'not found',
});
const { wrapper } = await mountPage('/tools/missing-tool');
await flushPromises();
expect(wrapper.text()).toContain('手册不存在或已下线');
expect(wrapper.text()).toContain('返回工具列表');
});
it('shows an empty manual state when description is blank', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
description: ' ',
}),
);
const { wrapper } = await mountPage();
await flushPromises();
expect(wrapper.text()).toContain('暂未提供用户手册');
expect(wrapper.text()).toContain('Demo Tool');
expect(wrapper.text()).toContain('打开网页');
});
it('renders the download action label for downloadable tools', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
accessMode: 'download',
latestVersion: '2.0.0',
fileSize: 4096,
}),
);
const { wrapper } = await mountPage();
await flushPromises();
expect(wrapper.text()).toContain('下载');
expect(wrapper.text()).toContain('2.0.0');
});
it('renders a table of contents for markdown headings', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
description: '# Overview\n\n## Install\n\n## Usage',
}),
);
const { wrapper } = await mountPage();
await flushPromises();
expect(wrapper.text()).toContain('目录');
expect(wrapper.find('.detail-outline a[href="#overview"]').exists()).toBe(true);
expect(wrapper.find('.detail-outline a[href="#install"]').exists()).toBe(true);
});
it('hides the table of contents when the manual has no headings', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
description: 'Plain manual without headings.',
}),
);
const { wrapper } = await mountPage();
await flushPromises();
expect(wrapper.find('.detail-outline').exists()).toBe(false);
expect(wrapper.text()).toContain('Plain manual without headings.');
});
});

View File

@@ -0,0 +1,312 @@
<template>
<div id="tool-detail-top" class="tool-detail-shell">
<header class="header-wrap is-scrolled">
<div class="container header">
<button type="button" class="brand detail-back-link" @click="goHome">
<AppIcon name="arrowLeft" :size="18" />
<span class="brand-mark" aria-hidden="true">
<img src="/favicon.svg" alt="" width="32" height="32" />
</span>
<span>返回工具列表</span>
</button>
</div>
</header>
<main class="container detail-main-content">
<section v-if="loading" class="detail-state-card">
<p>正在加载用户手册...</p>
</section>
<section v-else-if="notFound" class="detail-state-card">
<h1>手册不存在或已下线</h1>
<p>当前链接无法找到可访问的工具详情</p>
<button type="button" class="btn btn-primary" @click="goHome">返回工具列表</button>
</section>
<section v-else-if="errorMessage" class="detail-state-card">
<h1>加载失败</h1>
<p>{{ errorMessage }}</p>
<div class="detail-state-actions">
<button type="button" class="btn btn-primary" @click="loadDetail">重新加载</button>
<button type="button" class="btn" @click="goHome">返回工具列表</button>
</div>
</section>
<template v-else-if="detail">
<section class="detail-overview-card">
<div class="detail-hero-section">
<div class="detail-hero-copy">
<p class="detail-category-label">{{ detail.category?.name || '未分类' }}</p>
<h1>{{ detail.name }}</h1>
<div class="detail-summary-row">
<p class="detail-summary-text">
更新时间{{ formatDate(detail.updatedAt) }} · {{ toolModeSummary(detail) }}
</p>
<div v-if="detail.tags?.length" class="tags detail-inline-tags">
<span v-for="tag in detail.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
</div>
<div class="detail-hero-actions">
<button
type="button"
class="btn btn-primary btn-with-icon"
:disabled="isPrimaryActionDisabled"
@click="triggerPrimaryAction"
>
<AppIcon :name="primaryActionIconName" :size="16" />
{{ primaryActionLabel }}
</button>
<p v-if="launchError" class="detail-inline-error">{{ launchError }}</p>
</div>
</div>
<div v-if="detail.features?.length" class="detail-summary-section">
<div class="detail-feature-block">
<h2 class="title-with-icon">
<AppIcon name="sparkles" :size="18" />
核心能力
</h2>
<ul class="feature-list">
<li v-for="(feature, index) in detail.features" :key="`${detail.id}-${index}`">
<AppIcon name="star" :size="15" />
<div class="markdown markdown-inline" v-html="renderInlineMarkdown(feature)"></div>
</li>
</ul>
</div>
</div>
</section>
<section class="detail-manual-card">
<div class="detail-manual-header">
<div>
<h2 class="title-with-icon">
<AppIcon name="book" :size="18" />
用户手册
</h2>
</div>
<a v-if="hasManual" class="detail-back-top" href="#tool-detail-top">回到顶部</a>
</div>
<p v-if="!hasManual" class="detail-empty-manual">暂未提供用户手册</p>
<div v-else class="detail-manual-layout">
<aside v-if="outline.length" class="detail-outline">
<p>目录</p>
<a
v-for="item in outline"
:key="item.id"
:href="`#${item.id}`"
class="detail-outline-link"
:class="`level-${item.level}`"
>
{{ item.text }}
</a>
</aside>
<div
class="detail-manual-body markdown markdown-detail"
v-html="manualHtml"
></div>
</div>
</section>
</template>
</main>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import AppIcon from '../components/AppIcon.vue';
import {
fetchToolDetailBySlug,
getApiErrorMessage,
launchTool,
notifyToolInteraction,
resolveActionUrl,
} from '../api';
import { renderInlineMarkdown, renderMarkdown } from '../utils/markdown';
import { extractMarkdownOutline, injectOutlineAnchors } from '../utils/markdown-outline';
const CLIENT_VERSION = 'web-1.0.0';
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const notFound = ref(false);
const errorMessage = ref('');
const launchError = ref('');
const detail = ref(null);
const launching = ref(false);
const hasManual = computed(() => Boolean(detail.value?.description?.trim()));
const outline = computed(() => (hasManual.value ? extractMarkdownOutline(detail.value.description) : []));
const primaryActionIconName = computed(() => accessModeIconName(detail.value?.accessMode));
const manualHtml = computed(() => {
if (!hasManual.value) {
return '';
}
return injectOutlineAnchors(renderMarkdown(detail.value.description), outline.value);
});
const primaryActionLabel = computed(() => {
if (!detail.value) {
return '';
}
if (launching.value) {
return '处理中...';
}
if (detail.value.accessMode === 'web') {
return '打开网页';
}
if (!detail.value.downloadReady) {
return '暂无可下载资源';
}
return '下载';
});
const isPrimaryActionDisabled = computed(() => {
if (!detail.value || launching.value) {
return true;
}
return detail.value.accessMode === 'download' && !detail.value.downloadReady;
});
function formatNumber(value) {
const numeric = Number(value);
return new Intl.NumberFormat('zh-CN').format(Number.isFinite(numeric) ? numeric : 0);
}
function formatDate(dateText) {
if (!dateText) {
return '-';
}
const date = new Date(dateText);
if (Number.isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
function formatFileSize(bytes) {
const size = Number(bytes);
if (!Number.isFinite(size) || size <= 0) {
return '-';
}
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unitIndex]}`;
}
function toolModeSummary(tool) {
if (tool.accessMode === 'download') {
return `下载 ${formatNumber(tool.downloadCount)}`;
}
return `访问 ${formatNumber(tool.openCount)}`;
}
function accessModeIconName(accessMode) {
return accessMode === 'download' ? 'download' : 'externalLink';
}
function goHome() {
router.push({ name: 'public-home' });
}
function isNotFoundError(error) {
return Number(error?.response?.status) === 404;
}
// Keep route-driven state in one loader so direct visits and in-app navigation behave the same.
async function loadDetail() {
const slug = String(route.params.slug ?? '').trim();
loading.value = true;
notFound.value = false;
errorMessage.value = '';
launchError.value = '';
detail.value = null;
if (!slug) {
loading.value = false;
notFound.value = true;
return;
}
try {
detail.value = await fetchToolDetailBySlug(slug);
} catch (error) {
if (isNotFoundError(error)) {
notFound.value = true;
} else {
errorMessage.value = getApiErrorMessage(error);
}
} finally {
loading.value = false;
}
}
async function triggerPrimaryAction() {
if (!detail.value || isPrimaryActionDisabled.value) {
return;
}
launching.value = true;
launchError.value = '';
try {
const result = await launchTool(detail.value.id, {
channel: 'official',
clientVersion: CLIENT_VERSION,
});
const isWebLaunch = result?.mode === 'web';
const isDownloadLaunch = result?.mode === 'download';
if (isWebLaunch || isDownloadLaunch) {
notifyToolInteraction(detail.value.id, {
action: isWebLaunch ? 'open' : 'download',
channel: 'official',
clientVersion: CLIENT_VERSION,
});
}
const actionUrl = resolveActionUrl(result?.actionUrl);
if (!actionUrl) {
throw new Error('未获取到可执行地址');
}
if ((isWebLaunch || isDownloadLaunch) && result.openIn === 'same_tab') {
window.location.assign(actionUrl);
return;
}
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
if (!page) {
window.location.assign(actionUrl);
}
} catch (error) {
launchError.value = getApiErrorMessage(error);
} finally {
launching.value = false;
}
}
watch(
() => route.params.slug,
() => {
loadDetail();
},
{ immediate: true },
);
</script>

View File

@@ -65,6 +65,10 @@ select {
color: inherit; color: inherit;
} }
.app-icon {
flex-shrink: 0;
}
.container { .container {
width: min(1200px, calc(100% - 32px)); width: min(1200px, calc(100% - 32px));
margin: 0 auto; margin: 0 auto;
@@ -106,8 +110,8 @@ select {
} }
.brand-mark { .brand-mark {
width: 32px; width: 50px;
height: 32px; height: 50px;
display: block; display: block;
} }
@@ -140,6 +144,12 @@ select {
border-color var(--duration-fast) var(--ease-standard); border-color var(--duration-fast) var(--ease-standard);
} }
.nav-btn {
display: inline-flex;
align-items: center;
gap: 8px;
}
.nav a:hover, .nav a:hover,
.nav-btn:hover { .nav-btn:hover {
background: rgba(233, 249, 255, 0.86); background: rgba(233, 249, 255, 0.86);
@@ -196,6 +206,10 @@ h3 {
padding: 0 12px; padding: 0 12px;
} }
.search-box .app-icon {
color: var(--primary-strong);
}
.search-box input { .search-box input {
border: none; border: none;
outline: none; outline: none;
@@ -283,13 +297,20 @@ h3 {
.tips { .tips {
margin: 12px 0 0; margin: 12px 0 0;
padding-left: 18px; padding-left: 0;
list-style: none;
color: var(--muted); color: var(--muted);
display: grid; display: grid;
gap: 8px; gap: 8px;
font-size: 14px; font-size: 14px;
} }
.tips li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.toolbar { .toolbar {
margin-top: 0; margin-top: 0;
margin-bottom: 14px; margin-bottom: 14px;
@@ -306,6 +327,12 @@ h3 {
font-size: 14px; font-size: 14px;
} }
.result-tip {
display: inline-flex;
align-items: center;
gap: 8px;
}
.tool-grid { .tool-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
@@ -339,6 +366,12 @@ h3 {
font-size: 18px; font-size: 18px;
} }
.title-with-icon {
display: inline-flex;
align-items: center;
gap: 8px;
}
.sidebar-tip { .sidebar-tip {
margin: 6px 0 12px; margin: 6px 0 12px;
color: var(--muted); color: var(--muted);
@@ -571,6 +604,21 @@ h3 {
font-size: 13px; font-size: 13px;
} }
.meta-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.meta-label {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.meta-list strong { .meta-list strong {
color: var(--text); color: var(--text);
} }
@@ -580,6 +628,9 @@ h3 {
} }
.download-num { .download-num {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
} }
@@ -602,6 +653,13 @@ h3 {
transform var(--duration-fast) var(--ease-standard); transform var(--duration-fast) var(--ease-standard);
} }
.btn-with-icon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-small:hover { .btn-small:hover {
background: rgba(240, 251, 255, 0.95); background: rgba(240, 251, 255, 0.95);
border-color: rgba(20, 143, 179, 0.3); border-color: rgba(20, 143, 179, 0.3);
@@ -738,15 +796,258 @@ h3 {
.feature-list { .feature-list {
margin: 0; margin: 0;
padding-left: 18px; padding-left: 0;
list-style: none;
display: grid; display: grid;
gap: 6px; gap: 6px;
} }
.feature-list li { .feature-list li {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--muted); color: var(--muted);
} }
.tool-detail-shell {
min-height: 100vh;
}
.detail-back-link {
border: none;
background: transparent;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
}
.detail-main-content {
padding: 22px 0 40px;
display: grid;
gap: 16px;
}
.detail-state-card,
.detail-overview-card,
.detail-manual-card {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(160deg, rgba(255, 255, 255, 0.94), rgba(248, 253, 255, 0.86));
box-shadow: var(--shadow-soft);
}
.detail-state-card {
padding: 28px;
text-align: center;
color: var(--muted);
}
.detail-state-card h1 {
margin-bottom: 10px;
}
.detail-state-actions {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 16px;
}
.detail-overview-card {
overflow: hidden;
}
.detail-hero-section {
padding: 24px;
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
}
.detail-hero-copy {
display: grid;
gap: 8px;
min-width: 0;
}
.detail-category-label {
margin: 0;
font-size: 13px;
color: #0c6f8d;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.detail-hero-section h1 {
font-size: clamp(28px, 4vw, 42px);
line-height: 1.05;
}
.detail-summary-text,
.detail-manual-tip {
margin: 0;
color: var(--muted);
}
.detail-summary-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.detail-inline-tags {
gap: 8px;
align-items: center;
}
.detail-inline-tags .tag {
white-space: nowrap;
}
.detail-hero-meta {
margin-top: 2px;
}
.detail-hero-actions {
min-width: 220px;
display: grid;
gap: 10px;
}
.detail-inline-error {
margin: 0;
color: #b91c1c;
font-size: 13px;
}
.detail-summary-section {
padding: 20px 24px;
display: grid;
gap: 14px;
border-top: 1px solid rgba(18, 117, 150, 0.14);
background: rgba(248, 253, 255, 0.68);
}
.detail-feature-block {
display: grid;
gap: 8px;
}
.detail-feature-block h2,
.detail-manual-card h2 {
font-size: 20px;
}
.detail-manual-card {
padding: 24px;
display: grid;
gap: 16px;
}
.detail-manual-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.detail-back-top {
font-size: 13px;
color: var(--primary-strong);
}
.detail-empty-manual {
margin: 0;
color: var(--muted);
}
.detail-manual-layout {
display: grid;
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.detail-outline {
position: sticky;
top: 86px;
border: 1px solid rgba(18, 117, 150, 0.16);
border-radius: var(--radius-md);
background: rgba(241, 251, 255, 0.88);
padding: 14px;
display: grid;
gap: 8px;
}
.detail-outline p {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #0c6f8d;
}
.detail-outline-link {
display: block;
color: var(--muted);
font-size: 14px;
line-height: 1.4;
}
.detail-outline-link.level-2 {
padding-left: 10px;
}
.detail-outline-link.level-3,
.detail-outline-link.level-4,
.detail-outline-link.level-5,
.detail-outline-link.level-6 {
padding-left: 20px;
font-size: 13px;
}
.detail-manual-body {
min-width: 0;
max-width: 780px;
padding: 4px 0;
}
.detail-manual-body.markdown-detail {
margin: 0;
}
.detail-manual-body :where(h1, h2, h3, h4, h5, h6) {
scroll-margin-top: 92px;
}
.detail-manual-body :where(h1) {
font-size: 30px;
margin-bottom: 16px;
}
.detail-manual-body :where(h2) {
font-size: 24px;
margin-top: 28px;
margin-bottom: 12px;
}
.detail-manual-body :where(h3) {
font-size: 20px;
margin-top: 22px;
margin-bottom: 10px;
}
.detail-manual-body :where(p, li) {
font-size: 15px;
line-height: 1.8;
}
.icon-btn { .icon-btn {
width: 36px; width: 36px;
height: 36px; height: 36px;
@@ -785,6 +1086,16 @@ h3 {
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: rgba(248, 253, 255, 0.92); background: rgba(248, 253, 255, 0.92);
padding: 12px; padding: 12px;
display: grid;
gap: 10px;
}
.kpi-label {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
} }
.kpi-grid strong { .kpi-grid strong {
@@ -793,11 +1104,6 @@ h3 {
line-height: 1; line-height: 1;
} }
.kpi-grid span {
color: var(--muted);
font-size: 13px;
}
.toast { .toast {
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;
@@ -893,6 +1199,21 @@ select:focus-visible {
.btn { .btn {
width: 100%; width: 100%;
} }
.detail-hero-section,
.detail-manual-header,
.detail-manual-layout {
display: grid;
grid-template-columns: 1fr;
}
.detail-hero-actions {
min-width: 0;
}
.detail-outline {
position: static;
}
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {

13
client/src/test/setup.js Normal file
View File

@@ -0,0 +1,13 @@
import { afterEach, vi } from 'vitest';
const noop = vi.fn();
window.scrollTo = noop;
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = noop;
}
afterEach(() => {
vi.clearAllMocks();
});

View File

@@ -0,0 +1,81 @@
import { marked } from 'marked';
function readInlineText(tokens = []) {
return tokens
.map((token) => {
if (Array.isArray(token.tokens) && token.tokens.length > 0) {
return readInlineText(token.tokens);
}
if (typeof token.text === 'string') {
return token.text;
}
if (typeof token.raw === 'string') {
return token.raw;
}
return '';
})
.join('');
}
export function slugifyHeading(value) {
const normalized = String(value ?? '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'section';
}
export function extractMarkdownOutline(markdown) {
const source = String(markdown ?? '').trim();
if (!source) {
return [];
}
// Preserve deterministic heading anchors even when titles repeat in the same manual.
const duplicateCounts = new Map();
return marked
.lexer(source)
.filter((token) => token.type === 'heading')
.map((token) => {
const text = readInlineText(token.tokens).trim() || token.text?.trim() || 'Untitled';
const baseId = slugifyHeading(text);
const count = (duplicateCounts.get(baseId) ?? 0) + 1;
duplicateCounts.set(baseId, count);
return {
id: count === 1 ? baseId : `${baseId}-${count}`,
level: token.depth,
text,
};
});
}
export function injectOutlineAnchors(html, outline = []) {
if (!html || !outline.length || typeof DOMParser === 'undefined') {
return html;
}
const parser = new DOMParser();
const document = parser.parseFromString(`<div data-outline-root>${html}</div>`, 'text/html');
const container = document.querySelector('[data-outline-root]');
if (!container) {
return html;
}
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
headings.forEach((heading, index) => {
if (outline[index]) {
heading.id = outline[index].id;
}
});
return container.innerHTML;
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { extractMarkdownOutline, injectOutlineAnchors } from './markdown-outline';
describe('extractMarkdownOutline', () => {
it('extracts headings with stable ids', () => {
const markdown = `
# Overview
## Install
### Step One
`;
expect(extractMarkdownOutline(markdown)).toEqual([
{ id: 'overview', level: 1, text: 'Overview' },
{ id: 'install', level: 2, text: 'Install' },
{ id: 'step-one', level: 3, text: 'Step One' },
]);
});
it('deduplicates repeated headings', () => {
const markdown = `
## Install
## Install
## Install
`;
expect(extractMarkdownOutline(markdown)).toEqual([
{ id: 'install', level: 2, text: 'Install' },
{ id: 'install-2', level: 2, text: 'Install' },
{ id: 'install-3', level: 2, text: 'Install' },
]);
});
it('returns an empty outline when no headings are present', () => {
expect(extractMarkdownOutline('Plain text only.')).toEqual([]);
});
it('injects heading ids into rendered html using the extracted outline', () => {
const outline = extractMarkdownOutline('# Overview\n\n## Install');
const html = '<h1>Overview</h1><h2>Install</h2>';
expect(injectOutlineAnchors(html, outline)).toContain('id="overview"');
expect(injectOutlineAnchors(html, outline)).toContain('id="install"');
});
});

11
client/vitest.config.js Normal file
View File

@@ -0,0 +1,11 @@
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.js'],
},
});

View File

@@ -0,0 +1,83 @@
# 项目上下文
## 基本信息
| 字段 | 内容 |
|------|------|
| **项目名称** | Tools Show |
| **技术栈** | Vue 3、vue-router、Vite、Vitest、NestJS、Prisma、Jest、SQLite |
| **创建日期** | 2026-04-11 |
| **最后更新** | 2026-04-11 |
| **项目阶段** | 开发中 |
## 已完成功能
| 功能 | 描述 | 完成日期 | 状态 |
|------|------|---------|------|
| 访客端工具列表与筛选 | 首页支持搜索、分类、排序、分页和站点概览弹窗 | 2026-04-11 | 稳定 |
| 工具用户手册详情页 | 新增基于 `slug` 的访客端详情页,支持 Markdown 手册、空状态、404、返回入口和长文目录导航 | 2026-04-11 | 稳定 |
| 公开详情 `slug` 接口 | 服务端新增按 `slug` 查询已发布工具详情的公开接口,并保留原有按 `id` 查询能力 | 2026-04-11 | 稳定 |
| 前端测试基建 | 客户端补充 `Vitest + Vue Test Utils + jsdom`,覆盖首页跳转、详情页和 Markdown 目录工具 | 2026-04-11 | 稳定 |
## 项目结构
### 目录结构
```text
/
├── client/ # Vue 前端与管理端
├── server/ # NestJS 后端
├── docs/ # 项目文档
├── design/ # 需求、规格、计划与上下文
└── docker/ # 运行环境脚本
```
### 模块划分
| 模块 | 职责 | 依赖关系 |
|------|------|---------|
| `client/src/App.vue` | 访客端工具列表、筛选和概览入口 | `client/src/api.js` |
| `client/src/pages/ToolDetailPage.vue` | 访客端工具详情与 Markdown 手册阅读 | `client/src/api.js`, `client/src/utils/markdown.js`, `client/src/utils/markdown-outline.js` |
| `client/src/admin/*` | 后台工具、分类和审计管理 | `client/src/admin/api.js` |
| `server/src/modules/tools/*` | 公开工具列表与详情查询 | Prisma |
| `server/src/modules/access/*` | 网页打开与下载行为 | `server/src/modules/tools/*` |
## 架构决策记录
| 日期 | 决策 | 理由 | 影响 |
|------|------|------|------|
| 2026-04-11 | 访客端详情页使用 `slug` 路由 | 链接可读、适合长期分享和直达访问 | 前后端同步支持 `/tools/:slug` |
| 2026-04-11 | 继续复用 `Tool.description` 作为用户手册源 | 避免新增数据模型和后台复杂度 | 后台只需优化说明文案 |
| 2026-04-11 | 保留按 `id` 获取详情接口,同时新增按 `slug` 接口 | 降低回归风险,兼容既有调用 | `ToolsService` 维护共享详情映射 |
| 2026-04-11 | 客户端引入 `Vitest + Vue Test Utils + jsdom` | 详情页和路由改造需要前端自动化回归 | `client/package.json` 和锁文件更新 |
## 依赖管理
### 项目依赖
| 依赖名称 | 版本 | 用途 | 来源 |
|---------|------|------|------|
| `vitest` | `^3.2.4` | 客户端单元测试 | npm |
| `@vue/test-utils` | `^2.4.6` | Vue 组件测试挂载 | npm |
| `jsdom` | `^26.1.0` | 客户端测试 DOM 环境 | npm |
| `marked` | `^17.0.5` | Markdown 解析 | npm |
| `dompurify` | `^3.3.3` | Markdown HTML 安全净化 | npm |
### 外部服务
| 服务 | 用途 | 配置位置 |
|------|------|---------|
| SQLite | 工具、分类与访问数据存储 | `server/prisma/schema.prisma` |
## 技术债务
| 债务 | 影响 | 优先级 | 备注 |
|------|------|-------|------|
| 客户端构建产物单 chunk 体积较大 | Vite 构建有 chunk size warning | 中 | 后续可考虑按公开页/管理页拆分 chunk |
| 首页与详情页存在重复的 launch 行为逻辑 | 后续维护动作文案或追踪逻辑时需双处同步 | 中 | 可考虑抽成共享 composable |
## 更新历史
| 日期 | 更新内容 | 触发来源 |
|------|---------|---------|
| 2026-04-11 | 初始创建,记录访客端详情页与前端测试基建落地情况 | executing-plans 完成 |

View File

@@ -0,0 +1,56 @@
# 用户画像
## 基本信息
| 字段 | 内容 | 更新日期 |
|------|------|---------|
| 用户定位 | 高级开发者 | 2026-04-11 |
| 技术背景 | Vue / Node.js 全栈开发,能直接指定页面与交互改动 | 2026-04-11 |
| 首次记录 | 2026-04-11 | 2026-04-11 |
| 最后更新 | 2026-04-11 | 2026-04-11 |
## 技术偏好
| 类型 | 偏好 | 备注 |
|------|------|------|
| 前端框架 | Vue | 当前项目直接基于 Vue 页面提出功能修改 |
| 后端语言 | Node.js | 当前项目服务端为 NestJS |
| 代码风格 | 简洁直接 | 偏向明确需求,不需要冗长解释 |
## 决策倾向
| 场景 | 倾向 | 表现 |
|------|------|------|
| 常规开发 | 偏质量优先 | 在可控范围内倾向补齐完整体验,如空状态和 404 |
| 紧急情况 | 效率优先 | 倾向直接说明要加什么功能 |
| 重构场景 | 中步迭代 | 先做增量功能,而不是整体推翻 |
## 风险偏好
- **技术选型**:适中
- **重构决策**:中步迭代
- **依赖引入**:最小依赖
## 交互风格
| 方面 | 偏好 | 说明 |
|------|------|------|
| 详细程度 | 简洁 | 更偏向短句和明确结论 |
| 反馈频率 | 关键节点 | 适合在决策点确认 |
| 解释深度 | 概要说明 | 不需要铺垫式解释 |
| 问题形式 | 选择题 | 当前对话对数字选项响应直接 |
## 能力评估
| 能力领域 | 自评等级 | 备注 |
|---------|---------|------|
| 前端开发 | 高级 | 能明确提出页面形态与交互需求 |
| 后端开发 | 中级 | 当前尚未直接表达更强偏好 |
| 架构设计 | 中级 | 关注功能增量落地 |
| 性能优化 | 中级 | 当前未体现特殊关注点 |
## 更新历史
| 日期 | 更新内容 | 触发场景 |
|------|---------|---------|
| 2026-04-11 | 初始创建,记录为高级开发者,偏好简洁和选择题沟通 | brainstorming 初次激活 |

View File

@@ -0,0 +1,189 @@
# 用户手册详情页实施计划
**目标**:将访客端原有详情弹窗替换为基于 `slug` 的独立详情页,使 `description` 可以长期作为 Markdown 用户手册展示并补齐直达访问、空内容、404 和长文阅读体验。
**架构**:本次为单一功能子系统改造,不创建纲领文件。后端先补齐按 `slug` 查询已发布工具的公开详情能力,再由前端新增公开详情页路由和页面组件,最后替换首页详情入口并完善长文阅读样式与测试。前端测试基建当前缺失,因此计划第一步先补齐 `Vitest + Vue Test Utils + jsdom`,确保后续页面改造遵循测试优先。
**技术栈**Vue 3、vue-router、Vite、Vitest、Vue Test Utils、NestJS、Prisma、Jest、Supertest、marked、DOMPurify
---
## 文件结构
### 新建文件
- `client/vitest.config.js` - 前端测试配置,提供 Vue SFC 和 `jsdom` 运行环境
注释需求:低优先级,配置项仅在非直观处补简短说明
复杂度:低
- `client/src/test/setup.js` - 前端测试初始化,挂载全局匹配器、滚动和路由相关基础 mock
注释需求:中优先级,需要说明全局 mock 的用途
复杂度:中
- `client/src/utils/markdown-outline.js` - 提取 Markdown 标题、生成稳定锚点和目录结构
注释需求:高优先级,需要为重复标题去重和锚点规范化逻辑添加说明
复杂度:中
- `client/src/utils/markdown-outline.spec.js` - 验证目录提取、标题去重和无标题场景
注释需求:低优先级
复杂度:中
- `client/src/pages/ToolDetailPage.vue` - 公开详情页,负责按 `slug` 拉取详情、渲染摘要和 Markdown 手册、处理空状态/404/返回入口
注释需求:高优先级,需要在状态流转和 launch 复用入口处补说明
复杂度:高
- `client/src/pages/ToolDetailPage.spec.js` - 详情页组件测试覆盖加载态、404、空内容、目录和主操作按钮
注释需求:低优先级
复杂度:高
- `client/src/App.spec.js` - 首页交互测试,覆盖详情按钮跳转和旧弹窗移除
注释需求:低优先级
复杂度:中
- `server/src/modules/tools/tools.service.spec.ts` - 后端服务单测,覆盖按 `slug` 查询详情的业务规则
注释需求:低优先级
复杂度:中
- `server/test/tools-detail.e2e-spec.ts` - 公开详情接口 e2e覆盖成功命中和 404
注释需求:中优先级,需要说明测试数据准备和清理
复杂度:中
### 修改文件
- `client/package.json` - 增加前端测试脚本和测试依赖声明
注释需求:低优先级
复杂度:低
- `client/package-lock.json` - 锁定前端测试依赖版本
注释需求:无
复杂度:低
- `client/src/api.js` - 新增按 `slug` 获取详情的方法,复用现有错误和 launch 行为
注释需求:中优先级,需要说明新接口和旧按 `id` 接口的职责差异
复杂度:中
- `client/src/admin/router.js` - 增加公开详情页路由 `/tools/:slug`
注释需求:低优先级
复杂度:低
- `client/src/App.vue` - 移除详情弹窗状态和模板,将详情入口改为路由跳转
注释需求:中优先级,需要说明首页仅负责列表和跳转,不再持有详情状态
复杂度:高
- `client/src/style.scss` - 增加详情页布局、目录、空状态、404 和长文阅读样式
注释需求:低优先级
复杂度:高
- `client/src/admin/components/ToolFormDialog.vue` - 微调“工具简介”提示文案,明确其可承载 Markdown 手册内容
注释需求:低优先级
复杂度:低
- `server/src/modules/tools/tools.service.ts` - 抽出详情映射逻辑,新增按 `slug` 查询已发布工具详情的方法
注释需求:高优先级,需要说明 `id``slug` 两套公开查询入口的职责分层
复杂度:中
- `server/src/modules/tools/tools.controller.ts` - 新增按 `slug` 获取公开详情的接口
注释需求:低优先级
复杂度:低
### 测试文件
- `client/src/utils/markdown-outline.spec.js` - 验证 Markdown 目录提取和锚点生成规则
- `client/src/pages/ToolDetailPage.spec.js` - 验证详情页数据状态和长文阅读体验
- `client/src/App.spec.js` - 验证首页详情入口从弹窗切为路由跳转
- `server/src/modules/tools/tools.service.spec.ts` - 验证按 `slug` 查询的业务规则
- `server/test/tools-detail.e2e-spec.ts` - 验证公开详情接口路由行为
---
## 任务列表
### 任务 1建立前端测试基建和 Markdown 目录工具
**文件**
- 创建:`client/vitest.config.js`
- 创建:`client/src/test/setup.js`
- 创建:`client/src/utils/markdown-outline.js`
- 测试:`client/src/utils/markdown-outline.spec.js`
- 修改:`client/package.json`
- 修改:`client/package-lock.json`
- [ ] **步骤 1**:在 `client/package.json` 中增加 `test``test:run` 脚本和 `vitest``@vue/test-utils``jsdom` 等依赖声明;创建 `client/vitest.config.js``client/src/test/setup.js`;编写 `client/src/utils/markdown-outline.spec.js`,先描述标题提取、重复标题去重、无标题返回空目录的失败用例。
- [ ] **步骤 2**:运行 `npm --prefix client install`;随后运行 `npm --prefix client run test:run -- client/src/utils/markdown-outline.spec.js`,预期输出为测试失败,原因是 `markdown-outline.js` 尚未实现或断言不满足。
- [ ] **步骤 3**:创建 `client/src/utils/markdown-outline.js`,实现 `extractMarkdownOutline` 和稳定锚点生成逻辑,保证只提取正文标题并对重复标题追加序号。
- [ ] **步骤 4**:再次运行 `npm --prefix client run test:run -- client/src/utils/markdown-outline.spec.js`,预期输出 `1 passed`,前端测试基建可用。
### 任务 2实现后端按 `slug` 查询详情的服务层能力
**文件**
- 创建:`server/src/modules/tools/tools.service.spec.ts`
- 修改:`server/src/modules/tools/tools.service.ts`
- [ ] **步骤 1**:编写 `server/src/modules/tools/tools.service.spec.ts`,覆盖已发布工具可按 `slug` 查询、未发布工具不可见、缺失工具抛出 404、详情映射字段与现有按 `id` 详情保持一致的失败测试。
- [ ] **步骤 2**:运行 `npm --prefix server test -- --runTestsByPath src/modules/tools/tools.service.spec.ts`,预期输出为编译失败或测试失败,因为 `getToolDetailBySlug` 及共享映射尚未存在。
- [ ] **步骤 3**:在 `server/src/modules/tools/tools.service.ts` 中抽出详情映射方法,新增 `getToolDetailBySlug(slug: string)`,查询条件限定为 `status=published``isDeleted=false`,并复用现有详情返回结构。
- [ ] **步骤 4**:再次运行 `npm --prefix server test -- --runTestsByPath src/modules/tools/tools.service.spec.ts`,预期输出 `Test Suites: 1 passed`
### 任务 3补齐公开详情 `slug` 接口和后端 e2e
**文件**
- 创建:`server/test/tools-detail.e2e-spec.ts`
- 修改:`server/src/modules/tools/tools.controller.ts`
- [ ] **步骤 1**:编写 `server/test/tools-detail.e2e-spec.ts`,准备一条已发布工具测试数据,定义 `GET /tools/slug/:slug` 成功返回详情和未知 `slug` 返回 404 的失败测试。
- [ ] **步骤 2**:运行 `npm --prefix server run test:e2e -- --runTestsByPath test/tools-detail.e2e-spec.ts`,预期输出为 404 或断言失败,因为控制器路由尚未暴露该接口。
- [ ] **步骤 3**:在 `server/src/modules/tools/tools.controller.ts` 中新增 `GET /tools/slug/:slug` 路由,调用 `toolsService.getToolDetailBySlug`;按需要调整 e2e 中的测试数据清理,避免污染现有数据。
- [ ] **步骤 4**:再次运行 `npm --prefix server run test:e2e -- --runTestsByPath test/tools-detail.e2e-spec.ts`,预期输出 `1 passed``2 passed`,接口直达行为稳定。
### 任务 4新增公开详情页路由、API 接入和页面状态
**文件**
- 创建:`client/src/pages/ToolDetailPage.vue`
- 测试:`client/src/pages/ToolDetailPage.spec.js`
- 修改:`client/src/api.js`
- 修改:`client/src/admin/router.js`
- [ ] **步骤 1**:编写 `client/src/pages/ToolDetailPage.spec.js`,覆盖按路由 `slug` 拉取详情、加载态、404 提示、空内容提示、返回首页入口以及主操作按钮展示的失败测试。
- [ ] **步骤 2**:运行 `npm --prefix client run test:run -- client/src/pages/ToolDetailPage.spec.js`,预期输出为模块不存在或多个断言失败,因为详情页组件、路由和新 API 方法尚未实现。
- [ ] **步骤 3**:在 `client/src/api.js` 中新增 `fetchToolDetailBySlug(slug)`;在 `client/src/admin/router.js` 中增加公开路由 `/tools/:slug`;创建 `client/src/pages/ToolDetailPage.vue`,完成按 `slug` 加载数据、页面级加载/错误/404/空内容状态和返回入口,并复用现有 launch 行为。
- [ ] **步骤 4**:再次运行 `npm --prefix client run test:run -- client/src/pages/ToolDetailPage.spec.js`,预期输出 `1 passed`,详情页基础行为成立。
### 任务 5替换首页详情弹窗为路由跳转
**文件**
- 创建:`client/src/App.spec.js`
- 修改:`client/src/App.vue`
- [ ] **步骤 1**:编写 `client/src/App.spec.js`,覆盖“详情”按钮改为跳转到 `/tools/:slug`、旧详情弹窗 DOM 不再渲染、首页仍保留概览弹窗的失败测试。
- [ ] **步骤 2**:运行 `npm --prefix client run test:run -- client/src/App.spec.js`,预期输出为断言失败,因为首页仍然保留 `openDetailModal` 流程和旧弹窗模板。
- [ ] **步骤 3**:修改 `client/src/App.vue`,移除 `detailModalOpen/detailLoading/detailError/detail` 等状态及弹窗模板,将详情按钮改为 `router.push``RouterLink` 跳转,并保留列表、筛选和概览弹窗逻辑不变。
- [ ] **步骤 4**:再次运行 `npm --prefix client run test:run -- client/src/App.spec.js client/src/pages/ToolDetailPage.spec.js`,预期输出全部通过,首页到详情页的主链路打通。
### 任务 6完善长文阅读体验、后台提示文案和整体验证
**文件**
- 修改:`client/src/style.scss`
- 修改:`client/src/pages/ToolDetailPage.vue`
- 修改:`client/src/pages/ToolDetailPage.spec.js`
- 修改:`client/src/admin/components/ToolFormDialog.vue`
- [ ] **步骤 1**:扩展 `client/src/pages/ToolDetailPage.spec.js`,新增目录渲染、无标题时不显示目录、长文时保留清晰段落层级和返回入口的失败测试。
- [ ] **步骤 2**:运行 `npm --prefix client run test:run -- client/src/pages/ToolDetailPage.spec.js`,预期输出为目录相关断言失败,因为页面尚未接入目录工具和阅读态样式。
- [ ] **步骤 3**:修改 `client/src/pages/ToolDetailPage.vue``client/src/style.scss`,接入 `extractMarkdownOutline`、渲染目录导航、优化正文宽度/标题层级/锚点区块;同步修改 `client/src/admin/components/ToolFormDialog.vue` 的简介提示文案,明确其支持 Markdown 手册内容。
- [ ] **步骤 4**:依次运行 `npm --prefix client run test:run -- client/src/utils/markdown-outline.spec.js client/src/pages/ToolDetailPage.spec.js client/src/App.spec.js``npm --prefix client run build``npm --prefix server test -- --runTestsByPath src/modules/tools/tools.service.spec.ts``npm --prefix server run test:e2e -- --runTestsByPath test/tools-detail.e2e-spec.ts`,预期输出均为通过,且客户端构建成功无报错。
---
## 执行顺序说明
1. 先补前端测试基建,否则后续页面任务无法按 TDD 执行。
2. 后端先做服务层,再做控制器和 e2e可减少路由级调试成本。
3. 前端先完成详情页自身状态,再替换首页入口,避免“按钮先改了但页面未就绪”。
4. 长文阅读体验放在最后收口,避免样式和目录逻辑反复返工。
## 风险提示
- 前端当前没有测试依赖,任务 1 会同时引入测试脚手架和锁文件变更。
- `App.vue` 体量较大,任务 5 需要谨慎删除旧弹窗状态,避免误伤概览弹窗和列表筛选逻辑。
- `slug` 接口与按 `id` 接口将同时存在,任务 2 和任务 3 需要确保两者返回结构一致,避免前端出现双标准。
- e2e 测试需要自行准备和清理工具数据,避免依赖现有 seed 内容导致测试不稳定。

44
design/session-state.md Normal file
View File

@@ -0,0 +1,44 @@
# 会话状态
## 基本信息
- **技能**: executing-plans
- **主题**: 用户手册详情页(支持 Markdown
- **开始时间**: 2026-04-11 10:26
- **最后更新**: 2026-04-11 11:28
## 当前状态
- **阶段**: 阶段 3完成与报告
- **上一步**: 已完成全部 6 个任务,并通过前端测试、客户端构建、服务端单测、服务端 e2e 与服务端构建。
- **下一步**: 输出完成报告并结束本轮执行。
## 已确认内容
- 2026-04-11 10:26-需求目标是增加一个新的详情页,可显示 Markdown 格式的用户手册。
- 2026-04-11 10:26-主站当前没有独立详情页,详情内容以弹窗形式展示。
- 2026-04-11 10:26-前端已存在 Markdown 渲染工具,可作为后续方案基础。
- 2026-04-11 10:27-详情页主要用户为普通访客。
- 2026-04-11 10:31-详情页交互方式为站内路由跳转。
- 2026-04-11 10:32-用户手册内容来源先复用现有 `description` 字段承载 Markdown。
- 2026-04-11 10:33-旧的详情弹窗将被新详情页替换。
- 2026-04-11 10:37-本次实现范围包含详情页空状态、404 状态与返回入口体验。
- 2026-04-11 10:39-验收标准为长期可用,要求详情页具备清晰信息结构。
- 2026-04-11 10:42-边界情况需覆盖直达路由、空内容、404 及超长 Markdown 阅读体验。
- 2026-04-11 10:58-最终方案为基于 `slug` 的详情页路由与详情接口。
- 2026-04-11 11:02-规格文档已获批准,开始编写实施计划。
- 2026-04-11 11:05-实施计划已保存,包含 6 个按 TDD 拆分的执行任务。
- 2026-04-11 11:08-执行前检查发现当前分支为 `master`,按技能规则需用户确认后才能继续。
- 2026-04-11 11:09-用户已确认允许在 `master` 分支执行计划,任务 1 开始。
- 2026-04-11 11:12-任务 1 完成:前端测试基建建立,`markdown-outline` 工具和测试通过。
- 2026-04-11 11:17-任务 2 和任务 3 完成:后端 `slug` 详情服务单测和控制器 e2e 通过。
- 2026-04-11 11:28-任务 4、5、6 完成:公开详情页、首页路由跳转、目录阅读体验和后台文案已落地并完成最终验证。
## 待处理问题
- [x] 任务 1前端测试基建和 Markdown 目录工具。
- [x] 任务 2后端按 `slug` 查询详情的服务层能力。
- [x] 任务 3公开详情 `slug` 接口和后端 e2e。
- [x] 任务 4公开详情页路由、API 接入和页面状态。
- [x] 任务 5首页详情弹窗替换为路由跳转。
- [x] 任务 6长文阅读体验、后台提示文案和整体验证。

View File

@@ -0,0 +1,100 @@
# 用户手册详情页设计规格
## 概述
当前访客端首页通过弹窗展示工具详情,适合快速浏览,但不适合作为稳定的用户手册阅读入口。现有工具数据中的 `description` 字段已经支持 Markdown 渲染,因此可以直接复用它承载用户手册内容,无需新增数据库字段。
本次设计目标是在现有公开站点中增加一个正式的详情页入口,替换原有弹窗。详情页使用工具的 `slug` 作为公开访问标识,支持站内路由直达、长篇 Markdown 阅读、空内容状态、404 状态和明确的返回入口,使其可以长期承担“工具介绍 + 用户手册”的双重职责。
## 目标定位
- **主要用户**:普通访客
- **使用场景**:访客从工具列表点击“详情”进入独立页面,阅读工具说明、能力介绍和 Markdown 格式的用户手册;也支持用户直接访问详情页链接
## 约束条件
- **技术限制**:前端基于 Vue 3 + vue-router主站与管理端共用同一路由实例后端基于 NestJS + PrismaMarkdown 渲染复用现有 `marked + DOMPurify` 能力;用户手册内容先复用 `Tool.description`
- **时间限制**:本轮以增量改造为主,不引入新的内容模型,不重做管理端工具编辑流程
- **资源限制**:仅在现有项目代码结构内完成,避免增加新依赖或引入第二套公开站点入口
## 成功标准
- [ ] 访客在首页点击“详情”后进入新的站内详情页,不再弹出旧弹窗
- [ ] 详情页路由使用 `slug`,可通过 URL 直达访问
- [ ] 详情页能够稳定展示 `description` Markdown、基础元信息和功能点适合作为长期手册页面
- [ ] 当工具不存在时展示明确的 404 状态;当 `description` 为空时展示清晰的空内容提示
- [ ] 对于较长 Markdown 内容,页面具备目录感和良好的阅读体验,用户可以快速回到列表或继续执行“打开网页/下载”操作
## 架构设计
### 系统结构
本次改造保持单一公开站点结构不变,在现有 `vue-router` 中新增公开详情路由。前端首页仍负责列表、筛选和跳转;详情页负责根据 `slug` 拉取数据并渲染完整内容。后端在保留现有按 `id` 获取详情接口的前提下,新增按 `slug` 查询的公开详情接口,避免影响既有逻辑,并为长期公开链接提供稳定入口。
详情页信息结构分为三层:
1. 页面顶部:返回列表、工具名称、分类、更新时间、主操作按钮
2. 信息摘要区:评分、访问方式、下载/访问次数、版本、标签、核心能力
3. 手册正文区:将 `description` 作为 Markdown 正文渲染,并为长文提供标题导航或可跳转的章节锚点
### 组件划分
| 组件 | 职责 | 依赖 |
|------|------|------|
| `client/src/App.vue` | 保留首页列表、筛选和分页;将“详情”按钮改为路由跳转;移除旧详情弹窗状态与模板 | `vue-router`, `client/src/api.js` |
| `client/src/pages/ToolDetailPage.vue` | 详情页容器;根据 `slug` 拉取详情数据处理加载、404、空内容、长文阅读与返回入口 | `vue-router`, `client/src/api.js`, `client/src/utils/markdown.js` |
| `client/src/api.js` | 新增按 `slug` 获取详情的方法;复用已有错误处理与启动行为 | Axios |
| `client/src/utils/markdown.js` | 继续负责安全渲染 Markdown可扩展标题提取或锚点处理支持长文目录体验 | `marked`, `DOMPurify` |
| `client/src/admin/components/ToolFormDialog.vue` | 保持数据录入入口不变;若实施时顺手优化文案,可将简介提示改为更明确的“支持手册 Markdown” | Element Plus |
| `server/src/modules/tools/tools.controller.ts` | 新增按 `slug` 获取公开详情的接口 | `ToolsService` |
| `server/src/modules/tools/tools.service.ts` | 新增按 `slug` 查询已发布工具详情的方法,并复用现有详情映射逻辑 | Prisma |
## 数据流
首页进入详情页的数据流如下:
1. 首页加载工具列表,列表项已带有 `id``slug`
2. 用户点击“详情”按钮
3. 前端通过路由跳转到 `/tools/:slug`
4. 详情页组件从路由参数读取 `slug`
5. 前端调用新的公开接口,例如 `GET /api/v1/tools/slug/:slug`
6. 后端按 `slug + status=published + isDeleted=false` 查询工具
7. 查询成功后返回详情数据,包括 `description``features`、分类、评分、访问方式、访问量/下载量、版本等
8. 前端渲染详情页摘要区和 Markdown 正文;若正文存在标题,则生成目录锚点或章节导航
9. 用户可从详情页返回首页,或继续点击“打开网页/下载”执行现有 launch 流程
长文阅读的数据处理建议如下:
1. 详情页获取 Markdown 原文
2. 使用现有 Markdown 解析能力渲染 HTML
3. 在渲染前或渲染时提取标题层级,生成页面内目录
4. 为标题生成稳定锚点,支持目录点击跳转
5. 若正文无标题,则不显示目录,保留清晰的文章排版和回顶能力
## 错误处理
| 错误类型 | 处理方式 |
|----------|----------|
| 详情接口加载中 | 展示页面级骨架或加载态,不显示旧弹窗 |
| `slug` 对应工具不存在 | 展示 404 状态页,包含返回首页入口 |
| 工具存在但 `description` 为空 | 展示“暂未提供用户手册”空状态,同时仍保留工具摘要信息和主操作按钮 |
| 接口请求失败 | 展示页面级错误提示,提供重试操作和返回首页入口 |
| Markdown 内容很长 | 启用目录/锚点导航、限制正文宽度、保证标题层级和段落间距,避免整页难以浏览 |
| 从详情页执行打开或下载失败 | 复用现有 `launchTool` 和错误提示逻辑,不在详情页定义第二套行为 |
## 测试策略
- **单元测试**:校验按 `slug` 查询详情的服务方法;校验 Markdown 标题提取或目录生成逻辑;校验空内容和 404 的状态判断
- **集成测试**:覆盖公开详情接口按 `slug` 返回已发布工具;覆盖不存在 `slug` 时返回 404覆盖详情页直达访问、列表跳转访问和返回行为
- **验收标准**:从首页点击“详情”可稳定进入 `/tools/:slug`;直达链接可用;长篇 Markdown 有清晰阅读结构空内容、404、请求失败均有明确页面反馈旧详情弹窗已移除
## 决策记录
| 决策 | 理由 | 影响 |
|------|------|------|
| 详情页采用站内路由而不是新标签页 | 用户明确要求站内跳转,且可形成连续阅读路径 | 前端需新增公开详情路由 |
| 详情页公开地址使用 `slug` | `slug` 已存在且唯一,适合长期公开链接和分享 | 后端需新增按 `slug` 查询接口,前端详情页按 `slug` 加载 |
| 用户手册先复用 `description` 字段 | 当前已支持 Markdown避免新增数据库字段和后台复杂度 | 本轮不做数据模型迁移 |
| 旧弹窗详情被新详情页替换 | 用户希望详情入口统一,避免双入口维护 | 首页需移除弹窗模板和相关状态 |
| 长文阅读加入目录感设计 | 用户明确要求处理超长 Markdown 的阅读体验 | 详情页和 Markdown 工具层需要支持标题导航或锚点 |
| 现有按 `id` 的详情接口保留,新增长期用的 `slug` 接口 | 兼容已有内部调用,减少回归风险 | 后端存在两个公开详情查询入口,但职责清晰 |

View File

@@ -8,10 +8,10 @@
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start", "start": "node scripts/start-with-migrate.js npx nest start",
"start:dev": "nest start --watch", "start:dev": "node scripts/start-with-migrate.js npx nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "node scripts/start-with-migrate.js npx nest start --debug --watch",
"start:prod": "node dist/src/main.js", "start:prod": "node scripts/start-with-migrate.js node dist/src/main.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
const { spawn, spawnSync } = require('child_process');
function normalizeWrappedEnv(name) {
const value = process.env[name];
if (!value || value.length < 2) {
return;
}
const first = value[0];
const last = value[value.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
process.env[name] = value.slice(1, -1);
}
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
...options,
});
if (result.error) {
throw result.error;
}
if (typeof result.status === 'number' && result.status !== 0) {
process.exit(result.status);
}
}
function main() {
if (!process.env.DATABASE_URL) {
process.env.DATABASE_URL = 'file:./dev.db';
}
normalizeWrappedEnv('DATABASE_URL');
run(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['prisma', 'migrate', 'deploy']);
const args = process.argv.slice(2);
if (args.length === 0) {
return;
}
const child = spawn(args[0], args.slice(1), {
stdio: 'inherit',
env: process.env,
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
}
main();

10
server/skills-lock.json Normal file
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"better-icons": {
"source": "better-auth/better-icons",
"sourceType": "github",
"computedHash": "656a618d6d5d5e9c567253eff57a06bee3be5d4fb983bdc401a46a209c5b76de"
}
}
}

View File

@@ -37,7 +37,9 @@ async function bootstrap() {
const swaggerConfig = new DocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle('ToolsShow Backend API') .setTitle('ToolsShow Backend API')
.setDescription('NestJS backend for ToolsShow with hybrid web/download tool access.') .setDescription(
'NestJS backend for ToolsShow with hybrid web/download tool access.',
)
.setVersion('1.0.0') .setVersion('1.0.0')
.addBearerAuth( .addBearerAuth(
{ {

View File

@@ -31,7 +31,6 @@ export class CreateToolDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@MinLength(10) @MinLength(10)
@MaxLength(2000)
description!: string; description!: string;
@ApiPropertyOptional({ minimum: 0, maximum: 5, default: 0 }) @ApiPropertyOptional({ minimum: 0, maximum: 5, default: 0 })

View File

@@ -14,6 +14,12 @@ export class ToolsController {
return this.toolsService.getTools(query); return this.toolsService.getTools(query);
} }
@Get('slug/:slug')
@ApiOperation({ summary: 'Get tool detail by slug' })
getToolDetailBySlug(@Param('slug') slug: string) {
return this.toolsService.getToolDetailBySlug(slug);
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get tool detail' }) @ApiOperation({ summary: 'Get tool detail' })
getToolDetail(@Param('id') id: string) { getToolDetail(@Param('id') id: string) {

View File

@@ -0,0 +1,119 @@
import { AccessMode, ArtifactStatus, Prisma } from '@prisma/client';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
import { ToolsService } from './tools.service';
function createToolEntity(overrides = {}) {
return {
id: 'tool_demo',
name: 'Demo Tool',
slug: 'demo-tool',
description: '# Demo Tool\n\n## Install',
rating: 4.5,
downloadCount: 18,
openCount: 42,
accessMode: AccessMode.download,
openUrl: 'https://example.com/download',
updatedAt: '2026-04-11',
isDeleted: false,
status: 'published',
category: {
id: 'cat_dev',
name: 'Developer Tools',
},
tags: [{ tag: { name: 'cli' } }, { tag: { name: 'automation' } }],
features: [{ featureText: 'Fast setup' }, { featureText: 'Offline mode' }],
latestArtifact: {
version: '2.0.0',
fileSizeBytes: 4096,
status: ArtifactStatus.active,
},
...overrides,
};
}
describe('ToolsService', () => {
type ToolEntity = ReturnType<typeof createToolEntity>;
type FindFirstArgs = {
where?: Prisma.ToolWhereInput;
};
function createService() {
const findFirst = jest.fn<Promise<ToolEntity | null>, [FindFirstArgs]>();
const prisma = {
tool: {
findFirst,
},
};
return {
prisma,
service: new ToolsService(prisma as never),
};
}
it('returns published tool detail by slug', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(createToolEntity());
await expect(
service.getToolDetailBySlug('demo-tool'),
).resolves.toMatchObject({
id: 'tool_demo',
slug: 'demo-tool',
name: 'Demo Tool',
latestVersion: '2.0.0',
fileSize: 4096,
downloadReady: true,
});
const [callArg] = prisma.tool.findFirst.mock.calls[0];
expect(callArg?.where).toMatchObject({
slug: 'demo-tool',
isDeleted: false,
});
});
it('throws not found when slug does not match a published tool', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(null);
await expect(
service.getToolDetailBySlug('missing-tool'),
).rejects.toMatchObject({
errorCode: ERROR_CODES.NOT_FOUND,
} satisfies Partial<AppException>);
});
it('returns the same detail shape for slug and id lookups', async () => {
const { prisma, service } = createService();
const tool = createToolEntity();
prisma.tool.findFirst
.mockResolvedValueOnce(tool)
.mockResolvedValueOnce(tool);
const detailById = await service.getToolDetail('tool_demo');
const detailBySlug = await service.getToolDetailBySlug('demo-tool');
expect(detailBySlug).toEqual(detailById);
});
it('marks downloadReady false when no artifact or external url exists', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createToolEntity({
accessMode: AccessMode.download,
openUrl: null,
latestArtifact: null,
}),
);
await expect(
service.getToolDetailBySlug('demo-tool'),
).resolves.toMatchObject({
downloadReady: false,
latestVersion: null,
fileSize: null,
});
});
});

View File

@@ -5,6 +5,25 @@ import { AppException } from '../../common/exceptions/app.exception';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { GetToolsQueryDto, ToolSortBy } from './dto/get-tools-query.dto'; import { GetToolsQueryDto, ToolSortBy } from './dto/get-tools-query.dto';
const toolDetailInclude = {
category: true,
tags: {
include: {
tag: true,
},
},
features: {
orderBy: {
sortOrder: 'asc',
},
},
latestArtifact: true,
} satisfies Prisma.ToolInclude;
type ToolDetailEntity = Prisma.ToolGetPayload<{
include: typeof toolDetailInclude;
}>;
@Injectable() @Injectable()
export class ToolsService { export class ToolsService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -34,20 +53,7 @@ export class ToolsService {
this.prisma.tool.count({ where }), this.prisma.tool.count({ where }),
this.prisma.tool.findMany({ this.prisma.tool.findMany({
where, where,
include: { include: toolDetailInclude,
category: true,
tags: {
include: {
tag: true,
},
},
features: {
orderBy: {
sortOrder: 'asc',
},
},
latestArtifact: true,
},
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
orderBy: this.buildOrderBy(sortBy), orderBy: this.buildOrderBy(sortBy),
@@ -55,9 +61,71 @@ export class ToolsService {
]); ]);
return { return {
list: tools.map((tool) => { list: tools.map((tool) => this.mapToolListItem(tool)),
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async getToolDetail(id: string) {
const tool = await this.findPublishedToolDetail({ id });
return this.mapToolDetail(tool);
}
// Keep both public entry points so legacy id-based flows and new shareable slug URLs
// can reuse the exact same detail mapping without drifting apart.
async getToolDetailBySlug(slug: string) {
const tool = await this.findPublishedToolDetail({ slug });
return this.mapToolDetail(tool);
}
private buildOrderBy(
sortBy: ToolSortBy,
): Prisma.ToolOrderByWithRelationInput[] {
switch (sortBy) {
case ToolSortBy.created:
return [{ createdAt: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.popular:
return [
{ downloadCount: 'desc' },
{ openCount: 'desc' },
{ modifiedAt: 'desc' },
];
case ToolSortBy.rating:
return [{ rating: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.name:
return [{ name: 'asc' }];
case ToolSortBy.latest:
default:
return [{ modifiedAt: 'desc' }];
}
}
private async findPublishedToolDetail(where: Prisma.ToolWhereInput) {
const tool = await this.prisma.tool.findFirst({
where: {
...where,
isDeleted: false,
status: ToolStatus.published,
},
include: toolDetailInclude,
});
if (!tool) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', 404);
}
return tool;
}
private mapToolListItem(tool: ToolDetailEntity) {
const hasArtifact = Boolean( const hasArtifact = Boolean(
tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active, tool.latestArtifact &&
tool.latestArtifact.status === ArtifactStatus.active,
); );
const hasExternalDownloadUrl = const hasExternalDownloadUrl =
tool.accessMode === 'download' && Boolean(tool.openUrl); tool.accessMode === 'download' && Boolean(tool.openUrl);
@@ -75,54 +143,24 @@ export class ToolsService {
downloadCount: tool.downloadCount, downloadCount: tool.downloadCount,
openCount: tool.openCount, openCount: tool.openCount,
latestVersion: latestVersion:
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null, tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.version
: null,
accessMode: tool.accessMode, accessMode: tool.accessMode,
openUrl: tool.openUrl, openUrl: tool.openUrl,
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false, hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
downloadReady: downloadReady:
tool.accessMode === 'download' ? hasArtifact || hasExternalDownloadUrl : false, tool.accessMode === 'download'
? hasArtifact || hasExternalDownloadUrl
: false,
tags: tool.tags.map((item) => item.tag.name), tags: tool.tags.map((item) => item.tag.name),
features: tool.features.map((item) => item.featureText), features: tool.features.map((item) => item.featureText),
updatedAt: tool.updatedAt, updatedAt: tool.updatedAt,
}; };
}),
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
} }
async getToolDetail(id: string) { private mapToolDetail(tool: ToolDetailEntity) {
const tool = await this.prisma.tool.findFirst({ return {
where: {
id,
isDeleted: false,
status: ToolStatus.published,
},
include: {
category: true,
tags: {
include: {
tag: true,
},
},
features: {
orderBy: {
sortOrder: 'asc',
},
},
latestArtifact: true,
},
});
if (!tool) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', 404);
}
const detail = {
id: tool.id, id: tool.id,
name: tool.name, name: tool.name,
slug: tool.slug, slug: tool.slug,
@@ -140,7 +178,9 @@ export class ToolsService {
updatedAt: tool.updatedAt, updatedAt: tool.updatedAt,
openUrl: tool.openUrl, openUrl: tool.openUrl,
latestVersion: latestVersion:
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null, tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.version
: null,
fileSize: fileSize:
tool.accessMode === 'download' && tool.latestArtifact tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.fileSizeBytes ? tool.latestArtifact.fileSizeBytes
@@ -149,27 +189,10 @@ export class ToolsService {
tool.accessMode === 'download' tool.accessMode === 'download'
? Boolean( ? Boolean(
tool.openUrl || tool.openUrl ||
(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active), (tool.latestArtifact &&
tool.latestArtifact.status === ArtifactStatus.active),
) )
: false, : false,
}; };
return detail;
}
private buildOrderBy(sortBy: ToolSortBy): Prisma.ToolOrderByWithRelationInput[] {
switch (sortBy) {
case ToolSortBy.created:
return [{ createdAt: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.popular:
return [{ downloadCount: 'desc' }, { openCount: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.rating:
return [{ rating: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.name:
return [{ name: 'asc' }];
case ToolSortBy.latest:
default:
return [{ modifiedAt: 'desc' }];
}
} }
} }

View File

@@ -0,0 +1,96 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AccessMode, ToolStatus } from '@prisma/client';
import { randomUUID } from 'crypto';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
interface ToolDetailResponse {
id: string;
slug: string;
name: string;
description: string;
}
describe('Tools detail by slug (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let categoryId = '';
let toolId = '';
let toolSlug = '';
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
prisma = app.get(PrismaService);
});
beforeEach(async () => {
categoryId = `cat_${randomUUID().replace(/-/g, '')}`;
toolId = `tool_${randomUUID().replace(/-/g, '')}`;
toolSlug = `tool-${randomUUID().slice(0, 8)}`;
await prisma.category.create({
data: {
id: categoryId,
name: `Category ${toolSlug}`,
},
});
await prisma.tool.create({
data: {
id: toolId,
name: 'Slug Detail Tool',
slug: toolSlug,
categoryId,
description: '# Manual\n\n## Install',
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
status: ToolStatus.published,
updatedAt: '2026-04-11',
},
});
});
afterEach(async () => {
await prisma.tool.deleteMany({
where: {
id: toolId,
},
});
await prisma.category.deleteMany({
where: {
id: categoryId,
},
});
});
afterAll(async () => {
await app.close();
});
function getHttpServer(): Parameters<typeof request>[0] {
return app.getHttpServer() as Parameters<typeof request>[0];
}
it('returns a published tool detail by slug', async () => {
await request(getHttpServer())
.get(`/tools/slug/${toolSlug}`)
.expect(200)
.expect(({ body }: { body: ToolDetailResponse }) => {
expect(body.id).toBe(toolId);
expect(body.slug).toBe(toolSlug);
expect(body.name).toBe('Slug Detail Tool');
expect(body.description).toContain('# Manual');
});
});
it('returns 404 for an unknown slug', async () => {
await request(getHttpServer()).get('/tools/slug/missing-tool').expect(404);
});
});