refactor(github): GitHub 全面退役——免登录 + 发布/导入/同步链路移除 + 凭据去 GitHub 化
D1 免登录改造:App 启动直接进主界面,删 LoginPage/github.rs/OAuth 全套命令、
Sidebar 账户区与 GitHub OAuth 设置弹窗、健康检查 GitHub Token 项
D2 移除发布/导入/PR/CI 入口:删 PublishModal/ImportRepoModal、ProjectCard 的
PR compare 链接与 Actions 状态灯、RepoRegistryModal 同步 tab、CicdModal runs tab、
publish.rs 的 github_create_repo/git_push_to_github
D3 spacesSync 单机化:删 spacesSync/useSpacesSync/SpacesPage/DiscoveryPanel,
拆除 App→Dashboard→ProjectCard 的 spacesJson 链
D4 git_ops 凭据链重写:ssh agent → git credential helper → gitea_instances token
前缀匹配 → default,不再依赖 github_token
保留:server_software GitHub 镜像(Releases 下载加速)、gitea_migrate 迁移入口、
cicd.rs(待 Gitea Actions 改造卡);DB 表不删(R05 migration 兼容)
顺带:修复 servers.rs 两个存量测试的 schema 漂移(测试建表缺 deploy_type 列);
包含会话前未提交的 agent-infra F3(get_agent_health 健康诊断,与 lib.rs/
commands.ts 物理耦合无法拆分提交)
验证:cargo test 53 passed / typecheck 全绿 / vitest passWithNoTests
This commit is contained in:
parent
dcbf1f9355
commit
289b558278
@ -6,7 +6,7 @@ description: >
|
||||
成为每个项目的贴身导师。炼境本身亦被纳入其中,
|
||||
形成可自我进化的开发智能闭环。
|
||||
iteration: 1
|
||||
updated: 2026-06-30
|
||||
updated: 2026-07-02
|
||||
|
||||
|
||||
|
||||
@ -385,8 +385,17 @@ modules:
|
||||
area: backend
|
||||
status: done
|
||||
progress: 100
|
||||
updated: 2026-07-01
|
||||
position: [3750, 0]
|
||||
|
||||
- id: github-decommission
|
||||
name: GitHub 退役清理
|
||||
area: backend
|
||||
status: done
|
||||
progress: 100
|
||||
updated: 2026-07-02
|
||||
position: [3750, 150]
|
||||
|
||||
edges:
|
||||
# 前端依赖
|
||||
- from: workspace-mgmt
|
||||
@ -622,3 +631,6 @@ edges:
|
||||
- from: gitea-integration
|
||||
to: flywheel-intelligence
|
||||
type: related
|
||||
- from: gitea-integration
|
||||
to: github-decommission
|
||||
type: related
|
||||
|
||||
@ -118,3 +118,10 @@
|
||||
- depends: C
|
||||
- files: src/components/dashboard/ProjectCard.tsx, src/lib/commands.ts
|
||||
- acceptance: ProjectCard 右上角显示腐化评分小圆点(🟢🟡🔴),读取 `doc_freshness_cache` 缓存(无缓存则不显示);不发起新的 git 扫描(仅读缓存)
|
||||
|
||||
### ✅ F3. Agent 健康诊断 + 可复制修正指令
|
||||
- status: done
|
||||
- complexity: M
|
||||
- depends: C, D
|
||||
- files: src-tauri/src/commands/agent_infra.rs, src-tauri/src/lib.rs, src/lib/commands.ts, src/components/dashboard/AgentInfraPanel.tsx
|
||||
- acceptance: `get_agent_health(project_path, project_id)` 聚合 infra stack + doc freshness,生成问题列表 + Markdown 修正文本;AgentInfraPanel 加载时从 cache 读健康徽章,「重新检测 & 生成修正指令」按钮触发完整诊断,结果显示健康状态+可复制 textarea(含复制按钮)
|
||||
|
||||
@ -112,8 +112,8 @@ Gitea push → POST /webhook/gitea/:project_id
|
||||
- files: src-tauri/src/db.rs, src-tauri/src/commands/gitea.rs, src-tauri/src/lib.rs, src/lib/commands.ts
|
||||
- acceptance: gitea_instances + gitea_repos 表建好(migrate);save/list/delete/test_connection 命令可调;test_connection 调 GET /api/v1/user 返回 Gitea 用户名验证 token 有效性
|
||||
|
||||
### 📋 G2. 仓库创建 / 迁移 / 绑定
|
||||
- status: todo
|
||||
### 🔵 G2. 仓库创建 / 迁移 / 绑定
|
||||
- status: in_progress
|
||||
- complexity: M
|
||||
- depends: G1
|
||||
- files: src-tauri/src/commands/gitea.rs, src/lib/commands.ts
|
||||
@ -126,8 +126,8 @@ Gitea push → POST /webhook/gitea/:project_id
|
||||
- files: src-tauri/src/mcp_server.rs
|
||||
- acceptance: POST /webhook/gitea/:project_id 路由上线;HMAC-SHA256 验签通过后解析 commits 数组批量写 commit_metrics;验签失败记 log_event;单元测试覆盖 parse_push_payload + hmac_verify
|
||||
|
||||
### 📋 G4. Webhook 注册命令
|
||||
- status: todo
|
||||
### 🔵 G4. Webhook 注册命令
|
||||
- status: in_progress
|
||||
- complexity: M
|
||||
- depends: G2, G3
|
||||
- files: src-tauri/src/commands/gitea.rs, src/lib/commands.ts
|
||||
|
||||
@ -5,7 +5,8 @@ status: deprecated
|
||||
progress: 100
|
||||
---
|
||||
|
||||
> ❄️ **已冻结(2026-06-19)**:全面统一自建 Gitea,弃用 GitHub。功能保留不删,但不再演进;新接入走 onboarding-pack / gitea-integration 模块。
|
||||
> ❄️ **已冻结(2026-06-19)**:全面统一自建 Gitea,弃用 GitHub。
|
||||
> 🗑️ **已退役(2026-07-02)**:代码全部移除(github.rs 删除、登录门槛拆除、OAuth 命令下线),详见 github-decommission 模块。本文件仅作历史记录。
|
||||
|
||||
## 概述
|
||||
GitHub OAuth Device Flow 认证,获取 token 后支持 API 操作和 Git 推送。
|
||||
|
||||
59
.blueprint/modules/github-decommission.md
Normal file
59
.blueprint/modules/github-decommission.md
Normal file
@ -0,0 +1,59 @@
|
||||
# GitHub 退役清理
|
||||
|
||||
围绕自建 Gitea 的战略确定后(见 gitea-integration),移除 GitHub 作为 git 后端的全部纠缠。
|
||||
排查发现纠缠比蓝图标记的深:GitHub OAuth 是应用登录门槛(App.tsx 无账号连界面都进不去),
|
||||
spaces.json 每 5 分钟同步 GitHub 仓库,git_ops 所有凭据硬依赖 github_token。
|
||||
|
||||
## 边界(保留项)
|
||||
|
||||
- `server_software.rs` 的 GitHub 镜像管理:用途是 Releases 下载加速,与 git 后端无关,保留
|
||||
- `gitea_migrate_repo` 对 GitHub 的引用:迁移功能需要读旧家,保留
|
||||
- `cicd.rs` 后端命令:保留,待 gitea-integration「Gitea Actions workflow 生成」卡改造
|
||||
- DB 表(github_mirrors、settings 旧 key 等):代码停用但表不删,避免旧版本升级 migration 断裂(R05)
|
||||
|
||||
## 任务卡
|
||||
|
||||
### ✅ D1. 免登录改造(去掉 GitHub OAuth 登录门槛)
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src/App.tsx, src/components/auth/LoginPage.tsx(删), src/components/layout/Sidebar.tsx, src-tauri/src/commands/github.rs(删), src-tauri/src/commands/settings.rs, src-tauri/src/lib.rs, src/lib/commands.ts
|
||||
- acceptance: 应用启动直接进主界面,无登录页;github.rs 及 8 个 OAuth 命令删除;Sidebar 账户区与「应用设置」GitHub OAuth 弹窗删除;健康检查移除 GitHub Token 项;GLOBAL_KEYS 移除 github_client_id/secret;typecheck + cargo test 通过
|
||||
|
||||
### ✅ D2. 移除 GitHub 发布 / 导入 / PR / CI 状态入口
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src/components/dashboard/PublishModal.tsx(删), src/components/dashboard/ImportRepoModal.tsx(删), src/components/dashboard/ProjectCard.tsx, src/components/dashboard/RepoRegistryModal.tsx, src/components/dashboard/CicdModal.tsx, src/components/manage/ManagePage.tsx, src-tauri/src/commands/publish.rs, src/lib/commands.ts
|
||||
- acceptance: 发布按钮 / 从 GitHub 导入 / PR compare 链接 / Actions 运行状态显示全部移除;publish.rs 仅删 github_create_repo / git_push_to_github / get_token,通用工具(WSL 路径、scan_local_git 等)保留;CicdModal 保留配置 tab、删 runs tab;typecheck 通过
|
||||
|
||||
### ✅ D3. 删除 spacesSync(单机化,跨空间 GitHub 云同步下线)
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src/lib/spacesSync.ts(删), src/hooks/useSpacesSync.ts(删), src/components/spaces/SpacesPage.tsx(删,已无引用的死代码), src/components/dashboard/Dashboard.tsx, src/components/dashboard/ProjectCard.tsx, src/components/dashboard/ProjectModal.tsx
|
||||
- acceptance: spacesJson prop 链(App → Dashboard → ProjectCard)拆除;跨空间感知/接续 UI、可发现项目区块、push 后 catalog 上报全部移除;typecheck 通过
|
||||
|
||||
### ✅ D4. git_ops 凭据去 GitHub 化
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/git_ops.rs
|
||||
- acceptance: git_clone/fetch/pull/push/upstream_behind 不再硬依赖 settings.github_token;凭据链改为 git credential helper → gitea_instances token 前缀匹配 → ssh agent → default;无 token 时不再报「未登录 GitHub 账户」;cargo test 通过
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 免登录而非换 Gitea 登录:炼境是单人本地工具,"登录"概念本身无收益;Gitea 身份已由 gitea_instances.access_token 承载,远端操作按需取凭据
|
||||
- spacesSync 直接删而非迁 Gitea:用户确认单机使用,跨空间感知无真实场景
|
||||
- git 凭据优先走 git credential helper:与用户 CLI 使用同一凭据源(Windows Credential Manager),GitHub/Gitea 通吃,炼境不再自管 git 凭据
|
||||
|
||||
## 复盘笔记
|
||||
|
||||
【复盘】GitHub 退役清理(D1-D4,2026-07-02)
|
||||
任务类型: 架构调整
|
||||
复杂度: M×3 + S×1
|
||||
实际轮数: 1轮(预估 2 轮)
|
||||
是否返工: 否
|
||||
有效策略: 先全量排查(grep 全代码库)再动手,发现蓝图未标记的两个深耦合(登录门槛、spacesSync 定时同步);按"前端入口 → 封装层 → 后端命令 → 凭据层"顺序拆除,每层完成即 typecheck
|
||||
遗留风险: ① Gitea token 作 basic auth username 的凭据方式(userpass_plaintext(token, ""))未在真实 Gitea 实例上验证过 push;② CicdModal 仍生成 GitHub Actions 模板,待 gitea-integration 的 workflow 改造卡处理;③ 前端测试清零(parseRepoPath 测试随功能删除),vitest 配了 passWithNoTests
|
||||
auto_applied_rules: R01, R05
|
||||
rule_effectiveness: R01=有效(删命令时同步删 commands.ts 封装,无断层), R05=有效(保留 DB 表不删避免 migration 断裂)
|
||||
|
||||
【陷阱记录】servers.rs 存量测试 schema 漂移(顺手修复)
|
||||
测试 setup 的 CREATE TABLE 手抄了 project_server_map 旧 schema,缺 deploy_type/container_name/start_command 三列,与 db.rs migrate() 不一致导致 2 个测试常红。已补齐。根因:测试建表语句是手抄副本而非复用 migrate(),schema 演进时必然漂移——后续新测试应从 db::migrate 复制对应段(CONVENTIONS 测试模板已有此要求)。
|
||||
@ -1,6 +1,7 @@
|
||||
# GitHub 发布
|
||||
|
||||
> ❄️ **已冻结(2026-06-19)**:全面统一自建 Gitea,弃用 GitHub。功能保留不删,不再演进;项目发布走 Gitea(见 gitea-integration 模块)。
|
||||
> ❄️ **已冻结(2026-06-19)**:全面统一自建 Gitea,弃用 GitHub。
|
||||
> 🗑️ **已退役(2026-07-02)**:发布链路代码全部移除(PublishModal / ImportRepoModal / github_create_repo / git_push_to_github),详见 github-decommission 模块。本文件仅作历史记录。
|
||||
|
||||
在炼境内通过 GitHub OAuth 直接将本地项目推送到远程仓库,支持新建仓库或关联已有仓库,完成发布后自动回填 remote URL。
|
||||
|
||||
|
||||
@ -8102,6 +8102,222 @@
|
||||
"todo": 1,
|
||||
"total": 182
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "2026-07-01T00:19:51Z",
|
||||
"blocked_reasons": [],
|
||||
"conventions_version": "1.8.0",
|
||||
"delta": {
|
||||
"newly_blocked": [],
|
||||
"newly_done": [
|
||||
"gitea-integration/G1. DB schema + Gitea 实例 CRUD"
|
||||
],
|
||||
"unblocked": []
|
||||
},
|
||||
"iteration": 1,
|
||||
"modules": {
|
||||
"blocked": 0,
|
||||
"concept": 0,
|
||||
"done": 47,
|
||||
"in_progress": 2,
|
||||
"planned": 0,
|
||||
"stalled": [
|
||||
"flywheel-intelligence"
|
||||
],
|
||||
"total": 51
|
||||
},
|
||||
"task_ids": {
|
||||
"blocked": [],
|
||||
"done": [
|
||||
"agent-infrastructure/A. 框架层扫描 + 契约路径推断",
|
||||
"agent-infrastructure/B. 三件套文件生成",
|
||||
"agent-infrastructure/C. 文档腐化评分",
|
||||
"agent-infrastructure/D. 栈确认 UI + 生成按钮",
|
||||
"agent-infrastructure/E. 看板腐化指示器",
|
||||
"api-keys/API 密钥 CRUD 与安全展示",
|
||||
"auto-update/Capabilities 权限配置",
|
||||
"auto-update/下载 & 安装",
|
||||
"auto-update/更新检测",
|
||||
"auto-update/自动检查更新",
|
||||
"auto-update/错误提示",
|
||||
"blueprint-buff/BlueprintView 跨项目切换",
|
||||
"blueprint-buff/Buff 生命周期 Rust 命令",
|
||||
"blueprint-buff/blueprint_buffs 数据表",
|
||||
"blueprint-buff/git watcher + 任务匹配写回 + 飞轮快照",
|
||||
"blueprint-buff/前端 Buff 管理 UI",
|
||||
"blueprint-buff/前端实时刷新",
|
||||
"blueprint-feedback/归档 command",
|
||||
"blueprint-feedback/归档候选展示",
|
||||
"blueprint-feedback/梦核分析入口(蓝图弹窗)",
|
||||
"blueprint-feedback/梦核提示词增加停滞模块数据",
|
||||
"blueprint-flywheel/get_flywheel_stats command",
|
||||
"blueprint-flywheel/get_flywheel_stats 支持迭代感知",
|
||||
"blueprint-flywheel/usage.json 埋点写入",
|
||||
"blueprint-flywheel/梦核提示词生成 command",
|
||||
"blueprint-flywheel/飞轮健康面板",
|
||||
"blueprint-governance/CLAUDE.md 模板定义",
|
||||
"blueprint-governance/CONVENTIONS.md 版本化",
|
||||
"blueprint-governance/GovernancePanel 规则编辑器 UI",
|
||||
"blueprint-governance/get_blueprint_status command",
|
||||
"blueprint-governance/sync/梦核/summary 切换为 effective 内容",
|
||||
"blueprint-governance/sync_blueprint_rules command",
|
||||
"blueprint-governance/批量同步所有项目规则",
|
||||
"blueprint-governance/用户版 CONVENTIONS 存储层初始化",
|
||||
"blueprint-governance/用户版 CONVENTIONS 读写 command",
|
||||
"blueprint-onboarding/generate_blueprint_prompt command",
|
||||
"blueprint-onboarding/蓝图操作弹窗",
|
||||
"blueprint-onboarding/项目列表蓝图状态徽章",
|
||||
"blueprint-reader/合并返回完整蓝图数据",
|
||||
"blueprint-reader/读取 manifest.yaml",
|
||||
"blueprint-reader/读取 modules/*.md",
|
||||
"blueprint-view/.blueprint/ 数据结构设计",
|
||||
"blueprint-view/React Flow 节点图渲染",
|
||||
"blueprint-view/blocked 状态支持",
|
||||
"blueprint-view/下一步汇总面板",
|
||||
"blueprint-view/任务卡复制派发",
|
||||
"blueprint-view/批量执行提示词入口",
|
||||
"blueprint-view/智能连线路由",
|
||||
"blueprint-view/模块详情面板",
|
||||
"blueprint-view/统计汇总条",
|
||||
"blueprint-view/蓝图治理面板",
|
||||
"blueprint-view/蓝图读取 Command",
|
||||
"blueprint-view/边高亮与模块聚焦",
|
||||
"blueprint-view/顶部状态行扩展",
|
||||
"blueprint-view/项目卡片蓝图入口",
|
||||
"cicd-workflow/Actions 运行历史",
|
||||
"cicd-workflow/CI/CD 配置 CRUD",
|
||||
"cicd-workflow/Tauri OSS 分发模板",
|
||||
"cicd-workflow/Web SSH 部署模板",
|
||||
"cicd-workflow/Workflow 文件生成",
|
||||
"claude-autonomy-config/Beast 全局状态读取与展示",
|
||||
"cloud-db-instances/云数据库实例信息管理",
|
||||
"cloud-docker-apps/Docker 应用列表与状态管理",
|
||||
"cloud-products/DB 迁移:cloud_products 表",
|
||||
"cloud-products/Dashboard 接入:云产品标签页",
|
||||
"cloud-products/Rust CRUD:cloud_products.rs",
|
||||
"cloud-products/前端 Panel:CloudProductsPanel.tsx",
|
||||
"cloud-products/接线:mod.rs + lib.rs + commands.ts",
|
||||
"deploy-info/项目部署信息记录面板",
|
||||
"devtools-scan/WSL 工具扫描",
|
||||
"devtools-scan/Windows 工具扫描",
|
||||
"devtools-scan/工具信息持久化",
|
||||
"flywheel-intelligence/F1. Git 健康度后端——全量历史导入 + 跨项目聚合",
|
||||
"flywheel-intelligence/F2. 飞轮面板——Git 健康度区块 + 全量导入按钮",
|
||||
"flywheel-intelligence/MCP Server 支持 SSE 传输",
|
||||
"flywheel-intelligence/复盘笔记覆盖率可见性",
|
||||
"flywheel-intelligence/独立项目自动创建产品组",
|
||||
"flywheel-intelligence/补测试:飞轮存量函数安全网",
|
||||
"flywheel-intelligence/阶段一:复盘笔记接入梦核飞轮",
|
||||
"flywheel-intelligence/阶段二:usage.json 扩展采集维度",
|
||||
"git-ops/上游状态检测",
|
||||
"git-ops/分支管理",
|
||||
"git-ops/基础操作",
|
||||
"gitea-integration/G1. DB schema + Gitea 实例 CRUD",
|
||||
"github-auth/OAuth Device Flow",
|
||||
"github-auth/Token 管理",
|
||||
"github-publish/GitHub 仓库创建与推送",
|
||||
"health-center/T1 · Backend 健康检查聚合命令",
|
||||
"health-center/T2 · 质量指标聚合",
|
||||
"health-center/T3 · 前端健康中心页面",
|
||||
"health-center/T4 · 扩展 MCP get_diagnostics",
|
||||
"host-apps/宿主机应用清单与快启",
|
||||
"mcp-config-inject/加入产品组时注入配置",
|
||||
"mcp-config-inject/端口变更时重新注入所有配置",
|
||||
"mcp-config-inject/退出产品组时清除配置",
|
||||
"mcp-server/MCP 端口可配置(Rust 侧)",
|
||||
"mcp-server/启动时绑定 HTTP 端口",
|
||||
"mcp-server/实现 MCP JSON-RPC 基础协议",
|
||||
"mcp-server/实现产品组上下文工具",
|
||||
"onboarding-pack/A 模板分发引擎",
|
||||
"onboarding-pack/B apply_onboarding_pack 编排 + 前端入口",
|
||||
"onboarding-pack/C1 commit_metrics 表 + Conventional 解析器",
|
||||
"onboarding-pack/C2 挂接 buff watcher + 飞轮快照",
|
||||
"onboarding-pack/D 数据可信度提示",
|
||||
"product-group-mcp/产品组详情显示 MCP 注入状态",
|
||||
"product-group-mcp/设置页添加 MCP 端口配置",
|
||||
"project-dashboard/分组画布",
|
||||
"project-dashboard/项目卡片列表",
|
||||
"project-dashboard/项目发现面板",
|
||||
"project-dashboard/项目快捷操作",
|
||||
"project-env-scan/环境信息持久化",
|
||||
"project-env-scan/项目环境检测",
|
||||
"project-mentor/T1 · 后端聚合 command(MentorContext)",
|
||||
"project-mentor/T10 · 启动健康扫描 + 前端 Alert Banner",
|
||||
"project-mentor/T11 · 项目历史快照",
|
||||
"project-mentor/T2 · MCP 项目导师查询接口",
|
||||
"project-mentor/T3 · 前端导师面板",
|
||||
"project-mentor/T4 · 炼境自指接入",
|
||||
"project-mentor/T5 · project_notes 数据层 + 后端命令",
|
||||
"project-mentor/T6 · MCP append_project_note 工具",
|
||||
"project-mentor/T7 · MentorPanel 展示 AI 笔记",
|
||||
"project-mentor/T8 · 后端全组巡检命令",
|
||||
"project-mentor/T9 · 前端全组巡检按钮",
|
||||
"project-mgmt/子文件夹扫描",
|
||||
"project-mgmt/批量导入项目",
|
||||
"project-mgmt/项目 CRUD",
|
||||
"project-mgmt/项目档案(Profile)",
|
||||
"project-templates/P1+P3 模板扩充(React 生态全覆盖)",
|
||||
"project-templates/从模板创建项目",
|
||||
"project-templates/内置模板种植",
|
||||
"project-templates/模板 CRUD",
|
||||
"project-tools/工具 CRUD",
|
||||
"project-tools/工具启动",
|
||||
"repo-registry/仓库导入",
|
||||
"repo-registry/仓库挂载",
|
||||
"repo-registry/仓库注册表管理",
|
||||
"runtime-diagnostics/T1 · SQLite runtime_logs 表",
|
||||
"runtime-diagnostics/T2 · 替换 eprintln! 为结构化日志",
|
||||
"runtime-diagnostics/T3 · MCP Server 新增 get_diagnostics 工具",
|
||||
"runtime-diagnostics/T4 · 注入失败结果返回前端",
|
||||
"runtime-diagnostics/T5 · 设置页 MCP 真实健康检查",
|
||||
"server-registry/服务器 CRUD 与项目关联",
|
||||
"server-software/服务器软件扫描与展示",
|
||||
"service-config/服务配置 CRUD 与环境分组",
|
||||
"settings-page/WSL 发行版选择",
|
||||
"settings-page/关于 & 更新",
|
||||
"settings-page/终端配置",
|
||||
"settings-page/编辑器检测",
|
||||
"shell-launch/打开终端",
|
||||
"shell-launch/打开编辑器",
|
||||
"source-library/源码库分类管理与项目详情",
|
||||
"sqlite-db/数据库迁移",
|
||||
"sqlite-db/连接池初始化",
|
||||
"ssh-config-sync/SSH Config 写入与同步",
|
||||
"tags-groups/分组管理",
|
||||
"tags-groups/标签管理",
|
||||
"tray-service/关闭窗口最小化到托盘",
|
||||
"tray-service/系统托盘图标与菜单",
|
||||
"tray-service/集成 tauri-plugin-single-instance",
|
||||
"user-project-todos/前端:备忘清单 UI 组件",
|
||||
"user-project-todos/数据层:user_project_todos 表 + Rust commands",
|
||||
"v2rayn-import/当前节点读取",
|
||||
"v2rayn-import/数据库路径检测",
|
||||
"v2rayn-import/节点列表扫描",
|
||||
"vscode-remote/VS Code Remote SSH 一键连接",
|
||||
"workspace-mgmt/空间 CRUD",
|
||||
"workspace-mgmt/空间级设置",
|
||||
"workspace-mgmt/部署命令管理",
|
||||
"wsl-proxy/代理一致性测试",
|
||||
"wsl-proxy/代理同步",
|
||||
"wsl-proxy/代理清除",
|
||||
"wsl-proxy/代理状态检测"
|
||||
]
|
||||
},
|
||||
"tasks": {
|
||||
"avg_rounds_M": null,
|
||||
"blocked": 0,
|
||||
"complexity_dist": {
|
||||
"L": 6,
|
||||
"M": 72,
|
||||
"S": 49
|
||||
},
|
||||
"dispatched": 0,
|
||||
"done": 172,
|
||||
"in_progress": 2,
|
||||
"rework_count": 23,
|
||||
"todo": 4,
|
||||
"total": 188
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -46,7 +46,7 @@ docs/templates/ 炼境接入包模板草案(agents-meta-rules
|
||||
- 代码重构/简化:/simplify → /code-review
|
||||
- 发现深层问题:/Dev-deep-think
|
||||
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.6.0" -->
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.8.0" -->
|
||||
## 项目蓝图
|
||||
|
||||
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
|
||||
@ -55,7 +55,6 @@ docs/templates/ 炼境接入包模板草案(agents-meta-rules
|
||||
|
||||
1. 读取 `.blueprint/manifest.yaml` 了解项目全景和当前状态
|
||||
2. 读取 `.blueprint/CONVENTIONS.md` 了解完整工作流规则
|
||||
3. 若 `.blueprint/rules.md` 存在,读取并与全局规则合并应用(项目私有规则,优先级高于全局)
|
||||
|
||||
### 需求讨论 → 蓝图同步规则
|
||||
|
||||
@ -63,7 +62,7 @@ docs/templates/ 炼境接入包模板草案(agents-meta-rules
|
||||
|----------|--------------|
|
||||
| "我想加个 XX 功能" | 确认需求后:manifest 新增模块(status: concept),创建 `.blueprint/modules/<id>.md` |
|
||||
| "XX 功能需要 A、B、C" | 在模块文件中拆分为任务卡 |
|
||||
| "这个方案确定了" | 模块 status → planned,补充任务卡 acceptance(files 可选) |
|
||||
| "这个方案确定了" | 模块 status → planned,补充任务卡 files/acceptance |
|
||||
| "开始做 XX" | 模块 status → in_progress |
|
||||
| 没提蓝图但在讨论功能 | 主动提议更新蓝图 |
|
||||
|
||||
@ -71,7 +70,7 @@ docs/templates/ 炼境接入包模板草案(agents-meta-rules
|
||||
|
||||
| complexity | 执行流程 |
|
||||
|-----------|---------|
|
||||
| S / M | 直接拆任务卡,确保每张卡有 `acceptance` + `complexity` → 标记 📋(`files` 可选) |
|
||||
| S / M | 直接拆任务卡,确保每张卡有 `files` + `acceptance` + `complexity` → 标记 📋 |
|
||||
| L(复杂系统设计) | 先 `/architect` 输出设计文档,用户确认后 `/splitter` 拆成 S/M 任务卡 |
|
||||
|
||||
> L 级任务禁止直接写代码,必须先经过 `/architect` → `/splitter` 流程。
|
||||
|
||||
@ -1,35 +1,21 @@
|
||||
# 炼境契约路径映射
|
||||
# 炼境自身版本,手工维护(等 agent-infrastructure 模块完成后由炼境自动生成)
|
||||
# 供 AGENTS.md 元规则 T1 查询:「哪个文件是这个项目的契约?」
|
||||
# 项目契约路径映射
|
||||
# 由炼境根据技术栈扫描生成,技术栈变更后重新运行接入包生成
|
||||
# 供 AGENTS.md 元规则 T1 查询:「哪个文件是这个项目的外部契约?」
|
||||
|
||||
contracts:
|
||||
- label: API 接口(Axum)
|
||||
paths:
|
||||
- "src-tauri/src/mcp_server.rs"
|
||||
doc: "路由变更需同步 API 文档"
|
||||
|
||||
- label: Tauri command 接口(前后端契约)
|
||||
paths:
|
||||
- "src-tauri/src/commands/**/*.rs"
|
||||
- "src/lib/commands.ts"
|
||||
doc: "新增 command 时两端必须同步;src/lib/commands.ts 是唯一前端调用入口"
|
||||
|
||||
- label: 数据库 schema
|
||||
paths:
|
||||
- "src-tauri/src/db.rs"
|
||||
doc: "SQLite 表结构变更必须在 migrate() 函数中补 migration 语句"
|
||||
|
||||
- label: MCP Server 接口
|
||||
paths:
|
||||
- "src-tauri/src/mcp_server.rs"
|
||||
- "src-tauri/src/mcp_inject.rs"
|
||||
doc: "MCP 接口变更影响 Claude Code 对炼境的调用,接口路由和参数需同步"
|
||||
|
||||
- label: 接入包分发模板
|
||||
paths:
|
||||
- "src-tauri/resources/onboarding/**"
|
||||
doc: "模板变更需同步检查 docs/templates/agents-meta-rules.md(设计层来源)"
|
||||
|
||||
doc: "新增 command 时前后端必须同步"
|
||||
|
||||
architecture_docs:
|
||||
- "AGENTS.md"
|
||||
- "CLAUDE.md"
|
||||
- ".blueprint/manifest.yaml"
|
||||
|
||||
agent_docs:
|
||||
- "AGENTS.md"
|
||||
|
||||
@ -781,6 +781,172 @@ pub struct CachedFreshness {
|
||||
pub checked_at: String,
|
||||
}
|
||||
|
||||
// ── Agent 健康诊断 ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AgentHealthIssue {
|
||||
pub level: String, // "error" | "warn"
|
||||
pub title: String,
|
||||
pub fix: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AgentHealthReport {
|
||||
pub overall: String, // "green" | "yellow" | "red"
|
||||
pub issues: Vec<AgentHealthIssue>,
|
||||
/// 可直接复制给子项目 agent 的修正指令文本(Markdown)
|
||||
pub fix_prompt: String,
|
||||
pub checked_at: String,
|
||||
}
|
||||
|
||||
/// 诊断项目 agent 友好度,生成可复制的修正指令。
|
||||
/// 内部调用 scan_agent_infra_stack + scan_doc_freshness,同时刷新 doc_freshness_cache。
|
||||
#[tauri::command]
|
||||
pub fn get_agent_health(
|
||||
project_path: String,
|
||||
project_id: String,
|
||||
) -> Result<AgentHealthReport, String> {
|
||||
let root = Path::new(project_path.trim_end_matches(['/', '\\']));
|
||||
if !root.exists() {
|
||||
return Err(format!("目录不存在: {}", root.display()));
|
||||
}
|
||||
|
||||
let stack = scan_agent_infra_stack(project_path.clone())?;
|
||||
let freshness = scan_doc_freshness(project_path.clone(), project_id.clone())?;
|
||||
|
||||
let mut issues: Vec<AgentHealthIssue> = Vec::new();
|
||||
let mut overall = "green".to_string();
|
||||
|
||||
if !stack.context_yaml_exists {
|
||||
issues.push(AgentHealthIssue {
|
||||
level: "error".into(),
|
||||
title: "缺少 docs/ai-context/project-context.yaml".into(),
|
||||
fix: "在炼境「Agent 基础设施」面板点击「扫描技术栈」→「确认并生成三件套」".into(),
|
||||
});
|
||||
overall = "red".to_string();
|
||||
}
|
||||
|
||||
if !stack.meta_rules_present {
|
||||
issues.push(AgentHealthIssue {
|
||||
level: "warn".into(),
|
||||
title: "AGENTS.md 缺少「文档防腐元规则」段落".into(),
|
||||
fix: "在炼境「Agent 基础设施」面板生成三件套,或手动在 AGENTS.md 末尾添加 ## 文档防腐元规则 章节".into(),
|
||||
});
|
||||
if overall != "red" {
|
||||
overall = "yellow".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
match freshness.level.as_str() {
|
||||
"red" if !stack.context_yaml_exists => {
|
||||
// yaml 不存在导致的 red 已在上面记录,不重复
|
||||
}
|
||||
"red" => {
|
||||
let stale_count = freshness.details.iter().filter(|d| d.level == "red").count();
|
||||
issues.push(AgentHealthIssue {
|
||||
level: "error".into(),
|
||||
title: format!("文档严重腐化:{stale_count} 个契约文件更新超 30 天未同步 project-context.yaml"),
|
||||
fix: "检查并更新 docs/ai-context/project-context.yaml 的契约描述,或重新生成三件套".into(),
|
||||
});
|
||||
overall = "red".to_string();
|
||||
}
|
||||
"yellow" => {
|
||||
let stale_count = freshness.details.iter()
|
||||
.filter(|d| d.level == "yellow" || d.level == "red")
|
||||
.count();
|
||||
issues.push(AgentHealthIssue {
|
||||
level: "warn".into(),
|
||||
title: format!("文档腐化风险:{stale_count} 个契约文件更新后 14-30 天未同步"),
|
||||
fix: "检查并更新 docs/ai-context/project-context.yaml 中的契约描述".into(),
|
||||
});
|
||||
if overall != "red" {
|
||||
overall = "yellow".to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let proj_name = root
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("未知项目");
|
||||
let fix_prompt = build_fix_prompt(proj_name, &overall, &issues, &stack, &freshness);
|
||||
|
||||
Ok(AgentHealthReport {
|
||||
overall,
|
||||
issues,
|
||||
fix_prompt,
|
||||
checked_at: freshness.checked_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_fix_prompt(
|
||||
project_name: &str,
|
||||
overall: &str,
|
||||
issues: &[AgentHealthIssue],
|
||||
stack: &AgentInfraStack,
|
||||
freshness: &FreshnessResult,
|
||||
) -> String {
|
||||
let status_emoji = match overall {
|
||||
"red" => "🔴",
|
||||
"yellow" => "🟡",
|
||||
_ => "🟢",
|
||||
};
|
||||
let status_label = match overall {
|
||||
"red" => "需要修复",
|
||||
"yellow" => "存在风险",
|
||||
_ => "良好",
|
||||
};
|
||||
let date_label = freshness.checked_at.get(..10).unwrap_or(&freshness.checked_at);
|
||||
|
||||
let mut lines = vec![
|
||||
format!("# Agent 友好度诊断 — {project_name}"),
|
||||
format!("整体状态:{status_emoji} {status_label}(检测时间:{date_label})"),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
if issues.is_empty() {
|
||||
lines.push("✅ 所有检查通过,项目 agent 友好度良好,无需修复。".into());
|
||||
} else {
|
||||
lines.push("## 待修复项".into());
|
||||
lines.push(String::new());
|
||||
for issue in issues {
|
||||
let icon = if issue.level == "error" { "❌" } else { "⚠️" };
|
||||
lines.push(format!("{icon} **{}**", issue.title));
|
||||
lines.push(format!(" 修复方法:{}", issue.fix));
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
|
||||
let mut passed = Vec::new();
|
||||
if stack.context_yaml_exists {
|
||||
passed.push("docs/ai-context/project-context.yaml 已存在".to_string());
|
||||
}
|
||||
if stack.meta_rules_present {
|
||||
passed.push("AGENTS.md 已包含文档防腐元规则".to_string());
|
||||
}
|
||||
if !stack.detected_contracts.is_empty() && freshness.level == "green" {
|
||||
passed.push(format!(
|
||||
"{} 个契约路径均在 14 天内同步",
|
||||
stack.detected_contracts.len()
|
||||
));
|
||||
}
|
||||
|
||||
if !passed.is_empty() {
|
||||
lines.push("## 已通过项".into());
|
||||
lines.push(String::new());
|
||||
for p in &passed {
|
||||
lines.push(format!("✅ {p}"));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
lines.push("---".into());
|
||||
lines.push("*由炼境 dev-manager 自动生成。请根据上述修复项逐项处理,完成后在炼境中点击「重新检测」刷新状态。*".into());
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
// ── 单元测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -12,39 +12,60 @@ pub struct GitStatus {
|
||||
pub last_commit_time: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
fn get_token() -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'github_token'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map_err(|_| "未登录 GitHub 账户".to_string())
|
||||
/// 按 URL 前缀匹配已注册的 Gitea 实例,返回其 access_token
|
||||
fn gitea_token_for_url(url: &str) -> Option<String> {
|
||||
let conn = db::pool().get().ok()?;
|
||||
let mut stmt = conn.prepare("SELECT url, access_token FROM gitea_instances").ok()?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
|
||||
.ok()?;
|
||||
for row in rows.flatten() {
|
||||
let (base, token) = row;
|
||||
let base = base.trim_end_matches('/');
|
||||
if !base.is_empty() && url.starts_with(base) {
|
||||
return Some(token);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn make_fetch_options(token: &str) -> FetchOptions<'static> {
|
||||
let token = token.to_string();
|
||||
/// 凭据链:ssh agent → git credential helper(与 CLI 同一凭据源)→ Gitea 实例 token → default
|
||||
/// 不再依赖 GitHub 登录,任何 remote(Gitea / GitHub / 其他)走系统已有凭据
|
||||
fn make_callbacks() -> RemoteCallbacks<'static> {
|
||||
let mut callbacks = RemoteCallbacks::new();
|
||||
callbacks.credentials(move |_url, _username, _allowed| {
|
||||
git2::Cred::userpass_plaintext(&token, "x-oauth-basic")
|
||||
callbacks.credentials(|url, username_from_url, allowed| {
|
||||
if allowed.contains(git2::CredentialType::SSH_KEY) {
|
||||
if let Ok(cred) = git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) {
|
||||
return Ok(cred);
|
||||
}
|
||||
}
|
||||
if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
|
||||
if let Ok(config) = git2::Config::open_default() {
|
||||
if let Ok(cred) = git2::Cred::credential_helper(&config, url, username_from_url) {
|
||||
return Ok(cred);
|
||||
}
|
||||
}
|
||||
if let Some(token) = gitea_token_for_url(url) {
|
||||
if let Ok(cred) = git2::Cred::userpass_plaintext(&token, "") {
|
||||
return Ok(cred);
|
||||
}
|
||||
}
|
||||
}
|
||||
git2::Cred::default()
|
||||
});
|
||||
callbacks
|
||||
}
|
||||
|
||||
fn make_fetch_options() -> FetchOptions<'static> {
|
||||
let mut fetch_opts = FetchOptions::new();
|
||||
fetch_opts.remote_callbacks(callbacks);
|
||||
fetch_opts.remote_callbacks(make_callbacks());
|
||||
fetch_opts
|
||||
}
|
||||
|
||||
/// clone 仓库到指定本地路径
|
||||
#[tauri::command]
|
||||
pub fn git_clone(repo_url: String, local_path: String, upstream_url: Option<String>) -> Result<(), String> {
|
||||
let token = get_token()?;
|
||||
let token_clone = token.clone();
|
||||
let mut callbacks = RemoteCallbacks::new();
|
||||
callbacks.credentials(move |_url, _username, _allowed| {
|
||||
git2::Cred::userpass_plaintext(&token_clone, "x-oauth-basic")
|
||||
});
|
||||
let mut fetch_opts = FetchOptions::new();
|
||||
fetch_opts.remote_callbacks(callbacks);
|
||||
let fetch_opts = make_fetch_options();
|
||||
|
||||
let repo = RepoBuilder::new()
|
||||
.fetch_options(fetch_opts)
|
||||
@ -111,11 +132,10 @@ pub fn git_status(local_path: String) -> Result<GitStatus, String> {
|
||||
/// fetch 远端最新信息(不修改本地工作区)
|
||||
#[tauri::command]
|
||||
pub fn git_fetch(local_path: String) -> Result<(), String> {
|
||||
let token = get_token()?;
|
||||
let repo = Repository::open(&local_path)
|
||||
.map_err(|e| format!("无法打开仓库: {}", e))?;
|
||||
|
||||
let mut fetch_opts = make_fetch_options(&token);
|
||||
let mut fetch_opts = make_fetch_options();
|
||||
repo.find_remote("origin")
|
||||
.map_err(|e| e.to_string())?
|
||||
.fetch(&[] as &[&str], Some(&mut fetch_opts), None)
|
||||
@ -127,12 +147,11 @@ pub fn git_fetch(local_path: String) -> Result<(), String> {
|
||||
/// pull(fetch + merge fast-forward)
|
||||
#[tauri::command]
|
||||
pub fn git_pull(local_path: String) -> Result<String, String> {
|
||||
let token = get_token()?;
|
||||
let repo = Repository::open(&local_path)
|
||||
.map_err(|e| format!("无法打开仓库: {}", e))?;
|
||||
|
||||
// fetch
|
||||
let mut fetch_opts = make_fetch_options(&token);
|
||||
let mut fetch_opts = make_fetch_options();
|
||||
let mut remote = repo.find_remote("origin").map_err(|e| e.to_string())?;
|
||||
remote
|
||||
.fetch(&[] as &[&str], Some(&mut fetch_opts), None)
|
||||
@ -194,7 +213,6 @@ pub fn git_head_commit(local_path: String) -> Result<Option<String>, String> {
|
||||
/// 检查 upstream 远端落后情况(公司仓库是否有新提交)
|
||||
#[tauri::command]
|
||||
pub fn git_upstream_behind(local_path: String, default_branch: String) -> Result<usize, String> {
|
||||
let token = get_token()?;
|
||||
let repo = Repository::open(&local_path)
|
||||
.map_err(|e| format!("无法打开仓库: {}", e))?;
|
||||
|
||||
@ -204,13 +222,7 @@ pub fn git_upstream_behind(local_path: String, default_branch: String) -> Result
|
||||
}
|
||||
|
||||
// fetch upstream
|
||||
let mut callbacks = RemoteCallbacks::new();
|
||||
let token_clone = token.clone();
|
||||
callbacks.credentials(move |_url, _username, _allowed| {
|
||||
git2::Cred::userpass_plaintext(&token_clone, "x-oauth-basic")
|
||||
});
|
||||
let mut fetch_opts = FetchOptions::new();
|
||||
fetch_opts.remote_callbacks(callbacks);
|
||||
let mut fetch_opts = make_fetch_options();
|
||||
|
||||
if let Ok(mut remote) = repo.find_remote("upstream") {
|
||||
let _ = remote.fetch(&[&default_branch], Some(&mut fetch_opts), None);
|
||||
@ -236,7 +248,6 @@ pub fn git_upstream_behind(local_path: String, default_branch: String) -> Result
|
||||
/// push 当前分支到 origin
|
||||
#[tauri::command]
|
||||
pub fn git_push(local_path: String) -> Result<String, String> {
|
||||
let token = get_token()?;
|
||||
let repo = Repository::open(&local_path)
|
||||
.map_err(|e| format!("无法打开仓库: {}", e))?;
|
||||
|
||||
@ -248,14 +259,8 @@ pub fn git_push(local_path: String) -> Result<String, String> {
|
||||
|
||||
let refspec = format!("refs/heads/{}:refs/heads/{}", branch, branch);
|
||||
|
||||
let mut callbacks = RemoteCallbacks::new();
|
||||
let token_clone = token.clone();
|
||||
callbacks.credentials(move |_url, _username, _allowed| {
|
||||
git2::Cred::userpass_plaintext(&token_clone, "x-oauth-basic")
|
||||
});
|
||||
|
||||
let mut push_opts = git2::PushOptions::new();
|
||||
push_opts.remote_callbacks(callbacks);
|
||||
push_opts.remote_callbacks(make_callbacks());
|
||||
|
||||
repo.find_remote("origin")
|
||||
.map_err(|e| e.to_string())?
|
||||
|
||||
@ -1,359 +0,0 @@
|
||||
use crate::db;
|
||||
use crate::models::GitAccount;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── OAuth Authorization Code Flow 共享状态 ────────────────────────────────────
|
||||
|
||||
static OAUTH_RESULT: Lazy<Arc<Mutex<Option<Result<String, String>>>>> =
|
||||
Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||
|
||||
fn read_setting(key: &str) -> String {
|
||||
let conn = match db::pool().get() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = ?1",
|
||||
rusqlite::params![key],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// URL 编码(不依赖外部 crate)
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for byte in s.as_bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
|
||||
| b'-' | b'_' | b'.' | b'~' => out.push(*byte as char),
|
||||
b => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 用 ureq 把 code 换成 access_token
|
||||
fn exchange_code(client_id: &str, client_secret: &str, code: &str, port: u16)
|
||||
-> Result<String, String>
|
||||
{
|
||||
let redirect_uri = format!("http://localhost:{}/callback", port);
|
||||
let body = format!(
|
||||
"client_id={}&client_secret={}&code={}&redirect_uri={}",
|
||||
url_encode(client_id),
|
||||
url_encode(client_secret),
|
||||
url_encode(code),
|
||||
url_encode(&redirect_uri),
|
||||
);
|
||||
|
||||
let resp = ureq::post("https://github.com/login/oauth/access_token")
|
||||
.set("Accept", "application/json")
|
||||
.set("Content-Type", "application/x-www-form-urlencoded")
|
||||
.send_string(&body)
|
||||
.map_err(|e| format!("网络请求失败: {e}"))?;
|
||||
|
||||
let json: serde_json::Value = resp.into_json()
|
||||
.map_err(|e| format!("解析响应失败: {e}"))?;
|
||||
|
||||
if let Some(token) = json["access_token"].as_str() {
|
||||
return Ok(token.to_string());
|
||||
}
|
||||
Err(json["error_description"]
|
||||
.as_str()
|
||||
.or_else(|| json["error"].as_str())
|
||||
.unwrap_or("token 交换失败")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// 启动 Authorization Code Flow。
|
||||
/// 绑定随机端口,后台等待浏览器回调,返回 GitHub 授权 URL。
|
||||
#[tauri::command]
|
||||
pub fn github_start_oauth() -> Result<String, String> {
|
||||
let client_id = read_setting("github_client_id");
|
||||
let client_secret = read_setting("github_client_secret");
|
||||
|
||||
if client_id.trim().is_empty() {
|
||||
return Err("未配置 GitHub Client ID,请在设置中填写".to_string());
|
||||
}
|
||||
if client_secret.trim().is_empty() {
|
||||
return Err("未配置 GitHub Client Secret,请在设置中填写".to_string());
|
||||
}
|
||||
|
||||
// 绑定随机可用端口
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| format!("无法绑定本地端口: {e}"))?;
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
// 重置上一次结果
|
||||
*OAUTH_RESULT.lock().unwrap() = None;
|
||||
|
||||
let client_id_c = client_id.clone();
|
||||
let client_secret_c = client_secret.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = stream.read(&mut buf).unwrap_or(0);
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
|
||||
// 从 "GET /callback?code=xxx HTTP/1.1" 提取 code
|
||||
let code = request.lines().next().and_then(|line| {
|
||||
let path = line.split_whitespace().nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
query
|
||||
.split('&')
|
||||
.find(|seg| seg.starts_with("code="))
|
||||
.map(|seg| seg[5..].to_string())
|
||||
});
|
||||
|
||||
let (status, html, result) = match code {
|
||||
Some(code) => match exchange_code(&client_id_c, &client_secret_c, &code, port) {
|
||||
Ok(token) => (
|
||||
"200 OK",
|
||||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px;background:#f6f8fa'><h2 style='color:#1a7f37'>✅ 授权成功</h2><p style='color:#57606a'>可以关闭此标签页,返回应用继续操作。</p></body></html>",
|
||||
Ok(token),
|
||||
),
|
||||
Err(e) => (
|
||||
"400 Bad Request",
|
||||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 授权失败</h2><p>请返回应用重试。</p></body></html>",
|
||||
Err(e),
|
||||
),
|
||||
},
|
||||
None => (
|
||||
"400 Bad Request",
|
||||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 未收到授权码</h2></body></html>",
|
||||
Err("GitHub 未返回授权码".to_string()),
|
||||
),
|
||||
};
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
|
||||
html.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
*OAUTH_RESULT.lock().unwrap() = Some(result);
|
||||
}
|
||||
});
|
||||
|
||||
let redirect_uri = format!("http://localhost:{}/callback", port);
|
||||
Ok(format!(
|
||||
"https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=repo+user&allow_signup=false",
|
||||
url_encode(&client_id),
|
||||
url_encode(&redirect_uri),
|
||||
))
|
||||
}
|
||||
|
||||
/// 前端轮询:None = 等待中,Some(token) = 成功,Err = 失败
|
||||
#[tauri::command]
|
||||
pub fn github_poll_oauth() -> Result<Option<String>, String> {
|
||||
match &*OAUTH_RESULT.lock().unwrap() {
|
||||
None => Ok(None),
|
||||
Some(Ok(token)) => Ok(Some(token.clone())),
|
||||
Some(Err(e)) => Err(e.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测 github.com:443 是否可达(3 秒超时)
|
||||
fn check_github_reachable() -> bool {
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
let Ok(addrs) = "github.com:443".to_socket_addrs() else {
|
||||
return false;
|
||||
};
|
||||
addrs
|
||||
.into_iter()
|
||||
.any(|addr| TcpStream::connect_timeout(&addr, Duration::from_secs(3)).is_ok())
|
||||
}
|
||||
|
||||
/// 用系统默认浏览器打开 GitHub 授权页,TcpListener 接收回调(Authorization Code Flow)
|
||||
/// 使用系统浏览器避免内嵌 WebView2 的代理/渲染问题
|
||||
#[tauri::command]
|
||||
pub fn github_open_login_window(_app: tauri::AppHandle) -> Result<(), String> {
|
||||
if !check_github_reachable() {
|
||||
return Err("无法连接 GitHub(github.com:443 超时),请先确认代理已开启".to_string());
|
||||
}
|
||||
|
||||
let client_id = read_setting("github_client_id");
|
||||
let client_secret = read_setting("github_client_secret");
|
||||
|
||||
if client_id.trim().is_empty() {
|
||||
return Err("未配置 GitHub Client ID,请在设置中填写".to_string());
|
||||
}
|
||||
if client_secret.trim().is_empty() {
|
||||
return Err("未配置 GitHub Client Secret,请在设置中填写".to_string());
|
||||
}
|
||||
|
||||
*OAUTH_RESULT.lock().unwrap() = None;
|
||||
|
||||
// 绑定随机本地端口接收 GitHub 回调
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| format!("无法绑定本地端口: {e}"))?;
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let redirect_uri = format!("http://localhost:{}/callback", port);
|
||||
let auth_url = format!(
|
||||
"https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=repo+user&allow_signup=false",
|
||||
url_encode(&client_id),
|
||||
url_encode(&redirect_uri),
|
||||
);
|
||||
|
||||
// 后台线程:等待 GitHub 回调,换取 token
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = stream.read(&mut buf).unwrap_or(0);
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
|
||||
let code = request.lines().next().and_then(|line| {
|
||||
let path = line.split_whitespace().nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
query
|
||||
.split('&')
|
||||
.find(|s| s.starts_with("code="))
|
||||
.map(|s| s[5..].to_string())
|
||||
});
|
||||
|
||||
let (status, html, result) = match code {
|
||||
Some(code) => match exchange_code(&client_id, &client_secret, &code, port) {
|
||||
Ok(token) => (
|
||||
"200 OK",
|
||||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px;background:#f6f8fa'><h2 style='color:#1a7f37'>✅ 授权成功</h2><p style='color:#57606a'>可以关闭此标签页,返回应用继续操作。</p></body></html>",
|
||||
Ok(token),
|
||||
),
|
||||
Err(e) => (
|
||||
"400 Bad Request",
|
||||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 授权失败</h2><p>请返回应用重试。</p></body></html>",
|
||||
Err(e),
|
||||
),
|
||||
},
|
||||
None => (
|
||||
"400 Bad Request",
|
||||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 未收到授权码</h2></body></html>",
|
||||
Err("GitHub 未返回授权码".to_string()),
|
||||
),
|
||||
};
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
|
||||
html.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
*OAUTH_RESULT.lock().unwrap() = Some(result);
|
||||
}
|
||||
});
|
||||
|
||||
// 用系统默认浏览器打开(走系统代理,不受 WebView2 限制)
|
||||
open::that(&auth_url).map_err(|e| format!("无法打开浏览器: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 保存 GitHub 账户信息和 token(登录成功后由前端调用)
|
||||
#[tauri::command]
|
||||
pub fn github_save_account(
|
||||
username: String,
|
||||
avatar_url: String,
|
||||
access_token: String,
|
||||
) -> Result<GitAccount, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
// 已存在则复用同一个 id
|
||||
let existing_id: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM git_accounts WHERE username = ?1",
|
||||
rusqlite::params![username],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let id = existing_id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO git_accounts (id, username, avatar_url, provider)
|
||||
VALUES (?1, ?2, ?3, 'github')
|
||||
ON CONFLICT(username) DO UPDATE SET avatar_url = excluded.avatar_url",
|
||||
rusqlite::params![id, username, avatar_url],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// token 存入 settings 表
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('github_token', ?1)",
|
||||
rusqlite::params![access_token],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let account = conn
|
||||
.query_row(
|
||||
"SELECT id, username, avatar_url, provider, created_at FROM git_accounts WHERE id = ?1",
|
||||
rusqlite::params![id],
|
||||
GitAccount::from_row,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
/// 获取当前已登录账户(无则返回 None)
|
||||
#[tauri::command]
|
||||
pub fn github_get_account() -> Result<Option<GitAccount>, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
match conn.query_row(
|
||||
"SELECT id, username, avatar_url, provider, created_at FROM git_accounts LIMIT 1",
|
||||
[],
|
||||
GitAccount::from_row,
|
||||
) {
|
||||
Ok(account) => Ok(Some(account)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取已存储的 access_token(前端调用 GitHub API 时使用)
|
||||
#[tauri::command]
|
||||
pub fn github_get_token() -> Result<Option<String>, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
match conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'github_token'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(token) if !token.is_empty() => Ok(Some(token)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 GitHub Client ID(前端发起 Device Flow 时使用)
|
||||
#[tauri::command]
|
||||
pub fn github_get_client_id() -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let value: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'github_client_id'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
if value.trim().is_empty() {
|
||||
return Err("未配置 GitHub Client ID,请在「设置」页面填写后再登录".to_string());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 登出:清除账户和 token
|
||||
#[tauri::command]
|
||||
pub fn github_logout() -> Result<(), String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM git_accounts", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"DELETE FROM settings WHERE key = 'github_token'",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@ -8,7 +8,6 @@ pub mod projects;
|
||||
pub mod settings;
|
||||
pub mod shell;
|
||||
pub mod spaces;
|
||||
pub mod github;
|
||||
pub mod git_ops;
|
||||
pub mod proxy;
|
||||
pub mod git_repos;
|
||||
|
||||
@ -118,16 +118,6 @@ pub struct GitScanResult {
|
||||
|
||||
// ── 内部工具 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn get_token() -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'github_token'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map_err(|_| "未登录 GitHub 账户".to_string())
|
||||
}
|
||||
|
||||
/// 检测疑似敏感文件(只扫一层 + 常见子目录,避免遍历 node_modules)
|
||||
fn scan_suspicious(root: &Path) -> Vec<String> {
|
||||
let patterns = [
|
||||
@ -471,48 +461,6 @@ pub fn scan_local_git(path: String) -> Result<GitScanResult, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 调用 GitHub API 创建远端仓库,返回 clone_url
|
||||
#[tauri::command]
|
||||
pub fn github_create_repo(
|
||||
name: String,
|
||||
description: String,
|
||||
private: bool,
|
||||
) -> Result<String, String> {
|
||||
let token = get_token()?;
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"private": private,
|
||||
"auto_init": false,
|
||||
});
|
||||
|
||||
let resp = ureq::post("https://api.github.com/user/repos")
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.set("Accept", "application/vnd.github+json")
|
||||
.set("User-Agent", "dev-manager-tauri/1.0")
|
||||
.send_json(body)
|
||||
.map_err(|e| match e {
|
||||
ureq::Error::Status(422, resp) => {
|
||||
let msg = resp
|
||||
.into_json::<serde_json::Value>()
|
||||
.ok()
|
||||
.and_then(|v| v["message"].as_str().map(|s| s.to_string()))
|
||||
.unwrap_or("仓库名已存在或不合法".to_string());
|
||||
msg
|
||||
}
|
||||
other => format!("GitHub API 请求失败: {other}"),
|
||||
})?;
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.into_json()
|
||||
.map_err(|e| format!("解析响应失败: {e}"))?;
|
||||
|
||||
json["clone_url"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "未获取到仓库地址".to_string())
|
||||
}
|
||||
|
||||
/// git init(若还没有 .git)并提交所有改动
|
||||
#[tauri::command]
|
||||
pub fn git_init_and_commit(path: String, message: String) -> Result<(), String> {
|
||||
@ -569,32 +517,6 @@ pub fn git_remote_set(path: String, url: String) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// push 到 origin,设置 upstream tracking
|
||||
#[tauri::command]
|
||||
pub fn git_push_to_github(path: String, branch: String) -> Result<(), String> {
|
||||
let token = get_token()?;
|
||||
let win_path = wsl_to_win(&path);
|
||||
let repo = open_repo(&win_path)?;
|
||||
|
||||
let refspec = format!("refs/heads/{}:refs/heads/{}", branch, branch);
|
||||
|
||||
let mut callbacks = git2::RemoteCallbacks::new();
|
||||
let token_clone = token.clone();
|
||||
callbacks.credentials(move |_url, _username, _allowed| {
|
||||
git2::Cred::userpass_plaintext(&token_clone, "x-oauth-basic")
|
||||
});
|
||||
|
||||
let mut push_opts = git2::PushOptions::new();
|
||||
push_opts.remote_callbacks(callbacks);
|
||||
|
||||
repo.find_remote("origin")
|
||||
.map_err(|e| format!("未配置 origin remote: {e}"))?
|
||||
.push(&[refspec.as_str()], Some(&mut push_opts))
|
||||
.map_err(|e| format!("push 失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 写入 .gitignore 文件到指定目录
|
||||
#[tauri::command]
|
||||
pub fn write_gitignore(dir: String, content: String) -> Result<(), String> {
|
||||
|
||||
@ -320,7 +320,8 @@ mod tests {
|
||||
);
|
||||
CREATE TABLE project_server_map (
|
||||
project_id TEXT NOT NULL, server_id TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'production', deploy_path TEXT, notes TEXT,
|
||||
role TEXT DEFAULT 'production', deploy_type TEXT DEFAULT 'bare',
|
||||
deploy_path TEXT, container_name TEXT, start_command TEXT, notes TEXT,
|
||||
PRIMARY KEY (project_id, server_id)
|
||||
);",
|
||||
)
|
||||
|
||||
@ -6,7 +6,6 @@ use std::collections::HashMap;
|
||||
|
||||
/// 全局 key(不按空间隔离,属于整个应用/机器级别配置)
|
||||
const GLOBAL_KEYS: &[&str] = &[
|
||||
"github_client_id", "github_client_secret",
|
||||
"wsl_distro", // 机器级别,与空间无关
|
||||
"wsl_open_mode", // WSL 编辑器启动方式
|
||||
"editors", "active_editors", "active_editor",
|
||||
@ -17,7 +16,7 @@ const GLOBAL_KEYS: &[&str] = &[
|
||||
];
|
||||
|
||||
/// 获取 settings:
|
||||
/// - 有 space_id:返回该空间的 space_settings + 全局 key(github_client_id/secret)
|
||||
/// - 有 space_id:返回该空间的 space_settings + 全局 key(GLOBAL_KEYS)
|
||||
/// - 无 space_id:返回全局 settings 表
|
||||
#[tauri::command]
|
||||
pub fn get_settings(space_id: Option<String>) -> Result<HashMap<String, String>, String> {
|
||||
@ -326,43 +325,6 @@ fn check_project_paths() -> HealthItem {
|
||||
result.unwrap_or_else(|e| err_item("project_paths", "项目本地路径", &e))
|
||||
}
|
||||
|
||||
/// 检测 GitHub Token 是否有效(settings.github_token)
|
||||
fn check_github_token() -> HealthItem {
|
||||
let result: Result<HealthItem, String> = (|| {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
let token: Option<String> = conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'github_token'",
|
||||
[], |row| row.get(0),
|
||||
).ok();
|
||||
|
||||
let token = match token {
|
||||
Some(t) if !t.trim().is_empty() => t.trim().to_string(),
|
||||
_ => return Ok(skip_item("github_token", "GitHub Token", "未登录 GitHub 账户")),
|
||||
};
|
||||
|
||||
match ureq::builder().timeout(std::time::Duration::from_secs(8)).build()
|
||||
.get("https://api.github.com/user")
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.set("User-Agent", "dev-manager-tauri")
|
||||
.call()
|
||||
{
|
||||
Ok(resp) => {
|
||||
let username = resp.into_json::<Value>().ok()
|
||||
.and_then(|v| v["login"].as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "未知账户".into());
|
||||
Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "ok".into(),
|
||||
detail: Some(format!("Token 有效,账户: {}", username)) })
|
||||
}
|
||||
Err(ureq::Error::Status(401, _)) | Err(ureq::Error::Status(403, _)) =>
|
||||
Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "error".into(),
|
||||
detail: Some("Token 已失效或权限不足,请重新登录 GitHub 账户".into()) }),
|
||||
Err(e) => Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "warn".into(),
|
||||
detail: Some(format!("Token 已存储但无法联通 GitHub API(可能需要代理): {}", e)) }),
|
||||
}
|
||||
})();
|
||||
result.unwrap_or_else(|e| err_item("github_token", "GitHub Token", &e))
|
||||
}
|
||||
|
||||
/// 检测活跃编辑器路径是否可用
|
||||
/// 存储结构:editors = [{id,name,path}],active_editors = ["id1","id2"]
|
||||
fn check_editor() -> HealthItem {
|
||||
@ -590,7 +552,6 @@ pub fn collect_health_report() -> HealthReport {
|
||||
let h_mcp = thread::spawn(check_mcp);
|
||||
let h_git = thread::spawn(check_git_paths);
|
||||
let h_proj = thread::spawn(check_project_paths);
|
||||
let h_github = thread::spawn(check_github_token);
|
||||
let h_editor = thread::spawn(check_editor);
|
||||
let h_db = thread::spawn(check_db_latency);
|
||||
let h_quality = thread::spawn(collect_quality_metrics);
|
||||
@ -599,7 +560,6 @@ pub fn collect_health_report() -> HealthReport {
|
||||
h_mcp .join().unwrap_or_else(|_| err_item("mcp", "MCP Server", "检测线程异常")),
|
||||
h_git .join().unwrap_or_else(|_| err_item("git_paths", "Git 仓库路径", "检测线程异常")),
|
||||
h_proj .join().unwrap_or_else(|_| err_item("project_paths", "项目本地路径", "检测线程异常")),
|
||||
h_github.join().unwrap_or_else(|_| err_item("github_token", "GitHub Token", "检测线程异常")),
|
||||
h_editor.join().unwrap_or_else(|_| err_item("editor", "编辑器路径", "检测线程异常")),
|
||||
h_db .join().unwrap_or_else(|_| err_item("db_latency", "数据库延迟", "检测线程异常")),
|
||||
];
|
||||
@ -626,9 +586,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn global_keys_contains_auth_and_infra() {
|
||||
assert!(GLOBAL_KEYS.contains(&"github_client_id"));
|
||||
assert!(GLOBAL_KEYS.contains(&"github_client_secret"));
|
||||
fn global_keys_contains_infra() {
|
||||
assert!(GLOBAL_KEYS.contains(&"mcp_port"));
|
||||
assert!(GLOBAL_KEYS.contains(&"wsl_distro"));
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ pub(crate) fn silent_cmd(program: &str) -> std::process::Command {
|
||||
cmd
|
||||
}
|
||||
|
||||
use commands::{activity::*, blueprint::*, buff::{apply_blueprint_buff, remove_blueprint_buff, get_buff_status, list_buffed_projects}, canvas::*, cicd::*, claude_config::get_beast_global_status, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, mentor::{add_project_note, get_group_mentor_report, get_mentor_context, get_project_notes, get_startup_alerts, run_startup_health_scan}, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
|
||||
use commands::{activity::*, blueprint::*, buff::{apply_blueprint_buff, remove_blueprint_buff, get_buff_status, list_buffed_projects}, canvas::*, cicd::*, claude_config::get_beast_global_status, devtools::*, git_ops::*, git_repos::*, groups::*, mentor::{add_project_note, get_group_mentor_report, get_mentor_context, get_project_notes, get_startup_alerts, run_startup_health_scan}, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@ -193,15 +193,6 @@ pub fn run() {
|
||||
git_create_branch,
|
||||
git_list_branches,
|
||||
git_checkout_branch,
|
||||
// github
|
||||
github_save_account,
|
||||
github_get_account,
|
||||
github_get_token,
|
||||
github_get_client_id,
|
||||
github_logout,
|
||||
github_start_oauth,
|
||||
github_poll_oauth,
|
||||
github_open_login_window,
|
||||
// spaces
|
||||
get_spaces,
|
||||
create_space,
|
||||
@ -218,12 +209,10 @@ pub fn run() {
|
||||
update_git_repo_deploy,
|
||||
mount_repo,
|
||||
delete_git_repo,
|
||||
// publish to github
|
||||
// 本地 git 工具
|
||||
scan_local_git,
|
||||
github_create_repo,
|
||||
git_init_and_commit,
|
||||
git_remote_set,
|
||||
git_push_to_github,
|
||||
register_and_link_repo,
|
||||
write_gitignore,
|
||||
resolve_wsl_path,
|
||||
@ -272,6 +261,7 @@ pub fn run() {
|
||||
commands::agent_infra::generate_agent_infra_files,
|
||||
commands::agent_infra::scan_doc_freshness,
|
||||
commands::agent_infra::get_doc_freshness_cache,
|
||||
commands::agent_infra::get_agent_health,
|
||||
commands::commit_metrics::get_commit_stats,
|
||||
commands::commit_metrics::ingest_full_git_history,
|
||||
commands::commit_metrics::get_project_commit_health,
|
||||
|
||||
@ -146,27 +146,6 @@ impl Space {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct GitAccount {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub provider: String,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
impl GitAccount {
|
||||
pub fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
username: row.get("username")?,
|
||||
avatar_url: row.get("avatar_url")?,
|
||||
provider: row.get("provider")?,
|
||||
created_at: row.get("created_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DeployCommand {
|
||||
pub id: String,
|
||||
|
||||
37
src/App.tsx
37
src/App.tsx
@ -1,5 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { OverviewPage } from "./components/overview/OverviewPage";
|
||||
import { Dashboard } from "./components/dashboard/Dashboard";
|
||||
import { ProjectModal } from "./components/dashboard/ProjectModal";
|
||||
@ -10,24 +9,16 @@ import { HealthPage } from "./components/health/HealthPage";
|
||||
import { SourceLibraryPage } from "./components/source-library/SourceLibraryPage";
|
||||
import { Sidebar, type Page } from "./components/layout/Sidebar";
|
||||
import { Toast } from "./components/ui/Toast";
|
||||
import LoginPage from "./components/auth/LoginPage";
|
||||
import { githubGetAccount, githubLogout, getStartupAlerts, type GitAccount, type StartupAlert } from "./lib/commands";
|
||||
import { useSpacesSync } from "./hooks/useSpacesSync";
|
||||
import { getStartupAlerts, type StartupAlert } from "./lib/commands";
|
||||
import { useUIStore } from "./store/ui";
|
||||
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<Page>("overview");
|
||||
const [account, setAccount] = useState<GitAccount | null | undefined>(undefined);
|
||||
const [alerts, setAlerts] = useState<StartupAlert[]>([]);
|
||||
const [alertsDismissed, setAlertsDismissed] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
|
||||
useEffect(() => {
|
||||
githubGetAccount().then(setAccount).catch(() => setAccount(null));
|
||||
}, []);
|
||||
|
||||
// 启动时静默检查更新
|
||||
useEffect(() => {
|
||||
checkUpdate()
|
||||
@ -41,39 +32,17 @@ export default function App() {
|
||||
|
||||
// 启动健康扫描结果(延迟 2s 等后端扫描完成)
|
||||
useEffect(() => {
|
||||
if (account == null) return;
|
||||
const timer = setTimeout(() => {
|
||||
getStartupAlerts()
|
||||
.then((list) => { if (list.length > 0) setAlerts(list); })
|
||||
.catch(() => {});
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [account]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await githubLogout();
|
||||
qc.clear();
|
||||
setAccount(null);
|
||||
};
|
||||
|
||||
const { data: spacesJson } = useSpacesSync(account !== null && account !== undefined);
|
||||
|
||||
if (account === undefined) return null;
|
||||
|
||||
if (account === null) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 text-white flex flex-col">
|
||||
<LoginPage onLogin={setAccount} />
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar
|
||||
account={account}
|
||||
onLogout={handleLogout}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
@ -98,7 +67,7 @@ export default function App() {
|
||||
)}
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{page === "overview" && <OverviewPage />}
|
||||
{page === "dashboard" && <Dashboard spacesJson={spacesJson ?? null} />}
|
||||
{page === "dashboard" && <Dashboard />}
|
||||
{page === "source-library" && <SourceLibraryPage />}
|
||||
{page === "devtools" && <DevToolsPage />}
|
||||
{page === "proxy" && <ProxyPage />}
|
||||
|
||||
@ -1,213 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
githubOpenLoginWindow,
|
||||
githubPollOAuth,
|
||||
githubFetchUser,
|
||||
githubSaveAccount,
|
||||
githubGetClientId,
|
||||
updateSettings,
|
||||
type GitAccount,
|
||||
} from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
type Step = "checking" | "setup" | "idle" | "waiting" | "done" | "error";
|
||||
|
||||
interface Props {
|
||||
onLogin: (account: GitAccount) => void;
|
||||
}
|
||||
|
||||
export default function LoginPage({ onLogin }: Props) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const [step, setStep] = useState<Step>("checking");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [savingConfig, setSavingConfig] = useState(false);
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
// 启动时检查是否已配置 OAuth App
|
||||
useEffect(() => {
|
||||
githubGetClientId()
|
||||
.then(() => setStep("idle"))
|
||||
.catch(() => setStep("setup"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
cancelledRef.current = true;
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
}, []);
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!clientId.trim() || !clientSecret.trim()) {
|
||||
showToast("❌ Client ID 和 Client Secret 均不能为空");
|
||||
return;
|
||||
}
|
||||
setSavingConfig(true);
|
||||
try {
|
||||
await updateSettings({
|
||||
github_client_id: clientId.trim(),
|
||||
github_client_secret: clientSecret.trim(),
|
||||
});
|
||||
showToast("配置已保存 ✓");
|
||||
setStep("idle");
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
} finally {
|
||||
setSavingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
cancelledRef.current = false;
|
||||
setErrorMsg("");
|
||||
setStep("waiting");
|
||||
try {
|
||||
await githubOpenLoginWindow();
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (cancelledRef.current) { stopPolling(); return; }
|
||||
try {
|
||||
const token = await githubPollOAuth();
|
||||
if (token === null) return;
|
||||
stopPolling();
|
||||
const user = await githubFetchUser(token);
|
||||
const account = await githubSaveAccount(user.login, user.avatar_url, token);
|
||||
if (cancelledRef.current) return;
|
||||
setStep("done");
|
||||
onLogin(account);
|
||||
} catch (e) {
|
||||
stopPolling();
|
||||
setStep("error");
|
||||
setErrorMsg(String(e));
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setStep("error");
|
||||
setErrorMsg(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelledRef.current = true;
|
||||
stopPolling();
|
||||
setStep("idle");
|
||||
setErrorMsg("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-8 p-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-2">Dev Space Manager</h1>
|
||||
<p className="text-zinc-400 text-sm">基于 Git 的本地开发空间管理器</p>
|
||||
</div>
|
||||
|
||||
{step === "checking" && (
|
||||
<div className="w-5 h-5 border-2 border-zinc-600 border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
|
||||
{step === "setup" && (
|
||||
<div className="w-80 bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white mb-1">配置 GitHub OAuth App</p>
|
||||
<p className="text-xs text-zinc-400 leading-relaxed">
|
||||
前往 GitHub → Settings → Developer settings → OAuth Apps 创建应用。
|
||||
Callback URL 填 <code className="bg-zinc-700 px-1 rounded">http://localhost</code>,
|
||||
勾选 Enable Device Flow 无需勾选。
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">Client ID</label>
|
||||
<input
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
placeholder="Ov23li..."
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded-lg px-3 py-2 text-sm text-white font-mono outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
placeholder="••••••••••••••••••••••••••••••••••••"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded-lg px-3 py-2 text-sm text-white font-mono outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">Secret 仅存于本机 SQLite,由 Rust 完成 token 交换。</p>
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={savingConfig}
|
||||
className="w-full py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{savingConfig ? "保存中…" : "保存并继续"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "idle" && (
|
||||
<div className="flex flex-col items-center gap-3 w-72">
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="w-full px-6 py-2.5 bg-zinc-100 text-zinc-900 rounded-lg font-medium hover:bg-white transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="w-4 h-4 fill-current shrink-0">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
使用 GitHub 登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep("setup")}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 underline"
|
||||
>
|
||||
重新配置 OAuth App
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "waiting" && (
|
||||
<div className="flex flex-col items-center gap-4 p-6 bg-zinc-800 rounded-xl border border-zinc-700 w-72 text-center">
|
||||
<div className="w-7 h-7 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-zinc-200">请在浏览器中完成 GitHub 授权</p>
|
||||
<p className="text-xs text-zinc-500">授权完成后应用自动登录</p>
|
||||
<button onClick={handleCancel} className="text-xs text-zinc-500 hover:text-zinc-300 underline">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<p className="text-green-400 text-sm">登录成功!</p>
|
||||
)}
|
||||
|
||||
{step === "error" && (
|
||||
<div className="flex flex-col items-center gap-3 w-80">
|
||||
<p className="text-red-400 text-sm text-center">{errorMsg}</p>
|
||||
{errorMsg.includes("代理") && (
|
||||
<p className="text-xs text-zinc-500 text-center">
|
||||
请确认 v2rayN 系统代理已开启后重试
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleCancel} className="px-4 py-2 bg-zinc-700 rounded-lg text-sm hover:bg-zinc-600">
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep("setup")}
|
||||
className="px-4 py-2 bg-zinc-700 rounded-lg text-sm hover:bg-zinc-600 text-zinc-300"
|
||||
>
|
||||
重新配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
scanAgentInfraStack,
|
||||
generateAgentInfraFiles,
|
||||
getDocFreshnessCache,
|
||||
getAgentHealth,
|
||||
type AgentInfraStack,
|
||||
type ContractInput,
|
||||
type FileResult,
|
||||
type CachedFreshness,
|
||||
type AgentHealthReport,
|
||||
} from "../../lib/commands";
|
||||
|
||||
const CONFIDENCE_BADGE: Record<string, string> = {
|
||||
@ -31,6 +35,17 @@ const ACTION_COLOR: Record<string, string> = {
|
||||
failed: "text-red-600",
|
||||
};
|
||||
|
||||
const HEALTH_BADGE: Record<string, string> = {
|
||||
green: "bg-green-50 text-green-700 border-green-200",
|
||||
yellow: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
red: "bg-red-50 text-red-700 border-red-200",
|
||||
};
|
||||
const HEALTH_LABEL: Record<string, string> = {
|
||||
green: "🟢 良好",
|
||||
yellow: "🟡 有风险",
|
||||
red: "🔴 需修复",
|
||||
};
|
||||
|
||||
export function AgentInfraPanel({
|
||||
projectPath,
|
||||
projectId,
|
||||
@ -45,6 +60,20 @@ export function AgentInfraPanel({
|
||||
const [report, setReport] = useState<FileResult[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 健康诊断状态
|
||||
const [healthCache, setHealthCache] = useState<CachedFreshness | null>(null);
|
||||
const [healthReport, setHealthReport] = useState<AgentHealthReport | null>(null);
|
||||
const [diagnosing, setDiagnosing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// 组件挂载时读取缓存健康状态
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
getDocFreshnessCache(projectId).then((cached) => {
|
||||
if (cached) setHealthCache(cached);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
async function handleScan() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@ -84,6 +113,29 @@ export function AgentInfraPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiagnose() {
|
||||
if (!projectId) return;
|
||||
setDiagnosing(true);
|
||||
setError(null);
|
||||
setHealthReport(null);
|
||||
try {
|
||||
const result = await getAgentHealth(projectPath, projectId);
|
||||
setHealthReport(result);
|
||||
setHealthCache({ score: result.overall, details: "[]", checked_at: result.checked_at });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setDiagnosing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!healthReport) return;
|
||||
await navigator.clipboard.writeText(healthReport.fix_prompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
|
||||
{/* 标题行 */}
|
||||
@ -213,6 +265,78 @@ export function AgentInfraPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 健康诊断区块 */}
|
||||
{projectId && (
|
||||
<div className="border-t border-gray-100 pt-2 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Agent 健康诊断
|
||||
</p>
|
||||
{healthCache && (
|
||||
<span
|
||||
className={`text-[10px] border px-1.5 py-0.5 rounded-full ${HEALTH_BADGE[healthCache.score] ?? HEALTH_BADGE.green}`}
|
||||
>
|
||||
{HEALTH_LABEL[healthCache.score] ?? healthCache.score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{healthCache && !healthReport && (
|
||||
<p className="text-[10px] text-gray-400">
|
||||
上次检测:{healthCache.checked_at.slice(0, 10)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDiagnose}
|
||||
disabled={diagnosing}
|
||||
className="w-full py-1.5 rounded-lg bg-violet-600 text-white text-xs hover:bg-violet-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{diagnosing ? "检测中…" : healthCache ? "🔄 重新检测 & 生成修正指令" : "🩺 生成修正指令"}
|
||||
</button>
|
||||
|
||||
{/* 诊断结果 */}
|
||||
{healthReport && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] text-gray-400">
|
||||
检测时间:{healthReport.checked_at.slice(0, 10)}
|
||||
</p>
|
||||
|
||||
{healthReport.issues.length === 0 ? (
|
||||
<p className="text-[10px] text-green-600">✅ 所有检查通过</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{healthReport.issues.map((issue, idx) => (
|
||||
<p key={idx} className="text-[10px] text-gray-600 flex gap-1 leading-relaxed">
|
||||
<span className="flex-shrink-0">
|
||||
{issue.level === "error" ? "❌" : "⚠️"}
|
||||
</span>
|
||||
<span className="min-w-0">{issue.title}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 可复制文本框 */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
readOnly
|
||||
value={healthReport.fix_prompt}
|
||||
rows={6}
|
||||
className="w-full text-[10px] font-mono bg-gray-50 border border-gray-200 rounded p-1.5 resize-none focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-1 right-1 text-[9px] bg-white border border-gray-200 rounded px-1.5 py-0.5 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{copied ? "✓ 已复制" : "复制"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-[11px] text-red-600 font-mono break-all">{error}</p>
|
||||
)}
|
||||
|
||||
@ -1,34 +1,27 @@
|
||||
// CI/CD 部署配置与运行管理
|
||||
// 完整部署教程与踩坑指南见 docs/tauri-cicd-guide.md
|
||||
// 涵盖:签名密钥、GitHub Secrets、Workflow 模板、OSS 上传、Tauri v2 更新格式等
|
||||
// 涵盖:签名密钥、Workflow 模板、OSS 上传、Tauri v2 更新格式等
|
||||
// 运行历史/手动触发(原 GitHub Actions API)已随 GitHub 退役移除,待 Gitea Actions 接入后重建
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getCicdConfig,
|
||||
saveCicdConfig,
|
||||
generateWorkflow,
|
||||
fetchActionsRuns,
|
||||
triggerWorkflow,
|
||||
parseRepoPath,
|
||||
githubGetToken,
|
||||
resolveWslPath,
|
||||
type CicdConfig,
|
||||
type ActionsRun,
|
||||
} from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
repoUrl?: string;
|
||||
winPath?: string;
|
||||
wslPath?: string;
|
||||
platform?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "config" | "runs";
|
||||
|
||||
const DEFAULT_CONFIG: Omit<CicdConfig, "id" | "project_id" | "created_at"> = {
|
||||
config_type: "web_ssh",
|
||||
trigger_branch: "main",
|
||||
@ -41,36 +34,9 @@ const DEFAULT_CONFIG: Omit<CicdConfig, "id" | "project_id" | "created_at"> = {
|
||||
oss_endpoint: "oss-cn-hangzhou.aliyuncs.com",
|
||||
};
|
||||
|
||||
function runStatusBadge(run: ActionsRun) {
|
||||
if (run.status === "in_progress" || run.status === "queued") {
|
||||
return <span className="text-yellow-400 text-xs">🔄 运行中</span>;
|
||||
}
|
||||
if (run.conclusion === "success") {
|
||||
return <span className="text-green-400 text-xs">✅ 成功</span>;
|
||||
}
|
||||
if (run.conclusion === "failure") {
|
||||
return <span className="text-red-400 text-xs">❌ 失败</span>;
|
||||
}
|
||||
if (run.conclusion === "cancelled") {
|
||||
return <span className="text-zinc-400 text-xs">⊘ 已取消</span>;
|
||||
}
|
||||
return <span className="text-zinc-400 text-xs">{run.status}</span>;
|
||||
}
|
||||
|
||||
function relativeTime(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "刚刚";
|
||||
if (m < 60) return `${m}分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}小时前`;
|
||||
return `${Math.floor(h / 24)}天前`;
|
||||
}
|
||||
|
||||
export function CicdModal({
|
||||
projectId,
|
||||
projectName,
|
||||
repoUrl,
|
||||
winPath,
|
||||
wslPath,
|
||||
platform,
|
||||
@ -78,16 +44,12 @@ export function CicdModal({
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const [tab, setTab] = useState<Tab>("config");
|
||||
const [form, setForm] = useState<Omit<CicdConfig, "id" | "project_id" | "created_at">>({
|
||||
...DEFAULT_CONFIG,
|
||||
config_type: platform === "windows" ? "tauri_oss" : "web_ssh",
|
||||
});
|
||||
const [generatedSecrets, setGeneratedSecrets] = useState<string[] | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [triggering, setTriggering] = useState(false);
|
||||
|
||||
const repoPath = repoUrl ? parseRepoPath(repoUrl) : null;
|
||||
|
||||
// 加载已保存配置
|
||||
const { data: saved } = useQuery<CicdConfig | null>({
|
||||
@ -111,20 +73,6 @@ export function CicdModal({
|
||||
}
|
||||
}, [saved]);
|
||||
|
||||
// GitHub Actions runs
|
||||
const { data: runs = [], refetch: refetchRuns, isFetching: fetchingRuns } = useQuery({
|
||||
queryKey: ["actionsRuns", projectId],
|
||||
queryFn: async () => {
|
||||
if (!repoPath) return [];
|
||||
const token = await githubGetToken();
|
||||
if (!token) return [];
|
||||
return fetchActionsRuns(token, repoPath, 8);
|
||||
},
|
||||
enabled: tab === "runs" && !!repoPath,
|
||||
staleTime: 30000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
saveCicdConfig({
|
||||
@ -162,23 +110,6 @@ export function CicdModal({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTrigger() {
|
||||
if (!repoPath) return;
|
||||
setTriggering(true);
|
||||
try {
|
||||
const token = await githubGetToken();
|
||||
if (!token) { showToast("未登录 GitHub"); return; }
|
||||
const workflowFile = form.config_type === "tauri_oss" ? "release.yml" : "deploy.yml";
|
||||
await triggerWorkflow(token, repoPath, workflowFile, form.trigger_branch);
|
||||
showToast("已触发 workflow,稍后刷新查看状态");
|
||||
setTimeout(() => refetchRuns(), 3000);
|
||||
} catch (e) {
|
||||
showToast(`触发失败: ${e}`);
|
||||
} finally {
|
||||
setTriggering(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isWebSsh = form.config_type === "web_ssh";
|
||||
const hasSavedConfig = !!saved?.id;
|
||||
const hasPath = !!(winPath || wslPath);
|
||||
@ -197,27 +128,8 @@ export function CicdModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex border-b border-zinc-700 px-5">
|
||||
{(["config", "runs"] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`py-2.5 mr-4 text-sm border-b-2 transition-colors ${
|
||||
tab === t
|
||||
? "border-blue-500 text-white"
|
||||
: "border-transparent text-zinc-400 hover:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{t === "config" ? "配置" : "运行历史"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||||
{/* ── 配置 Tab ── */}
|
||||
{tab === "config" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{/* 类型选择 */}
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400 mb-1 block">部署类型</label>
|
||||
@ -364,74 +276,6 @@ export function CicdModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 运行历史 Tab ── */}
|
||||
{tab === "runs" && (
|
||||
<div className="space-y-3">
|
||||
{!repoPath ? (
|
||||
<p className="text-zinc-500 text-sm text-center py-8">
|
||||
项目未关联 GitHub 仓库,无法查看 CI 状态
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-400">{repoPath}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => refetchRuns()}
|
||||
disabled={fetchingRuns}
|
||||
className="text-xs text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
{fetchingRuns ? "刷新中…" : "↻ 刷新"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTrigger}
|
||||
disabled={triggering || !hasSavedConfig}
|
||||
title={!hasSavedConfig ? "请先保存配置" : ""}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-xs rounded-lg transition-colors"
|
||||
>
|
||||
{triggering ? "触发中…" : "手动触发"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runs.length === 0 && !fetchingRuns && (
|
||||
<p className="text-zinc-500 text-sm text-center py-8">暂无运行记录</p>
|
||||
)}
|
||||
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="bg-zinc-800 rounded-lg px-3 py-2.5 flex items-center gap-3"
|
||||
>
|
||||
<div className="w-20 shrink-0">{runStatusBadge(run)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">
|
||||
#{run.run_number} · {run.head_branch}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400">{relativeTime(run.updated_at)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={run.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-blue-400 hover:text-blue-300 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
import("@tauri-apps/plugin-opener").then(({ openUrl }) =>
|
||||
openUrl(run.html_url)
|
||||
);
|
||||
}}
|
||||
>
|
||||
查看日志 →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,10 +12,8 @@ import { ApiKeysPanel } from "./ApiKeysPanel";
|
||||
import { CloudProductsPanel } from "./CloudProductsPanel";
|
||||
import { BatchAddModal } from "./BatchAddModal";
|
||||
import { MountRepoModal } from "./MountRepoModal";
|
||||
import { DiscoveryPanel } from "./DiscoveryPanel";
|
||||
import { ProjectToolsModal } from "./ProjectToolsModal";
|
||||
import { StatusBadge } from "../ui/Badge";
|
||||
import { getDiscoverableProjects, type SpacesJson } from "../../lib/spacesSync";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
@ -23,11 +21,7 @@ type DashView = "board" | "manage";
|
||||
type BoardTab = "groups" | "standalone" | "servers" | "software" | "apikeys" | "cloud";
|
||||
type GroupView = "default" | "resource";
|
||||
|
||||
interface Props {
|
||||
spacesJson: SpacesJson | null;
|
||||
}
|
||||
|
||||
export function Dashboard({ spacesJson }: Props) {
|
||||
export function Dashboard() {
|
||||
const [view, setView] = useState<DashView>("board");
|
||||
const [boardTab, setBoardTab] = useState<BoardTab>("groups");
|
||||
const [showBatch, setShowBatch] = useState(false);
|
||||
@ -53,15 +47,6 @@ export function Dashboard({ spacesJson }: Props) {
|
||||
const mappedIds = new Set(maps.map((m) => m.project_id));
|
||||
const standalone = projects.filter((p) => !mappedIds.has(p.id));
|
||||
|
||||
// 可从其他空间接续的项目(在 .dev-spaces 里有记录、但当前空间尚未有此 repo)
|
||||
const currentRepoUrls = new Set(
|
||||
projects.map((p) => p.repo_url).filter((u): u is string => !!u)
|
||||
);
|
||||
const discoverableProjects =
|
||||
spacesJson && currentSpace
|
||||
? getDiscoverableProjects(spacesJson, currentSpace.id, currentRepoUrls)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 子导航栏 */}
|
||||
@ -192,14 +177,6 @@ export function Dashboard({ spacesJson }: Props) {
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="w-full px-8 py-8 space-y-8 pb-16">
|
||||
|
||||
{/* 跨空间发现面板(始终显示) */}
|
||||
{discoverableProjects.length > 0 && currentSpace && (
|
||||
<DiscoveryPanel
|
||||
items={discoverableProjects}
|
||||
currentSpaceId={currentSpace.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 产品组标签 ── */}
|
||||
{boardTab === "groups" && (
|
||||
<>
|
||||
@ -216,7 +193,6 @@ export function Dashboard({ spacesJson }: Props) {
|
||||
key={g.id}
|
||||
group={g}
|
||||
members={members}
|
||||
spacesJson={spacesJson}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@ -233,7 +209,7 @@ export function Dashboard({ spacesJson }: Props) {
|
||||
{boardTab === "standalone" && (
|
||||
<>
|
||||
{standalone.length > 0 ? (
|
||||
<StandaloneBoardSection standalone={standalone} spacesJson={spacesJson} />
|
||||
<StandaloneBoardSection standalone={standalone} />
|
||||
) : (
|
||||
<div className="text-center py-24 text-gray-400">
|
||||
<p className="text-4xl mb-4">📂</p>
|
||||
@ -277,10 +253,9 @@ interface GroupMember {
|
||||
interface GroupBoardSectionProps {
|
||||
group: ProductGroup;
|
||||
members: GroupMember[];
|
||||
spacesJson: SpacesJson | null;
|
||||
}
|
||||
|
||||
function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionProps) {
|
||||
function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
|
||||
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
|
||||
const [groupView, setGroupView] = useState<GroupView>("default");
|
||||
|
||||
@ -350,7 +325,6 @@ function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionP
|
||||
key={project.id}
|
||||
project={project}
|
||||
role={role ?? undefined}
|
||||
spacesJson={spacesJson}
|
||||
onDeploy={setDeployTarget}
|
||||
/>
|
||||
))
|
||||
@ -368,7 +342,6 @@ function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionP
|
||||
key={project.id}
|
||||
project={project}
|
||||
role={role ?? undefined}
|
||||
spacesJson={spacesJson}
|
||||
onDeploy={setDeployTarget}
|
||||
/>
|
||||
))
|
||||
@ -394,18 +367,17 @@ function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionP
|
||||
/* ── ProjectRow ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function ProjectRow({
|
||||
project: p, role, spacesJson, onDeploy,
|
||||
project: p, role, onDeploy,
|
||||
}: {
|
||||
project: Project;
|
||||
role?: string;
|
||||
spacesJson: SpacesJson | null;
|
||||
onDeploy: (p: Project) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex divide-x divide-gray-100 border-b border-gray-50 last:border-b-0">
|
||||
{/* 左:项目卡片 */}
|
||||
<div className="flex-1 min-w-0 p-4">
|
||||
<ProjectCard project={p} role={role} spacesJson={spacesJson} />
|
||||
<ProjectCard project={p} role={role} />
|
||||
</div>
|
||||
|
||||
{/* 右:部署信息 */}
|
||||
@ -580,10 +552,9 @@ function GlobalQuickLaunchBar() {
|
||||
/* ── StandaloneBoardSection ────────────────────────────────────────────────── */
|
||||
|
||||
function StandaloneBoardSection({
|
||||
standalone, spacesJson,
|
||||
standalone,
|
||||
}: {
|
||||
standalone: Project[];
|
||||
spacesJson: SpacesJson | null;
|
||||
}) {
|
||||
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set());
|
||||
@ -673,7 +644,7 @@ function StandaloneBoardSection({
|
||||
</div>
|
||||
{winProjects.length > 0
|
||||
? winProjects.map((p) => (
|
||||
<ProjectRow key={p.id} project={p} spacesJson={spacesJson} onDeploy={setDeployTarget} />
|
||||
<ProjectRow key={p.id} project={p} onDeploy={setDeployTarget} />
|
||||
))
|
||||
: <p className="text-xs text-gray-300 py-5 text-center border-b border-gray-50">暂无</p>
|
||||
}
|
||||
@ -685,7 +656,7 @@ function StandaloneBoardSection({
|
||||
</div>
|
||||
{wslProjects.length > 0
|
||||
? wslProjects.map((p) => (
|
||||
<ProjectRow key={p.id} project={p} spacesJson={spacesJson} onDeploy={setDeployTarget} />
|
||||
<ProjectRow key={p.id} project={p} onDeploy={setDeployTarget} />
|
||||
))
|
||||
: <p className="text-xs text-gray-300 py-5 text-center">暂无</p>
|
||||
}
|
||||
|
||||
@ -1,251 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { createProject, gitClone } from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import type { SpaceCatalogItem } from "../../lib/spacesSync";
|
||||
|
||||
export interface DiscoverableItem extends SpaceCatalogItem {
|
||||
spaceId: string;
|
||||
spaceName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: DiscoverableItem[];
|
||||
currentSpaceId: string;
|
||||
}
|
||||
|
||||
/* ── 内联克隆表单 ─────────────────────────────────────────── */
|
||||
function CloneForm({
|
||||
item,
|
||||
spaceId,
|
||||
onDone,
|
||||
}: {
|
||||
item: DiscoverableItem;
|
||||
spaceId: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
// 从 repo_url 提取仓库名作为默认 id 和文件夹名
|
||||
const repoName = item.repo_url.split("/").pop()?.replace(/\.git$/, "") ?? item.name;
|
||||
|
||||
const [wsId, setWsId] = useState(repoName);
|
||||
const [parentDir, setParentDir] = useState("");
|
||||
const [platform, setPlatform] = useState<"windows" | "wsl">(
|
||||
(item.platform as "windows" | "wsl") ?? "windows"
|
||||
);
|
||||
const [cloning, setCloning] = useState(false);
|
||||
|
||||
// Windows 侧路径 = 父目录 + \ + 仓库名
|
||||
const winPath = parentDir ? `${parentDir}\\${repoName}` : "";
|
||||
|
||||
const pickParentDir = async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (selected) setParentDir(selected as string);
|
||||
};
|
||||
|
||||
const handleClone = async () => {
|
||||
if (!wsId.trim()) { showToast("❌ 项目 ID 不能为空"); return; }
|
||||
if (platform === "windows" && !parentDir) {
|
||||
showToast("❌ 请先选择本地目录"); return;
|
||||
}
|
||||
|
||||
setCloning(true);
|
||||
try {
|
||||
if (platform === "windows") {
|
||||
await gitClone(item.repo_url, winPath);
|
||||
}
|
||||
// WSL 模式下用户需要手动克隆,这里只注册项目
|
||||
await createProject({
|
||||
id: wsId.trim(),
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
tech_stack: item.tech_stack,
|
||||
repo_url: item.repo_url,
|
||||
default_branch: item.default_branch ?? "main",
|
||||
platform,
|
||||
space_id: spaceId,
|
||||
win_path: platform === "windows" ? winPath : undefined,
|
||||
});
|
||||
showToast(`✓ 已${platform === "windows" ? "克隆并" : ""}添加项目「${item.name}」`);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
} finally {
|
||||
setCloning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
"w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-200 focus:border-indigo-400";
|
||||
|
||||
return (
|
||||
<div className="border-t border-indigo-100 px-4 py-4 space-y-3 bg-indigo-50/40">
|
||||
{/* 平台 */}
|
||||
<div className="flex gap-2">
|
||||
{(["windows", "wsl"] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPlatform(p)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs border transition-colors ${
|
||||
platform === p
|
||||
? "border-indigo-400 bg-indigo-50 text-indigo-700 font-medium"
|
||||
: "border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{p === "windows" ? "Windows" : "WSL / Linux"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 项目 ID */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
项目 ID(本空间内唯一)
|
||||
</label>
|
||||
<input
|
||||
className={`${inputCls} font-mono`}
|
||||
value={wsId}
|
||||
onChange={(e) => setWsId(e.target.value)}
|
||||
placeholder={repoName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 本地路径 */}
|
||||
{platform === "windows" ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
克隆到目录
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={`${inputCls} font-mono flex-1`}
|
||||
value={winPath}
|
||||
readOnly
|
||||
placeholder="请先点击「选择目录」"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={pickParentDir}
|
||||
className="px-3 py-2 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-50 shrink-0"
|
||||
>
|
||||
选择目录
|
||||
</button>
|
||||
</div>
|
||||
{parentDir && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
将在此路径执行 <code>git clone</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
WSL 模式:请先在 WSL 终端中手动 <code>git clone {item.repo_url}</code>,
|
||||
添加后再在项目卡片上补充路径。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleClone}
|
||||
disabled={cloning || (platform === "windows" && !parentDir)}
|
||||
className="px-4 py-2 rounded-lg text-xs bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cloning
|
||||
? "克隆中…"
|
||||
: platform === "windows"
|
||||
? "克隆到本地"
|
||||
: "仅添加记录"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 发现面板主体 ──────────────────────────────────────────── */
|
||||
export function DiscoveryPanel({ items, currentSpaceId }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [expandedUrl, setExpandedUrl] = useState<string | null>(null);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const handleCloned = () => {
|
||||
setExpandedUrl(null);
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-indigo-200 bg-indigo-50 shadow-sm overflow-hidden">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-b border-indigo-100">
|
||||
<span className="text-indigo-500">⬇</span>
|
||||
<h2 className="text-sm font-semibold text-indigo-800">可从其他空间接续的项目</h2>
|
||||
<span className="ml-auto text-xs text-indigo-400">{items.length} 个</span>
|
||||
</div>
|
||||
|
||||
{/* 条目列表 */}
|
||||
<div className="p-4 space-y-2">
|
||||
{items.map((item) => {
|
||||
const key = item.repo_url;
|
||||
const expanded = expandedUrl === key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg border border-indigo-100 bg-white overflow-hidden"
|
||||
>
|
||||
{/* 条目行 */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-800">{item.name}</span>
|
||||
{item.tech_stack && (
|
||||
<span className="text-xs text-gray-400">{item.tech_stack}</span>
|
||||
)}
|
||||
{item.default_branch && (
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-500 font-mono">
|
||||
{item.default_branch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-indigo-400">
|
||||
来自 <span className="font-medium text-indigo-600">{item.spaceName}</span>
|
||||
</span>
|
||||
<span className="text-gray-300">·</span>
|
||||
<span className="text-xs font-mono text-gray-400 truncate max-w-xs" title={item.repo_url}>
|
||||
{item.repo_url}
|
||||
</span>
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpandedUrl(expanded ? null : key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs border shrink-0 transition-colors ${
|
||||
expanded
|
||||
? "border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
: "bg-indigo-600 text-white hover:bg-indigo-700 border-transparent"
|
||||
}`}
|
||||
>
|
||||
{expanded ? "收起" : "接续开发"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 克隆表单 */}
|
||||
{expanded && (
|
||||
<CloneForm
|
||||
item={item}
|
||||
spaceId={currentSpaceId}
|
||||
onDone={handleCloned}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,244 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
githubListRepos,
|
||||
githubGetToken,
|
||||
githubGetAccount,
|
||||
gitClone,
|
||||
createProject,
|
||||
getProjects,
|
||||
getSpaces,
|
||||
type GithubRepo,
|
||||
} from "../../lib/commands";
|
||||
import { syncProjectCatalog } from "../../lib/spacesSync";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportRepoModal({ onClose }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState<GithubRepo | null>(null);
|
||||
const [localPath, setLocalPath] = useState("");
|
||||
const [upstreamUrl, setUpstreamUrl] = useState("");
|
||||
const [cloning, setCloning] = useState(false);
|
||||
|
||||
const { data: repos = [], isLoading, error } = useQuery({
|
||||
queryKey: ["github-repos"],
|
||||
queryFn: async () => {
|
||||
const token = await githubGetToken();
|
||||
if (!token) throw new Error("未登录 GitHub");
|
||||
return githubListRepos(token);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: spaces = [] } = useQuery({
|
||||
queryKey: ["spaces"],
|
||||
queryFn: getSpaces,
|
||||
});
|
||||
|
||||
const currentSpace = spaces.find((s) => s.is_current);
|
||||
|
||||
const { data: existingProjects = [] } = useQuery({
|
||||
queryKey: ["projects", currentSpace?.id],
|
||||
queryFn: () => getProjects(undefined, currentSpace?.id),
|
||||
enabled: !!currentSpace,
|
||||
});
|
||||
|
||||
// 当前空间已有的 repo_url 集合(用于判重)
|
||||
const importedUrls = new Set(
|
||||
existingProjects.map((p) => p.repo_url).filter((u): u is string => !!u)
|
||||
);
|
||||
|
||||
const filtered = repos.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.full_name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
async function handlePickDir() {
|
||||
const dir = await openDialog({ directory: true, title: "选择本地存放目录" });
|
||||
if (dir && selected) {
|
||||
setLocalPath(`${dir}\\${selected.name}`.replace(/\\/g, "\\"));
|
||||
} else if (dir) {
|
||||
setLocalPath(dir as string);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!selected || !localPath) return;
|
||||
setCloning(true);
|
||||
try {
|
||||
const wsId = crypto.randomUUID();
|
||||
await gitClone(selected.clone_url, localPath, upstreamUrl || undefined);
|
||||
await createProject({
|
||||
id: wsId,
|
||||
name: selected.name,
|
||||
description: selected.description ?? undefined,
|
||||
win_path: localPath,
|
||||
status: "active",
|
||||
priority: "mid",
|
||||
repo_url: selected.clone_url,
|
||||
upstream_url: upstreamUrl || undefined,
|
||||
default_branch: selected.default_branch,
|
||||
space_id: currentSpace?.id,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
// 静默同步 catalog 到 .dev-spaces
|
||||
(async () => {
|
||||
try {
|
||||
const account = await githubGetAccount();
|
||||
const token = await githubGetToken();
|
||||
if (token && account && currentSpace) {
|
||||
await syncProjectCatalog(token, account.username, currentSpace.id, currentSpace.name, {
|
||||
id: wsId,
|
||||
name: selected.name,
|
||||
description: selected.description ?? undefined,
|
||||
repo_url: selected.clone_url,
|
||||
default_branch: selected.default_branch,
|
||||
platform: "windows",
|
||||
});
|
||||
}
|
||||
} catch { /* 同步失败不影响导入 */ }
|
||||
})();
|
||||
showToast(`${selected.name} 已导入`);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
showToast(`导入失败: ${e}`);
|
||||
} finally {
|
||||
setCloning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-zinc-900 rounded-xl shadow-2xl w-[620px] max-h-[80vh] flex flex-col border border-zinc-700">
|
||||
{/* 标题 */}
|
||||
<div className="px-5 py-4 border-b border-zinc-700 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-white">从 GitHub 导入仓库</h2>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-white text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
|
||||
加载仓库列表...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-6">
|
||||
{String(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
{/* 搜索 */}
|
||||
<div className="px-4 py-3 border-b border-zinc-800">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索仓库..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-zinc-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 仓库列表 */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{filtered.map((repo) => {
|
||||
const alreadyImported = importedUrls.has(repo.clone_url);
|
||||
return (
|
||||
<button
|
||||
key={repo.id}
|
||||
onClick={() => !alreadyImported && setSelected(repo)}
|
||||
disabled={alreadyImported}
|
||||
className={`w-full text-left px-4 py-3 border-b border-zinc-800 transition-colors ${
|
||||
alreadyImported
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: selected?.id === repo.id
|
||||
? "bg-zinc-800 border-l-2 border-l-blue-500"
|
||||
: "hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-sm font-medium">{repo.name}</span>
|
||||
{repo.private && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-zinc-700 text-zinc-300 rounded">私有</span>
|
||||
)}
|
||||
{alreadyImported && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-green-900 text-green-400 rounded">已导入</span>
|
||||
)}
|
||||
<span className="text-zinc-500 text-xs ml-auto">{repo.default_branch}</span>
|
||||
</div>
|
||||
{repo.description && (
|
||||
<p className="text-zinc-400 text-xs mt-0.5 truncate">{repo.description}</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center text-zinc-500 text-sm py-8">没有找到匹配的仓库</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 选中后的配置 */}
|
||||
{selected && (
|
||||
<div className="px-4 py-4 border-t border-zinc-700 space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400 mb-1 block">本地路径</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={localPath}
|
||||
onChange={(e) => setLocalPath(e.target.value)}
|
||||
placeholder="选择本地存放目录..."
|
||||
className="flex-1 bg-zinc-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-zinc-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handlePickDir}
|
||||
className="px-3 py-2 bg-zinc-700 text-zinc-200 text-sm rounded-lg hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400 mb-1 block">
|
||||
公司上游仓库地址 <span className="text-zinc-600">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={upstreamUrl}
|
||||
onChange={(e) => setUpstreamUrl(e.target.value)}
|
||||
placeholder="https://github.com/company/project.git"
|
||||
className="w-full bg-zinc-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-zinc-700 text-zinc-200 text-sm rounded-lg hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!localPath || cloning}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{cloning ? "克隆中..." : `导入 ${selected.name}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -115,7 +115,7 @@ export function MountRepoModal({ spaceId, onClose }: Props) {
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-12 gap-2">
|
||||
<p className="text-2xl">📦</p>
|
||||
<p className="text-sm">还没有注册仓库</p>
|
||||
<p className="text-xs text-gray-300">请先在「仓库管理」中从 GitHub 同步仓库</p>
|
||||
<p className="text-xs text-gray-300">请先在「仓库管理」中注册仓库</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
@ -1,20 +1,17 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, fetchActionsRuns, parseRepoPath, getBlueprintStatus, getBuffStatus, getDocFreshnessCache, type Project, type Space, type ActionsRun, type BlueprintStatus, type BuffStatus, type CachedFreshness } from "../../lib/commands";
|
||||
import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, gitCreateBranch, gitListBranches, gitCheckoutBranch, getBlueprintStatus, getBuffStatus, getDocFreshnessCache, type Project, type BlueprintStatus, type BuffStatus, type CachedFreshness } from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import { PriorityBadge, StatusBadge } from "../ui/Badge";
|
||||
import { ProjectToolsModal } from "./ProjectToolsModal";
|
||||
import { ProjectEnvModal } from "./ProjectEnvModal";
|
||||
import { CicdModal } from "./CicdModal";
|
||||
import { PublishModal } from "./PublishModal";
|
||||
import { BlueprintModal } from "./BlueprintModal";
|
||||
import { BlueprintPromptModal } from "./BlueprintPromptModal";
|
||||
import { MentorPanel } from "../mentor/MentorPanel";
|
||||
import { UserTodoPanel } from "./UserTodoPanel";
|
||||
import { ProjectServerPanel } from "./ProjectServerPanel";
|
||||
import { ClaudeConfigModal } from "./ClaudeConfigModal";
|
||||
import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
|
||||
// ── 蓝图状态徽章 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -57,16 +54,14 @@ function BlueprintStatusBadge({
|
||||
interface Props {
|
||||
project: Project;
|
||||
role?: string;
|
||||
spacesJson?: SpacesJson | null;
|
||||
}
|
||||
|
||||
export function ProjectCard({ project: p, spacesJson }: Props) {
|
||||
export function ProjectCard({ project: p }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showToolsMgr, setShowToolsMgr] = useState(false);
|
||||
const [toolsMgrDefaultAdd, setToolsMgrDefaultAdd] = useState(false);
|
||||
const [showEnvScan, setShowEnvScan] = useState(false);
|
||||
const [showCicd, setShowCicd] = useState(false);
|
||||
const [showPublish, setShowPublish] = useState(false);
|
||||
const [showBlueprint, setShowBlueprint] = useState(false);
|
||||
const [showBlueprintPrompt, setShowBlueprintPrompt] = useState(false);
|
||||
const [showMentor, setShowMentor] = useState(false);
|
||||
@ -131,32 +126,7 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
|
||||
});
|
||||
|
||||
const pushMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const msg = await gitPush(localPath!);
|
||||
// push 成功后,把最新信息同步到 .dev-spaces
|
||||
try {
|
||||
const token = await githubGetToken();
|
||||
const account = await githubGetAccount();
|
||||
// 从 React Query 缓存取 spaces,避免重复 DB 查询
|
||||
const spaces = queryClient.getQueryData<Space[]>(["spaces"]) ?? [];
|
||||
const currentSpace = spaces.find((s) => s.is_current);
|
||||
if (token && account && currentSpace && p.repo_url && gitStat) {
|
||||
await recordPushToRemote(
|
||||
token,
|
||||
account.username,
|
||||
currentSpace.id,
|
||||
currentSpace.name,
|
||||
p.repo_url,
|
||||
gitStat.branch,
|
||||
gitStat.last_commit_msg ?? undefined
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["spacesSync"] });
|
||||
}
|
||||
} catch {
|
||||
// 同步失败不阻断主流程
|
||||
}
|
||||
return msg;
|
||||
},
|
||||
mutationFn: () => gitPush(localPath!),
|
||||
onSuccess: (msg) => {
|
||||
showToast(msg);
|
||||
queryClient.invalidateQueries({ queryKey: ["gitStatus", p.id] });
|
||||
@ -164,11 +134,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
|
||||
onError: (e) => showToast(`Push 失败: ${e}`),
|
||||
});
|
||||
|
||||
// 跨空间感知:其他空间对同一仓库的最新 push 信息
|
||||
const otherSpacesInfo = spacesJson && p.repo_url && p.space_id
|
||||
? getOtherSpacesInfo(spacesJson, p.space_id, p.repo_url)
|
||||
: [];
|
||||
|
||||
// upstream 落后检查(有 upstream_url 且有本地路径时)
|
||||
const { data: upstreamBehind = 0 } = useQuery({
|
||||
queryKey: ["upstreamBehind", p.id],
|
||||
@ -178,22 +143,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// CI 最新 run 状态(有 repo_url 时才查)
|
||||
const ciRepoPath = p.repo_url ? parseRepoPath(p.repo_url) : null;
|
||||
const { data: latestRun } = useQuery<ActionsRun | null>({
|
||||
queryKey: ["ciLatest", p.id],
|
||||
queryFn: async () => {
|
||||
if (!ciRepoPath) return null;
|
||||
const token = await githubGetToken();
|
||||
if (!token) return null;
|
||||
const runs = await fetchActionsRuns(token, ciRepoPath, 1);
|
||||
return runs[0] ?? null;
|
||||
},
|
||||
enabled: !!ciRepoPath,
|
||||
staleTime: 60000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// 蓝图路径:优先 win_path;WSL 项目仅当 wsl_path 为 UNC 格式时可从 Windows 侧访问
|
||||
const blueprintPath = p.win_path
|
||||
|| (p.wsl_path?.startsWith("\\\\wsl.") ? p.wsl_path : undefined);
|
||||
@ -221,18 +170,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// PR 链接:从 repo_url 解析出 GitHub 路径
|
||||
const prUrl = (() => {
|
||||
if (!p.repo_url || !gitStat) return null;
|
||||
const match = p.repo_url.match(/github\.com[/:](.+?)(?:\.git)?$/);
|
||||
if (!match) return null;
|
||||
const repoPath = match[1];
|
||||
const base = p.default_branch ?? "main";
|
||||
const head = gitStat.branch;
|
||||
if (head === base) return `https://github.com/${repoPath}/compare`;
|
||||
return `https://github.com/${repoPath}/compare/${base}...${head}`;
|
||||
})();
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings", p.space_id],
|
||||
queryFn: () => getSettings(p.space_id ?? undefined),
|
||||
@ -329,30 +266,8 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const [resuming, setResuming] = useState<string | null>(null); // spaceId 正在接续
|
||||
|
||||
const customTags = (p.tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
||||
|
||||
const handleResume = async (branch: string, spaceId: string) => {
|
||||
if (!hasGit) { showToast("❌ 当前项目没有配置 git 路径"); return; }
|
||||
if (gitStat?.has_changes) {
|
||||
showToast("❌ 本地有未提交改动,请先 commit 或 stash 后再接续");
|
||||
return;
|
||||
}
|
||||
setResuming(spaceId);
|
||||
try {
|
||||
await gitCheckoutBranch(localPath!, branch);
|
||||
await gitPull(localPath!);
|
||||
showToast(`已切换到 ${branch} 并拉取最新代码 ✓`);
|
||||
queryClient.invalidateQueries({ queryKey: ["gitStatus", p.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["branches", p.id] });
|
||||
} catch (e) {
|
||||
showToast(`接续失败: ${e}`);
|
||||
} finally {
|
||||
setResuming(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
{/* ── 卡片头部 ── */}
|
||||
@ -560,30 +475,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
|
||||
{pushMutation.isPending ? "…" : "Push"}
|
||||
</button>
|
||||
)}
|
||||
{prUrl && (
|
||||
<button
|
||||
onClick={() => openUrl(prUrl).catch(() => {})}
|
||||
className="px-2 py-0.5 rounded border border-purple-200 bg-purple-50 text-purple-600 hover:bg-purple-100 transition-colors"
|
||||
title="在 GitHub 发起 PR"
|
||||
>
|
||||
PR
|
||||
</button>
|
||||
)}
|
||||
{latestRun && (
|
||||
<button
|
||||
onClick={() => setShowCicd(true)}
|
||||
title={`CI: ${latestRun.conclusion ?? latestRun.status}`}
|
||||
className="px-1.5 py-0.5 rounded border border-gray-200 bg-white hover:bg-gray-50 transition-colors text-[11px]"
|
||||
>
|
||||
{latestRun.status === "in_progress" || latestRun.status === "queued"
|
||||
? "🔄"
|
||||
: latestRun.conclusion === "success"
|
||||
? "✅"
|
||||
: latestRun.conclusion === "failure"
|
||||
? "❌"
|
||||
: "⊘"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : gitError ? (
|
||||
@ -595,32 +486,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 跨空间感知 ── */}
|
||||
{otherSpacesInfo.length > 0 && (
|
||||
<div className="mx-5 mb-3 flex flex-col gap-1.5">
|
||||
{otherSpacesInfo.map((info) => (
|
||||
<div key={info.spaceId} className="flex items-center gap-2 text-xs px-3 py-2 bg-indigo-50 rounded-lg border border-indigo-100">
|
||||
<span className="text-indigo-400 shrink-0">⇄</span>
|
||||
<span className="text-indigo-700 font-medium shrink-0">{info.spaceName}</span>
|
||||
<span className="font-mono text-indigo-500 shrink-0">{info.branch}</span>
|
||||
{info.last_commit_msg && (
|
||||
<span className="text-indigo-400 truncate flex-1">{info.last_commit_msg}</span>
|
||||
)}
|
||||
<span className="text-indigo-300 shrink-0">{formatRelativeTime(info.last_push_at)}</span>
|
||||
{hasGit && (
|
||||
<button
|
||||
onClick={() => handleResume(info.branch, info.spaceId)}
|
||||
disabled={resuming === info.spaceId}
|
||||
className="px-2.5 py-1 rounded-md border border-indigo-300 bg-white text-indigo-700 hover:bg-indigo-100 disabled:opacity-50 shrink-0 transition-colors"
|
||||
>
|
||||
{resuming === info.spaceId ? "…" : "接续"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Upstream 同步提示 ── */}
|
||||
{upstreamBehind > 0 && (
|
||||
<div className="mx-5 mb-3 flex items-center gap-2 text-xs px-3 py-2 bg-amber-50 rounded-lg border border-amber-200">
|
||||
@ -746,16 +611,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
|
||||
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="工具管理">
|
||||
🧰
|
||||
</button>
|
||||
{/* 未完全绑定时显示「发布到 GitHub」 */}
|
||||
{(!p.repo_id || !p.repo_url) && (p.win_path || p.wsl_path) && (
|
||||
<button
|
||||
onClick={() => setShowPublish(true)}
|
||||
className="px-2.5 py-1.5 rounded-md text-xs border border-blue-200 text-blue-600 bg-blue-50 hover:bg-blue-100 transition-colors"
|
||||
title="发布到 GitHub"
|
||||
>
|
||||
发布
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-4 bg-gray-200 mx-0.5" />
|
||||
<button onClick={() => openEdit(p)}
|
||||
className="px-3 py-1.5 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors">
|
||||
@ -784,15 +639,10 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPublish && (
|
||||
<PublishModal project={p} onClose={() => setShowPublish(false)} />
|
||||
)}
|
||||
|
||||
{showCicd && (
|
||||
<CicdModal
|
||||
projectId={p.id}
|
||||
projectName={p.name}
|
||||
repoUrl={p.repo_url ?? undefined}
|
||||
winPath={p.win_path ?? undefined}
|
||||
wslPath={p.wsl_path ?? undefined}
|
||||
platform={p.platform ?? undefined}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { createProject, updateProject, getSpaces, githubGetToken, githubGetAccount, getTags, getProjectTags, setProjectTags, type Project } from "../../lib/commands";
|
||||
import { syncProjectCatalog } from "../../lib/spacesSync";
|
||||
import { createProject, updateProject, getSpaces, getTags, getProjectTags, setProjectTags, type Project } from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import { Dialog } from "../ui/Dialog";
|
||||
|
||||
@ -93,27 +92,6 @@ const pickFolder = async (field: "wsl_path" | "win_path") => {
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
qc.invalidateQueries({ queryKey: ["tagMaps"] });
|
||||
// 若项目有 repo_url,静默同步到 .dev-spaces catalog
|
||||
const savedRepoUrl = isNew ? form.repo_url : (form.repo_url ?? editTarget?.repo_url);
|
||||
if (savedRepoUrl && currentSpace) {
|
||||
(async () => {
|
||||
try {
|
||||
const token = await githubGetToken();
|
||||
const account = await githubGetAccount();
|
||||
if (token && account) {
|
||||
await syncProjectCatalog(token, account.username, currentSpace.id, currentSpace.name, {
|
||||
id: (isNew ? form.id : editTarget!.id) ?? "",
|
||||
name: form.name ?? "",
|
||||
description: form.description,
|
||||
tech_stack: form.tech_stack,
|
||||
repo_url: savedRepoUrl,
|
||||
default_branch: form.default_branch ?? editTarget?.default_branch,
|
||||
platform: form.platform,
|
||||
});
|
||||
}
|
||||
} catch { /* 同步失败不影响本地保存 */ }
|
||||
})();
|
||||
}
|
||||
closeEdit();
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
|
||||
@ -1,444 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
scanLocalGit, githubCreateRepo, gitInitAndCommit,
|
||||
gitRemoteSet, gitPushToGithub, registerAndLinkRepo,
|
||||
type GitScanResult, type Project,
|
||||
} from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Step = "scan" | "config" | "executing" | "done";
|
||||
|
||||
interface ExecLog {
|
||||
label: string;
|
||||
status: "pending" | "ok" | "error";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const GITIGNORE_TEMPLATES: Record<string, string> = {
|
||||
Node: `node_modules/\ndist/\n.next/\n.nuxt/\n*.log\n.env\n.env.*\n!.env.example\n`,
|
||||
Rust: `target/\nCargo.lock\n*.pdb\n`,
|
||||
Python: `__pycache__/\n*.py[cod]\n.venv/\nvenv/\n*.egg-info/\ndist/\n.env\n`,
|
||||
Go: `*.exe\n*.test\n*.out\nvendor/\n`,
|
||||
Java: `target/\n*.class\n*.jar\n.gradle/\nbuild/\n`,
|
||||
VisualStudio: `bin/\nobj/\n*.user\n*.suo\n.vs/\n`,
|
||||
};
|
||||
|
||||
export function PublishModal({ project, onClose }: Props) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const localPath = project.platform === "wsl" ? project.wsl_path : project.win_path;
|
||||
|
||||
const [step, setStep] = useState<Step>("scan");
|
||||
const [scan, setScan] = useState<GitScanResult | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanError, setScanError] = useState("");
|
||||
|
||||
// 配置表单
|
||||
const [repoName, setRepoName] = useState(
|
||||
project.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")
|
||||
);
|
||||
const [repoDesc, setRepoDesc] = useState(project.description ?? "");
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [branch, setBranch] = useState("main");
|
||||
const [commitMsg, setCommitMsg] = useState("Initial commit");
|
||||
const [confirmedSensitive, setConfirmedSensitive] = useState(false);
|
||||
const [selectedTemplates, setSelectedTemplates] = useState<string[]>([]);
|
||||
|
||||
// 执行日志
|
||||
const [logs, setLogs] = useState<ExecLog[]>([]);
|
||||
|
||||
// ── 工具 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const updateLog = (index: number, status: ExecLog["status"], detail?: string) => {
|
||||
setLogs((prev) => prev.map((l, i) => i === index ? { ...l, status, detail } : l));
|
||||
};
|
||||
|
||||
// ── 步骤1:扫描 ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!localPath) { showToast("❌ 项目没有配置本地路径"); return; }
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await scanLocalGit(localPath);
|
||||
setScan(result);
|
||||
// 预选推荐模板
|
||||
setSelectedTemplates(result.suggested_templates);
|
||||
// 若已有分支名则使用
|
||||
if (result.branch) setBranch(result.branch);
|
||||
setStep("config");
|
||||
} catch (e) {
|
||||
setScanError(String(e));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 步骤2 → 步骤3:执行 ─────────────────────────────────────────────────
|
||||
|
||||
const buildSteps = (): ExecLog[] => {
|
||||
if (!scan) return [];
|
||||
const steps: ExecLog[] = [];
|
||||
const needInit = !scan.has_git || !scan.has_commits;
|
||||
const needGitignore = !scan.has_gitignore && selectedTemplates.length > 0;
|
||||
if (needGitignore) steps.push({ label: "生成 .gitignore", status: "pending" });
|
||||
steps.push({ label: "在 GitHub 创建远端仓库", status: "pending" });
|
||||
if (needInit) {
|
||||
steps.push({ label: `初始化并提交代码(${commitMsg})`, status: "pending" });
|
||||
}
|
||||
steps.push({ label: "配置 origin remote", status: "pending" });
|
||||
steps.push({ label: `推送到 GitHub (${branch})`, status: "pending" });
|
||||
steps.push({ label: "注册到本地仓库", status: "pending" });
|
||||
return steps;
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!scan || !localPath) return;
|
||||
if (scan.suspicious_files.length > 0 && !confirmedSensitive) {
|
||||
showToast("❌ 请确认敏感文件处理后再继续");
|
||||
return;
|
||||
}
|
||||
|
||||
const initialLogs = buildSteps();
|
||||
setLogs(initialLogs);
|
||||
setStep("executing");
|
||||
|
||||
let logIdx = 0;
|
||||
let cloneUrl = "";
|
||||
|
||||
try {
|
||||
// 1. 生成 .gitignore
|
||||
if (!scan.has_gitignore && selectedTemplates.length > 0) {
|
||||
updateLog(logIdx, "pending", "写入中…");
|
||||
const content = selectedTemplates
|
||||
.map((t) => `# ${t}\n${GITIGNORE_TEMPLATES[t] ?? ""}`)
|
||||
.join("\n");
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("write_gitignore", { dir: localPath, content });
|
||||
updateLog(logIdx++, "ok");
|
||||
}
|
||||
|
||||
// 2. 创建 GitHub 仓库
|
||||
updateLog(logIdx, "pending", "调用 GitHub API…");
|
||||
cloneUrl = await githubCreateRepo(repoName, repoDesc, isPrivate);
|
||||
updateLog(logIdx++, "ok", cloneUrl);
|
||||
|
||||
// 3. 全新项目:git init + 提交所有文件
|
||||
const needCommit = !scan.has_git || !scan.has_commits;
|
||||
if (needCommit) {
|
||||
updateLog(logIdx, "pending", "git add & commit…");
|
||||
await gitInitAndCommit(localPath, commitMsg);
|
||||
updateLog(logIdx++, "ok");
|
||||
}
|
||||
|
||||
// 4. 配置 remote
|
||||
updateLog(logIdx, "pending", "git remote set…");
|
||||
await gitRemoteSet(localPath, cloneUrl);
|
||||
updateLog(logIdx++, "ok");
|
||||
|
||||
// 5. Push
|
||||
updateLog(logIdx, "pending", `git push origin ${branch}…`);
|
||||
await gitPushToGithub(localPath, branch);
|
||||
updateLog(logIdx++, "ok");
|
||||
|
||||
// 6. 注册本地仓库 + 关联
|
||||
updateLog(logIdx, "pending", "写入本地仓库注册表…");
|
||||
await registerAndLinkRepo(
|
||||
project.id,
|
||||
cloneUrl,
|
||||
repoName,
|
||||
repoDesc || undefined,
|
||||
branch,
|
||||
);
|
||||
updateLog(logIdx++, "ok");
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
qc.invalidateQueries({ queryKey: ["git-repos"] });
|
||||
setStep("done");
|
||||
} catch (e) {
|
||||
updateLog(logIdx, "error", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// ── 渲染 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const hasRemoteConflict = scan?.remotes.some(
|
||||
(r) => r.name === "origin" && !r.url.includes(repoName)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[580px] max-h-[85vh] flex flex-col border border-gray-200">
|
||||
|
||||
{/* 标题 */}
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-800">发布到 GitHub</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{project.name}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 px-5 py-4 space-y-4">
|
||||
|
||||
{/* ── 步骤1:扫描 ── */}
|
||||
{step === "scan" && (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-4">
|
||||
<p className="text-2xl">🔍</p>
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
点击扫描,检测本地项目 git 状态、敏感文件与技术栈
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 font-mono bg-gray-50 px-3 py-1.5 rounded-lg">{localPath}</p>
|
||||
<button
|
||||
onClick={() => { setScanError(""); handleScan(); }}
|
||||
disabled={scanning}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{scanning ? "扫描中…" : "开始扫描"}
|
||||
</button>
|
||||
{scanError && (
|
||||
<div className="w-full bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-xs space-y-2">
|
||||
<p className="font-semibold text-red-600">扫描失败</p>
|
||||
<pre className="text-red-700 whitespace-pre-wrap break-all font-mono leading-relaxed">{scanError}</pre>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(scanError).then(() => showToast("已复制 ✓"))}
|
||||
className="px-3 py-1 rounded border border-red-200 text-red-500 hover:bg-red-100 text-xs"
|
||||
>
|
||||
复制错误信息
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 步骤2:配置 ── */}
|
||||
{step === "config" && scan && (
|
||||
<>
|
||||
{/* 扫描结果摘要 */}
|
||||
<div className="bg-gray-50 rounded-lg px-4 py-3 text-xs space-y-1.5">
|
||||
<p className="font-semibold text-gray-600 mb-2">扫描结果</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<span className="text-gray-500">Git 仓库</span>
|
||||
<span className={scan.has_git ? "text-green-600" : "text-orange-500"}>
|
||||
{scan.has_git ? "✓ 已初始化" : "✗ 未初始化,将自动 init"}
|
||||
</span>
|
||||
<span className="text-gray-500">提交记录</span>
|
||||
<span className={scan.has_commits ? "text-green-600" : "text-orange-500"}>
|
||||
{scan.has_commits ? "✓ 有历史提交" : "✗ 无提交,将自动提交"}
|
||||
</span>
|
||||
<span className="text-gray-500">未提交改动</span>
|
||||
<span className={scan.has_changes ? "text-orange-500" : "text-green-600"}>
|
||||
{scan.has_changes ? `⚠ ${scan.changed_files.length} 个文件` : "✓ 工作区干净"}
|
||||
</span>
|
||||
<span className="text-gray-500">.gitignore</span>
|
||||
<span className={scan.has_gitignore ? "text-green-600" : "text-orange-500"}>
|
||||
{scan.has_gitignore ? "✓ 已存在" : "✗ 不存在"}
|
||||
</span>
|
||||
{scan.remotes.length > 0 && (
|
||||
<>
|
||||
<span className="text-gray-500">现有 remote</span>
|
||||
<span className="text-gray-700 font-mono truncate">{scan.remotes[0].url}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote 冲突警告 */}
|
||||
{hasRemoteConflict && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-xs text-red-700">
|
||||
⚠ 当前 origin 指向不同地址,继续将覆盖 remote 配置,请确认。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 敏感文件警告 */}
|
||||
{scan.suspicious_files.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-xs space-y-2">
|
||||
<p className="font-semibold text-red-700">🚨 检测到疑似敏感文件</p>
|
||||
<ul className="space-y-0.5">
|
||||
{scan.suspicious_files.map((f) => (
|
||||
<li key={f} className="font-mono text-red-600">• {f}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-red-500">请确认已将上述文件加入 .gitignore 或删除,否则将推送到公开仓库。</p>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmedSensitive}
|
||||
onChange={(e) => setConfirmedSensitive(e.target.checked)}
|
||||
/>
|
||||
<span className="text-red-700 font-medium">我已确认,这些文件不包含敏感信息</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* .gitignore 模板 */}
|
||||
{!scan.has_gitignore && scan.suggested_templates.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-lg px-4 py-3 text-xs space-y-2">
|
||||
<p className="font-semibold text-gray-600">自动生成 .gitignore(推荐)</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scan.suggested_templates.map((t) => (
|
||||
<label key={t} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTemplates.includes(t)}
|
||||
onChange={(e) =>
|
||||
setSelectedTemplates((prev) =>
|
||||
e.target.checked ? [...prev, t] : prev.filter((x) => x !== t)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>{t}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未提交内容:阻断发布 */}
|
||||
{scan.has_changes && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg px-4 py-3 text-xs space-y-2">
|
||||
<p className="font-semibold text-orange-700">
|
||||
⚠ 存在 {scan.changed_files.length} 个未提交文件,请先提交后再发布
|
||||
</p>
|
||||
<div className="max-h-28 overflow-y-auto space-y-0.5">
|
||||
{scan.changed_files.slice(0, 50).map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2 font-mono text-gray-500">
|
||||
<span className={`w-4 font-bold ${
|
||||
f.status === "?" ? "text-green-500" :
|
||||
f.status === "D" ? "text-red-500" : "text-yellow-500"
|
||||
}`}>{f.status}</span>
|
||||
<span className="truncate">{f.path}</span>
|
||||
</div>
|
||||
))}
|
||||
{scan.changed_files.length > 50 && (
|
||||
<p className="text-gray-400">…还有 {scan.changed_files.length - 50} 个文件</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-orange-600">
|
||||
请在 <span className="font-semibold">VSCode</span> 或 <span className="font-semibold">Claude</span> 中完成提交,然后重新扫描。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全新项目提示(无 git / 无提交记录,但工作区干净) */}
|
||||
{!scan.has_changes && (!scan.has_git || !scan.has_commits) && (
|
||||
<div className="border border-blue-100 bg-blue-50 rounded-lg px-4 py-3 text-xs space-y-2">
|
||||
<p className="font-semibold text-blue-700">
|
||||
{!scan.has_git ? "全新项目:将自动执行 git init 并提交所有文件" : "尚无提交记录:将自动提交当前所有文件"}
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-blue-600 mb-1">初始 Commit 信息</label>
|
||||
<input
|
||||
className="w-full border border-blue-200 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-blue-200 bg-white"
|
||||
value={commitMsg}
|
||||
onChange={(e) => setCommitMsg(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GitHub 仓库配置 */}
|
||||
<div className="border border-gray-200 rounded-lg px-4 py-3 space-y-3">
|
||||
<p className="text-xs font-semibold text-gray-600">GitHub 仓库设置</p>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-gray-500 mb-1">仓库名称</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200"
|
||||
value={repoName}
|
||||
onChange={(e) => setRepoName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-gray-500 mb-1">描述(可选)</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
value={repoDesc}
|
||||
onChange={(e) => setRepoDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-gray-500 mb-1">默认分支</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
/>
|
||||
私有仓库
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 步骤3:执行进度 ── */}
|
||||
{(step === "executing" || step === "done") && (
|
||||
<div className="space-y-2">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="flex items-start gap-3 text-sm">
|
||||
<span className="shrink-0 mt-0.5">
|
||||
{log.status === "pending" && <span className="text-blue-400 animate-pulse">⏳</span>}
|
||||
{log.status === "ok" && <span className="text-green-500">✓</span>}
|
||||
{log.status === "error" && <span className="text-red-500">✗</span>}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={log.status === "error" ? "text-red-600" : "text-gray-700"}>
|
||||
{log.label}
|
||||
</span>
|
||||
{log.detail && (
|
||||
<p className="text-xs text-gray-400 font-mono truncate mt-0.5">{log.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{step === "done" && (
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm text-green-700 text-center">
|
||||
🎉 发布完成!三灯已全绿
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex justify-end gap-3 px-5 py-3 border-t border-gray-100 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{step === "done" ? "关闭" : "取消"}
|
||||
</button>
|
||||
{step === "config" && (
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={
|
||||
!repoName.trim() ||
|
||||
!scan ||
|
||||
scan.has_changes ||
|
||||
(scan.suspicious_files.length > 0 && !confirmedSensitive)
|
||||
}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
开始发布
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,11 +2,8 @@ import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getGitRepos,
|
||||
importGitRepos,
|
||||
updateGitRepoDeploy,
|
||||
deleteGitRepo,
|
||||
githubGetToken,
|
||||
githubListRepos,
|
||||
type GitRepo,
|
||||
type UpdateGitRepoDeployInput,
|
||||
} from "../../lib/commands";
|
||||
@ -16,8 +13,6 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "registry" | "sync";
|
||||
|
||||
// ── 部署信息编辑表单 ──────────────────────────────────────────────────────────
|
||||
|
||||
function DeployForm({
|
||||
@ -154,7 +149,7 @@ function RegistryTab() {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-16 gap-2">
|
||||
<p className="text-2xl">📦</p>
|
||||
<p className="text-sm">还没有注册仓库,切换到「从 GitHub 同步」标签页导入</p>
|
||||
<p className="text-sm">还没有注册仓库,通过「发布 / 绑定仓库」流程注册</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -220,151 +215,9 @@ function RegistryTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 从 GitHub 同步标签 ───────────────────────────────────────────────────────
|
||||
|
||||
function SyncTab() {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const { data: repos = [], isLoading, error } = useQuery({
|
||||
queryKey: ["github-repos"],
|
||||
queryFn: async () => {
|
||||
const token = await githubGetToken();
|
||||
if (!token) throw new Error("未登录 GitHub");
|
||||
return githubListRepos(token);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: existingRepos = [] } = useQuery({
|
||||
queryKey: ["git-repos"],
|
||||
queryFn: getGitRepos,
|
||||
});
|
||||
|
||||
const existingUrls = new Set(existingRepos.map((r) => r.repo_url));
|
||||
|
||||
const filtered = repos.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.full_name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const newRepos = filtered.filter((r) => !existingUrls.has(r.clone_url));
|
||||
setSelected(new Set(newRepos.map((r) => r.id)));
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
const toImport = repos.filter((r) => selected.has(r.id));
|
||||
if (toImport.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const inputs = toImport.map((r) => ({
|
||||
id: crypto.randomUUID(),
|
||||
repo_url: r.clone_url,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
default_branch: r.default_branch,
|
||||
}));
|
||||
const imported = await importGitRepos(inputs);
|
||||
qc.invalidateQueries({ queryKey: ["git-repos"] });
|
||||
setSelected(new Set());
|
||||
showToast(`已导入 ${imported.length} 个仓库 ✓`);
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="flex-1 flex items-center justify-center text-gray-400 text-sm">加载 GitHub 仓库…</div>;
|
||||
if (error) return <div className="flex-1 flex items-center justify-center text-red-400 text-sm p-6">{String(error)}</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b border-gray-100 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索仓库…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 border border-gray-200 text-sm rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
<button onClick={selectAll} className="px-3 py-2 text-xs border border-gray-200 rounded-lg text-gray-500 hover:bg-gray-50 shrink-0">
|
||||
全选未导入
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={selected.size === 0 || importing}
|
||||
className="px-3 py-2 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{importing ? "导入中…" : `导入 ${selected.size} 个`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{filtered.map((repo) => {
|
||||
const alreadyImported = existingUrls.has(repo.clone_url);
|
||||
const isSelected = selected.has(repo.id);
|
||||
return (
|
||||
<button
|
||||
key={repo.id}
|
||||
onClick={() => !alreadyImported && toggleSelect(repo.id)}
|
||||
disabled={alreadyImported}
|
||||
className={`w-full text-left px-4 py-3 border-b border-gray-100 transition-colors flex items-center gap-3 ${
|
||||
alreadyImported
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: isSelected
|
||||
? "bg-blue-50"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
|
||||
alreadyImported ? "border-gray-200 bg-gray-100" :
|
||||
isSelected ? "border-blue-500 bg-blue-500" : "border-gray-300"
|
||||
}`}>
|
||||
{(isSelected || alreadyImported) && (
|
||||
<svg className="w-2.5 h-2.5 text-white" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-800">{repo.name}</span>
|
||||
{repo.private && <span className="text-xs px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">私有</span>}
|
||||
{alreadyImported && <span className="text-xs px-1.5 py-0.5 bg-green-100 text-green-600 rounded">已注册</span>}
|
||||
<span className="text-xs text-gray-400 ml-auto font-mono">{repo.default_branch}</span>
|
||||
</div>
|
||||
{repo.description && <p className="text-xs text-gray-400 mt-0.5 truncate">{repo.description}</p>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center text-gray-400 text-sm py-8">没有找到匹配的仓库</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 主模态框 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RepoRegistryModal({ onClose }: Props) {
|
||||
const [tab, setTab] = useState<Tab>("registry");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[700px] max-h-[85vh] flex flex-col border border-gray-200">
|
||||
@ -373,27 +226,8 @@ export function RepoRegistryModal({ onClose }: Props) {
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-gray-100 px-5 flex">
|
||||
{([
|
||||
{ id: "registry" as Tab, label: "已注册仓库" },
|
||||
{ id: "sync" as Tab, label: "从 GitHub 同步" },
|
||||
]).map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
tab === id
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{tab === "registry" ? <RegistryTab /> : <SyncTab />}
|
||||
<RegistryTab />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getSpaces, setCurrentSpace, createSpace, updateSpace, deleteSpace,
|
||||
getSettings, updateSettings,
|
||||
type Space, type GitAccount,
|
||||
type Space,
|
||||
} from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import { RepoRegistryModal } from "../dashboard/RepoRegistryModal";
|
||||
@ -21,17 +20,14 @@ const NAV_ITEMS: { id: Page; label: string }[] = [
|
||||
];
|
||||
|
||||
interface Props {
|
||||
account: GitAccount;
|
||||
onLogout: () => void;
|
||||
page: Page;
|
||||
onPageChange: (page: Page) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ account, onLogout, page, onPageChange }: Props) {
|
||||
export function Sidebar({ page, onPageChange }: Props) {
|
||||
const [spaceOpen, setSpaceOpen] = useState(true);
|
||||
const [showForm, setShowForm] = useState<Space | null | undefined>(undefined);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Space | null>(null);
|
||||
const [showAppSettings, setShowAppSettings] = useState(false);
|
||||
const [showRepoRegistry, setShowRepoRegistry] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
@ -172,41 +168,6 @@ export function Sidebar({ account, onLogout, page, onPageChange }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 应用设置 */}
|
||||
<div className="border-t border-slate-700 px-3 py-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setShowAppSettings(true)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046.219.31.41.641.573.989.063.13.002.21-.048.253l-.809.645a1.975 1.975 0 00-.703 1.248 5.33 5.33 0 000 .572c.025.385.284.781.703 1.248l.809.645c.05.043.111.123.048.253a6.37 6.37 0 01-.573.989c-.02.03-.085.076-.195.046l-1.103-.303c-.559-.153-1.112-.008-1.529.27-.16.107-.327.204-.5.29-.449.222-.851.628-.998 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.593 6.593 0 01-1.142 0c-.036-.003-.108-.036-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a4.502 4.502 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.372 6.372 0 01-.573-.989c-.063-.13-.002-.21.048-.253l.809-.645a1.974 1.974 0 00.703-1.248 5.322 5.322 0 000-.572 1.974 1.974 0 00-.703-1.248l-.809-.645c-.05-.043-.111-.123-.048-.253.163-.348.354-.679.573-.989.02-.03.085-.076.195-.046l1.103.303c.559.153 1.112.008 1.529-.27.16-.107.327-.204.5-.29.449-.222.851-.628.998-1.189l.289-1.105c.029-.11.101-.143.137-.146zM8 5.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5z" clipRule="evenodd"/>
|
||||
</svg>
|
||||
<span>应用设置</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* GitHub 账户 */}
|
||||
<div className="border-t border-slate-700 px-3 py-3 shrink-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{account.avatar_url ? (
|
||||
<img src={account.avatar_url} alt="" className="w-6 h-6 rounded-full shrink-0" />
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-slate-600 flex items-center justify-center text-xs font-bold shrink-0">
|
||||
{account.username[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-slate-200 truncate">{account.username}</p>
|
||||
<p className="text-[10px] text-slate-500">GitHub</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full text-left px-2 py-1.5 rounded-md text-xs text-slate-400 hover:bg-slate-700 hover:text-red-400 transition-colors"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 新建/编辑模态框 */}
|
||||
@ -222,11 +183,6 @@ export function Sidebar({ account, onLogout, page, onPageChange }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 应用设置弹窗 */}
|
||||
{showAppSettings && (
|
||||
<AppSettingsModal onClose={() => setShowAppSettings(false)} />
|
||||
)}
|
||||
|
||||
{/* 仓库管理弹窗 */}
|
||||
{showRepoRegistry && (
|
||||
<RepoRegistryModal onClose={() => setShowRepoRegistry(false)} />
|
||||
@ -304,97 +260,3 @@ function SpaceFormModal({ space, onClose, onSaved }: {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 应用设置弹窗(GitHub OAuth,全局级别)─────────────────────────────────────
|
||||
|
||||
function AppSettingsModal({ onClose }: { onClose: () => void }) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: () => getSettings() });
|
||||
|
||||
const [clientId, setClientId] = useState(settings?.github_client_id ?? "");
|
||||
const [clientSecret, setClientSecret] = useState(settings?.github_client_secret ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// settings 异步加载后回填
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setClientId(settings.github_client_id ?? "");
|
||||
setClientSecret(settings.github_client_secret ?? "");
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!clientId.trim() || !clientSecret.trim()) {
|
||||
showToast("❌ Client ID 和 Client Secret 均不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateSettings({ github_client_id: clientId.trim(), github_client_secret: clientSecret.trim() });
|
||||
qc.invalidateQueries({ queryKey: ["settings"] });
|
||||
showToast("已保存 ✓");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls = "w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-96 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-800">应用设置</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">GitHub OAuth</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
在 GitHub → Settings → Developer settings → OAuth Apps 创建应用,
|
||||
Callback URL 填 <code className="bg-gray-100 px-1 rounded">http://localhost</code>。
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Client ID</label>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
placeholder="Ov23li..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
className={inputCls}
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
placeholder="••••••••••••••••••••••••••••••••••••••••"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
仅存储本机 SQLite,由 Rust 完成 token 交换,不经中间服务器。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import { PriorityBadge, StatusBadge } from "../ui/Badge";
|
||||
import { Dialog } from "../ui/Dialog";
|
||||
import { ImportRepoModal } from "../dashboard/ImportRepoModal";
|
||||
import { TemplateEditor } from "./TemplateEditor";
|
||||
import { NewProjectModal } from "./NewProjectModal";
|
||||
import { TagsManager } from "./TagsManager";
|
||||
@ -58,7 +57,6 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [delTarget, setDelTarget] = useState<string | null>(null);
|
||||
const [deleteLocal, setDeleteLocal] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showNewProject, setShowNewProject] = useState(false);
|
||||
|
||||
const { data: projects = [] } = useQuery({
|
||||
@ -80,12 +78,6 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700">共 {projects.length} 个项目</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowImport(true)}
|
||||
className="px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 text-sm hover:bg-gray-50"
|
||||
>
|
||||
导入仓库
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewProject(true)}
|
||||
className="px-3 py-1.5 rounded-lg border border-indigo-200 text-indigo-600 bg-indigo-50 text-sm hover:bg-indigo-100"
|
||||
@ -194,9 +186,6 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{showImport && (
|
||||
<ImportRepoModal onClose={() => { setShowImport(false); qc.invalidateQueries({ queryKey: ["projects"] }); }} />
|
||||
)}
|
||||
{showNewProject && (
|
||||
<NewProjectModal spaceId={spaceId} onClose={() => setShowNewProject(false)} />
|
||||
)}
|
||||
|
||||
@ -1,285 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getSpaces, createSpace, updateSpace, deleteSpace, setCurrentSpace,
|
||||
githubGetToken, githubGetAccount, gitStatus, getProjects, type Space,
|
||||
} from "../../lib/commands";
|
||||
import type { SpacesJson } from "../../lib/spacesSync";
|
||||
import { formatRelativeTime, recordPushToRemote } from "../../lib/spacesSync";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
interface Props {
|
||||
spacesJson: SpacesJson | null;
|
||||
}
|
||||
|
||||
export function SpacesPage({ spacesJson }: Props) {
|
||||
const [showSpaceForm, setShowSpaceForm] = useState<Space | null | undefined>(undefined);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
|
||||
const { data: spaces = [], refetch: refetchSpaces } = useQuery({
|
||||
queryKey: ["spaces"],
|
||||
queryFn: getSpaces,
|
||||
});
|
||||
const currentSpace = spaces.find((s) => s.is_current);
|
||||
|
||||
const { data: projects = [] } = useQuery({
|
||||
queryKey: ["projects", currentSpace?.id],
|
||||
queryFn: () => getProjects(undefined, currentSpace?.id),
|
||||
enabled: !!currentSpace,
|
||||
});
|
||||
|
||||
const handleManualSync = async () => {
|
||||
if (!currentSpace) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const token = await githubGetToken();
|
||||
const account = await githubGetAccount();
|
||||
if (!token || !account) {
|
||||
showToast("未登录 GitHub,无法同步");
|
||||
return;
|
||||
}
|
||||
const syncable = projects.filter((p) => p.repo_url && (p.win_path || p.wsl_path));
|
||||
let successCount = 0;
|
||||
for (const project of syncable) {
|
||||
const localPath = project.win_path ?? project.wsl_path!;
|
||||
try {
|
||||
const status = await gitStatus(localPath);
|
||||
await recordPushToRemote(
|
||||
token,
|
||||
account.username,
|
||||
currentSpace.id,
|
||||
currentSpace.name,
|
||||
project.repo_url!,
|
||||
status.branch,
|
||||
status.last_commit_msg
|
||||
);
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
console.warn(`同步项目 ${project.name} 失败:`, e);
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["spacesSync"] });
|
||||
showToast(
|
||||
syncable.length === 0
|
||||
? "没有可同步的项目"
|
||||
: `已同步 ${successCount}/${syncable.length} 个项目`
|
||||
);
|
||||
} catch (e) {
|
||||
showToast("同步失败");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full px-6 py-6 pb-12 space-y-8">
|
||||
{/* 本机空间管理 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700">本机空间</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleManualSync}
|
||||
disabled={syncing || !currentSpace}
|
||||
className="px-3 py-1.5 rounded-lg text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
title="将当前空间各项目的 git 状态同步到云端(.dev-spaces)"
|
||||
>
|
||||
{syncing ? "同步中…" : "同步到云端"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSpaceForm(null)}
|
||||
className="px-3 py-1.5 rounded-lg text-xs bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
+ 新建空间
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{spaces.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`flex items-center gap-3 p-4 rounded-xl border transition-colors ${
|
||||
s.is_current
|
||||
? "border-blue-200 bg-blue-50"
|
||||
: "border-gray-100 bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-800 text-sm">{s.name}</span>
|
||||
{s.is_current && (
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-700">当前</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
{!s.is_current && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await setCurrentSpace(s.id);
|
||||
refetchSpaces();
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
}}
|
||||
className="px-2.5 py-1 rounded border border-blue-200 text-xs text-blue-600 hover:bg-blue-50"
|
||||
>
|
||||
切换
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowSpaceForm(s)}
|
||||
className="px-2.5 py-1 rounded border border-gray-200 text-xs text-gray-600 hover:bg-white"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
{!s.is_current && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await deleteSpace(s.id);
|
||||
refetchSpaces();
|
||||
}}
|
||||
className="px-2.5 py-1 rounded border border-red-200 text-xs text-red-500 hover:bg-red-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{spaces.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm border border-dashed border-gray-200 rounded-xl">
|
||||
还没有空间,点击「新建空间」开始
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 跨端动态 */}
|
||||
{spacesJson && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-4">跨端动态</h2>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(spacesJson.spaces)
|
||||
.filter(([spaceId]) => !currentSpace || spaceId !== currentSpace.id)
|
||||
.map(([spaceId, spaceInfo]) => {
|
||||
const projectEntries = Object.entries(spaceInfo.projects);
|
||||
if (projectEntries.length === 0) return null;
|
||||
return (
|
||||
<div key={spaceId} className="bg-white rounded-xl border border-gray-100 shadow-sm p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-indigo-500">⇄</span>
|
||||
<span className="font-medium text-gray-800 text-sm">{spaceInfo.name}</span>
|
||||
<span className="text-xs text-gray-400">{projectEntries.length} 个项目有动态</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{projectEntries
|
||||
.sort(([, a], [, b]) => b.last_push_at.localeCompare(a.last_push_at))
|
||||
.map(([repoUrl, info]) => {
|
||||
const repoName = repoUrl.split("/").pop()?.replace(/\.git$/, "") ?? repoUrl;
|
||||
return (
|
||||
<div
|
||||
key={repoUrl}
|
||||
className="flex items-center gap-2 text-xs text-gray-600 py-1 border-b border-gray-50 last:border-0"
|
||||
>
|
||||
<span className="font-mono text-gray-500 truncate flex-1" title={repoUrl}>{repoName}</span>
|
||||
<span className="font-mono text-indigo-500 shrink-0">{info.branch}</span>
|
||||
{info.last_commit_msg && (
|
||||
<span className="text-gray-400 truncate max-w-[200px]" title={info.last_commit_msg}>
|
||||
{info.last_commit_msg}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-300 shrink-0">{formatRelativeTime(info.last_push_at)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(spacesJson.spaces).filter(
|
||||
(id) => !currentSpace || id !== currentSpace.id
|
||||
).length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">
|
||||
暂无其他空间的动态
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showSpaceForm !== undefined && (
|
||||
<SpaceFormModal
|
||||
space={showSpaceForm}
|
||||
onClose={() => setShowSpaceForm(undefined)}
|
||||
onSaved={() => {
|
||||
setShowSpaceForm(undefined);
|
||||
refetchSpaces();
|
||||
showToast(showSpaceForm ? "已更新" : "已创建");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpaceFormModal({
|
||||
space,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
space: Space | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(space?.name ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (space) {
|
||||
await updateSpace(space.id, name.trim());
|
||||
} else {
|
||||
await createSpace(name.trim());
|
||||
}
|
||||
onSaved();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-96 p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-gray-800">{space ? "编辑空间" : "新建空间"}</h2>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">空间名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="笔记本 / 日本服务器 / 公司台式机"
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || saving}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { githubGetToken, githubGetAccount } from "../lib/commands";
|
||||
import { fetchRemoteSpaces, type SpacesJson } from "../lib/spacesSync";
|
||||
|
||||
/**
|
||||
* 启动时拉取远端 spaces.json,缓存跨空间感知数据
|
||||
* 每 5 分钟自动刷新一次,未登录时不执行
|
||||
*/
|
||||
export function useSpacesSync(isLoggedIn: boolean) {
|
||||
return useQuery<SpacesJson | null>({
|
||||
queryKey: ["spacesSync"],
|
||||
queryFn: async () => {
|
||||
const token = await githubGetToken();
|
||||
if (!token) return null;
|
||||
const account = await githubGetAccount();
|
||||
if (!account) return null;
|
||||
return fetchRemoteSpaces(token, account.username);
|
||||
},
|
||||
enabled: isLoggedIn,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchInterval: isLoggedIn ? 5 * 60 * 1000 : false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseRepoPath } from "./commands";
|
||||
|
||||
describe("parseRepoPath", () => {
|
||||
it("解析标准 HTTPS GitHub URL", () => {
|
||||
expect(parseRepoPath("https://github.com/facebook/react")).toBe("facebook/react");
|
||||
});
|
||||
|
||||
it("解析带 .git 后缀的 URL", () => {
|
||||
expect(parseRepoPath("https://github.com/facebook/react.git")).toBe("facebook/react");
|
||||
});
|
||||
|
||||
it("解析 SSH 格式 URL", () => {
|
||||
expect(parseRepoPath("git@github.com:facebook/react.git")).toBe("facebook/react");
|
||||
});
|
||||
|
||||
it("非 GitHub URL 返回 null", () => {
|
||||
expect(parseRepoPath("https://gitlab.com/foo/bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("空字符串返回 null", () => {
|
||||
expect(parseRepoPath("")).toBeNull();
|
||||
});
|
||||
|
||||
it("纯域名无路径返回 null", () => {
|
||||
expect(parseRepoPath("https://github.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("非 URL 的随机字符串返回 null", () => {
|
||||
expect(parseRepoPath("not-a-url")).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
export interface Project {
|
||||
/** workspace.id(也是旧 projects.id) */
|
||||
@ -330,75 +329,6 @@ export const openEditor = (projectId: string, env: "wsl" | "win", editorPath: st
|
||||
export const openTerminal = (projectId: string, env: "wsl" | "win") =>
|
||||
invoke<string>("open_terminal", { projectId, env });
|
||||
|
||||
// ── GitHub 账户(DB 操作,Rust 侧)────────────────────────────────────────────
|
||||
|
||||
export interface GitAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar_url?: string;
|
||||
provider: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const githubGetAccount = () =>
|
||||
invoke<GitAccount | null>("github_get_account");
|
||||
|
||||
export const githubGetToken = () =>
|
||||
invoke<string | null>("github_get_token");
|
||||
|
||||
export const githubGetClientId = () =>
|
||||
invoke<string>("github_get_client_id");
|
||||
|
||||
export const githubSaveAccount = (
|
||||
username: string,
|
||||
avatarUrl: string,
|
||||
accessToken: string
|
||||
) => invoke<GitAccount>("github_save_account", { username, avatarUrl, accessToken });
|
||||
|
||||
export const githubLogout = () =>
|
||||
invoke<void>("github_logout");
|
||||
|
||||
// ── GitHub API(前端 fetch)───────────────────────────────────────────────────
|
||||
|
||||
export interface GithubRepo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
clone_url: string;
|
||||
ssh_url: string;
|
||||
description?: string;
|
||||
default_branch: string;
|
||||
}
|
||||
|
||||
/** 打开应用内 GitHub 登录窗口(用户在窗口内完成授权,无需外部浏览器) */
|
||||
export const githubOpenLoginWindow = () => invoke<void>("github_open_login_window");
|
||||
|
||||
/** 轮询授权结果(null = 等待中,string = token) */
|
||||
export const githubPollOAuth = () => invoke<string | null>("github_poll_oauth");
|
||||
|
||||
/** 启动本地回调服务器,返回授权 URL(备用:外部浏览器方式) */
|
||||
export const githubStartOAuth = () => invoke<string>("github_start_oauth");
|
||||
|
||||
/** 用 token 获取 GitHub 用户信息 */
|
||||
export async function githubFetchUser(token: string) {
|
||||
const resp = await tauriFetch("https://api.github.com/user", {
|
||||
headers: { Authorization: `Bearer ${token}`, "User-Agent": "dev-manager-tauri" },
|
||||
});
|
||||
if (!resp.ok) throw new Error("获取用户信息失败");
|
||||
return resp.json() as Promise<{ login: string; avatar_url: string }>;
|
||||
}
|
||||
|
||||
/** 获取用户的 GitHub 仓库列表 */
|
||||
export async function githubListRepos(token: string): Promise<GithubRepo[]> {
|
||||
const resp = await tauriFetch(
|
||||
"https://api.github.com/user/repos?per_page=100&sort=updated&affiliation=owner",
|
||||
{ headers: { Authorization: `Bearer ${token}`, "User-Agent": "dev-manager-tauri" } }
|
||||
);
|
||||
if (!resp.ok) throw new Error("获取仓库列表失败");
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── Spaces ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Space {
|
||||
@ -623,7 +553,7 @@ export const mountRepo = (input: MountRepoInput) =>
|
||||
export const deleteGitRepo = (id: string) =>
|
||||
invoke<void>("delete_git_repo", { id });
|
||||
|
||||
// ── Publish to GitHub ─────────────────────────────────────────────────────────
|
||||
// ── 本地 Git 工具(扫描 / init / remote / WSL 路径)───────────────────────────
|
||||
|
||||
export interface ChangedFile {
|
||||
status: string; // "M" | "A" | "D" | "?" | "R"
|
||||
@ -650,18 +580,12 @@ export interface GitScanResult {
|
||||
export const scanLocalGit = (path: string) =>
|
||||
invoke<GitScanResult>("scan_local_git", { path });
|
||||
|
||||
export const githubCreateRepo = (name: string, description: string, privateRepo: boolean) =>
|
||||
invoke<string>("github_create_repo", { name, description, private: privateRepo });
|
||||
|
||||
export const gitInitAndCommit = (path: string, message: string) =>
|
||||
invoke<void>("git_init_and_commit", { path, message });
|
||||
|
||||
export const gitRemoteSet = (path: string, url: string) =>
|
||||
invoke<void>("git_remote_set", { path, url });
|
||||
|
||||
export const gitPushToGithub = (path: string, branch: string) =>
|
||||
invoke<void>("git_push_to_github", { path, branch });
|
||||
|
||||
/** 将 Linux WSL 路径转换为 Windows UNC 路径(\\wsl.localhost\distro\...) */
|
||||
export const resolveWslPath = (path: string) =>
|
||||
invoke<string>("resolve_wsl_path", { path });
|
||||
@ -808,63 +732,6 @@ export const saveCicdConfig = (input: CicdConfig) =>
|
||||
export const generateWorkflow = (projectId: string, projectPath: string) =>
|
||||
invoke<WorkflowResult>("generate_workflow", { projectId, projectPath });
|
||||
|
||||
// ── GitHub Actions API(前端直接调用,使用已存储的 token)────────────────────
|
||||
|
||||
export interface ActionsRun {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string; // "queued" | "in_progress" | "completed"
|
||||
conclusion: string | null; // "success" | "failure" | "cancelled" | null
|
||||
head_branch: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
html_url: string;
|
||||
run_number: number;
|
||||
}
|
||||
|
||||
/** 解析 repo_url 为 {owner}/{repo} 格式,失败返回 null */
|
||||
export function parseRepoPath(repoUrl: string): string | null {
|
||||
const m = repoUrl.match(/github\.com[/:](.+?)(?:\.git)?$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** 获取最近 N 次 Actions run */
|
||||
export async function fetchActionsRuns(
|
||||
token: string,
|
||||
repoPath: string,
|
||||
perPage = 5
|
||||
): Promise<ActionsRun[]> {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${repoPath}/actions/runs?per_page=${perPage}`,
|
||||
{ headers: { Authorization: `Bearer ${token}`, "User-Agent": "dev-manager-tauri" } }
|
||||
);
|
||||
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.workflow_runs ?? [];
|
||||
}
|
||||
|
||||
/** 手动触发 workflow_dispatch */
|
||||
export async function triggerWorkflow(
|
||||
token: string,
|
||||
repoPath: string,
|
||||
workflowFile: string,
|
||||
branch: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${repoPath}/actions/workflows/${workflowFile}/dispatches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": "dev-manager-tauri",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ref: branch }),
|
||||
}
|
||||
);
|
||||
if (!res.ok && res.status !== 204) throw new Error(`触发失败: ${res.status}`);
|
||||
}
|
||||
|
||||
/** 根据节点数据构造 share link(前端完成,避免 Rust 侧 URL 编码依赖) */
|
||||
export function buildShareLink(node: V2raynNode): string {
|
||||
const remarks = encodeURIComponent(node.remarks);
|
||||
@ -1678,6 +1545,22 @@ export const scanDocFreshness = (projectPath: string, projectId: string) =>
|
||||
export const getDocFreshnessCache = (projectId: string) =>
|
||||
invoke<CachedFreshness | null>('get_doc_freshness_cache', { projectId });
|
||||
|
||||
export interface AgentHealthIssue {
|
||||
level: 'error' | 'warn';
|
||||
title: string;
|
||||
fix: string;
|
||||
}
|
||||
|
||||
export interface AgentHealthReport {
|
||||
overall: 'green' | 'yellow' | 'red';
|
||||
issues: AgentHealthIssue[];
|
||||
fix_prompt: string;
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
export const getAgentHealth = (projectPath: string, projectId: string) =>
|
||||
invoke<AgentHealthReport>('get_agent_health', { projectPath, projectId });
|
||||
|
||||
export interface CommitStats {
|
||||
total: number;
|
||||
conventional: number;
|
||||
|
||||
@ -1,326 +0,0 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
/**
|
||||
* 跨空间感知同步模块
|
||||
*
|
||||
* 使用 GitHub Contents API 读写 .dev-spaces/spaces.json
|
||||
* 记录每个空间各项目的最新 push 信息,实现多设备互相感知
|
||||
*
|
||||
* spaces.json 结构:
|
||||
* {
|
||||
* "version": 1,
|
||||
* "spaces": {
|
||||
* "<space_id>": {
|
||||
* "name": "会旅游的笔记本",
|
||||
* "projects": {
|
||||
* "<repo_url>": {
|
||||
* "branch": "feature/login",
|
||||
* "last_push_at": "2026-03-31T10:00:00Z",
|
||||
* "last_commit_msg": "fix: login bug"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
const DEV_SPACES_REPO = ".dev-spaces";
|
||||
const SPACES_FILE = "spaces.json";
|
||||
|
||||
export interface SpaceProjectInfo {
|
||||
branch: string;
|
||||
last_push_at: string;
|
||||
last_commit_msg?: string;
|
||||
}
|
||||
|
||||
/** 项目档案条目:记录某个空间内某个项目的元信息 */
|
||||
export interface SpaceCatalogItem {
|
||||
id: string; // 在该空间内的 workspace_id
|
||||
name: string;
|
||||
description?: string;
|
||||
tech_stack?: string;
|
||||
repo_url: string;
|
||||
default_branch?: string;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
export interface SpaceInfo {
|
||||
name: string;
|
||||
projects: Record<string, SpaceProjectInfo>; // key = repo_url,记录 push 事件
|
||||
catalog?: SpaceCatalogItem[]; // 项目档案列表,用于跨空间发现
|
||||
}
|
||||
|
||||
export interface SpacesJson {
|
||||
version: number;
|
||||
spaces: Record<string, SpaceInfo>; // key = space_id
|
||||
}
|
||||
|
||||
interface GithubFileResponse {
|
||||
content: string;
|
||||
sha: string;
|
||||
encoding: string;
|
||||
}
|
||||
|
||||
// ── GitHub Contents API ─────────────────────────────────────
|
||||
|
||||
async function apiHeaders(token: string) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": "dev-manager-tauri",
|
||||
Accept: "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
/** 确保 .dev-spaces 仓库存在(不存在则创建) */
|
||||
export async function ensureDevSpacesRepo(token: string, username: string): Promise<void> {
|
||||
const headers = await apiHeaders(token);
|
||||
|
||||
// 检查是否已存在
|
||||
const check = await tauriFetch(
|
||||
`https://api.github.com/repos/${username}/${DEV_SPACES_REPO}`,
|
||||
{ headers }
|
||||
);
|
||||
if (check.ok) return;
|
||||
|
||||
// 创建私有仓库
|
||||
const create = await tauriFetch("https://api.github.com/user/repos", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
name: DEV_SPACES_REPO,
|
||||
private: true,
|
||||
description: "Dev Space Manager - 跨设备开发空间配置",
|
||||
auto_init: true,
|
||||
}),
|
||||
});
|
||||
if (!create.ok) {
|
||||
const err = await create.json().catch(() => ({}));
|
||||
// 422 通常是仓库已存在,忽略
|
||||
if (create.status !== 422) {
|
||||
throw new Error(`创建 .dev-spaces 仓库失败: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取 spaces.json,返回内容和 sha(用于后续更新) */
|
||||
export async function readSpacesJson(
|
||||
token: string,
|
||||
username: string
|
||||
): Promise<{ data: SpacesJson; sha: string } | null> {
|
||||
const headers = await apiHeaders(token);
|
||||
const resp = await tauriFetch(
|
||||
`https://api.github.com/repos/${username}/${DEV_SPACES_REPO}/contents/${SPACES_FILE}`,
|
||||
{ headers }
|
||||
);
|
||||
if (resp.status === 404) return null;
|
||||
if (!resp.ok) throw new Error(`读取 spaces.json 失败: ${resp.status}`);
|
||||
|
||||
const file: GithubFileResponse = await resp.json();
|
||||
const content = atob(file.content.replace(/\n/g, ""));
|
||||
const data: SpacesJson = JSON.parse(content);
|
||||
return { data, sha: file.sha };
|
||||
}
|
||||
|
||||
/** 将 SpacesJson 编码为 base64 content */
|
||||
function encodeSpacesJson(data: SpacesJson): string {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
// 分块处理,防止大文件时 String.fromCharCode(...bytes) 超出调用栈
|
||||
let binary = "";
|
||||
const chunkSize = 8192;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** 写入 spaces.json(create 或 update),409 冲突时自动重新获取 sha 重试一次 */
|
||||
export async function writeSpacesJson(
|
||||
token: string,
|
||||
username: string,
|
||||
data: SpacesJson,
|
||||
sha?: string
|
||||
): Promise<void> {
|
||||
const headers = await apiHeaders(token);
|
||||
const content = encodeSpacesJson(data);
|
||||
|
||||
const doWrite = async (currentSha?: string) => {
|
||||
const body: Record<string, string> = {
|
||||
message: "chore: update dev spaces config",
|
||||
content,
|
||||
};
|
||||
if (currentSha) body.sha = currentSha;
|
||||
return tauriFetch(
|
||||
`https://api.github.com/repos/${username}/${DEV_SPACES_REPO}/contents/${SPACES_FILE}`,
|
||||
{ method: "PUT", headers, body: JSON.stringify(body) }
|
||||
);
|
||||
};
|
||||
|
||||
let resp = await doWrite(sha);
|
||||
|
||||
// 409 说明远端 sha 已变,重新读取最新 sha 再重试一次
|
||||
if (resp.status === 409) {
|
||||
const latest = await readSpacesJson(token, username);
|
||||
resp = await doWrite(latest?.sha);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(`写入 spaces.json 失败: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 高层操作 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 启动时同步:拉取远端 spaces.json
|
||||
* 返回所有空间的项目信息(用于跨空间感知展示)
|
||||
*/
|
||||
export async function fetchRemoteSpaces(
|
||||
token: string,
|
||||
username: string
|
||||
): Promise<SpacesJson | null> {
|
||||
try {
|
||||
await ensureDevSpacesRepo(token, username);
|
||||
const result = await readSpacesJson(token, username);
|
||||
return result?.data ?? null;
|
||||
} catch (e) {
|
||||
console.warn("跨空间同步失败(不影响本地功能):", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push 后更新:把当前空间的项目 push 信息写回远端
|
||||
*/
|
||||
export async function recordPushToRemote(
|
||||
token: string,
|
||||
username: string,
|
||||
spaceId: string,
|
||||
spaceName: string,
|
||||
repoUrl: string,
|
||||
branch: string,
|
||||
lastCommitMsg?: string
|
||||
): Promise<void> {
|
||||
await ensureDevSpacesRepo(token, username);
|
||||
const existing = await readSpacesJson(token, username);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const spacesData: SpacesJson = existing?.data ?? { version: 1, spaces: {} };
|
||||
|
||||
if (!spacesData.spaces[spaceId]) {
|
||||
spacesData.spaces[spaceId] = { name: spaceName, projects: {} };
|
||||
}
|
||||
spacesData.spaces[spaceId].name = spaceName;
|
||||
spacesData.spaces[spaceId].projects[repoUrl] = {
|
||||
branch,
|
||||
last_push_at: now,
|
||||
last_commit_msg: lastCommitMsg,
|
||||
};
|
||||
|
||||
await writeSpacesJson(token, username, spacesData, existing?.sha);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前空间的某个项目同步到 .dev-spaces catalog
|
||||
* 在项目创建/更新后调用,让其他空间可以发现此项目
|
||||
*/
|
||||
export async function syncProjectCatalog(
|
||||
token: string,
|
||||
username: string,
|
||||
spaceId: string,
|
||||
spaceName: string,
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
tech_stack?: string;
|
||||
repo_url: string;
|
||||
default_branch?: string;
|
||||
platform?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
await ensureDevSpacesRepo(token, username);
|
||||
const existing = await readSpacesJson(token, username);
|
||||
const spacesData: SpacesJson = existing?.data ?? { version: 1, spaces: {} };
|
||||
|
||||
if (!spacesData.spaces[spaceId]) {
|
||||
spacesData.spaces[spaceId] = { name: spaceName, projects: {} };
|
||||
}
|
||||
spacesData.spaces[spaceId].name = spaceName;
|
||||
|
||||
const catalog = spacesData.spaces[spaceId].catalog ?? [];
|
||||
const idx = catalog.findIndex((c) => c.repo_url === project.repo_url);
|
||||
const item: SpaceCatalogItem = {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
tech_stack: project.tech_stack,
|
||||
repo_url: project.repo_url,
|
||||
default_branch: project.default_branch,
|
||||
platform: project.platform,
|
||||
};
|
||||
if (idx >= 0) catalog[idx] = item;
|
||||
else catalog.push(item);
|
||||
spacesData.spaces[spaceId].catalog = catalog;
|
||||
|
||||
await writeSpacesJson(token, username, spacesData, existing?.sha);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 SpacesJson 中提取某个项目在其他空间的信息
|
||||
*/
|
||||
export function getOtherSpacesInfo(
|
||||
spacesJson: SpacesJson,
|
||||
currentSpaceId: string,
|
||||
repoUrl: string
|
||||
): Array<{ spaceId: string; spaceName: string } & SpaceProjectInfo> {
|
||||
const result: Array<{ spaceId: string; spaceName: string } & SpaceProjectInfo> = [];
|
||||
for (const [spaceId, spaceInfo] of Object.entries(spacesJson.spaces)) {
|
||||
if (spaceId === currentSpaceId) continue;
|
||||
const proj = spaceInfo.projects[repoUrl];
|
||||
if (proj) {
|
||||
result.push({ spaceId, spaceName: spaceInfo.name, ...proj });
|
||||
}
|
||||
}
|
||||
// 按时间倒序
|
||||
result.sort((a, b) => b.last_push_at.localeCompare(a.last_push_at));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 找出其他空间有、但当前空间没有(按 repo_url 判断)的项目
|
||||
*/
|
||||
export function getDiscoverableProjects(
|
||||
spacesJson: SpacesJson,
|
||||
currentSpaceId: string,
|
||||
currentRepoUrls: Set<string>
|
||||
): Array<SpaceCatalogItem & { spaceId: string; spaceName: string }> {
|
||||
const result: Array<SpaceCatalogItem & { spaceId: string; spaceName: string }> = [];
|
||||
// 用 repo_url 去重(同一仓库可能出现在多个其他空间)
|
||||
const seen = new Set<string>();
|
||||
for (const [spaceId, spaceInfo] of Object.entries(spacesJson.spaces)) {
|
||||
if (spaceId === currentSpaceId) continue;
|
||||
for (const item of (spaceInfo.catalog ?? [])) {
|
||||
if (!currentRepoUrls.has(item.repo_url) && !seen.has(item.repo_url)) {
|
||||
seen.add(item.repo_url);
|
||||
result.push({ ...item, spaceId, spaceName: spaceInfo.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 格式化相对时间(如 "3小时前") */
|
||||
export function formatRelativeTime(isoString: string): string {
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "刚刚";
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}天前`;
|
||||
return new Date(isoString).toLocaleDateString("zh-CN");
|
||||
}
|
||||
@ -7,5 +7,7 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
// GitHub 退役清理删掉了唯一的测试文件(parseRepoPath),暂无前端纯逻辑测试
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user