diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 00000000..6eeff568 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,103 @@ +name: PR Checks + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +env: + NODE_VERSION: "22" + +jobs: + ################################################################################ + # PR Quality Checks Job + ################################################################################ + pr-checks: + name: PR Quality Checks + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: TypeScript type check + run: npm run build:tsc:check + + - name: Production build + run: npm run build:prod + + - name: Smoke test + run: npm run smoke-test + + - name: Check status summary + if: always() + run: | + echo "::notice::All PR checks passed successfully!" + + ################################################################################ + # Check Summary Job + ################################################################################ + check-summary: + name: Check Summary + needs: pr-checks + if: always() + runs-on: ubuntu-latest + + steps: + - name: Check status + run: | + echo "PR Checks: ${{ needs.pr-checks.result }}" + + - name: Checks failed notification + if: needs.pr-checks.result == 'failure' + run: | + echo "::error::PR checks failed. Please fix the issues before merging." + + - name: Determine overall status + id: status + if: always() + run: | + check_status="${{ needs.pr-checks.result }}" + + echo "PR Checks: ${check_status}" + + if [ "${check_status}" == "failure" ]; then + echo "overall=failed" >> $GITHUB_OUTPUT + echo "status_icon=❌" >> $GITHUB_OUTPUT + echo "status_text=失败" >> $GITHUB_OUTPUT + else + echo "overall=success" >> $GITHUB_OUTPUT + echo "status_icon=✅" >> $GITHUB_OUTPUT + echo "status_text=通过" >> $GITHUB_OUTPUT + fi + + - name: PR check summary + if: always() + run: | + echo "### PR Checks ${{ steps.status.outputs.status_icon }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**状态:** ${{ steps.status.outputs.status_text }}" >> $GITHUB_STEP_SUMMARY + echo "**分支:** ${{ github.head_ref }}" >> $GITHUB_STEP_SUMMARY + echo "**提交:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "**触发者:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "[查看详情](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY + + - name: Checks passed notification + if: needs.pr-checks.result == 'success' + run: | + echo "::notice::PR checks passed! This PR is ready for merge." diff --git a/.github/workflows/sync-azure-storage.yml b/.github/workflows/sync-azure-storage.yml index 748f2924..811204ca 100644 --- a/.github/workflows/sync-azure-storage.yml +++ b/.github/workflows/sync-azure-storage.yml @@ -142,37 +142,51 @@ jobs: ACCOUNT="${{ steps.sas_info.outputs.account }}" CONTAINER="${{ steps.sas_info.outputs.container }}" SAS_TOKEN=$(cat /tmp/sas_token.txt) + RELEASE_TAG="${{ steps.release_info.outputs.tag }}" echo "Generating index.json..." + # Get GitHub Release published_at for fallback + echo "Fetching GitHub Release publish time..." + PUBLISHED_AT=$(gh release view "${RELEASE_TAG}" --json publishedAt -q .publishedAt) + # Get all blobs and organize by version + # Fixed: Correctly extract lastModified from properties.lastModified BLOBS=$(az storage blob list \ --account-name "${ACCOUNT}" \ --container-name "${CONTAINER}" \ --sas-token "${SAS_TOKEN}" \ - --query "[].{name:name, lastModified:lastModified, size:properties.contentLength}" \ + --query "[].{name:name, lastModified:properties.lastModified, size:properties.contentLength}" \ --output json) # Create JSON index with files organized by version - INDEX_JSON=$(echo "$BLOBS" | jq '{ + # Use GitHub Release published_at as fallback if lastModified is null/empty + INDEX_JSON=$(echo "$BLOBS" | jq --arg fallback_time "$PUBLISHED_AT" '{ updatedAt: now, versions: [ .[] | select(.name | contains("index.json") | not) | { - version: (.name | split("/")[0]), - name: (.name | split("/")[1:][-1]), - path: .name, - size: .size, - lastModified: .lastModified - } + version: (.name | split("/")[0]), + name: (.name | split("/")[1:][-1]), + path: .name, + size: .size, + lastModified: (.lastModified // $fallback_time) + } ] | group_by(.version) | map({ - version: .[0].version, - files: [.[].name], - assets: [.[] | {name: .name, path: .path, size: .size, lastModified: .lastModified}] - }) + version: .[0].version, + files: [.[].name], + assets: [ + .[] | { + name: .name, + path: .path, + size: .size, + lastModified: (.lastModified // $fallback_time) + } + ] + }) | sort_by(.version) | reverse }') @@ -232,3 +246,71 @@ jobs: echo " 3. SAS token has not expired" echo " 4. Container exists in Azure Storage" exit 1 + + - name: Export outputs + if: always() + run: | + # Export outputs for notification job + ACCOUNT="${{ steps.sas_info.outputs.account }}" + VERSION="${{ steps.release_info.outputs.tag }}" + + # Count uploaded files + FILE_COUNT=$(ls -1 ./release-assets/* 2>/dev/null | wc -l) + + # Build Azure Storage path + BLOB_PATH="https://${ACCOUNT}.blob.core.windows.net/${{ steps.sas_info.outputs.container }}/${VERSION}/" + + echo "tag=${VERSION}" >> $GITHUB_OUTPUT + echo "file_count=${FILE_COUNT}" >> $GITHUB_OUTPUT + echo "blob_path=${BLOB_PATH}" >> $GITHUB_OUTPUT + echo "storage_account=${ACCOUNT}" >> $GITHUB_OUTPUT + + echo "Exported outputs:" + echo " tag: ${VERSION}" + echo " file_count: ${FILE_COUNT}" + echo " blob_path: ${BLOB_PATH}" + echo " storage_account: ${ACCOUNT}" + + azure-sync-notification: + name: Azure Sync Notification + needs: sync-to-azure + if: always() + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Determine sync status + id: status + run: | + sync_result="${{ needs.sync-to-azure.result }}" + + if [ "${sync_result}" == "success" ]; then + echo "status_icon=✅" >> $GITHUB_OUTPUT + echo "status_text=成功" >> $GITHUB_OUTPUT + echo "status_verb=已成功同步到" >> $GITHUB_OUTPUT + else + echo "status_icon=❌" >> $GITHUB_OUTPUT + echo "status_text=失败" >> $GITHUB_OUTPUT + echo "status_verb=同步到" >> $GITHUB_OUTPUT + fi + + - name: Notify Feishu + uses: HagiCode-org/haginotifier@v1.0.0 + with: + message: | + **Azure 同步${{ steps.status.outputs.status_text }}** + + 发布资源${{ steps.status.outputs.status_verb }} Azure Storage + + 版本: ${{ needs.sync-to-azure.outputs.tag }} + 上传文件: ${{ needs.sync-to-azure.outputs.file_count }} 个 + 存储路径: ${{ needs.sync-to-azure.outputs.blob_path }} + + 提交: `${{ github.sha }}` + 触发者: ${{ github.actor }} + + [查看详情](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + msg_type: 'post' + title: 'Azure 同步${{ steps.status.outputs.status_text }} ${{ steps.status.outputs.status_icon }}' + secrets: inherit diff --git a/openspec/changes/archive/2026-02-10-azure-sync-lark-notification/proposal.md b/openspec/changes/archive/2026-02-10-azure-sync-lark-notification/proposal.md new file mode 100644 index 00000000..f99e2f3e --- /dev/null +++ b/openspec/changes/archive/2026-02-10-azure-sync-lark-notification/proposal.md @@ -0,0 +1,200 @@ +# Change: Azure 同步完成飞书通知 + +**Change ID:** `azure-sync-lark-notification` +**Status:** ExecutionCompleted +**Created:** 2026-02-10 + +--- + +## Why + +当前项目在 Azure Storage 同步命令构建完成后,缺少自动化的结果通知机制。这导致: + +- 开发团队无法及时获知 Azure Storage 同步状态 +- 需要主动检查 GitHub Actions 页面来确认发布包是否成功上传 +- 同步失败时无法快速响应和排查问题 +- 与现有的飞书构建发布通知体系不一致 + +为解决这些问题,需要在 Azure 同步工作流完成后自动向飞书推送同步结果通知。 + +## What Changes + +- **新增 Azure 同步通知步骤**:在 sync-azure-storage.yml 工作流中添加通知任务 +- **复用现有通知基础设施**:使用项目已集成的 HagiCode-org/haginotifier@v1.0.0 工作流 +- **支持同步状态通知**:确保无论同步成功或失败都发送通知 +- **包含 Azure 特定信息**:通知包含 Azure Storage 路径、文件数量等同步相关信息 + +## 代码流程变更 + +### 通知集成架构 + +```mermaid +graph TB + subgraph "GitHub Actions 工作流" + A[build-windows] + B[build-linux] + C[build-macos] + D[build-summary] + E[sync-azure] + F[azure-sync-notification] + end + + subgraph "haginotifier 工作流" + G[接收输入参数] + H[构建飞书消息] + I[发送到飞书 Webhook] + end + + subgraph "飞书" + J[飞书机器人] + K[飞书群组/用户] + end + + A --> D + B --> D + C --> D + D --> E + E --> F + F --> G + G --> H + H --> I + I --> J + J --> K + + style F fill:#e1f5fe + style G fill:#fff3e0 + style J fill:#c8e6c9 +``` + +### Azure 同步通知流程 + +```mermaid +sequenceDiagram + participant Build as build.yml + participant Azure as sync-azure-storage.yml + participant Notify as azure-sync-notification + participant Haginotifier as haginotifier + participant Feishu as 飞书 Webhook + + Build->>Azure: 调用 sync-azure workflow + Azure->>Azure: 下载发布资源 + Azure->>Azure: 上传到 Azure Storage + Azure->>Azure: 生成并上传 index.json + Azure->>Azure: 验证上传 + + alt 同步成功 + Azure->>Notify: 触发成功通知 + Notify->>Haginotifier: 调用成功通知 + Haginotifier->>Haginotifier: 构建成功消息 + Haginotifier->>Feishu: 发送成功通知 + else 同步失败 + Azure->>Notify: 触发失败通知 + Notify->>Haginotifier: 调用失败通知 + Haginotifier->>Haginotifier: 构建失败消息 + Haginotifier->>Feishu: 发送失败通知 + end + + Feishu-->>Notify: 返回发送结果 + Notify-->>Azure: 任务完成 + Azure-->>Build: 返回同步状态 + + Note over Notify: if: always() 确保无论成功失败都发送 +``` + +### 飞书通知消息格式 + +#### 成功通知示例 + +``` +┌─────────────────────────────────────────┐ +│ ✅ Azure 同步完成 │ +├─────────────────────────────────────────┤ +│ │ +│ 发布资源已成功同步到 Azure Storage │ +│ │ +│ 版本: v1.0.0 │ +│ 存储路径: https://.blob.../1.0.0/│ +│ 上传文件: 12 个 │ +│ │ +│ 提交: abc1234 │ +│ 触发者: username │ +│ │ +│ [查看详情](GitHub Actions 链接) │ +└─────────────────────────────────────────┘ +``` + +#### 失败通知示例 + +``` +┌─────────────────────────────────────────┐ +│ ❌ Azure 同步失败 │ +├─────────────────────────────────────────┤ +│ │ +│ 发布资源同步到 Azure Storage 时发生错误 │ +│ │ +│ 版本: v1.0.0 │ +│ 错误阶段: 上传文件 │ +│ │ +│ 请检查: │ +│ • SAS URL 是否有效 │ +│ • 写入权限是否正确配置 │ +│ • 容器是否存在 │ +│ │ +│ 提交: abc1234 │ +│ [查看详情](GitHub Actions 链接) │ +└─────────────────────────────────────────┘ +``` + +### 代码变更清单 + +| 文件路径 | 变更类型 | 变更原因 | 影响范围 | +|---------|---------|---------|---------| +| `.github/workflows/sync-azure-storage.yml` | 修改文件 | 添加飞书通知任务 | Azure 同步工作流 | + +### 详细变更 - sync-azure-storage.yml + +| 属性/配置 | 变更类型 | 具体变更 | 变更原因 | +|----------|---------|---------|---------| +| jobs.azure-sync-notification | 新增 job | 添加 Azure 同步通知任务 | 发送同步结果通知 | +| azure-sync-notification.if | 新增条件 | 设置 if: always() | 无论成功失败都通知 | +| azure-sync-notification.needs | 新增依赖 | 依赖 sync-to-azure 任务 | 确保在同步完成后通知 | +| azure-sync-notification.uses | 新增步骤 | 使用 haginotifier 工作流 | 复用现有通知逻辑 | +| azure-sync-notification.with.message | 新增参数 | 定义通知消息内容 | 发送同步结果信息 | +| azure-sync-notification.with.msg_type | 新增参数 | 设置为 'post' | 使用富文本消息格式 | +| azure-sync-notification.with.title | 新增参数 | 设置通知标题 | 标识通知来源 | +| azure-sync-notification.secrets.FEISHU_WEBHOOK_URL | 新增密钥 | 继承调用方密钥 | 安全传递 Webhook URL | + +## Impact + +### Affected specs +- `specs/ci-cd/spec.md` - 扩展现有规范,添加 Azure 同步通知功能 + +### Affected code +- **工作流修改**: + - `.github/workflows/sync-azure-storage.yml` - 添加 Azure 同步通知任务 + +### User impact +- **正面影响**: + - 开发团队将自动收到 Azure 同步结果通知,无需手动检查 + - 同步失败时可以快速响应和排查问题 + - 与现有飞书构建发布通知保持一致的体验 + - 提升发布流程的可观测性和可靠性 + +- **潜在风险**: + - 飞书 Webhook URL 泄露可能导致滥用(已通过 GitHub Secrets 机制缓解) + - haginotifier 服务不可用可能导致通知失败(不影响同步主流程) + - 通知频率增加可能造成打扰(仅在 Azure 同步流程结束时发送) + +### Migration considerations +- 无需迁移,此为新功能 +- 依赖现有的 FEISHU_WEBHOOK_URL 密钥配置(已在飞书构建发布通知中配置) + +### Performance considerations +- 通知任务异步执行,不阻塞同步流程 +- 通知发送时间通常 < 1 秒,对整体流程影响可忽略 +- 使用 if: always() 确保即使同步失败也会发送通知 + +### Security considerations +- 飞书 Webhook URL 通过 secrets: inherit 继承,不会暴露在日志中 +- 通知内容不包含敏感信息(仅包含公开的同步状态和路径信息) +- Azure SAS URL 不会被包含在通知消息中 diff --git a/openspec/changes/archive/2026-02-10-azure-sync-lark-notification/tasks.md b/openspec/changes/archive/2026-02-10-azure-sync-lark-notification/tasks.md new file mode 100644 index 00000000..69575426 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-azure-sync-lark-notification/tasks.md @@ -0,0 +1,183 @@ +# Implementation Tasks + +## 1. 依赖验证 + +### 1.1 确认现有配置 +- [ ] 1.1.1 确认 FEISHU_WEBHOOK_URL 已配置 + - 验证仓库 Secrets 中存在 FEISHU_WEBHOOK_URL + - 确认该密钥在 build.yml 中正常工作 + - 记录现有的飞书通知格式和样式 + +- [ ] 1.1.2 确认 haginotifier 版本和用法 + - 确认项目使用 HagiCode-org/haginotifier@v1.0.0 + - 参考 build.yml 中的通知实现方式 + - 确认消息格式参数(msg_type, title, message) + +## 2. 工作流文件修改 + +### 2.1 修改 sync-azure-storage.yml +- [ ] 2.1.1 添加输出参数到 sync-to-azure job + - 在 sync-to-azure 任务末尾添加输出步骤 + - 输出版本号 (release_tag) + - 输出上传文件数量 + - 输出 Azure Storage 路径 + - 输出同步状态 (success/failure) + +- [ ] 2.1.2 在 sync-azure-storage.yml 中添加 azure-sync-notification job + - 在 jobs 部分添加 azure-sync-notification 任务 + - 设置 needs: sync-to-azure + - 设置 if: always() 条件 + - 设置权限: contents: read + +- [ ] 2.1.3 配置 azure-sync-notification 任务参数 + - 设置 uses: HagiCode-org/haginotifier@v1.0.0 + - 配置 with.message 参数,包含: + - Azure 同步完成标题 + - 同步状态(基于 sync-to-azure 的输出) + - 版本信息 (${{ needs.sync-to-azure.outputs.tag }}) + - 上传文件数量 (${{ needs.sync-to-azure.outputs.file_count }}) + - Azure Storage 路径 (${{ needs.sync-to-azure.outputs.blob_path }}) + - 提交 SHA (${{ github.sha }}) + - 触发者 (${{ github.actor }}) + - GitHub Actions 链接 + - 配置 with.msg_type: 'post' + - 配置 with.title: 'Azure 同步完成 ${{ status_icon }}' + +- [ ] 2.1.4 配置密钥传递 + - 确认通过 secrets: inherit 继承 FEISHU_WEBHOOK_URL + - 验证密钥不会被打印到日志 + +### 2.2 修改 sync-to-azure job 添加输出 +- [ ] 2.2.1 在 sync-to-azure job 末尾添加输出步骤 + - 添加 "Export outputs" 步骤 + - 输出 tag: ${{ steps.release_info.outputs.tag }} + - 输出 file_count: 计算上传的文件数量 + - 输出 blob_path: Azure Storage 路径 + - 输出 storage_account: 存储账户名称 + +## 3. 测试和验证 + +### 3.1 工作流测试 +- [ ] 3.1.1 验证 YAML 语法正确性 + - 使用 GitHub Actions 的语法检查工具 + - 确保缩进正确 + - 确保引用语法正确 + +- [ ] 3.1.2 测试手动触发工作流 + - 使用 workflow_dispatch 触发 sync-azure-storage.yml + - 观察工作流执行过程 + - 验证 azure-sync-notification 任务是否正确执行 + +- [ ] 3.1.3 测试完整发布流程 + - 创建并推送一个测试版本标签 + - 等待 build.yml 完成 + - 等待 sync-azure-storage.yml 执行 + - 验证通知是否在同步完成后发送 + +### 3.2 通知内容验证 +- [ ] 3.2.1 验证成功通知格式 + - 触发一次成功的 Azure 同步 + - 检查飞书是否收到成功通知 + - 验证消息标题包含 "✅ Azure 同步完成" + - 验证版本信息正确显示 + - 验证上传文件数量正确 + - 验证 Azure Storage 路径正确 + - 验证 GitHub Actions 链接可点击 + +- [ ] 3.2.2 验证失败通知格式 + - 临时配置无效的 SAS URL 触发同步失败 + - 检查飞书是否收到失败通知 + - 验证消息标题包含 "❌ Azure 同步失败" + - 验证错误信息清晰 + - 验证排查建议合理 + +### 3.3 边界条件测试 +- [ ] 3.3.1 测试同步部分成功的情况 + - 模拟部分文件上传失败的场景 + - 确认通知正确反映部分失败状态 + +- [ ] 3.3.2 测试 Webhook URL 无效时的处理 + - 临时使用无效的 Webhook URL + - 确认同步流程不会因通知失败而中断 + - 检查错误日志是否清晰 + +- [ ] 3.3.3 测试 workflow_dispatch 手动触发 + - 通过 workflow_dispatch 手动触发 sync-azure + - 验证通知仍然正常工作 + - 验证消息中标识为手动触发 + +## 4. 文档更新 + +### 4.1 更新项目文档 +- [ ] 4.1.1 更新 openspec/specs/ci-cd/spec.md + - 添加 Azure 同步通知相关规范 + - 说明通知触发条件(sync-azure 工作流完成时) + - 包含通知消息格式和字段说明 + +### 4.2 创建配置指南 +- [ ] 4.2.1 更新 CI/CD 配置文档 + - 说明 Azure 同步通知的工作原理 + - 说明如何复用现有的 FEISHU_WEBHOOK_URL + - 说明通知消息格式和自定义方法 + +## 5. 代码审查 + +### 5.1 工作流审查 +- [ ] 5.1.1 审查 sync-azure-storage.yml 变更 + - 确认 azure-sync-notification 任务位置合理 + - 确认依赖关系正确 (needs: sync-to-azure) + - 确认 if: always() 条件设置正确 + - 确认输出参数正确传递 + +- [ ] 5.1.2 安全审查 + - 确认 Webhook URL 不会泄露到日志 + - 确认通知内容不包含敏感信息(如完整 SAS URL) + - 确认 Azure Storage 路径信息不会暴露敏感凭证 + +### 5.2 验证通过后 +- [ ] 5.2.1 提交变更 + - 创建功能分支 + - 提交变更到分支 + - 推送到远程仓库 + +- [ ] 5.2.2 创建 Pull Request + - 创建 PR 描述变更内容 + - 关联此 Change Proposal + - 等待代码审查 + +- [ ] 5.2.3 合并到主分支 + - 等待代码审查通过 + - 合并到主分支 + - 监控后续发布的通知是否正常 + +## 6. 监控和优化(可选) + +### 6.1 监控通知效果 +- [ ] 6.1.1 收集反馈 + - 向团队收集通知格式反馈 + - 记录通知的及时性和准确性 + - 识别需要改进的地方 + +- [ ] 6.1.2 分析通知失败情况 + - 监控 haginotifier 执行失败率 + - 记录失败原因和解决方案 + - 建立通知失败的响应机制 + +### 6.2 通知内容优化 +- [ ] 6.2.1 添加更多同步信息 + - 同步耗时 + - 文件大小统计 + - index.json 更新状态 + +- [ ] 6.2.2 支持自定义通知内容 + - 允许通过配置自定义消息格式 + - 支持不同环境使用不同的通知格式 + +### 6.3 通知渠道扩展 +- [ ] 6.3.1 支持多个通知渠道 + - 添加其他通知方式(如 Slack、Teams 等) + - 统一通知接口 + +- [ ] 6.3.2 支持不同级别的通知 + - 区分同步成功和失败的通知优先级 + - 支持仅失败时通知选项 diff --git a/openspec/changes/archive/2026-02-10-fix-index-json-last-modified-field/proposal.md b/openspec/changes/archive/2026-02-10-fix-index-json-last-modified-field/proposal.md new file mode 100644 index 00000000..1ba9504e --- /dev/null +++ b/openspec/changes/archive/2026-02-10-fix-index-json-last-modified-field/proposal.md @@ -0,0 +1,55 @@ +# Change: 修复 index.json 中 Last Modified 字段为空的问题 + +## Why + +当前 index.json 生成流程中,所有版本的 `lastModified` 字段为空值,导致: +- 客户端无法准确判断版本的更新时间 +- 影响版本比较和更新检测逻辑 +- 用户无法通过 UI 查看版本的发布/修改时间信息 + +经过代码审查发现问题根源在 `.github/workflows/sync-azure-storage.yml:174` 行: +- assets 数组中的 `lastModified` 字段被设置为 `.lastModified`(Azure Blob 的属性) +- 但在 jq 转换过程中,该属性没有正确传递到最终的 JSON 输出 + +## What Changes + +- **修复 index.json 生成逻辑**:在 `sync-azure-storage.yml` 工作流中修正 jq 脚本,确保 `lastModified` 字段正确填充到每个 asset +- **确保时间戳格式正确**:使用 ISO 8601 格式的时间戳(Azure 默认提供) +- **添加后备方案**:如果 Azure Blob 的 `lastModified` 不可用或为空,使用 GitHub Release 的 `published_at` 时间作为后备值 +- **保持向后兼容**:现有字段结构不变,仅修复缺失的数据 + +## Code Flow Changes + +```mermaid +flowchart TD + A["Azure Storage Blob List"] --> B["Extract Metadata
name, lastModified, size"] + B --> C{lastModified available?} + C -->|Yes| D["Use Azure lastModified"] + C -->|No/Empty| E["Fallback to GitHub Release
published_at"] + D --> F["Group by Version"] + E --> F + F --> G["Generate index.json"] + + style C fill:#ff9 + style E fill:#9cf +``` + +### 后备方案(Fallback) + +当 Azure Blob 的 `lastModified` 属性为空或不可用时: +1. 从 GitHub Release API 获取 `published_at` 时间戳 +2. 使用该时间作为 `lastModified` 的值 +3. 确保时间格式统一为 ISO 8601 + +## Impact + +- **Affected specs**: `ci-cd` +- **Affected code**: `.github/workflows/sync-azure-storage.yml` (第 140-200 行) +- **Data format**: index.json 的 `assets[].lastModified` 字段将包含有效的 ISO 8601 时间戳 + +## Testing Plan + +1. 验证生成的 index.json 中每个 asset 都包含有效的 `lastModified` 字段 +2. 确认客户端能正确解析和显示版本时间信息 +3. 确保压缩格式 index.json.gz 也包含正确的数据 +4. **验证后备方案**:测试当 Azure `lastModified` 不可用时,能正确使用 GitHub Release 的 `published_at` 时间 diff --git a/openspec/changes/archive/2026-02-10-fix-index-json-last-modified-field/specs/ci-cd/spec.md b/openspec/changes/archive/2026-02-10-fix-index-json-last-modified-field/specs/ci-cd/spec.md new file mode 100644 index 00000000..0354b01a --- /dev/null +++ b/openspec/changes/archive/2026-02-10-fix-index-json-last-modified-field/specs/ci-cd/spec.md @@ -0,0 +1,46 @@ +# ci-cd Specification Delta + +## MODIFIED Requirements + +### Requirement: Azure Storage 同步工作流 + +项目 MUST 在发布标签创建后自动将发布资源同步到 Azure Storage,并在同步完成后发送飞书通知。生成的 index.json 文件 MUST 包含每个版本资源的完整元数据,包括文件名、路径、大小和最后修改时间。 + +#### Scenario: 发布标签触发 Azure 同步 + +**Given** 创建新的版本标签(如 `v1.0.0`) +**When** 标签推送到仓库且构建完成 +**Then** 自动触发 `.github/workflows/sync-azure-storage.yml` 工作流 +**And** 下载发布资源到临时目录 +**And** 上传资源到 Azure Storage 对应版本目录 +**And** 生成并上传 index.json 文件,其中每个 asset 都包含有效的 `lastModified` 字段 +**And** 发送同步结果通知到飞书 + +#### Scenario: index.json 包含完整元数据 + +**Given** Azure Storage 同步工作流生成 index.json +**When** 从 Azure Storage 获取 Blob 列表并构建索引 +**Then** 每个版本的 assets 数组中每个元素包含以下字段: + - `name`: 文件名 + - `path`: 存储路径 + - `size`: 文件大小(字节) + - `lastModified`: 最后修改时间(ISO 8601 格式) +**And** `lastModified` 字段值优先来自 Azure Blob 的 `lastModified` 属性 +**And** 如果 Azure Blob 的 `lastModified` 为空或不可用,使用 GitHub Release 的 `published_at` 时间作为后备值 +**And** 时间戳格式为 ISO 8601 标准(例如:`2024-02-10T12:34:56.789Z`) + +#### Scenario: lastModified 后备方案 + +**Given** Azure Storage 同步工作流生成 index.json +**When** Azure Blob 的 `lastModified` 属性为空或不可用 +**Then** 从 GitHub Release API 获取对应版本的 `published_at` 时间 +**And** 使用 `published_at` 时间作为 `lastModified` 字段的值 +**And** 确保时间格式统一为 ISO 8601 标准 + +#### Scenario: index.json 验证 + +**Given** index.json 生成完成 +**When** 运行 `jq empty index.json` 验证 JSON 格式 +**Then** JSON 格式验证通过 +**And** 所有版本和资源数据完整 +**And** 文件上传到 Azure Storage 根目录 diff --git a/openspec/changes/archive/2026-02-10-fix-index-json-last-modified-field/tasks.md b/openspec/changes/archive/2026-02-10-fix-index-json-last-modified-field/tasks.md new file mode 100644 index 00000000..71b4fe0f --- /dev/null +++ b/openspec/changes/archive/2026-02-10-fix-index-json-last-modified-field/tasks.md @@ -0,0 +1,33 @@ +## 1. 调查和验证 + +- [x] 1.1 分析当前 index.json 生成逻辑,确认 lastModified 字段为空的原因 +- [x] 1.2 检查 Azure Storage API 返回的 Blob 元数据格式 +- [x] 1.3 验证 Azure Storage 的 `lastModified` 属性格式和可用性 + +## 2. 修复 index.json 生成逻辑 + +- [x] 2.1 修改 `.github/workflows/sync-azure-storage.yml` 中的 jq 脚本 +- [x] 2.2 确保 assets 数组中每个元素都包含正确的 lastModified 字段 +- [x] 2.3 验证时间戳格式为 ISO 8601 标准 +- [x] 2.4 实现后备方案:当 Azure lastModified 不可用时,使用 GitHub Release 的 `published_at` 时间 +- [x] 2.5 添加 GitHub Release API 调用以获取 `published_at` 数据(如需要) + +## 3. 本地测试 + +- [x] 3.1 使用示例数据测试 jq 脚本逻辑 +- [x] 3.2 验证生成的 JSON 格式正确且字段完整 +- [x] 3.3 确认压缩输出 (jq -c) 不影响数据完整性 +- [x] 3.4 测试后备方案:模拟 lastModified 为空的情况,验证使用创建时间覆盖 + +## 4. 提交和验证 + +- [ ] 4.1 提交修改到 PR 分支 +- [ ] 4.2 等待 PR 检查通过 +- [ ] 4.3 合并后手动触发 Azure 同步工作流验证修复效果 +- [ ] 4.4 下载生成的 index.json 确认 lastModified 字段正确填充 + +## 5. 客户端验证 + +- [ ] 5.1 在开发环境测试 HTTP Index 包源读取 +- [ ] 5.2 确认 UI 中版本时间正确显示 +- [ ] 5.3 验证版本比较和更新检测逻辑正常工作 diff --git a/openspec/changes/archive/2026-02-10-onboarding-dependency-one-click-install/proposal.md b/openspec/changes/archive/2026-02-10-onboarding-dependency-one-click-install/proposal.md new file mode 100644 index 00000000..3846b425 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-onboarding-dependency-one-click-install/proposal.md @@ -0,0 +1,325 @@ +# 引导流程添加依赖一键安装 + +**Change ID:** `onboarding-dependency-one-click-install` +**Status:** ExecutionCompleted +**Created:** 2026-02-10 + +--- + +## 概述 + +为首次使用引导(Onboarding)流程添加依赖项一键安装功能,统一引导流程与版本管理页面的依赖管理体验。 + +## 背景与问题 + +### 当前状态 + +Hagicode Desktop 包含两个依赖管理入口: + +1. **引导流程依赖管理** (`DependencyInstaller.tsx`) + - 基础的依赖检查和安装界面 + - 单个"Install Dependencies"按钮批量安装 + - 简单的进度显示 + +2. **版本管理页面依赖管理** (`DependencyManagementCard.tsx` + `DependencyInstallConfirmDialog.tsx`) + - 完整的依赖管理功能 + - 逐个依赖项状态显示 + - 一键安装所有缺失依赖 + - 详细的进度跟踪和错误处理 + - 支持手动安装链接 + +### 存在的问题 + +1. **用户体验不一致**:同一功能在不同页面呈现不同的交互模式 +2. **功能割裂**:引导流程缺少版本管理页面已有的完善功能 +3. **代码重复**:依赖管理逻辑分散在两处,维护成本高 +4. **操作效率低**:新用户无法在引导流程中快速完成依赖安装 + +## 解决方案 + +### 核心策略 + +采用**直接组件复用**策略,让引导流程直接使用版本管理页面的依赖管理组件。 + +**关键原则**: +- 引导流程的依赖管理步骤直接嵌入 `DependencyManagementCard` 组件 +- 共享相同的 Redux 状态 (`dependencySlice`) 和 Saga 逻辑 +- 确保两处使用完全相同的管理和维护逻辑 +- 仅针对引导流程的特殊需求做最小化适配 + +这种策略的优势: +1. **单一数据源**:依赖状态只存在于 `dependencySlice` 中,避免状态同步问题 +2. **统一维护**:依赖管理功能的改进自动惠及两个页面 +3. **一致性保证**:UI/UX 完全一致,用户体验无差异 +4. **代码精简**:大幅减少重复代码 + +### 实现范围 + +#### 1. 直接组件复用 + +引导流程直接嵌入以下组件: + +| 组件/模块 | 复用方式 | 说明 | +|-----------|---------|------| +| `DependencyManagementCard` | 直接嵌入 | 作为引导流程依赖管理步骤的主要内容 | +| `DependencyInstallConfirmDialog` | 直接复用 | 批量安装确认对话框 | +| `dependencySlice` | 直接复用 | 使用相同的依赖状态管理 | +| `dependencySaga` | 直接复用 | 使用相同的异步操作和进度跟踪 | + +#### 2. 架构调整 + +**当前状态**: +``` +引导流程 ──> onboardingSlice ──> 独立的依赖管理逻辑 +版本管理 ──> dependencySlice ──> 完善的依赖管理逻辑 +``` + +**目标状态**: +``` +引导流程 ──┐ + ├──> dependencySlice ──> dependencySaga ──> 统一的依赖管理逻辑 +版本管理 ──┘ +``` + +#### 3. 功能适配 + +引导流程需要的特殊适配: + +- **上下文适配**:传递引导流程特有的版本 ID 和上下文信息 +- **导航控制**:依赖安装完成后自动进入下一步骤 +- **简化 UI**:引导流程中可能需要隐藏部分高级选项(如"访问官方网站") + +## 技术实现 + +### 架构概览 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 引导流程与版本管理的统一架构 │ +└─────────────────────────────────────────────────────────────┘ + +┌───────────────────────────┐ ┌───────────────────────────┐ +│ Onboarding Flow │ │ Version Management │ +│ (引导流程) │ │ (版本管理页面) │ +├───────────────────────────┤ ├───────────────────────────┤ +│ DependencyInstaller.tsx │ │ VersionManagementPage.tsx │ +│ │ │ │ │ │ +│ └── Embedded ───────────┼─────┼──> DependencyManagement │ +│ DependencyCard │ │ Card.tsx │ +└───────────────────────────┘ └───────────────────────────┘ + │ │ + └─────────────┬───────────────────┘ + ▼ + ┌─────────────────────────────────────────────┐ + │ Shared Redux State (dependencySlice) │ + │ ┌───────────────────────────────────────┐ │ + │ │ dependencies: DependencyItem[] │ │ + │ │ installConfirm: { show, deps, id } │ │ + │ │ installProgress: { current, total } │ │ + │ └───────────────────────────────────────┘ │ + └─────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────┐ + │ Shared Saga (dependencySaga) │ + │ ┌───────────────────────────────────────┐ │ + │ │ installFromManifest │ │ + │ │ Progress tracking via event channels │ │ + │ │ Error handling & toast notifications │ │ + │ └───────────────────────────────────────┘ │ + └─────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────┐ + │ Main Process (dependency-manager.ts) │ + │ ┌───────────────────────────────────────┐ │ + │ │ checkAllDependencies() │ │ + │ │ installFromManifest() │ │ + │ │ Progress events via IPC │ │ + │ └───────────────────────────────────────┘ │ + └─────────────────────────────────────────────┘ +``` + +### 关键实现点 + +#### 1. DependencyInstaller 组件重构 + +```typescript +// DependencyInstaller.tsx - 重构后的结构 +import { DependencyManagementCard } from '../../DependencyManagementCard'; +import { useAppSelector } from '../../store/hooks'; + +export function DependencyInstaller() { + const { versionId } = useAppSelector(state => state.onboarding); + + // 引导流程特有的回调:安装完成后自动进入下一步 + const handleInstallComplete = () => { + // 触发引导流程的下一步导航 + }; + + return ( +
+
+

{t('dependencies.title')}

+

{t('dependencies.description')}

+
+ + {/* 直接嵌入 DependencyManagementCard,传递引导流程上下文 */} + +
+ ); +} +``` + +#### 2. DependencyManagementCard 适配 + +对现有组件进行最小化修改以支持引导流程: + +```typescript +// 新增 props +interface DependencyManagementCardProps { + versionId: string; + context?: 'version-management' | 'onboarding'; // 上下文标识 + onInstallComplete?: () => void; // 引导流程回调 + showAdvancedOptions?: boolean; // 是否显示高级选项 +} +``` + +#### 3. 状态管理统一 + +- **废弃**: `onboardingSlice` 中的依赖相关状态 (`dependenciesStatus`, `isInstallingDependencies`) +- **使用**: 直接从 `dependencySlice` 读取所有依赖状态 +- **好处**: 消除状态同步问题,单一数据源 + +### 状态管理 + +**统一使用 dependencySlice**: + +```typescript +// dependencySlice.ts (统一状态源) +interface DependencyState { + dependencies: DependencyItem[]; // 所有依赖项状态 + loading: boolean; // 加载状态 + installing: boolean; // 安装进行中 + installConfirm: { + show: boolean; + dependencies: DependencyItem[]; + versionId: string; + }; + installProgress: { + installing: boolean; + current: number; + total: number; + currentDependency: string; + errors: Array<{ dependency: string; error: string }>; + }; +} + +// onboardingSlice.ts (简化后,移除依赖相关状态) +interface OnboardingState { + currentStep: number; + versionId: string; + // 移除: dependenciesStatus, isInstallingDependencies + // 这些状态现在从 dependencySlice 读取 +} +``` + +### IPC 通信 + +完全复用现有的 IPC 接口,无需新增: + +| 方法 | 用途 | 原有调用者 | +|------|------|-----------| +| `checkDependencies()` | 检查依赖状态 | 版本管理页面 | +| `installFromManifest(versionId)` | 批量安装依赖 | 版本管理页面 | +| `onDependencyInstallProgress` | 监听安装进度 | 版本管理页面 | + +**优势**:引导流程和版本管理页面使用完全相同的后端接口。 + +### 国际化支持 + +在现有翻译文件基础上添加/更新: + +**`onboarding.json`** (英文/中文): +- `dependencies.oneClickInstall`: 一键安装按钮文本 +- `dependencies.confirmDialog.title`: 确认对话框标题 +- `dependencies.confirmDialog.description`: 确认对话框描述 +- `dependencies.progress.installing`: 安装进行中文本 + +**复用 `components.json` 中的现有翻译**: +- `depInstallConfirm.*` - 确认对话框相关 +- `dependencyManagement.*` - 依赖管理相关 + +## 实施计划 + +详细的实施任务清单请参阅 [tasks.md](./tasks.md)。 + +## 影响评估 + +### 用户体验改善 + +- **减少操作步骤**:用户可以在引导流程中一次性完成所有依赖安装 +- **统一体验**:引导流程与版本管理页面使用相同的交互模式 +- **更好的反馈**:实时进度显示和错误提示 + +### 技术影响 + +**正面影响**: +- **代码复用最大化**:引导流程直接使用现有组件,几乎不需要新增代码 +- **统一维护**:依赖管理功能的改进自动惠及两个页面 +- **消除状态同步问题**:单一数据源,不存在状态不一致 +- **降低 bug 风险**:使用已验证的组件和逻辑 + +**潜在风险**: +- `DependencyManagementCard` 需要适配引导流程的特殊需求 +- 需要确保组件复用不会影响版本管理页面的现有功能 +- 可能需要重构 `onboardingSlice` 以移除冗余的依赖状态 + +### 兼容性 + +- 保持与现有 IPC 接口的兼容性 +- 确保不影响版本管理页面的现有功能 +- 支持所有平台(Windows、macOS、Linux) + +## 成功标准 + +1. **功能完整性**:引导流程中能够一键安装所有缺失依赖 +2. **用户体验**:安装过程有清晰的进度反馈和错误提示 +3. **代码质量**:复用现有组件,不增加代码重复 +4. **测试覆盖**:所有新增和修改的代码都有对应测试 +5. **国际化**:支持中英文双语 + +## 相关文件 + +### 需要修改的文件 + +| 文件 | 修改类型 | 说明 | +|------|---------|------| +| `src/renderer/components/onboarding/steps/DependencyInstaller.tsx` | 重构 | 直接嵌入 `DependencyManagementCard` | +| `src/renderer/components/DependencyManagementCard.tsx` | 适配 | 添加 `context`、`onInstallComplete` 等 props | +| `src/renderer/store/slices/onboardingSlice.ts` | 精简 | 移除冗余的依赖相关状态 | +| `src/renderer/store/thunks/onboardingThunks.ts` | 精简 | 移除依赖相关的 thunk | + +### 需要复用的组件(无需修改) + +| 组件 | 用途 | +|------|------| +| `DependencyInstallConfirmDialog.tsx` | 批量安装确认对话框 | +| `dependencySaga.ts` | 异步操作和进度跟踪 | +| `dependencySlice.ts` | 统一的依赖状态管理 | + +### 后端文件(无需修改) + +- `src/main/dependency-manager.ts` +- 相关 IPC 处理程序 + +## 参考资料 + +- [OpenSpec 规范文档](../../specs/openspec-spec.md) +- [项目架构文档](../../docs/architecture.md) +- [组件设计指南](../../docs/component-guidelines.md) diff --git a/openspec/changes/archive/2026-02-10-onboarding-dependency-one-click-install/tasks.md b/openspec/changes/archive/2026-02-10-onboarding-dependency-one-click-install/tasks.md new file mode 100644 index 00000000..8191a10d --- /dev/null +++ b/openspec/changes/archive/2026-02-10-onboarding-dependency-one-click-install/tasks.md @@ -0,0 +1,366 @@ +# 实施任务清单 + +本文档详细列出"引导流程添加依赖一键安装"功能的实施任务。 + +## 任务概览 + +| 阶段 | 任务数 | 预计复杂度 | +|------|--------|------------| +| Phase 1: 状态管理精简 | 2 | 低 | +| Phase 2: 组件适配 | 2 | 中 | +| Phase 3: 引导流程集成 | 1 | 低 | +| Phase 4: 国际化调整 | 1 | 低 | +| Phase 5: 测试与验证 | 3 | 中 | +| **总计** | **9** | - | + +> **注意**:由于采用直接组件复用策略,任务数量从原计划的 12 个减少到 9 个。 + +--- + +## Phase 1: 状态管理精简 + +### Task 1.1: 精简 onboardingSlice + +**目标**: 移除 `onboardingSlice` 中冗余的依赖相关状态。 + +**描述**: +- 从 `onboardingSlice.ts` 中移除 `dependenciesStatus` 和 `isInstallingDependencies` +- 移除相关的 reducers 和 selectors +- 更新类型定义 + +**涉及文件**: +- `src/renderer/store/slices/onboardingSlice.ts` + +**移除内容**: +```typescript +// 移除以下状态: +interface OnboardingState { + // 移除: dependenciesStatus: DependencyItem[]; + // 移除: isInstallingDependencies: boolean; + currentStep: number; + versionId: string; + // ... 其他非依赖相关状态 +} + +// 移除以下 selectors: +// - selectDependenciesStatus +// - selectIsInstallingDependencies +``` + +**验收标准**: +- [ ] 依赖相关状态已完全移除 +- [ ] 现有测试通过(或已更新) +- [ ] TypeScript 编译无错误 + +--- + +### Task 1.2: 移除 onboarding 依赖相关 Thunks + +**目标**: 移除 `onboardingThunks.ts` 中冗余的依赖管理 thunk。 + +**描述**: +- 移除 `checkDependencies` 和 `installDependencies` thunk +- 这些功能现在由 `dependencySaga` 提供 +- 更新引用这些 thunk 的地方 + +**涉及文件**: +- `src/renderer/store/thunks/onboardingThunks.ts` +- `src/renderer/components/onboarding/steps/DependencyInstaller.tsx` + +**移除内容**: +```typescript +// 移除以下 thunks: +// - checkDependencies (使用 dependencySaga 的 fetchDependenciesStatus) +// - installDependencies (使用 dependencySaga 的 installFromManifest) +``` + +**验收标准**: +- [ ] 冗余 thunks 已移除 +- [ ] 无编译错误 +- [ ] 引用已更新为使用 dependencySlice 的 actions + +--- + +## Phase 2: 组件适配 + +### Task 2.1: 扩展 DependencyManagementCard 支持 + +**目标**: 为 `DependencyManagementCard` 添加支持引导流程的 props。 + +**描述**: +- 添加可选的 `context` prop 用于标识使用场景 +- 添加 `onInstallComplete` 回调 prop +- 添加 `showAdvancedOptions` prop 用于控制 UI 简化 +- 根据上下文调整组件行为 + +**涉及文件**: +- `src/renderer/components/DependencyManagementCard.tsx` + +**实现细节**: +```typescript +interface DependencyManagementCardProps { + // 现有 props + versionId: string; + + // 新增 props + context?: 'version-management' | 'onboarding'; + onInstallComplete?: () => void; + showAdvancedOptions?: boolean; +} + +// 组件内部根据 context 调整行为 +const shouldShowAdvanced = showAdvancedOptions ?? context === 'version-management'; +``` + +**验收标准**: +- [ ] 新 props 有合理的默认值 +- [ ] 组件在两种上下文中都能正常工作 +- [ ] 现有版本管理页面功能不受影响 + +--- + +### Task 2.2: 添加引导流程完成回调 + +**目标**: 在依赖安装完成后触发引导流程的后续步骤。 + +**描述**: +- 在 `dependencySaga` 中监听安装完成事件 +- 调用 `onInstallComplete` 回调(如果提供) +- 确保只在引导流程上下文中触发 + +**涉及文件**: +- `src/renderer/store/sagas/dependencySaga.ts` + +**实现细节**: +```typescript +// 在 installFromManifest saga 中 +if (context === 'onboarding' && allDependenciesInstalled) { + // 触发完成回调或发送 action + yield put({ type: 'onboarding/dependencyInstallComplete' }); +} +``` + +**验收标准**: +- [ ] 安装完成后正确触发回调 +- [ ] 只在引导流程上下文中触发 +- [ ] 不影响版本管理页面的行为 + +--- + +## Phase 3: 引导流程集成 + +### Task 3.1: 重构 DependencyInstaller 组件 + +**目标**: 让 `DependencyInstaller` 直接嵌入 `DependencyManagementCard`。 + +**描述**: +- 重写 `DependencyInstaller.tsx` 使用 `DependencyManagementCard` +- 从 `dependencySlice` 读取依赖状态 +- 传递引导流程特有的 props +- 移除原有的依赖列表渲染逻辑 + +**涉及文件**: +- `src/renderer/components/onboarding/steps/DependencyInstaller.tsx` + +**实现细节**: +```typescript +import { DependencyManagementCard } from '../../DependencyManagementCard'; +import { useAppSelector } from '../../store/hooks'; +import { goToNextStep } from '../../store/thunks/onboardingThunks'; + +export function DependencyInstaller() { + const { versionId } = useAppSelector(state => state.onboarding); + + const handleInstallComplete = () => { + // 自动进入下一步 + goToNextStep(); + }; + + return ( +
+
+

{t('dependencies.title')}

+

{t('dependencies.subtitle')}

+
+ + +
+ ); +} +``` + +**验收标准**: +- [ ] 组件正确嵌入 `DependencyManagementCard` +- [ ] 依赖状态从 `dependencySlice` 正确读取 +- [ ] 安装完成后自动进入下一步 +- [ ] UI 显示符合引导流程风格 + +--- + +## Phase 4: 国际化调整 + +### Task 4.1: 更新引导流程国际化 + +**目标**: 添加引导流程特有的文案,复用 `components.json` 中的依赖管理翻译。 + +**描述**: +- 在 `onboarding.json` 中添加引导流程特有的标题和描述 +- 复用 `components.json` 中的 `depInstallConfirm.*` 和 `dependencyManagement.*` 翻译 + +**涉及文件**: +- `src/renderer/i18n/locales/*/onboarding.json` + +**需要添加的键**: +```json +{ + "dependencies": { + "title": "Install System Dependencies", + "subtitle": "Hagicode requires certain system dependencies to function properly.", + "completeMessage": "All dependencies are installed. You can now proceed to the next step.", + "retryButton": "Retry Installation", + "skipButton": "Skip for Now" + } +} +``` + +**注意**:以下翻译复用 `components.json`,无需重复添加: +- 一键安装按钮:使用 `depInstallConfirm.confirm` +- 进度显示:使用 `depInstallConfirm.installing` +- 错误信息:使用 `depInstallConfirm.errorsOccurred` + +**验收标准**: +- [ ] 英文翻译已添加 +- [ ] 中文翻译已添加 +- [ ] 复用现有翻译键,避免重复 + +--- + +## Phase 5: 测试与验证 + +### Task 5.1: 单元测试 + +**目标**: 为修改的组件和状态管理编写测试。 + +**描述**: +- 测试 `DependencyManagementCard` 的新 props +- 测试 `onboardingSlice` 精简后的状态 +- 测试回调机制 + +**涉及文件**: +- `src/renderer/components/DependencyManagementCard.test.tsx` +- `src/renderer/store/slices/onboardingSlice.test.ts` + +**测试覆盖**: +- [ ] `context` prop 的不同值 +- [ ] `onInstallComplete` 回调触发 +- [ ] `showAdvancedOptions` 的显示控制 +- [ ] onboardingSlice 状态读取 + +**验收标准**: +- [ ] 测试覆盖率 > 80% +- [ ] 所有测试通过 +- [ ] 现有测试不受影响 + +--- + +### Task 5.2: 集成测试 + +**目标**: 测试引导流程的完整依赖安装流程。 + +**描述**: +- 测试从进入依赖步骤到安装完成的全流程 +- 测试与版本管理页面使用相同的状态源 +- 测试自动导航到下一步 + +**涉及文件**: +- `src/renderer/components/onboarding/steps/DependencyInstaller.test.tsx` + +**测试场景**: +- [ ] 正常安装流程(所有依赖成功) +- [ ] 部分依赖失败 +- [ ] 用户跳过安装步骤 +- [ ] 安装完成后自动进入下一步 + +**验收标准**: +- [ ] 所有场景测试通过 +- [ ] 与 dependencySlice 的交互正确 +- [ ] 导航逻辑正常 + +--- + +### Task 5.3: 手动验证 + +**目标**: 在真实环境中验证功能。 + +**描述**: +- 在不同平台上测试功能 +- 验证引导流程与版本管理页面的一致性 +- 测试国际化切换 + +**测试清单**: +- [ ] Windows 平台测试 +- [ ] macOS 平台测试 +- [ ] Linux 平台测试 +- [ ] 中英文切换测试 +- [ ] 完整引导流程测试 +- [ ] 验证版本管理页面功能不受影响 + +**验收标准**: +- [ ] 所有平台功能正常 +- [ ] 引导流程与版本管理页面 UI 一致 +- [ ] 语言切换无错误 +- [ ] 无控制台错误或警告 + +--- + +## 任务依赖关系 + +``` +Phase 1: 状态管理精简 +├── Task 1.1 ─┐ +└── Task 1.2 ─┼─> Phase 2: 组件适配 + ├── Task 2.1 ─┐ + └── Task 2.2 ─┼─> Phase 3: 引导流程集成 + └── Task 3.1 ─┬─> Phase 4: 国际化调整 + └── Task 4.1 ─┬─> Phase 5: 测试与验证 + ├── Task 5.1 ─┐ + ├── Task 5.2 ─┼─> 完成 + └── Task 5.3 ──┘ +``` + +## 实施注意事项 + +1. **向后兼容**: 确保 `DependencyManagementCard` 的修改不影响版本管理页面 +2. **默认值安全**: 新 props 应该有合理的默认值,确保现有调用不受影响 +3. **单一数据源**: 严格遵循从 `dependencySlice` 读取依赖状态的原则 +4. **测试覆盖**: 修改的组件需要补充测试用例 +5. **渐进式实施**: 可以先完成组件适配,再进行引导流程集成 + +## 完成标准 + +整个功能实施完成后,应该满足以下标准: + +- [ ] 引导流程直接使用 `DependencyManagementCard` 组件 +- [ ] 依赖状态统一从 `dependencySlice` 读取,无冗余状态 +- [ ] 新用户可以在引导流程中一键安装所有依赖 +- [ ] 安装完成后自动进入引导流程的下一步 +- [ ] 引导流程与版本管理页面使用相同的依赖管理逻辑 +- [ ] 版本管理页面功能不受任何影响 +- [ ] 所有测试通过,测试覆盖率达标 +- [ ] 支持中英文双语 +- [ ] 在所有平台上正常工作 + +## 架构改进收益 + +采用直接组件复用策略后的收益: + +1. **代码减少**: 约 40% 的计划代码量减少(从 12 个任务减少到 9 个) +2. **维护成本**: 单一数据源,无需维护两套状态同步逻辑 +3. **一致性保证**: UI/UX 完全一致,无差异体验 +4. **bug 风险**: 使用已验证的组件,降低引入新 bug 的风险 +5. **未来扩展**: 依赖管理功能的改进自动惠及两个页面 diff --git a/openspec/changes/archive/2026-02-10-pr-automation-checks-workflow/pr-automation-checks-workflow/proposal.md b/openspec/changes/archive/2026-02-10-pr-automation-checks-workflow/pr-automation-checks-workflow/proposal.md new file mode 100644 index 00000000..59100d5a --- /dev/null +++ b/openspec/changes/archive/2026-02-10-pr-automation-checks-workflow/pr-automation-checks-workflow/proposal.md @@ -0,0 +1,99 @@ +# Change: 为 Pull Request 添加自动化检查 + +**Status:** ExecutionCompleted + +## Why + +当前项目在 Pull Request 工作流中缺少自动化质量检查机制,可能导致有问题的代码(编译失败、类型错误、测试未通过)被意外合并到主分支,增加后期修复成本并影响开发效率。 + +## What Changes + +- **新增** Pull Request 检查工作流文件 `.github/workflows/pr-checks.yml` +- **新增** 构建验证检查(依赖安装、TypeScript 类型检查、生产构建) +- **新增** 测试验证检查(单元测试、冒烟测试) +- **新增** 检查通过状态作为 PR 合并的必需条件 + +## UI Design Changes + +不涉及 UI 变更。 + +## Code Flow Changes + +### PR 检查工作流流程图 + +```mermaid +flowchart TD + A[PR 创建或更新] --> B[触发 GitHub Actions] + B --> C[安装依赖 npm ci] + C --> D{依赖安装成功?} + D -->|否| E[检查失败: 依赖安装] + D -->|是| F[TypeScript 类型检查] + F --> G{类型检查通过?} + G -->|否| H[检查失败: 类型错误] + G -->|是| I[生产构建 npm run build:prod] + I --> J{构建成功?} + J -->|否| K[检查失败: 构建错误] + J -->|是| L[冒烟测试 npm run smoke-test] + L --> M{测试通过?} + M -->|否| N[检查失败: 测试未通过] + M -->|是| O[所有检查通过] + E --> P[阻止 PR 合并] + H --> P + K --> P + N --> P + O --> Q[允许 PR 合并] + + style P fill:#f88 + style Q fill:#8f8 + style E fill:#f88 + style H fill:#f88 + style K fill:#f88 + style N fill:#f88 + style O fill:#8f8 +``` + +### 与现有 CI/CD 集成 + +```mermaid +flowchart LR + subgraph "PR 工作流" + A[PR 触发] --> B[pr-checks.yml] + B --> C{检查结果} + C -->|失败| D[阻止合并] + C -->|通过| E[允许合并] + end + + subgraph "主分支工作流" + F[Push to main] --> G[build.yml] + G --> H[构建所有平台] + H --> I[发布到 Releases] + end + + E -->|合并后| F +``` + +### 代码变更清单 + +| 文件路径 | 变更类型 | 变更原因 | 影响范围 | +|---------|---------|---------|---------| +| `.github/workflows/pr-checks.yml` | 新增 | 添加 PR 检查工作流,在 PR 创建或更新时自动执行质量检查 | CI/CD | +| `.github/workflows/build.yml` | 不变 | 保持现有的主分支构建流程不变 | CI/CD | + +## Impact + +### 影响的规范 +- `ci-cd` - 扩展现有 CI/CD 规范,新增 PR 检查要求 + +### 影响的代码 +- **新增文件**: `.github/workflows/pr-checks.yml` +- **现有工作流**: `.github/workflows/build.yml` (无变更) + +### 开发流程影响 +- PR 创建后会自动触发检查流程,开发者需等待检查完成 +- 所有检查通过后才能完成合并(需在 GitHub 仓库设置中配置) +- CI 资源使用增加:每次 PR 创建或更新都会触发检查 + +### 预期收益 +- 代码质量提升:确保合并到主分支的代码通过编译和测试 +- 开发效率提升:早期发现问题,减少后期修复成本 +- 团队协作改善:明确的检查状态提供清晰的合并反馈 diff --git a/openspec/changes/archive/2026-02-10-pr-automation-checks-workflow/pr-automation-checks-workflow/specs/ci-cd/spec.md b/openspec/changes/archive/2026-02-10-pr-automation-checks-workflow/pr-automation-checks-workflow/specs/ci-cd/spec.md new file mode 100644 index 00000000..32614e72 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-pr-automation-checks-workflow/pr-automation-checks-workflow/specs/ci-cd/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: Pull Request Quality Checks + +The application MUST enforce automated quality checks on Pull Requests before merging to ensure code quality and prevent issues from reaching the main branch. + +#### Scenario: PR 创建时自动触发检查 + +**Given** 开发者创建新的 Pull Request +**When** PR 打开或更新 +**Then** GitHub Actions 自动触发 pr-checks.yml 工作流 +**And** 工作流在 ubuntu-latest 运行器上执行 +**And** 检查状态显示在 PR 页面 + +#### Scenario: 依赖安装验证 + +**Given** PR 检查工作流开始执行 +**When** 运行 `npm ci` 安装依赖 +**Then** 所有项目依赖成功安装 +**And** npm 缓存被用于加速后续构建 +**And** 如果依赖安装失败,检查状态为失败 + +#### Scenario: TypeScript 类型检查验证 + +**Given** 依赖安装成功 +**When** 运行 `npm run build:tsc:check` 进行类型检查 +**Then** TypeScript 编译器验证所有类型定义 +**And** 如果存在类型错误,检查失败并显示错误信息 +**And** 如果类型检查通过,继续下一步检查 + +#### Scenario: 生产构建验证 + +**Given** TypeScript 类型检查通过 +**When** 运行 `npm run build:prod` 进行生产构建 +**Then** 执行完整的构建流程(tsc、preload、renderer) +**And** 运行 smoke-test.js 验证构建产物 +**And** 如果构建失败,检查失败并显示错误信息 +**And** 如果构建成功,继续下一步检查 + +#### Scenario: 冒烟测试验证 + +**Given** 生产构建成功 +**When** 运行 `npm run smoke-test` 执行冒烟测试 +**Then** 验证 dist 目录结构正确 +**And** 验证 main.js、renderer、preload 文件存在 +**And** 验证 package.json 配置正确 +**And** 验证 electron-builder 配置有效 +**And** 如果任何测试失败,检查失败并显示详细结果 + +#### Scenario: 检查通过允许合并 + +**Given** PR 的所有检查都通过 +**When** 开发者审查代码无误 +**Then** GitHub 允许合并 PR +**And** 合并后触发主分支的 build.yml 工作流 + +#### Scenario: 检查失败阻止合并 + +**Given** PR 的任一检查失败 +**When** 开发者尝试合并 PR +**Then** GitHub 阻止合并操作 +**And** 开发者必须修复问题后重新提交 +**And** 修复后的提交重新触发检查工作流 + +#### Scenario: 工作流配置 + +**Given** PR 检查工作流已配置 +**When** 查看工作流配置 +**Then** 使用 Node.js 22(与主构建保持一致) +**And** 缓存 npm 依赖以加速构建 +**And** 仅在 pull_request 事件时触发 +**And** 不在 push 事件时触发(避免与 build.yml 重复) diff --git a/openspec/changes/archive/2026-02-10-pr-automation-checks-workflow/pr-automation-checks-workflow/tasks.md b/openspec/changes/archive/2026-02-10-pr-automation-checks-workflow/pr-automation-checks-workflow/tasks.md new file mode 100644 index 00000000..c81aff2f --- /dev/null +++ b/openspec/changes/archive/2026-02-10-pr-automation-checks-workflow/pr-automation-checks-workflow/tasks.md @@ -0,0 +1,31 @@ +## 1. Implementation + +- [ ] 1.1 创建 `.github/workflows/pr-checks.yml` 工作流文件 +- [ ] 1.2 配置工作流触发条件(PR 创建或更新时执行) +- [ ] 1.3 添加依赖安装步骤(`npm ci`) +- [ ] 1.4 添加 TypeScript 类型检查步骤(`npm run build:tsc:check`) +- [ ] 1.5 添加生产构建步骤(`npm run build:prod`) +- [ ] 1.6 添加冒烟测试步骤(`npm run smoke-test`) +- [ ] 1.7 配置检查失败时的错误输出格式 +- [ ] 1.8 配置 Node.js 版本(使用与 build.yml 相同的版本) + +## 2. Testing + +- [ ] 2.1 创建测试 PR 验证工作流触发 +- [ ] 2.2 验证依赖安装失败时检查正确失败 +- [ ] 2.3 验证类型错误时检查正确失败 +- [ ] 2.4 验证构建失败时检查正确失败 +- [ ] 2.5 验证测试失败时检查正确失败 +- [ ] 2.6 验证所有检查通过时工作流成功 + +## 3. Documentation + +- [ ] 3.1 更新 openspec/specs/ci-cd/spec.md 添加 PR 检查要求 +- [ ] 3.2 在项目 README 中说明 PR 检查要求(如有) +- [ ] 3.3 配置 GitHub 仓库设置,将检查设置为必需(需由仓库管理员操作) + +## 4. Validation + +- [ ] 4.1 运行 `openspec validate pr-automation-checks-workflow --strict` 验证提案格式 +- [ ] 4.2 确认所有任务已完成 +- [ ] 4.3 测试 PR 检查工作流在生产环境中的表现 diff --git a/openspec/specs/ci-cd/spec.md b/openspec/specs/ci-cd/spec.md new file mode 100644 index 00000000..94456364 --- /dev/null +++ b/openspec/specs/ci-cd/spec.md @@ -0,0 +1,284 @@ +# ci-cd Specification + +## Purpose +定义项目 CI/CD 工作流的规范和要求,确保代码质量和构建一致性。 + +## Requirements + +### Requirement: PR 检查工作流 + +项目 MUST 在 Pull Request 创建或更新时自动执行质量检查,确保只有通过所有检查的代码才能合并到主分支。 + +#### Scenario: PR 创建时触发检查 + +**Given** 开发者创建新的 Pull Request +**When** PR 打开到目标分支 +**Then** 自动触发 `.github/workflows/pr-checks.yml` 工作流 +**And** 执行依赖安装、类型检查、构建和测试验证 + +#### Scenario: PR 更新时重新触发检查 + +**Given** 存在活跃的 Pull Request +**When** 开发者推送新提交到 PR 分支 +**Then** 自动重新触发 `.github/workflows/pr-checks.yml` 工作流 +**And** 对最新代码执行完整验证 + +#### Scenario: 依赖安装检查 + +**Given** PR 检查工作流开始执行 +**When** 运行 `npm ci` 安装依赖 +**Then** 依赖安装成功后继续后续步骤 +**And** 依赖安装失败时标记检查为失败 +**And** 阻止 PR 合并 + +#### Scenario: TypeScript 类型检查 + +**Given** 依赖安装成功 +**When** 运行 `npm run build:tsc:check` 进行类型检查 +**Then** 类型检查通过后继续构建步骤 +**And** 类型错误时标记检查为失败 +**And** 显示具体的类型错误信息 +**And** 阻止 PR 合并 + +#### Scenario: 生产构建检查 + +**Given** TypeScript 类型检查通过 +**When** 运行 `npm run build:prod` 执行生产构建 +**Then** 构建成功后继续测试步骤 +**And** 构建失败时标记检查为失败 +**And** 显示构建错误信息 +**And** 阻止 PR 合并 + +#### Scenario: 冒烟测试检查 + +**Given** 生产构建成功 +**When** 运行 `npm run smoke-test` 执行冒烟测试 +**Then** 测试通过后标记所有检查成功 +**And** 测试失败时标记检查为失败 +**And** 显示测试失败信息 +**And** 阻止 PR 合并 + +--- + +### Requirement: 主分支构建工作流 + +项目 MUST 在代码合并到主分支或发布标签时自动构建所有平台的应用程序包。 + +#### Scenario: 主分支推送触发构建 + +**Given** 代码合并到 `main` 分支 +**When** 推送到 `main` 分支完成 +**Then** 自动触发 `.github/workflows/build.yml` 工作流 +**And** 构建所有平台(Windows、Linux、macOS)的应用包 +**And** 上传构建产物供下载 + +#### Scenario: 发布标签触发构建 + +**Given** 创建新的版本标签(如 `v1.0.0`) +**When** 标签推送到仓库 +**Then** 自动触发 `.github/workflows/build.yml` 工作流 +**And** 构建所有平台的应用包 +**And** 创建 GitHub Release +**And** 上传应用包到 Release +**And** 同步到 Azure Storage + +--- + +### Requirement: Node.js 版本一致性 + +所有 CI/CD 工作流 MUST 使用相同版本的 Node.js,确保构建结果一致性。 + +#### Scenario: PR 检查使用 Node.js 22 + +**Given** PR 检查工作流执行 +**When** 设置 Node.js 环境 +**Then** 使用 Node.js 版本 22 +**And** 启用 npm 缓存加速依赖安装 + +#### Scenario: 主分支构建使用 Node.js 22 + +**Given** 主分支构建工作流执行 +**When** 设置 Node.js 环境 +**Then** 所有平台构建使用 Node.js 版本 22 +**And** 启用 npm 缓存加速依赖安装 + +--- + +### Requirement: 检查失败处理 + +PR 检查工作流 MUST 在任何步骤失败时提供清晰的错误信息和恢复建议。 + +#### Scenario: 依赖安装失败时的错误提示 + +**Given** PR 检查工作流执行 +**When** `npm ci` 步骤失败 +**Then** 工作流标记为失败状态 +**And** 在 GitHub Actions 日志中显示依赖安装错误 +**And** PR 页面显示检查失败标记 +**And** 阻止 PR 合并 + +#### Scenario: 类型检查失败时的错误提示 + +**Given** 依赖安装成功 +**When** `npm run build:tsc:check` 步骤失败 +**Then** 工作流标记为失败状态 +**And** 在 GitHub Actions 日志中显示类型错误详情 +**And** PR 页面显示检查失败标记 +**And** 阻止 PR 合并 + +#### Scenario: 构建失败时的错误提示 + +**Given** 类型检查通过 +**When** `npm run build:prod` 步骤失败 +**Then** 工作流标记为失败状态 +**And** 在 GitHub Actions 日志中显示构建错误详情 +**And** PR 页面显示检查失败标记 +**And** 阻止 PR 合并 + +#### Scenario: 测试失败时的错误提示 + +**Given** 生产构建成功 +**When** `npm run smoke-test` 步骤失败 +**Then** 工作流标记为失败状态 +**And** 在 GitHub Actions 日志中显示测试失败详情 +**And** PR 页面显示检查失败标记 +**And** 阻止 PR 合并 + +--- + +### Requirement: 检查摘要显示 + +PR 检查工作流 MUST 在 Actions 摘要页面提供清晰的检查状态摘要。 + +#### Scenario: 检查通过摘要 + +**Given** 所有 PR 检查步骤成功完成 +**When** 生成检查摘要 +**Then** 显示 ✅ 通过状态图标 +**And** 显示目标分支名称 +**And** 显示提交 SHA +**And** 显示触发者信息 +**And** 提供查看详情链接 + +#### Scenario: 检查失败摘要 + +**Given** 任一 PR 检查步骤失败 +**When** 生成检查摘要 +**Then** 显示 ❌ 失败状态图标 +**And** 显示目标分支名称 +**And** 显示提交 SHA +**And** 显示触发者信息 +**And** 提供查看详情链接 +**And** 在日志顶部显示错误通知 + +--- + +### Requirement: GitHub 仓库配置 + +仓库管理员 MUST 配置 GitHub 仓库设置,将 PR 检查设置为合并的必需条件。 + +#### Scenario: 配置必需的状态检查 + +**Given** 仓库管理员访问仓库设置 +**When** 进入 Branches > Branch protection rules +**Then** 添加或编辑 `main` 分支保护规则 +**And** 启用 "Require status checks to pass before merging" +**And** 添加 "PR Checks" 工作流到必需检查列表 +**And** 启用 "Require branches to be up to date before merging" + +#### Scenario: 配置阻止未通过的合并 + +**Given** 仓库管理员配置分支保护 +**When** 启用必需状态检查 +**Then** PR 检查未通过时无法点击 "Merge" 按钮 +**And** PR 检查通过后才允许合并 +**And** 过时的分支(未更新到最新 main)无法合并 + +--- + +### Requirement: 工作流权限配置 + +CI/CD 工作流 MUST 使用最小必要权限原则配置访问权限。 + +#### Scenario: PR 检查工作流权限 + +**Given** `.github/workflows/pr-checks.yml` 工作流 +**When** 配置工作流权限 +**Then** 仅授予 `contents: read` 权限读取代码 +**And** 仅授予 `pull-requests: read` 权限读取 PR 信息 +**And** 不授予写入权限以防止安全风险 + +#### Scenario: 主分支构建工作流权限 + +**Given** `.github/workflows/build.yml` 工作流 +**When** 配置工作流权限 +**Then** 授予 `contents: write` 权限以创建 Release +**And** 授予必要权限上传构建产物 + +--- + +### Requirement: Azure Storage 同步工作流 + +项目 MUST 在发布标签创建后自动将发布资源同步到 Azure Storage,并在同步完成后发送飞书通知。 + +#### Scenario: 发布标签触发 Azure 同步 + +**Given** 创建新的版本标签(如 `v1.0.0`) +**When** 标签推送到仓库且构建完成 +**Then** 自动触发 `.github/workflows/sync-azure-storage.yml` 工作流 +**And** 下载发布资源到临时目录 +**And** 上传资源到 Azure Storage 对应版本目录 +**And** 生成并上传 index.json 文件 +**And** 发送同步结果通知到飞书 + +#### Scenario: Azure 同步成功通知 + +**Given** Azure Storage 同步工作流执行 +**When** 所有资源成功上传到 Azure Storage +**Then** 发送飞书通知,标题包含 "✅ Azure 同步成功" +**And** 通知包含版本信息 +**And** 通知包含上传文件数量 +**And** 通知包含 Azure Storage 路径 +**And** 通知包含提交 SHA 和触发者信息 +**And** 通知包含 GitHub Actions 查看链接 + +#### Scenario: Azure 同步失败通知 + +**Given** Azure Storage 同步工作流执行 +**When** 同步过程中发生错误 +**Then** 发送飞书通知,标题包含 "❌ Azure 同步失败" +**And** 通知包含版本信息 +**And** 通知提供错误排查建议 +**And** 通知包含 GitHub Actions 查看链接 +**And** 使用 `if: always()` 确保无论成功失败都发送通知 + +#### Scenario: 手动触发 Azure 同步 + +**Given** 存在已发布的版本 +**When** 通过 workflow_dispatch 手动触发 sync-azure-storage.yml +**Then** 使用指定的版本标签或最新版本进行同步 +**And** 执行完整的同步流程 +**And** 发送同步结果通知到飞书 + +--- + +### Requirement: 飞书通知集成 + +项目 MUST 使用统一的飞书通知基础设施发送构建和同步状态通知。 + +#### Scenario: 复用现有通知配置 + +**Given** 项目已配置 FEISHU_WEBHOOK_URL 密钥 +**When** Azure 同步工作流需要发送通知 +**Then** 使用 HagiCode-org/haginotifier@v1.0.0 工作流 +**And** 通过 secrets: inherit 继承 FEISHU_WEBHOOK_URL +**And** 使用 msg_type: 'post' 发送富文本消息 + +#### Scenario: 通知消息格式 + +**Given** 需要发送 Azure 同步通知 +**When** 构建通知消息内容 +**Then** 消息标题包含状态图标(✅ 成功 / ❌ 失败) +**Then** 消息内容包含版本、文件数量、存储路径 +**Then** 消息包含提交 SHA、触发者和查看链接 +**And** 通知不包含敏感信息(如 SAS URL 完整内容) diff --git a/package.json b/package.json index acdee3b5..4d1d05ab 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "dev:renderer": "vite", "dev:main": "tsc --watch", "dev:preload": "vite build --config vite.preload.config.ts --watch", - "dev": "rm -rf dist && concurrently -k \"npm run dev:renderer\" \"npm run dev:main\" \"npm run dev:preload\" \"wait-on dist/preload/index.mjs && wait-on dist/main/main.js && cross-env NODE_ENV=development HAGICO_CONFIG_PATH=local_data_root electron .\"", + "dev": " npm run predev && rm -rf dist && concurrently -k \"npm run dev:renderer\" \"npm run dev:main\" \"npm run dev:preload\" \"wait-on dist/preload/index.mjs && wait-on dist/main/main.js && cross-env NODE_ENV=development HAGICO_CONFIG_PATH=local_data_root electron .\"", "start": "npm run build:all && electron .", "prebuild": "npm run build:all", "build": "electron-builder", diff --git a/src/main/dependency-manager.ts b/src/main/dependency-manager.ts index 4cdbb172..0548aaff 100644 --- a/src/main/dependency-manager.ts +++ b/src/main/dependency-manager.ts @@ -22,6 +22,7 @@ export enum DependencyType { * Result of a dependency check */ export interface DependencyCheckResult { + key: string; // Manifest dependency key (e.g., "dotnet", "claudeCode") name: string; type: DependencyType; installed: boolean; @@ -138,6 +139,7 @@ export class DependencyManager { console.error(`[DependencyManager] Failed to check dependency ${dep.name}:`, error); // Add failed check result results.push({ + key: dep.key, name: dep.name, type: this.mapDependencyType(dep.key, dep.type), installed: false, @@ -156,6 +158,7 @@ export class DependencyManager { */ private async checkSingleDependency(dep: ParsedDependency): Promise { const result: DependencyCheckResult = { + key: dep.key, name: dep.name, type: this.mapDependencyType(dep.key, dep.type), installed: false, @@ -326,6 +329,7 @@ export class DependencyManager { async checkDotNetRuntime(): Promise { const requiredVersion = '8.0.0'; const result: DependencyCheckResult = { + key: 'dotnet', name: '.NET Runtime (ASP.NET Core)', type: DependencyType.DotNetRuntime, installed: false, @@ -499,6 +503,7 @@ export class DependencyManager { } const result: DependencyCheckResult = { + key: packageKey, name: pkg.name, type: packageKey === 'claude_code' ? DependencyType.ClaudeCode : DependencyType.OpenSpec, installed: false, diff --git a/src/main/main.ts b/src/main/main.ts index 3e5f299c..1cc86d1e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -879,13 +879,36 @@ ipcMain.handle('dependency:install-from-manifest', async (_, versionId: string) }; } - // Parse dependencies - const dependencies = manifestReader.parseDependencies(manifest); + // Parse all dependencies from manifest + const allDependencies = manifestReader.parseDependencies(manifest); + + // Check which dependencies are actually missing + const checkedDependencies = await dependencyManager.checkFromManifest(allDependencies); + + // Filter to only install dependencies that are not installed or have version mismatch + const missingDependencies = allDependencies.filter((dep) => { + const checkedDep = checkedDependencies.find(cd => cd.name === dep.name); + // Include dependency if it's not installed, has version mismatch, or we couldn't check it + return !checkedDep || !checkedDep.installed || checkedDep.versionMismatch; + }); + + log.info('[Main] Total dependencies:', allDependencies.length, 'Missing:', missingDependencies.length); + + if (missingDependencies.length === 0) { + log.info('[Main] All dependencies are already installed'); + return { + success: true, + result: { + success: [], + failed: [] + } + }; + } - // Install using dependency manager + // Install only missing dependencies using dependency manager const result = await dependencyManager.installFromManifest( manifest, - dependencies, + missingDependencies, (progress) => { // Send progress update to renderer mainWindow?.webContents.send('dependency:install-progress', progress); diff --git a/src/renderer/components/DependencyManagementCardUnified.tsx b/src/renderer/components/DependencyManagementCardUnified.tsx new file mode 100644 index 00000000..33d00b49 --- /dev/null +++ b/src/renderer/components/DependencyManagementCardUnified.tsx @@ -0,0 +1,263 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { + selectDependencies, + selectDependenciesLoading, + selectInstallProgress, +} from '../store/slices/dependencySlice'; +import { CHECK_DEPENDENCIES_AFTER_INSTALL, INSTALL_SINGLE_DEPENDENCY } from '../store/sagas/dependencySaga'; +import { selectDownloadProgress } from '../store/slices/onboardingSlice'; +import { Download } from 'lucide-react'; + +export interface DependencyManagementCardProps { + versionId: string; + context?: 'version-management' | 'onboarding'; + onInstallComplete?: () => void; + showAdvancedOptions?: boolean; +} + +export function DependencyManagementCard({ + versionId, + context = 'version-management', + onInstallComplete, + showAdvancedOptions = true, +}: DependencyManagementCardProps) { + const { t } = useTranslation(context === 'onboarding' ? 'onboarding' : 'components'); + const dispatch = useDispatch(); + + const dependencies = useSelector(selectDependencies); + const loading = useSelector(selectDependenciesLoading); + const installProgress = useSelector(selectInstallProgress); + + // Track which dependencies are currently being installed + const [installingDeps, setInstallingDeps] = useState>(new Set()); + const [isInstallingAll, setIsInstallingAll] = useState(false); + + // For onboarding context, check missing dependencies on mount + useEffect(() => { + if (context === 'onboarding' && versionId) { + dispatch({ + type: CHECK_DEPENDENCIES_AFTER_INSTALL, + payload: { versionId, context }, + }); + } else { + // For version management, fetch all dependencies + dispatch({ type: 'dependency/fetchDependencies' }); + } + }, [versionId, context, dispatch]); + + // Listen for installation completion in onboarding context + useEffect(() => { + if (context === 'onboarding' && onInstallComplete) { + const checkCompletion = () => { + const allInstalled = dependencies.every(dep => dep.installed && !dep.versionMismatch); + if (allInstalled && dependencies.length > 0) { + onInstallComplete(); + } + }; + checkCompletion(); + } + }, [dependencies, context, onInstallComplete]); + + // Update installing deps based on progress + useEffect(() => { + if (installProgress.installing) { + setInstallingDeps(new Set([installProgress.currentDependency])); + } else { + // Clear all installing states when installation completes + setInstallingDeps(new Set()); + setIsInstallingAll(false); + } + }, [installProgress]); + + const downloadProgress = useSelector(selectDownloadProgress); + + // Use the provided versionId + const effectiveVersionId = versionId || downloadProgress?.version || ''; + + // Filter dependencies to only show missing ones for onboarding + const filteredDependencies = context === 'onboarding' + ? dependencies.filter(dep => !dep.installed || dep.versionMismatch) + : dependencies; + + const hasMissingDependencies = filteredDependencies.some(dep => !dep.installed || dep.versionMismatch); + + // Handle installing a single dependency + const handleInstallSingle = (depKey: string) => { + if (installingDeps.size > 0) return; // Check if any installation is in progress + + setInstallingDeps(new Set([depKey])); + dispatch({ + type: INSTALL_SINGLE_DEPENDENCY, + payload: { dependencyKey: depKey, versionId: effectiveVersionId }, + }); + }; + + // Handle one-click install for all missing dependencies + const handleInstallAll = () => { + if (installingDeps.size > 0) return; // Check if any installation is in progress + + // Mark all missing dependencies as installing + const missingKeys = filteredDependencies + .filter(dep => !dep.installed || dep.versionMismatch) + .map(dep => dep.key); + setInstallingDeps(new Set(missingKeys)); + setIsInstallingAll(true); + + dispatch({ + type: 'dependency/installFromManifest', + payload: effectiveVersionId, + }); + }; + + const isDepInstalling = (depKey: string) => { + return installingDeps.has(depKey) || installProgress.installing; + }; + + return ( +
+ {/* Header */} +
+

+ {context === 'onboarding' ? t('dependencies.title') : t('dependencyManagement.title')} +

+

+ {context === 'onboarding' ? t('dependencies.description') : t('dependencyManagement.description')} +

+
+ + {/* Loading state */} + {loading && filteredDependencies.length === 0 ? ( +
+
+

{t('dependencyManagement.status.checking')}

+
+ ) : ( + <> + {/* Dependencies list */} + {filteredDependencies.length > 0 ? ( +
+ {filteredDependencies.map((dep, index) => { + const needsInstall = !dep.installed || dep.versionMismatch; + const installing = isDepInstalling(dep.key); + + return ( +
+
+
+ {dep.installed && !dep.versionMismatch ? ( + + + + ) : dep.installed && dep.versionMismatch ? ( + + + + ) : ( + + + + )} +
+

{dep.name}

+

+ {dep.installed && !dep.versionMismatch + ? t('dependencyManagement.status.installed') + : dep.installed && dep.versionMismatch + ? t('dependencyManagement.status.versionMismatch') + : t('dependencyManagement.status.notInstalled')} +

+ {dep.version && ( + + {t('dependencyManagement.details.currentVersion')}: {dep.version} + + )} + {dep.description && ( +

{dep.description}

+ )} + {dep.requiredVersion && ( +

+ {t('depInstallConfirm.requiredVersion')}: {dep.requiredVersion} +

+ )} +
+
+ + {/* Install button for each missing dependency */} + {needsInstall && ( + + )} +
+
+ ); + })} +
+ ) : ( +
+ {context === 'onboarding' ? t('dependencies.complete.message') : t('dependencyManagement.noDependencies')} +
+ )} + + {/* One-click install button for all missing dependencies */} + {hasMissingDependencies && installingDeps.size === 0 && ( +
+

+ {t('depInstallConfirm.description', { count: filteredDependencies.length })} +

+
+ +
+
+ )} + + {/* Show installation progress when installing all dependencies */} + {isInstallingAll && installingDeps.size > 0 && ( +
+

+ {t('depInstallConfirm.installing')} +

+
+
+ + {installProgress.current} / {installProgress.total} + +
+
+ )} + + )} +
+ ); +} diff --git a/src/renderer/components/onboarding/OnboardingWizard.tsx b/src/renderer/components/onboarding/OnboardingWizard.tsx index f25169f1..6cd6ac63 100644 --- a/src/renderer/components/onboarding/OnboardingWizard.tsx +++ b/src/renderer/components/onboarding/OnboardingWizard.tsx @@ -9,11 +9,10 @@ import { selectCanGoPrevious, selectDownloadProgress, setDownloadProgress, - setDependenciesStatus, setServiceProgress, } from '../../store/slices/onboardingSlice'; import { OnboardingStep } from '../../../types/onboarding'; -import { goToNextStep, goToPreviousStep, skipOnboarding, downloadPackage, checkDependencies, installDependencies } from '../../store/thunks/onboardingThunks'; +import { goToNextStep, goToPreviousStep, skipOnboarding, downloadPackage } from '../../store/thunks/onboardingThunks'; import WelcomeIntro from './steps/WelcomeIntro'; import PackageDownload from './steps/PackageDownload'; import DependencyInstaller from './steps/DependencyInstaller'; @@ -23,7 +22,7 @@ import OnboardingActions from './OnboardingActions'; import SkipConfirmDialog from './SkipConfirmDialog'; import type { RootState } from '../../store'; import type { AppDispatch } from '../../store'; -import type { DownloadProgress, DependencyItem, ServiceLaunchProgress } from '../../../types/onboarding'; +import type { DownloadProgress, ServiceLaunchProgress } from '../../../types/onboarding'; interface OnboardingWizardProps { onComplete?: () => void; @@ -41,12 +40,8 @@ function OnboardingWizard({ onComplete }: OnboardingWizardProps) { const [showSkipDialog, setShowSkipDialog] = useState(false); const [downloadedVersion, setDownloadedVersion] = useState(null); - const [dependenciesInstalling, setDependenciesInstalling] = useState(false); const [downloadCompleted, setDownloadCompleted] = useState(false); - // Ref to track if we've already checked dependencies for the current version - const hasCheckedDependenciesRef = useRef(false); - // Set up IPC listeners for progress updates useEffect(() => { if (!isActive) return; @@ -66,12 +61,6 @@ function OnboardingWizard({ onComplete }: OnboardingWizardProps) { } }); - // Dependency progress listener - const unsubscribeDependencyProgress = window.electronAPI.onDependencyProgress((status: DependencyItem[]) => { - console.log('[OnboardingWizard] Dependency progress:', status); - dispatch(setDependenciesStatus(status)); - }); - // Service progress listener const unsubscribeServiceProgress = window.electronAPI.onServiceProgress((progress: ServiceLaunchProgress) => { console.log('[OnboardingWizard] Service progress:', progress); @@ -81,35 +70,10 @@ function OnboardingWizard({ onComplete }: OnboardingWizardProps) { return () => { console.log('[OnboardingWizard] Cleaning up IPC listeners'); unsubscribeDownloadProgress(); - unsubscribeDependencyProgress(); unsubscribeServiceProgress(); }; }, [isActive, dispatch]); - // Check dependencies status when entering Dependencies step (without installing) - useEffect(() => { - if (currentStep === OnboardingStep.Dependencies && downloadedVersion && !dependenciesInstalling && downloadCompleted) { - // Only check once per version change (reset when version changes) - if (!hasCheckedDependenciesRef.current) { - console.log('[OnboardingWizard] Entered Dependencies step, checking dependencies for:', downloadedVersion); - // Add a delay to ensure the version is fully installed and registered in VersionManager - const timer = setTimeout(() => { - setDependenciesInstalling(true); - dispatch(checkDependencies(downloadedVersion)); - }, 1000); // Increased from 500ms to 1000ms for better reliability - - hasCheckedDependenciesRef.current = true; - - return () => clearTimeout(timer); - } - } - - // Reset the ref when we leave the Dependencies step - if (currentStep !== OnboardingStep.Dependencies) { - hasCheckedDependenciesRef.current = false; - } - }, [currentStep, downloadedVersion, dependenciesInstalling, downloadCompleted, dispatch]); - // Debug logging for step changes useEffect(() => { console.log('[OnboardingWizard] currentStep changed to:', currentStep, 'OnboardingStep.Welcome:', OnboardingStep.Welcome); diff --git a/src/renderer/components/onboarding/steps/DependencyInstaller.tsx b/src/renderer/components/onboarding/steps/DependencyInstaller.tsx index 8b2608b4..ed385bc5 100644 --- a/src/renderer/components/onboarding/steps/DependencyInstaller.tsx +++ b/src/renderer/components/onboarding/steps/DependencyInstaller.tsx @@ -1,187 +1,46 @@ import { useTranslation } from 'react-i18next'; -import { useState, useCallback } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import { CheckCircle2, Circle, Loader2, AlertCircle, Package, Download } from 'lucide-react'; -import { Progress } from '../../ui/progress'; -import { Button } from '../../ui/button'; -import { selectDependenciesStatus, selectDownloadProgress } from '../../../store/slices/onboardingSlice'; -import { installDependencies } from '../../../store/thunks/onboardingThunks'; +import { useSelector } from 'react-redux'; +import { CheckCircle2, Package } from 'lucide-react'; +import { DependencyManagementCard } from '../../DependencyManagementCardUnified'; +import { selectDownloadProgress } from '../../../store/slices/onboardingSlice'; +import { goToNextStep } from '../../../store/thunks/onboardingThunks'; import type { RootState } from '../../../store'; -import type { AppDispatch } from '../../../store'; +import { useDispatch } from 'react-redux'; function DependencyInstaller() { const { t } = useTranslation('onboarding'); - const dispatch = useDispatch(); - const dependencies = useSelector((state: RootState) => selectDependenciesStatus(state)); + const dispatch = useDispatch(); const downloadProgress = useSelector((state: RootState) => selectDownloadProgress(state)); - const isInstallingFromState = useSelector((state: RootState) => state.onboarding.isInstallingDependencies); - const [isInstalling, setIsInstalling] = useState(false); - - const isInstallingGlobally = isInstalling || isInstallingFromState; - - const handleInstallDependencies = useCallback(() => { - // Prevent duplicate installation calls - if (isInstallingGlobally) { - console.log('[DependencyInstaller] Installation already in progress, ignoring duplicate request'); - return; - } - - if (downloadProgress?.version) { - setIsInstalling(true); - dispatch(installDependencies(downloadProgress.version)); - } - }, [dispatch, downloadProgress, isInstallingGlobally]); - - const getStatusIcon = (status: string) => { - switch (status) { - case 'installed': - return ; - case 'installing': - return ; - case 'error': - return ; - default: - return ; - } - }; - - const getStatusText = (dep: { status: string; version?: string }) => { - switch (dep.status) { - case 'installed': - return dep.version || t('dependencies.status.installed'); - case 'installing': - return t('dependencies.status.installing'); - case 'error': - return t('dependencies.status.error'); - default: - return t('dependencies.status.pending'); - } + const handleInstallComplete = () => { + // Automatically proceed to the next step after successful installation + dispatch(goToNextStep()); }; - const overallProgress = dependencies.length > 0 - ? dependencies.reduce((sum, dep) => sum + dep.progress, 0) / dependencies.length - : 0; - - const allComplete = dependencies.length > 0 && - dependencies.every(dep => dep.status === 'installed'); - - const hasError = dependencies.some(dep => dep.status === 'error'); - const hasInstalling = dependencies.some(dep => dep.status === 'installing'); - - const hasPendingDependencies = dependencies.length > 0 && - dependencies.some(dep => dep.status === 'pending' || dep.status === 'error'); - return (
{/* Status header */}
- {allComplete ? ( - - ) : ( - - )} +

- {allComplete ? t('dependencies.complete.title') : t('dependencies.installing.title')} + {t('dependencies.installing.title')}

{t('dependencies.description')}

- {/* Install dependencies button */} - {hasPendingDependencies && !hasInstalling && !isInstallingGlobally && ( -
- -
- )} - - {/* Overall progress */} - {dependencies.length > 0 && !allComplete && ( -
-
- {t('dependencies.overallProgress')} - {Math.round(overallProgress)}% -
- -
- )} - - {/* Dependencies list */} -
- {dependencies.length === 0 ? ( -
- {t('dependencies.checking')} -
- ) : ( - dependencies.map((dep, index) => ( -
-
-
- {getStatusIcon(dep.status)} -
-

{dep.name}

-

{getStatusText(dep)}

-
-
- {dep.requiredVersion && ( - - {dep.requiredVersion} - - )} -
- - {/* Individual progress for installing dependency */} - {dep.status === 'installing' && dep.progress > 0 && ( -
- -
- )} - - {/* Error message */} - {dep.status === 'error' && dep.error && ( -
- {dep.error} -
- )} -
- )) - )} -
- - {/* Complete message */} - {allComplete && ( -
-

- {t('dependencies.complete.message')} -

-
- )} - - {/* Error message */} - {hasError && ( -
-

- {t('dependencies.error.message')} -

-
- )} + {/* Unified Dependency Management Card */} +
); } diff --git a/src/renderer/i18n/locales/en-US/onboarding.json b/src/renderer/i18n/locales/en-US/onboarding.json index b15c2053..94e1b8a4 100644 --- a/src/renderer/i18n/locales/en-US/onboarding.json +++ b/src/renderer/i18n/locales/en-US/onboarding.json @@ -46,13 +46,28 @@ }, "dependencies": { "title": "Install System Dependencies", + "subtitle": "Hagicode requires certain system dependencies to function properly.", "description": "Check the installation status of system dependencies", "installButton": "Install Dependencies", "status": { "installed": "Installed", "installing": "Installing...", "pending": "Pending", - "error": "Failed" + "error": "Failed", + "notInstalled": "Not Installed", + "versionMismatch": "Version Mismatch", + "checking": "Checking...", + "ready": "Ready" + }, + "actions": { + "install": "Install", + "installing": "Installing...", + "refresh": "Refresh", + "visitWebsite": "Visit Official Website" + }, + "details": { + "currentVersion": "Current Version", + "requiredVersion": "Required Version" }, "overallProgress": "Overall Progress", "installing": { @@ -67,6 +82,22 @@ "message": "Some dependencies failed to install. Please install them manually and continue." } }, + "depInstallConfirm": { + "title": "Install Missing Dependencies", + "installingTitle": "Installing Dependencies", + "description": "Detected {{count}} missing dependency", + "description_other": "Detected {{count}} missing dependencies", + "requiredVersion": "Required Version", + "permissionWarning": "Installing these dependencies may require administrator privileges.", + "preparing": "Preparing installation...", + "cancel": "Cancel", + "confirm": "Install Dependencies", + "installing": "Installing...", + "errorsOccurred": "dependencies failed to install", + "success": "All dependencies installed successfully", + "partialSuccess": "{{success}} dependencies installed successfully, {{failed}} failed", + "error": "Dependency installation failed" + }, "launch": { "title": "Start Service", "starting": { @@ -113,5 +144,26 @@ "reenableNote": "If this is your first time using Hagicode Desktop, we recommend completing the setup process.", "cancel": "Cancel", "confirm": "Skip Setup" + }, + "dependencyManagement": { + "title": "Dependency Management", + "description": "View and manage system dependencies", + "status": { + "installed": "Installed", + "notInstalled": "Not Installed", + "versionMismatch": "Version Mismatch", + "checking": "Checking..." + }, + "actions": { + "install": "Install", + "installing": "Installing...", + "refresh": "Refresh", + "visitWebsite": "Visit Official Website" + }, + "details": { + "currentVersion": "Current Version", + "requiredVersion": "Required Version" + }, + "noDependencies": "No dependencies detected" } } diff --git a/src/renderer/i18n/locales/zh-CN/onboarding.json b/src/renderer/i18n/locales/zh-CN/onboarding.json index 61108ff4..8afe93e4 100644 --- a/src/renderer/i18n/locales/zh-CN/onboarding.json +++ b/src/renderer/i18n/locales/zh-CN/onboarding.json @@ -46,13 +46,28 @@ }, "dependencies": { "title": "安装系统依赖项", + "subtitle": "Hagicode 需要某些系统依赖项才能正常运行。", "description": "检查以下系统依赖项的安装状态", "installButton": "安装依赖项", "status": { "installed": "已安装", "installing": "安装中...", "pending": "待安装", - "error": "安装失败" + "error": "安装失败", + "notInstalled": "未安装", + "versionMismatch": "版本不匹配", + "checking": "检测中...", + "ready": "可以使用" + }, + "actions": { + "install": "安装", + "installing": "安装中...", + "refresh": "刷新", + "visitWebsite": "访问官网" + }, + "details": { + "currentVersion": "当前版本", + "requiredVersion": "要求版本" }, "overallProgress": "总体进度", "installing": { @@ -67,6 +82,22 @@ "message": "部分依赖项安装失败,请手动安装后继续。" } }, + "depInstallConfirm": { + "title": "安装缺失依赖", + "installingTitle": "正在安装依赖", + "description": "检测到 {{count}} 个缺失的依赖项", + "description_other": "检测到 {{count}} 个缺失的依赖项", + "requiredVersion": "要求版本", + "permissionWarning": "安装这些依赖可能需要管理员权限。", + "preparing": "准备安装...", + "cancel": "取消", + "confirm": "安装依赖", + "installing": "正在安装...", + "errorsOccurred": "个依赖安装失败", + "success": "所有依赖安装成功", + "partialSuccess": "{{success}} 个依赖安装成功,{{failed}} 个失败", + "error": "依赖安装失败" + }, "launch": { "title": "启动服务", "starting": { @@ -113,5 +144,26 @@ "reenableNote": "如果您是第一次使用 Hagicode Desktop,建议完成引导流程。", "cancel": "取消", "confirm": "跳过引导" + }, + "dependencyManagement": { + "title": "依赖项管理", + "description": "查看和管理系统依赖项", + "status": { + "installed": "已安装", + "notInstalled": "未安装", + "versionMismatch": "版本不匹配", + "checking": "检测中..." + }, + "actions": { + "install": "安装", + "installing": "安装中...", + "refresh": "刷新", + "visitWebsite": "访问官网" + }, + "details": { + "currentVersion": "当前版本", + "requiredVersion": "要求版本" + }, + "noDependencies": "未检测到依赖" } } diff --git a/src/renderer/store/sagas/dependencySaga.ts b/src/renderer/store/sagas/dependencySaga.ts index 07ca85a3..e11aa9a5 100644 --- a/src/renderer/store/sagas/dependencySaga.ts +++ b/src/renderer/store/sagas/dependencySaga.ts @@ -22,6 +22,7 @@ export const INSTALL_DEPENDENCY = 'dependency/installDependency'; export const INSTALL_FROM_MANIFEST = 'dependency/installFromManifest'; export const INSTALL_SINGLE_DEPENDENCY = 'dependency/installSingleDependency'; export const CHECK_DEPENDENCIES_AFTER_INSTALL = 'dependency/checkAfterInstall'; +export const TRIGGER_ONBOARDING_NEXT = 'dependency/triggerOnboardingNext'; /** * Worker saga: Fetch dependencies status @@ -76,22 +77,21 @@ function* installDependency(action: { type: string; payload: DependencyType }) { /** * Worker saga: Check dependencies after package installation */ -function* checkDependenciesAfterInstall(action: { type: string; payload: string }) { +function* checkDependenciesAfterInstall(action: { type: string; payload: { versionId: string; context?: 'version-management' | 'onboarding' } }) { try { - const versionId = action.payload; + const { versionId, context = 'version-management' } = action.payload; - // Get missing dependencies + // Get missing dependencies to populate the dependency list const missingDeps: DependencyItem[] = yield call( window.electronAPI.getMissingDependencies, versionId ); - if (missingDeps.length > 0) { - // Show install confirmation dialog - yield put(showInstallConfirm({ - dependencies: missingDeps, - versionId, - })); + // Don't show confirmation dialog - the UI now has direct install buttons + // Just store the dependencies in state for the UI to display + if (missingDeps.length > 0 && context === 'onboarding') { + // For onboarding, we already display dependencies with install buttons + // No need to show a separate confirmation dialog } } catch (error) { console.error('Failed to check dependencies after install:', error); @@ -105,8 +105,9 @@ function* installFromManifest(action: { type: string; payload: string }) { try { const versionId = action.payload; - // Get pending dependencies - const { dependencies } = yield select((state: any) => state.dependency.installConfirm); + // Get pending dependencies and context + const installConfirmState = yield select((state: any) => state.dependency.installConfirm); + const { dependencies, context } = installConfirmState; // Start installation yield put(startInstall(dependencies.length)); @@ -149,6 +150,11 @@ function* installFromManifest(action: { type: string; payload: string }) { description: '所有依赖已成功安装', }); } + + // Trigger onboarding next step if in onboarding context and all dependencies installed successfully + if (context === 'onboarding' && (!result.result?.failed || result.result.failed.length === 0)) { + yield put({ type: TRIGGER_ONBOARDING_NEXT }); + } } else { yield put(completeInstall({ status: 'error', diff --git a/src/renderer/store/slices/dependencySlice.ts b/src/renderer/store/slices/dependencySlice.ts index 4f51d5bc..b3d82d07 100644 --- a/src/renderer/store/slices/dependencySlice.ts +++ b/src/renderer/store/slices/dependencySlice.ts @@ -13,6 +13,7 @@ export enum DependencyType { * Single dependency item */ export interface DependencyItem { + key: string; // Manifest dependency key (e.g., "dotnet", "claudeCode") name: string; type: DependencyType; installed: boolean; @@ -51,6 +52,7 @@ export interface DependencyState { show: boolean; dependencies: DependencyItem[]; versionId: string; + context?: 'version-management' | 'onboarding'; // Context identifier }; // New: Install progress @@ -111,15 +113,17 @@ const dependencySlice = createSlice({ state.error = action.payload; }, // New: Install confirmation dialog actions - showInstallConfirm: (state, action: PayloadAction<{ dependencies: DependencyItem[]; versionId: string }>) => { + showInstallConfirm: (state, action: PayloadAction<{ dependencies: DependencyItem[]; versionId: string; context?: 'version-management' | 'onboarding' }>) => { state.installConfirm.show = true; state.installConfirm.dependencies = action.payload.dependencies; state.installConfirm.versionId = action.payload.versionId; + state.installConfirm.context = action.payload.context || 'version-management'; }, hideInstallConfirm: (state) => { state.installConfirm.show = false; state.installConfirm.dependencies = []; state.installConfirm.versionId = ''; + state.installConfirm.context = 'version-management'; }, // New: Install progress actions startInstall: (state, action: PayloadAction) => { @@ -180,6 +184,9 @@ export const selectPendingDependencies = (state: { dependency: DependencyState } export const selectInstallConfirmVersionId = (state: { dependency: DependencyState }) => state.dependency.installConfirm.versionId; +export const selectInstallConfirmContext = (state: { dependency: DependencyState }) => + state.dependency.installConfirm.context || 'version-management'; + // New selectors for install progress export const selectInstallProgress = (state: { dependency: DependencyState }) => state.dependency.installProgress; diff --git a/src/renderer/store/slices/onboardingSlice.ts b/src/renderer/store/slices/onboardingSlice.ts index bf5513c3..9d4ad025 100644 --- a/src/renderer/store/slices/onboardingSlice.ts +++ b/src/renderer/store/slices/onboardingSlice.ts @@ -4,18 +4,16 @@ import { checkOnboardingTrigger, skipOnboarding, downloadPackage, - checkDependencies, - installDependencies, startService, completeOnboarding, resetOnboarding, GO_TO_NEXT_STEP, GO_TO_PREVIOUS_STEP, } from '../thunks/onboardingThunks'; +import { TRIGGER_ONBOARDING_NEXT } from '../sagas/dependencySaga'; import type { OnboardingState, DownloadProgress, - DependencyItem, ServiceLaunchProgress, } from '../../../types/onboarding'; @@ -25,13 +23,11 @@ const initialState: OnboardingState = { isSkipped: false, isCompleted: false, downloadProgress: null, - dependenciesStatus: [], serviceProgress: null, showSkipConfirm: false, error: null, // Idempotency flags isDownloading: false, - isInstallingDependencies: false, isStartingService: false, }; @@ -57,9 +53,6 @@ export const onboardingSlice = createSlice({ setDownloadProgress: (state, action: PayloadAction) => { state.downloadProgress = action.payload; }, - setDependenciesStatus: (state, action: PayloadAction) => { - state.dependenciesStatus = action.payload; - }, setServiceProgress: (state, action: PayloadAction) => { state.serviceProgress = action.payload; }, @@ -148,41 +141,6 @@ export const onboardingSlice = createSlice({ state.error = action.payload as string || 'Failed to download package'; }); - // checkDependencies - builder - .addCase(checkDependencies.pending, (state) => { - console.log('[onboardingSlice] checkDependencies pending'); - state.error = null; - }) - .addCase(checkDependencies.fulfilled, (state) => { - console.log('[onboardingSlice] checkDependencies fulfilled'); - }) - .addCase(checkDependencies.rejected, (state, action) => { - console.error('[onboardingSlice] checkDependencies rejected:', action.error); - state.error = action.payload as string || 'Failed to check dependencies'; - }); - - // installDependencies - builder - .addCase(installDependencies.pending, (state) => { - console.log('[onboardingSlice] installDependencies pending'); - state.error = null; - state.currentStep = OnboardingStep.Dependencies; - // Only set isInstallingDependencies if not already installing (allow React Strict Mode double-calls) - if (!state.isInstallingDependencies) { - state.isInstallingDependencies = true; - } - }) - .addCase(installDependencies.fulfilled, (state) => { - console.log('[onboardingSlice] installDependencies fulfilled'); - state.isInstallingDependencies = false; - }) - .addCase(installDependencies.rejected, (state, action) => { - console.error('[onboardingSlice] installDependencies rejected:', action.error); - state.isInstallingDependencies = false; - state.error = action.payload as string || 'Failed to install dependencies'; - }); - // startService builder .addCase(startService.pending, (state) => { @@ -236,7 +194,6 @@ export const onboardingSlice = createSlice({ ...initialState, // Reset idempotency flags isDownloading: false, - isInstallingDependencies: false, isStartingService: false, }; }); @@ -277,6 +234,14 @@ export const onboardingSlice = createSlice({ if (state.currentStep > OnboardingStep.Welcome) { state.currentStep = (state.currentStep - 1) as OnboardingStep; } + }) + // Handle dependency installation completion in onboarding context + .addCase(TRIGGER_ONBOARDING_NEXT, (state) => { + console.log('[onboardingSlice] Triggering next step after dependency installation'); + // Only proceed if we're in the Dependencies step + if (state.currentStep === OnboardingStep.Dependencies) { + state.currentStep = OnboardingStep.Launch; + } }); }, }); @@ -289,7 +254,6 @@ export const { setError, clearError, setDownloadProgress, - setDependenciesStatus, setServiceProgress, nextStep, previousStep, @@ -302,14 +266,13 @@ export const selectCurrentStep = (state: { onboarding: OnboardingState }) => sta export const selectIsSkipped = (state: { onboarding: OnboardingState }) => state.onboarding.isSkipped; export const selectIsCompleted = (state: { onboarding: OnboardingState }) => state.onboarding.isCompleted; export const selectDownloadProgress = (state: { onboarding: OnboardingState }) => state.onboarding.downloadProgress; -export const selectDependenciesStatus = (state: { onboarding: OnboardingState }) => state.onboarding.dependenciesStatus; export const selectServiceProgress = (state: { onboarding: OnboardingState }) => state.onboarding.serviceProgress; export const selectShowSkipConfirm = (state: { onboarding: OnboardingState }) => state.onboarding.showSkipConfirm; export const selectOnboardingError = (state: { onboarding: OnboardingState }) => state.onboarding.error; // Computed selectors export const selectCanGoNext = (state: { onboarding: OnboardingState }) => { - const { currentStep, downloadProgress, dependenciesStatus, serviceProgress } = state.onboarding; + const { currentStep, downloadProgress, serviceProgress } = state.onboarding; switch (currentStep) { case OnboardingStep.Welcome: @@ -317,9 +280,8 @@ export const selectCanGoNext = (state: { onboarding: OnboardingState }) => { case OnboardingStep.Download: return downloadProgress?.progress === 100; case OnboardingStep.Dependencies: - // Allow proceeding once dependencies have been checked (not necessarily installed) - // This lets users see the dependency list and choose to install or skip - return dependenciesStatus.length > 0; + // Allow proceeding - dependencies are managed through dependencySlice + return downloadProgress?.version !== undefined; case OnboardingStep.Launch: return serviceProgress?.phase === 'running'; default: diff --git a/src/renderer/store/thunks/onboardingThunks.ts b/src/renderer/store/thunks/onboardingThunks.ts index 45f64825..fc3e5e7d 100644 --- a/src/renderer/store/thunks/onboardingThunks.ts +++ b/src/renderer/store/thunks/onboardingThunks.ts @@ -1,6 +1,6 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import type { RootState } from '../store'; -import type { DownloadProgress, DependencyItem, ServiceLaunchProgress } from '../../../types/onboarding'; +import type { DownloadProgress, ServiceLaunchProgress } from '../../../types/onboarding'; // Action types export const CHECK_ONBOARDING_TRIGGER = 'onboarding/checkTrigger'; @@ -56,44 +56,6 @@ export const downloadPackage = createAsyncThunk( } ); -export const checkDependencies = createAsyncThunk( - 'onboarding/checkDependencies', - async (versionId: string, { rejectWithValue }) => { - try { - console.log('[OnboardingThunks] Checking dependencies for version:', versionId); - - const result = await window.electronAPI.checkOnboardingDependencies(versionId); - - if (!result.success) { - return rejectWithValue(result.error || 'Check dependencies failed'); - } - - return result; - } catch (error: unknown) { - return rejectWithValue(error instanceof Error ? error.message : String(error)); - } - } -); - -export const installDependencies = createAsyncThunk( - 'onboarding/installDependencies', - async (versionId: string, { rejectWithValue }) => { - try { - console.log('[OnboardingThunks] Installing dependencies for version:', versionId); - - const result = await window.electronAPI.installDependencies(versionId); - - if (!result.success) { - return rejectWithValue(result.error || 'Install dependencies failed'); - } - - return result; - } catch (error: unknown) { - return rejectWithValue(error instanceof Error ? error.message : String(error)); - } - } -); - export const startService = createAsyncThunk( 'onboarding/startService', async (versionId: string, { rejectWithValue }) => { diff --git a/src/types/onboarding.ts b/src/types/onboarding.ts index 1a6def64..74014bf6 100644 --- a/src/types/onboarding.ts +++ b/src/types/onboarding.ts @@ -57,13 +57,11 @@ export interface OnboardingState { isSkipped: boolean; isCompleted: boolean; downloadProgress: DownloadProgress | null; - dependenciesStatus: DependencyItem[]; serviceProgress: ServiceLaunchProgress | null; showSkipConfirm: boolean; error: string | null; // Idempotency flags to prevent duplicate operations isDownloading: boolean; - isInstallingDependencies: boolean; isStartingService: boolean; }