commit 5295c1cf58b0fb4d3debf304403367887bc4db6f Author: depei Date: Sat Oct 10 18:50:57 2026 +0800 Initial panel project deploy skill snapshot diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..14c38d1 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,91 @@ +# 技能安装与迁移说明 + +技能名称:**panel-project-deploy** +用途:自动化克隆、初始化、品牌化、Docker 部署开源项目,并强制改造成炫酷科技感玻璃面板风格 UI; +同时对齐《开源软件改造部署外包服务合同》的交付物与验收标准。 + +## 一、目录内容 + +```text +panel-project-deploy/ +├── SKILL.md # 技能主文件(18 步工作流,必须先读) +├── references/ +│ ├── panel-ui.md # 面板风格 UI 设计与验收规范 +│ └── delivery-contract.md # 外包合同对齐的交付/验收规范 +├── scripts/ +│ ├── workflow_manager.py # 工作流状态管理(初始化/恢复/报告) +│ ├── panel_ui_audit.py # 阻断式面板 UI 自动审计 +│ ├── deliverables_check.py # 交付物清单核查 +│ └── build_delivery_package.py # 交付压缩包构建 +├── requirements.txt # Python 第三方依赖 +├── install.ps1 / install.sh # 一键安装脚本 +└── INSTALL.md # 本文件 +``` + +## 二、环境要求 + +- Python **3.9+**(推荐 3.11/3.12) +- Node.js 18+、pnpm、Docker / Docker Compose(执行实际项目部署时需要) +- ffmpeg(项目内含视频推流/转码时需要) +- 网络可访问目标 Git 仓库与 npm / pip / apt 源 + +## 三、安装步骤 + +### Windows(PowerShell) + +```powershell +# 1. 复制整个技能目录到 Codex 技能目录 +Copy-Item .\panel-project-deploy "$env:USERPROFILE\.codex\skills\" -Recurse -Force + +# 2. 执行安装脚本(创建 venv 可选;安装 playwright) +.\panel-project-deploy\install.ps1 +``` + +### macOS / Linux + +```bash +# 1. 复制技能目录 +cp -r ./panel-project-deploy ~/.codex/skills/ + +# 2. 执行安装脚本 +bash ./panel-project-deploy/install.sh +``` + +> 若使用的是其他 Agent 框架,技能目录也可能是 `~/.agents/skills/`,按对应框架要求放置即可。 + +## 四、安装 Playwright 浏览器 + +UI 审计脚本需要 Playwright 的 Chromium 内核: + +```bash +python -m pip install -r requirements.txt +python -m playwright install chromium +# Linux 如缺系统库,再执行: +python -m playwright install-deps chromium +``` + +## 五、验证安装 + +```bash +python ~/.codex/skills/panel-project-deploy/scripts/panel_ui_audit.py --help +python ~/.codex/skills/panel-project-deploy/scripts/workflow_manager.py --help +``` + +能正常打印帮助即安装成功。 + +## 六、使用方式 + +在 Codex 对话中用自然语言触发,例如: + +- “初始化并部署项目,使用面板风格 UI ” +- “克隆并改造这个开源项目,Docker 部署” +- “项目页面改版成科技感大屏风格” + +Agent 会自动读取 `SKILL.md` 并按 18 步工作流执行,最终必须通过阻断式 UI 审计(0 violations)。 + +## 七、注意事项 + +- 技能脚本**不含任何硬编码本机路径**,可跨设备/跨平台直接迁移。 +- 是否 `git push`、是否本地提交,严格以用户授权为准;用户要求“不推送代码”时禁止推送。 +- 默认账号密码必须来自项目实际初始化逻辑,禁止猜测。 +- 合同相关交付规范见 `references/delivery-contract.md`。 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..99df644 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,310 @@ +--- +name: panel-project-deploy +description: 自动化克隆、初始化、品牌化、Docker 部署并提交项目,同时强制将前端页面改造成炫酷的科技感玻璃面板风格。适用于“初始化并部署项目”“项目页面改版”“面板风格 UI”“科技感大屏/仪表盘”等请求。 +--- + +# 项目初始化部署、面板风格 UI 改造与外包交付 + +## 目标 + +在原项目初始化与部署能力基础上,完成从 Git 克隆、项目分析、品牌信息替换、Docker 构建启动、健康验证到 Git 提交的完整流程,并将前端主要页面升级为**现代化、炫酷、科技感的面板风格 UI**。 + +面板改造是必做项,但不能破坏原有业务逻辑、路由、接口、权限流程和数据结构。 + +本技能对齐外包交付标准:完成部署、品牌化改版、素材录制归档、标准化 README、7 项交付物与 +7 条验收标准,并遵守保密、知识产权归属和售后 SLA。 + +## 对外材料来源口径规则(甲方可见材料) + +凡交付给甲方或其终端客户看到的 README、界面、截图、录屏、交付清单、演示文档、注释、配置及资源文件名, +均不得主动出现其他项目名称、外部仓库链接、原作者信息、致谢、迁移说明、版本对比或“基于某项目改造”等表述。 + +1. README 与一切对外文档使用当前产品口径,只描述本系统的功能、架构、部署、运维和授权使用方式。 +2. 交付包内只放当前产品界面截图,文件名使用页面语义化中文/英文名称,不出现 before/after、改造前、原版等字样。 +3. 内部初始启动截图仅用于质量核对,可命名为 `baseline-<页面>.png`,必须与对外交付物隔离,严禁打包给甲方。 +4. 清理依法可移除的第三方公司名、商标、LOGO、水印、联系方式、二维码、默认域名、包元数据和注释线索。 +5. 法律边界:MIT、Apache-2.0、BSD 等许可证要求随源码保留的版权声明、许可证全文或 NOTICE 文件不得删除、 + 替换或改写;随源码交付时必须完整保留。不得在 README 中虚假声明全部代码均由交付团队自主原创。 +6. 面向甲方的演示包可不放入源码和 LICENSE,仅保留当前产品 README、截图、录屏和必要部署说明; + 源码包与演示交付包应分开管理。 +7. 本规则约束对外呈现口径,不替代、不免除任何第三方许可证或法律规定的合规义务。 + +## 20 步工作流 + +| 步骤 | 名称 | 必做/跳过 | 要求 | +|---:|---|---|---| +| 0 | 克隆项目 | 必做 | 不修改任何业务代码 | +| 1 | 项目完整度评估 | 必做 | 判断前端资源、组件、样式入口、启动方式是否完整 | +| 2 | 项目整体分析 | 必做 | 识别框架、路由、主题色、布局、启动命令与端口策略 | +| 3 | 初始版本基线启动(内部) | 必做 | 改代码前用项目现有方式跑通初始版本;仅内部核对,不产生交付物 | +| 4 | 初始界面基线截图(内部) | 必做 | 代码零改动时采集初始界面用于内部对比;严禁进入交付包 | +| 5 | Docker 配置生成 | Docker 项目必做,非 Docker 跳过 | 不改变前端视觉 | +| 6 | 页面信息与品牌名称修改 | 必做 | 建立全局面板设计变量和玻璃面板基础样式 | +| 7 | Logo 生成与替换 | 必做 | 新 Logo 适配深色面板、发光边框和 Favicon | +| 8 | 版权与第三方标识规范 | 必做 | 清理依法可移除的来源和商业标识;源码许可证要求保留的版权/许可/NOTICE 不得删除 | +| 9 | 页脚、导航与社区链接 | 必做 | 导航、页脚改成半透明悬浮面板风格;页脚放「开源社区 https://www.gitcc.com/」和「GCC AI Coding https://www.gitvv.com/」超链接 | +| 10 | 登录页面 | 必做 | 改成居中玻璃面板、发光输入框、动态背景 | +| 11 | AI 对话功能 | 必做 | 对话窗口、设置抽屉、消息气泡面板风格;按用户指定服务商预填 Base URL 与默认模型,并内置图文教程 | +| 12 | 示例数据 | Docker 项目必做,非 Docker 跳过 | 数据卡片和列表面板承载 | +| 13 | CORS 配置检查 | Docker 项目必做,非 Docker 跳过 | 不改变 UI | +| 14 | 端口检查与分配 | Docker 项目必做,非 Docker 跳过 | 不改变 UI | +| 15 | **改造后构建与启动** | 必做 | Docker 项目构建镜像启动容器;非 Docker 项目手动构建并启动改造版 | +| 16 | 健康验证 | 必做 | 服务健康、面板审计 0 违规、全功能走查无报错 | +| 17 | 产品截图与全功能录屏 | 必做 | 采集当前产品全套高清界面截图(中性文件名)+ 全功能演示录屏;无 before/after | +| 18 | 标准化 README 与版本归档 | 必做 | 按当前产品口径编写 README;不主动出现来源/致谢/版本对比;按授权决定是否提交 | +| 19 | 交付物核查 | 必做 | 运行交付物阻断式核查,7 项交付物齐备并生成交付 manifest | + +非 Docker 项目跳过步骤 5、12–14,其余步骤仍有效;步骤 3–4 产物仅内部留存,不对外交付。 + +## 素材采集时序(内部核对 + 对外素材) + +内部基线素材用于核对改造完整性,对外素材只呈现最终产品: + +1. **内部基线**:先启动初始版本(步骤 3),代码零改动时截取 `baseline-<页面>.png`(步骤 4),再开始修改代码。 + - 克隆完成后,先用项目自身现有方式(Dockerfile / README 命令 / 本地脚本)确认初始版本可访问; + - 初始版本无法启动时,才允许使用仓库中的历史截图作为内部核对依据; + - 基线素材保存到内部目录,任何情况下都不进入交付包。 +2. **对外截图与录屏**必须在改造完成、重新构建部署并通过健康验证(步骤 16)后采集(步骤 17),禁止使用半成品。 +3. 对外界面截图必须页面全覆盖,使用 `login.png`、`overview.png`、`campus-state.png` 等中性页面名称。 +4. 全功能录屏应完整展示登录、概览大屏、业务页面、AI 配置、系统管理、表格/弹窗交互等核心流程; + 画面无卡顿、无黑屏、无报错,录屏界面必须与最终截图一致。 +5. 素材目录、采集时间、对应版本(git commit 或“未提交工作区”)记录在内部 manifest;对外清单只列甲方需要的信息。 + +## 执行原则 + +1. **先识别,再改造**:确认前端框架、样式方案、路由入口、布局组件、主题色和可复用组件。 +2. **小步修改**:优先修改全局样式、布局组件、导航、页脚、登录页、首页/仪表盘和 AI 页面。 +3. **保持功能稳定**:不改后端协议、数据库结构、权限模型和业务路由语义。 +4. **适配不同技术栈**:React、Vue、Angular、原生 HTML、Tailwind、CSS Modules、Less、SCSS、Ant Design、Element Plus 均可实现,不强制引入新的重型 UI 框架。 +5. **响应式和无障碍**:面板效果必须在移动端可用,文本对比度达标,键盘焦点可见。 +6. **动效克制**:允许发光、渐变、悬浮、过渡和背景动画,但必须尊重 `prefers-reduced-motion`。 +7. **对外材料中性化**:对外材料不主动出现其他项目名称、链接、原作者、致谢或版本对比;源码许可证要求保留的内容必须保留。 +8. **痕迹清理范围**:在合法合规前提下清除第三方商业标识和来源宣传线索;内部留档不进入交付物,法定版权与许可声明不得删除。 +9. **保密与成果归属**:项目需求、交付资料、客户信息不得外泄;交付成果不得私自转发、售卖、复用或二次分发。 +10. **交付留痕**:截图、录屏、审计报告、交付清单必须完整可溯源,禁止把未走查/未测试的功能描述为“已完成”。 + +## 主题一致性强约束 + +1. 面板风格 UI 的第一优先级是**全组件主题统一和文字可读**,第二优先级才是发光、模糊和动画。 +2. 项目使用组件库时,必须从官方主题入口配置暗色 Token: + - Ant Design:`ConfigProvider` + `theme.darkAlgorithm`,在 Umi 项目中通过 `antd.configProvider.theme` 配置。 + - Element Plus / ElementUI:使用官方 dark CSS variables 或项目的暗色主题变量。 + - MUI:`createTheme` + `CssVarsProvider/ThemeProvider`。 + - Vuetify / Angular Material:启用对应 dark theme。 +3. CSS 覆盖只能作为玻璃质感和局部修正,不能替代组件库官方暗色主题。 +4. 所有浮层都要检查:Modal、Drawer、Select Dropdown、DatePicker、Popover、Tooltip、Upload、Cascader、AutoComplete、ColorPicker。 +5. 所有内容容器都要检查:Card、Table、Descriptions、Tabs、Collapse、List、Form、Pagination、Empty、Result、Spin 容器。 +6. 如果某个业务页面必须保留浅色区域,必须同时使用浅色文字风格和浅色周边布局;禁止把浅色组件直接嵌入深色页面造成黑白割裂。 +## 项目概述大屏强制要求 + +每个项目**必须**新增一个炫酷的「项目概述大屏」页面(如路由 `/overview`),导航菜单顶部入口名称为「概览」。要求: + +1. **接入真实可展示数据**:通过后端聚合接口输出,至少覆盖 + - 核心指标卡片:设备/摄像头总数、在线数与在线率、离线数、报警数(待处理/已处理)、规则数、用户数等(按项目实际实体取数,禁止纯静态假数据); + - 趋势图:近 7 天(或可配置周期)业务/报警趋势; + - 分布图:设备/业务状态结构(在线/报警/离线等); + - 最新动态:最新事件实时滚动列表; + - 实时时钟,数据定时(建议 10 秒)自动刷新。 +2. **视觉炫酷、符合面板体系**:深色科技基底 + 玻璃面板 + 霓虹边缘 + 大标题光效;图表与卡片风格统一,支持大屏分辨率投放与响应式降级。 +3. **图表实现适配项目**:优先复用项目已有图表库;未引入时可用 SVG/CSS 自绘(折线、环形等), + 不强制为单个大屏新增重型依赖。 +4. **可访问性**:图表需配文字图例与数值,动态滚动可暂停,文字对比度通过审计。 +5. 该页面纳入改造后截图、录屏与 UI 审计范围。 + +## 社区与外部链接要求 + +1. 页脚必须包含两个可点击超链接(新标签页打开): + - 开源社区:[https://www.gitcc.com/](https://www.gitcc.com/) + - GCC AI Coding:[https://www.gitvv.com/](https://www.gitvv.com/) +2. README 需在显著位置(如「相关链接」章节)提供上述两个链接;概览大屏页面头部同样要展示这两个超链接。 +3. 链接文案与样式符合面板主题,hover 有发光反馈;不得写成失效或错误地址。 + +## AI 对话对接强制要求 + +步骤 9 除面板视觉外,当用户指定 AI 服务商(如 TokenFree、官方 OpenAI、第三方中转等)时,必须完成: + +1. **默认配置预填**:在 AI 设置的默认值中写入用户指定的 Base URL 与默认模型,而不是保留模板里的旧服务商。 + - OpenAI 兼容网关的地址必须带 `/v1`(如 `https://www.tokenfree.com/v1`)。 + - 模型 ID 必须以服务商实际支持的字符串为准(可通过其定价/模型列表页核实,例如 TokenFree 上为 `kimi-k2.6`),禁止凭名称臆造。 +2. **本地缓存版本迁移(强制)**:设置存储 key 必须带版本后缀(如 `parking-ai-settings-v2`)。 + - 切换服务商时升级版本,避免老用户 localStorage 中的旧 Base URL / 旧模型覆盖新默认值。 + - 如需迁移旧数据,只迁移用户自填的 API Key,地址与模型强制使用新默认值。 +3. **内置使用教程(强制)**:设置抽屉/弹窗内必须包含与面板主题一致的教程块,内容至少覆盖: + - 服务商官网链接(新标签页打开)与一句话说明; + - 注册 / 登录 / 邮箱验证; + - 在控制台创建并复制 API Key(说明 Key 前缀,如 `sk-`); + - 粘贴 Key → 测试连接 → 保存的完整操作路径; + - 明确展示 Base URL、默认模型,并说明 Key 仅保存在本地浏览器; + - 说明可替换为该服务商支持的其他模型。 +4. **README 同步**:项目 README 必须增加对应「AI 对话使用教程」章节(含配置表与步骤)。 +5. **协议兼容**:调用遵循 OpenAI 兼容协议(`POST {baseUrl}/chat/completions` 流式、`GET {baseUrl}/models` 测试连接);未拿到用户 Key 时不得伪造 Key,也不得把测试失败谎报为成功。 +6. **教程 UI 主题**:教程中的列表、行内代码、链接、注释文字必须使用深色玻璃面板配色,禁止出现白底黑字的教程块嵌入深色抽屉。 + +## 页面信息修改的强制要求 + +步骤 4 不能只替换文字,必须同时完成以下内容: + +1. 替换浏览器标题、导航品牌名、页脚系统名、README 中面向用户的新项目名称。 +2. 建立统一设计变量:背景色、主色、辅助光效色、面板背景、边框、模糊度、圆角、阴影和间距。 +3. 至少为这些区域应用面板风格: + - 顶部导航 / 侧边栏 + - 首页或仪表盘 + - 主要数据卡片 + - 表格、表单、弹窗或抽屉 + - 登录页 + - AI 对话页 + - 页脚 +4. 主要页面应呈现“深色科技基底 + 半透明玻璃面板 + 霓虹边缘光 + 渐变光斑/网格背景”的视觉方向;如果原项目必须保持浅色主题,则使用浅色玻璃拟态和柔和外发光。 +5. 面板不能只靠边框区分,应组合使用:半透明背景、模糊、内外发光、渐变边缘、角标或装饰线、悬浮位移和状态光效。 +6. 所有新增页面和核心组件要保持统一,不允许登录页科技感很强而业务页面仍是完全旧风格。 + +详细样式规范、组件结构、CSS/Tailwind 示例和验收清单见 [references/panel-ui.md](references/panel-ui.md)。执行页面改造前必须读取该文件。 + +## 阻断式视觉验收(必须执行) + +页面改造完成后,不能只凭肉眼抽查或截图判断;必须在应用启动后执行技能内置脚本: + +```bash +python scripts/panel_ui_audit.py \ + --base-url http://localhost:<端口> \ + --hash-routes \ + --username <账号> \ + --password <密码> \ + --submit-text <登录按钮文本> \ + --routes /页面1,/页面2,/页面3 \ + --open-selector '<抽屉或弹窗触发选择器>' \ + --wait-until domcontentloaded \ + --out .Codex/reports/panel-ui-audit.json +``` + +非 hash 路由去掉 `--hash-routes`;有多个抽屉、弹窗时重复传入 `--open-selector`。含轮询、WebSocket、SSE 或定时刷新的页面使用 `--wait-until domcontentloaded`,不要等待永远无法稳定的 `networkidle`。 + +硬性规则: + +1. 健康验证步骤必须运行该脚本,进程退出码为 0 且报告为 0 violations 才算通过。 +2. 审计范围必须覆盖登录页、首页/仪表盘、表格/列表页、表单页、详情页、AI 页面、系统管理页,以及所有 Modal、Drawer、Select、DatePicker、Popover、Tooltip 等浮层。 +3. 脚本同时检查两类 P0 问题:文字/背景对比度低于 WCAG 阈值,以及浅色组件嵌入深色外壳造成“外黑内白”。 +4. 地图、视频、画布等媒体区域可排除媒体内容本身,但悬浮状态、标签、操作按钮、提示文字和空状态仍必须通过审计。 +5. 若审计报出问题,先修复主题 Token 和组件样式,再重新构建、部署和审计;不能通过忽略脚本、删除选择器或降低阈值的方式完成任务。 +6. 用户明确要求“不推送代码”时禁止任何 `git push`;本地提交仍需遵守用户授权。若任务是修复/验收且用户未要求提交,则保留工作区变更并明确说明。 +7. 默认账号密码必须来自实际初始化逻辑,不能猜测;最终回复和 `.Codex/reports/` 验收材料中要写明账号来源。 + +## 外包交付强制规范(合同对齐) + +执行外包交付类任务前必须读取 [references/delivery-contract.md](references/delivery-contract.md)。核心约束: + +1. **六项服务全覆盖**:部署运行、界面改造、版权痕迹清理、素材录制归档、标准化文档、售后支撑,缺一不可。 +2. **痕迹清理与开源合规的边界(红线)**: + - 清除第三方公司名、LOGO、商标、水印、弹窗、页脚、版权注释中的商业归属与联系方式; + - LICENSE 重写为权属方中性版权声明,原作者、原许可证全文与原项目链接不出现在交付物中; + - 清理后用 `grep -rniE` 对原标识、原作者、原链接等关键词全文检索,交付物中不得命中。 +3. **素材归档(步骤 17)**: + - 对外只提供当前产品的高清界面截图,页面全覆盖、文件名中性,不出现 before/after; + - 全功能逐一操作演示录屏,无卡顿黑屏,完整演示所有功能; + - 素材分类整理,上传甲方指定网盘(默认百度网盘),链接写入交付清单,保证长期有效、可访问下载。 +4. **标准化 README(步骤 18,12 项必备)**:一句话简介、功能描述(逐条配图)、行业痛点、技术优势、 + 应用场景、目标行业人群、商业价值、技术架构(含拓扑图)、环境依赖、快速开始、完整部署教程 + (Docker 优先,无 Docker 提供可复现的手动步骤)、中性的版权与使用说明。 +5. **交付物 7 项**:源码、当前产品界面截图、全功能录屏、网盘归档链接、标准化 README、 + Docker 部署脚本(或不适配的书面说明)、环境依赖清单与部署注意事项。 +6. **验收 7 标准**:运行稳定、界面差异化、痕迹清零、素材完整、文档达标、部署可复现、合规无侵权;全部达标方可交付。 +7. **整改与售后**:验收问题 24 小时内免费整改并重新交付;两次整改不达标甲方可拒收; + 交付后终身免费基础质保、24 小时内响应。 +8. **保密与知识产权**:交付成果使用权/商用权归甲方,乙方不得自用/售卖/转发/二次分发; + 商业信息与客户资源永久保密。 + +### 构建交付包与交付清单(步骤 18) + +步骤 18 使用内置脚本构建交付压缩包,并把交付清单作为与 zip 并列的单独文件输出: + +```bash +python scripts/build_delivery_package.py --project-dir <项目目录> +``` + +脚本保证: + +- README 只在 zip 包根一份,「文档」目录不再放 README; +- README 引用图片自动随包拷入并保持相对路径,解压可显示(脚本内置解压校验); +- 交付清单《交付清单.md》输出在 zip 之外、与之并列,不打入包内; +- 包内无内部执行工具痕迹。 + +### 交付物阻断式核查(步骤 19) + +```bash +python scripts/deliverables_check.py \ + --project-dir <项目目录> \ + --trace-keywords "原公司名,原品牌,原域名" \ + --netdisk-link "<网盘归档链接>" +# 项目确实不适配 Docker 时加 --allow-no-docker, +# 并在交付目录给出书面说明 +``` + +退出码为 0 才算交付物齐备。 +禁止伪造截图/录屏/网盘链接,禁止把 README 写成与实际不符的宣传稿。 + +## 状态管理 + +状态统一放在目标项目的 `.Codex` 目录: + +```text +<项目目录>/.Codex/ +├── workflow_state.json # 步骤状态 +├── error_log.md # 错误日志 +├── reports/ # 审计与核查报告 +└── delivery/ # 交付材料 + ├── manifest.md # 交付清单(内部工作文件,不打入交付包) + ├── assets/ # 内部基线截图 + 产品截图、全功能录屏 + └── no-docker说明.md # 仅不适配 Docker 时需要 +``` + +状态流转: + +```text +⏭️ 未开始 → ⏸️ 进行中 → ✅ 已完成 + ↓ + ❌ 失败,可重试 +``` + +恢复执行时读取 `workflow_state.json`,跳过已完成步骤,从第一个进行中、失败或未开始的步骤继续。 + +可使用本技能自带脚本: + +```bash +python scripts/workflow_manager.py init "<项目名称>" "" "[端口范围]" +python scripts/workflow_manager.py status +python scripts/workflow_manager.py resume +python scripts/workflow_manager.py report +``` + +## 技术栈检测 + +- Java Spring:`pom.xml`、`build.gradle` +- Node.js Express:`package.json`、`app.js`、`server.js` +- Node.js Nest:`nest-cli.json`、`@nestjs` +- Python Django:`manage.py`、`django` +- Python FastAPI:`fastapi`、`main.py` +- Go Gin / Fiber:`go.mod`、`gin` / `fiber` +- React:`react`、`App.jsx`、`App.tsx` +- Vue:`vue`、`App.vue` +- Angular:`angular.json`、`@angular` + +## 完成标准 + +- 项目可正常安装、构建和启动。 +- Docker 项目的容器健康检查通过。 +- 新旧项目名称替换准确,未错误修改 lock 文件、依赖名或后端配置中的技术标识。 +- Logo、Favicon、浏览器标题、导航和页脚显示新品牌。 +- 主要页面具备统一的炫酷面板风格。 +- 已新增「项目概述大屏」页面,接入真实聚合数据(指标卡/趋势/分布/最新动态/实时时钟)并在导航提供入口。 +- 页脚、概览页与 README 均包含开源社区 gitcc.com 与 GCC AI Coding gitvv.com 的可点击超链接。 +- 阻断式面板 UI 审计退出码为 0,10 个核心页面/路由无对比度和“外黑内白”违规。 +- Modal、Drawer、Select、DatePicker、Popover、Tooltip 和 Alert/Tag 等组件均通过暗色主题与对比度检查。 +- 移动端布局正常,动效不会导致明显卡顿。 +- AI 对话功能可用,支持 OpenAI 兼容接口和流式输出;默认服务商、Base URL、默认模型与用户要求一致,内置使用教程,且本地配置 key 已做版本迁移。 +- 甲方可见材料无外部项目链接、原作者、致谢和版本对比;源码许可证/NOTICE 已按法律要求完整保留。 +- 商业痕迹与来源痕迹全部清理,grep 复查交付物中无残留。 +- 内部基线截图只用于核对、不随包交付;对外界面截图与录屏采集于最终构建并健康通过的版本,页面全覆盖、录屏完整,归档链接长期有效、可访问下载。 +- 标准化 README 覆盖全部必备内容,以当前产品口径编写,图文结合、架构图准确、部署教程可落地,且不作虚假权属表述。 +- 7 项交付物齐备,`.Codex/delivery/manifest.md` 完整;`deliverables_check.py` 退出码为 0。 +- 保密与知识产权要求已遵守:成果不私用/不外传,交付材料与验收记录可溯源。 +- 最终 Git 提交信息描述具体业务变化,不出现“工作流”。 \ No newline at end of file diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..79aa3cb --- /dev/null +++ b/install.ps1 @@ -0,0 +1,10 @@ +# panel-project-deploy 技能安装脚本 (Windows) +$ErrorActionPreference = "Stop" +Write-Host "==> 安装 Python 依赖 (playwright)" -ForegroundColor Cyan +python -m pip install --upgrade pip +python -m pip install -r "$PSScriptRoot\requirements.txt" +Write-Host "==> 安装 Playwright Chromium 内核" -ForegroundColor Cyan +python -m playwright install chromium +Write-Host "==> 安装完成。" -ForegroundColor Green +Write-Host "技能目录: $PSScriptRoot" +Write-Host "如需迁移到 Codex 技能目录,请将本文件夹复制到 `$env:USERPROFILE\.codex\skills\" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..3e033c3 --- /dev/null +++ b/install.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# panel-project-deploy 技能安装脚本 (macOS / Linux) +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +echo "==> 安装 Python 依赖 (playwright)" +python3 -m pip install --upgrade pip +python3 -m pip install -r "$SCRIPT_DIR/requirements.txt" +echo "==> 安装 Playwright Chromium 内核" +python3 -m playwright install chromium +echo "==> 安装完成。" +echo "技能目录: $SCRIPT_DIR" +echo "如需迁移,请将本文件夹复制到 ~/.codex/skills/" diff --git a/references/delivery-contract.md b/references/delivery-contract.md new file mode 100644 index 0000000..721db19 --- /dev/null +++ b/references/delivery-contract.md @@ -0,0 +1,149 @@ +# 软件改造部署外包交付规范 + +> 本文件是 `SKILL.md` 的强制配套规范。凡触发“初始化并部署项目”“面板风格 UI 改造”“项目外包交付”等请求,交付前必须逐条对照。 + +## 0. 三条红线 + +1. **甲方可见材料中性化**:README、界面、截图、录屏、交付清单、演示文档、注释、配置和资源文件名,不主动出现其他项目名称、外部仓库链接、原作者、致谢、迁移说明、改造前界面或版本对比。 +2. **许可证合规边界**:MIT、Apache-2.0、BSD 等许可证要求随源码保留的版权声明、许可证全文或 NOTICE 文件不得删除、替换或改写;不得把第三方代码虚假表述为全部由交付团队原创。 +3. **稳定交付**:部署可复现、功能可验证、截图录屏与最终版本一致、文档可落地;不得为了清理外观线索而破坏业务功能。 + +演示交付包可以不放入源码和 LICENSE;源码包必须按所适用许可证保留版权、许可和 NOTICE 材料。 + +## 1. 核心服务内容(6 项,必须全部完成) + +| # | 服务 | 执行要点 | +|---:|---|---| +| 1 | 项目部署运行 | 完整获取源码、搭建环境、编译配置调试;核心功能正常,无报错、闪退、功能缺失 | +| 2 | 界面定制改造 | UI 风格、色彩、布局、导航和数据展示全面优化;需要大屏的项目必须建设概览大屏 | +| 3 | 标识规范与许可证核对 | 清理依法可移除的公司名、LOGO、商标、专属标识、联系方式和来源宣传线索;核对源码 LICENSE/NOTICE 完整性 | +| 4 | 素材录制归档 | 内部基线仅用于核对;对外只提供最终产品的全页面截图与全功能录屏,分类上传约定网盘 | +| 5 | 文档编制优化 | 按当前产品口径编写可直接交付的标准化 README(见第 3 节) | +| 6 | 售后答疑支撑 | 交付后配合部署、使用、报错排查等答疑;基础质保、响应时限以合同约定为准 | + +### 1.1 素材采集时序 + +```text +获取项目(0) → 完整度/项目分析(1-2) + → 初始版本启动(3,内部) → 初始界面截图(4,仅内部) + → [此后才允许改代码] 品牌/界面/AI/标识规范(6-11) + → 产品构建启动(15) → 健康验证(16) + → 产品界面截图 + 全功能录屏(17) + → README/版本归档(18) → 交付核查(19) +``` + +- 内部基线截图必须是代码零改动状态下的真实画面,仅用于内部核对,任何情况下不进入交付物。 +- 对外截图与录屏必须来自最终构建部署并健康通过的版本,禁止使用半成品。 +- 对外截图页面全覆盖、文件名中性,不出现 before/after、改造前、原版等字样。 + +## 2. 标识规范检查清单 + +必须扫描并处理以下位置: + +- 前端:可见公司名/品牌名、Logo、Favicon、水印、关于弹窗、页脚、登录页、浏览器标题。 +- 代码注释:文件头中的公司名称、作者 ID、外部链接与联系方式。 +- 配置与元数据:清单文件的 `author`、`repository`、`homepage` 字段、Docker 镜像/容器名、环境变量中的专属域名、消息模板品牌签名。 +- 文档:README 及 docs 内的公司宣传内容、二维码、购买链接、商务联系方式、主动来源说明和致谢表述。 +- 许可证:源码包中的 LICENSE/NOTICE 必须核对并完整保留;不得删除法律要求保留的许可文本。 +- 资源文件:图片、视频、音频中嵌入的水印、商标和第三方商业标识。 + +验收前必须做全文检索(关键词按实际项目扩充): + +```bash +grep -rniE "原公司名|原域名|原品牌|原作者ID|原仓库|致谢|qq群|微信|商务合作|before|after|改造前|原版" . \ + --include="*.{ts,tsx,js,jsx,vue,java,py,go,json,md,html,yml,yaml,less,css,scss}" +``` + +甲方可见交付物中不得命中主动来源宣传、商业标识或版本对比关键词;源码许可证文件按许可证要求单独保留。 + +## 3. 标准化 README 必备内容(12 项,缺一不可) + +1. 一句话项目简介。 +2. 详细功能描述(逐条功能对应配套截图)。 +3. 解决的行业痛点。 +4. 核心技术优势。 +5. 具体应用场景。 +6. 适配目标行业与人群。 +7. 商业应用价值。 +8. 技术架构说明(含架构图或拓扑图)。 +9. 环境前置依赖要求。 +10. 详细快速开始指南。 +11. 完整安装部署教程(优先 Docker 一键部署;不适配 Docker 的提供可复现的手动步骤)。 +12. 版权与使用说明(当前产品口径,不作虚假权属表述)。 + +要求:图文结合、结构完整、内容详实、与项目实际一致;禁止出现未实现功能、虚构命令、无法访问链接、主动来源宣传及致谢表述。 + +## 4. 完整交付物清单(7 项,缺一即不合格) + +1. 可稳定运行的完整项目源码。 +2. 当前产品全页面高清界面截图(无初始界面对比、无 before/after)。 +3. 项目全功能操作演示录屏(无卡顿、无黑屏,完整展示核心功能)。 +4. 分类整理完毕的素材归档链接(长期有效,可正常访问下载)。 +5. 标准化完整版 README(含架构图、部署教程、场景说明)。 +6. Docker 一键部署脚本/部署包(不适配的提供书面说明)。 +7. 项目环境依赖清单、部署注意事项说明文档。 + +### 4.1 交付包结构与交付清单(强制) + +交付物分两部分:**一个给甲方/终端使用的交付压缩包** + **一份单独的交付清单**。 + +交付压缩包内部结构: + +```text +README.md # 必须在包根目录 +docs/screenshots/ # README 引用的全部配图,相对路径完整,解压即可显示 +截图/界面截图/ # 当前产品全页面截图(中性命名) +录屏/ # 全功能演示视频 +文档/环境依赖清单.md +文档/部署注意事项.md +``` + +硬性规则: + +1. README 只在包根出现一份;“文档”目录不得再放 README。 +2. 打包必须把 README 引用图片一并拷入并保持相对路径;交付前解压验证图片可显示。 +3. 《交付清单.md》不打入 zip,作为与 zip 并列的单独文件提供。 +4. 包内不出现内部工具、执行过程、外部项目线索及版本对比信息,命名使用甲方可读的中性名称。 +5. 交付清单列出每项交付物的包内路径/链接、默认账号、验收记录与售后说明。 + +## 5. 验收标准(7 条,全部达标方可验收) + +1. **运行**:正常启动、稳定运行;所有功能可正常操作,无报错、失效、页面错乱或接口异常。 +2. **界面**:布局、风格、导航全面优化,概览大屏美观、数据正常、适配主流分辨率。 +3. **标识**:甲方可见页面、注释、配置和文档无第三方商业标识、主动来源宣传及版本对比。 +4. **素材**:产品截图清晰、页面全覆盖;录屏流畅、完整演示核心功能;归档分类规整可下载。 +5. **文档**:README 覆盖第 3 节全部模块,图文结合、架构图准确、教程可落地、版权说明不作虚假表述。 +6. **部署**:Docker 一键部署无故障;不支持 Docker 的手动步骤可复现。 +7. **合规**:不侵犯第三方知识产权,源码许可证和 NOTICE 材料按要求保留。 + +## 6. 验收与整改流程 + +1. 提交完整交付物、归档链接与文档资料。 +2. 甲方按合同约定完成核验。 +3. 合格后进入验收/结算流程。 +4. 不合格时列明问题,在约定时限内整改并重新交付;每次整改必须更新审计和核查记录,形成闭环。 + +## 7. 售后与响应 SLA + +- 质保范围、响应时限和质保期以合同正文为准。 +- 通常包括:功能缺陷、部署问题、文档答疑、使用指导和基础技术咨询。 +- 新增功能、深度定制、第三方平台变更或超出原需求的工作,可另行协商。 + +## 8. 保密与知识产权 + +- 交付成果的使用权、商用权及知识产权归属以合同和授权文件为准。 +- 不得未经授权私自转发、售卖、复用、转让或二次分发甲方成果。 +- 对商业信息、项目需求、交付资料、客户资源和技术方案承担保密义务。 + +## 9. 交付前自检顺序(强制) + +```text +1. 全功能走查 → 无报错、无功能缺失 +2. panel_ui_audit.py → 0 violations +3. 标识/版本对比检索 → 甲方可见材料无残留 +4. README 12 项对照 → 图文齐全、链接可达、无主动来源表述 +5. 截图/录屏覆盖度核对 → 页面全覆盖、功能全演示 +6. deliverables_check.py → 7 项交付物齐备 +``` + +第 6 步脚本退出码非 0 时,禁止声明“已交付”。 diff --git a/references/panel-ui.md b/references/panel-ui.md new file mode 100644 index 0000000..a6b7364 --- /dev/null +++ b/references/panel-ui.md @@ -0,0 +1,395 @@ +# 炫酷科技面板风格 UI 规范 + +## 0. 主题根源统一(必须先做) + +在添加任何玻璃效果之前,先让组件库自身输出完整暗色主题。 + +### Ant Design / Umi + +必须通过官方 Token 配置暗色算法: + +```ts +import { theme } from 'antd'; + +const darkTheme = { + algorithm: theme.darkAlgorithm, + token: { + colorPrimary: '#22d3ee', + colorTextBase: '#e6f7ff', + colorBgBase: '#07142e', + borderRadius: 14, + }, + components: { + Card: { colorBgContainer: 'rgba(7, 18, 39, 0.84)' }, + Table: { colorBgContainer: 'rgba(2, 8, 23, 0.2)' }, + Modal: { colorBgContainer: '#081428' }, + Drawer: { colorBgElevated: '#081428' }, + }, +}; +``` + +Umi 项目放入: + +```ts +export default defineConfig({ + antd: { + configProvider: { + theme: darkTheme, + }, + }, +}); +``` + +### 必须消除的问题 + +- 页面或 Layout 外层是黑色,Card/Table/Form 内部仍是默认白色。 +- 组件库默认黑色文字落在白色小卡片里,而小卡片又嵌在黑色页面中。 +- Modal、Drawer、Select、DatePicker 弹出后仍是浅色。 +- 表格分页、Tabs、Empty、Descriptions 没继承暗色。 +- 只通过 `color` 或 `background` 做零散补丁,没有统一 Token。 + +### 最低检查范围 + +每个含前端交互的项目至少逐页打开:登录页、首页/仪表盘、列表/表格页、表单页、详情/描述页、弹窗或抽屉、AI 页面、系统管理页。 + + +## 1. 视觉方向 + +默认采用深色未来科技风: + +- 深色或蓝黑色背景。 +- 大面积低透明度玻璃面板。 +- 青色、蓝色、紫色等霓虹渐变光效。 +- 细微网格、点阵、光斑或扫描线。 +- 面板边缘有高亮描边、角标或发光装饰。 +- 卡片悬浮时轻微上浮并增强外发光。 + +如果项目明确要求浅色或商务风格,不强制深色;改用浅色玻璃拟态、浅蓝/紫渐变、柔和阴影和细发光边框。 + +## 2. 推荐设计 Token + +### CSS 变量 + +```css +:root { + --panel-bg: rgba(8, 18, 38, 0.68); + --panel-bg-strong: rgba(10, 22, 48, 0.86); + --panel-border: rgba(103, 232, 249, 0.24); + --panel-border-strong: rgba(34, 211, 238, 0.72); + --panel-radius: 18px; + --panel-blur: 18px; + --panel-shadow: 0 18px 50px rgba(2, 8, 23, 0.45); + --glow-cyan: #22d3ee; + --glow-blue: #3b82f6; + --glow-violet: #8b5cf6; + --text-primary: #e6f7ff; + --text-secondary: rgba(226, 242, 255, 0.68); + --grid-line: rgba(56, 189, 248, 0.08); +} +``` + +可根据项目主色调整,但必须保持统一,不应每个页面使用完全不同的发光色。 + +## 3. 全局背景 + +页面底层建议加入固定背景层: + +```css +.app-shell { + min-height: 100vh; + color: var(--text-primary); + background: + radial-gradient(circle at 15% 10%, rgba(34, 211, 238, 0.22), transparent 32%), + radial-gradient(circle at 85% 0%, rgba(139, 92, 246, 0.20), transparent 30%), + linear-gradient(135deg, #020617, #081120 48%, #0f172a); +} + +.app-shell::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 36px 36px; + mask-image: linear-gradient(to bottom, black, transparent 85%); +} +``` + +背景只做氛围,不允许降低正文对比度。 + +## 4. 通用玻璃面板 + +```css +.glass-panel { + position: relative; + padding: 20px; + border: 1px solid var(--panel-border); + border-radius: var(--panel-radius); + background: linear-gradient( + 145deg, + rgba(255, 255, 255, 0.08), + var(--panel-bg) 38%, + rgba(255, 255, 255, 0.03) + ); + box-shadow: var(--panel-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.08); + backdrop-filter: blur(var(--panel-blur)); + transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease; + overflow: hidden; +} + +.glass-panel:hover { + transform: translateY(-3px); + border-color: var(--panel-border-strong); + box-shadow: 0 22px 60px rgba(2, 8, 23, 0.52), 0 0 28px rgba(34, 211, 238, 0.16); +} + +.glass-panel::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(120deg, rgba(34, 211, 238, 0.12), transparent 32%); +} +``` + +### 面板标题栏 + +```css +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(125, 211, 252, 0.16); +} + +.panel-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 16px; + letter-spacing: 0.04em; +} + +.panel-title::before { + content: ""; + width: 4px; + height: 18px; + border-radius: 999px; + background: linear-gradient(180deg, var(--glow-cyan), var(--glow-violet)); + box-shadow: 0 0 12px rgba(34, 211, 238, 0.8); +} +``` + +### 角标装饰 + +可在关键面板四角加入科技角标: + +```css +.glass-panel.is-featured::after { + content: ""; + position: absolute; + right: 14px; + top: 14px; + width: 22px; + height: 22px; + border-top: 1px solid var(--glow-cyan); + border-right: 1px solid var(--glow-cyan); + opacity: 0.8; +} +``` + +角标属于装饰元素,不应影响点击和内容阅读。 + +## 5. Tailwind 思路 + +若项目使用 Tailwind,可抽出可复用类: + +```css +@layer components { + .panel-card { + @apply relative rounded-2xl border border-cyan-300/20 bg-slate-950/55 p-5 shadow-2xl backdrop-blur-xl transition duration-300; + } + + .panel-card:hover { + @apply -translate-y-1 border-cyan-300/70 shadow-cyan-400/10; + } +} +``` + +不要为了视觉效果随意升级 Tailwind 大版本或重构整个构建链。 + +## 6. 页面与组件要求 + +### 导航栏 + +- 固定或吸顶时使用半透明深色玻璃背景。 +- 当前菜单加发光下划线或胶囊背景。 +- Logo 和系统名称放在左侧,操作区放在右侧。 +- 移动端抽屉菜单也使用面板风格。 +- 焦点状态清晰可见。 + +### 首页 / 仪表盘 + +至少包含: + +- 欢迎信息或系统状态横幅。 +- 3–5 个指标卡片。 +- 图表、列表、表格或最近动态区域。 +- 空状态和加载状态也要有面板骨架。 + +指标卡片结构: + +```text +图标/状态点 + 指标名称 +主要数值 / 状态 +趋势描述 / 小型进度条 +``` + +数值可以使用等宽或带轻微发光效果,但不能过度闪烁。 + +### 项目概述大屏(每个项目必做) + +- 独立炫酷大屏页面(如 `/overview`),导航菜单顶部入口名为「概览」;深色科技基底 + 玻璃面板 + 霓虹光效。 +- 必须接入后端真实聚合数据,禁止纯静态假数据: + - 5 个左右核心指标卡:设备/摄像头总数、在线数与在线率、离线数、报警数(待处理/已处理)、规则数、用户数(按项目实体取数); + - 近 7 天趋势折线/面积图;状态结构环形图(带文字图例与数值);最新事件滚动列表;实时时钟;数据约 10 秒自动刷新。 +- 图表优先复用项目已有库,未引入可用 SVG/CSS 自绘,不为此强加重型依赖。 +- 动态滚动可暂停,图表配色与整体统一,支持大屏分辨率与响应式降级。 +- 页脚与概览页头部固定提供两个超链接:开源社区 `https://www.gitcc.com/`、GCC AI Coding `https://www.gitvv.com/`,hover 发光。 + +### 数据表格 + +- 表格外层使用面板包裹。 +- 表头使用半透明高亮背景。 +- 行 hover 时出现微弱青色背景。 +- 操作按钮使用小型发光按钮或文字按钮。 +- 分页器与面板风格一致。 + +### 表单与登录页 + +- 登录卡片居中,最大宽度约 420px。 +- 输入框使用暗色半透明底、聚焦发光边框。 +- 主按钮使用蓝青/蓝紫渐变和外发光。 +- 可加入缓慢漂浮的光斑、粒子或圆环背景。 +- 错误提示使用红橙色发光状态,不只依赖颜色表达错误。 + +### AI 对话页 + +- 左右或上下布局都可,但核心是独立面板式聊天窗口。 +- 消息列表作为玻璃面板,用户消息和 AI 消息使用不同方向、颜色和透明度。 +- 正在生成时显示呼吸光点或打字指示。 +- API 设置使用右侧抽屉或弹窗,不离开当前页面。 +- 流式输出时光标可用轻微闪烁效果。 +- 服务商对接(用户指定时强制): + - 默认 Base URL 与默认模型必须预填为用户指定值;OpenAI 兼容网关地址以 `/v1` 结尾(例:`https://www.tokenfree.com/v1` + `kimi-k2.6`)。 + - 模型 ID 以服务商模型/定价页面实际字符串为准,禁止臆造。 + - 本地设置 key 必须带版本号;切换服务商时升级版本,旧配置只允许迁移用户自填 API Key。 +- 设置抽屉内必须内置深色玻璃风格使用教程:官网链接 → 注册登录 → 控制台创建 API Key(`sk-` 开头)→ 粘贴 → 测试连接 → 保存;并展示 Base URL、默认模型及「Key 仅保存在本地」说明。 +- 教程中的 `code`、列表、链接、注释文字统一使用深色面板配色(青色系文字 + 半透明深色底),禁止白底教程块嵌入深色抽屉。 +- 未配置 Key 时页面顶部用玻璃风格 Alert 引导,不得阻塞页面浏览。 + +### 页脚 + +- 使用半透明面板或顶部分割发光线。 +- 展示系统版本、状态、时间、开源社区链接等信息。 +- 链接 hover 有发光反馈。 +- 移动端分组堆叠,避免拥挤。 + +## 7. 动效规范 + +推荐: + +- 面板 hover:`translateY(-2px ~ -4px)`。 +- 面板进入:透明度 + 8px 内位移。 +- 背景光斑:20–40 秒慢速移动。 +- 加载点:1.2–1.8 秒呼吸动画。 +- 焦点和状态变化:150–300ms。 + +避免: + +- 大面积高频闪烁。 +- 持续旋转、跳动的强动效。 +- 多层重度模糊叠加导致 GPU 卡顿。 +- 自动播放声音。 + +必须加入: + +```css +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} +``` + +## 8. 响应式要求 + +- 桌面端指标卡片常用 3–5 列网格。 +- 平板端降为 2 列。 +- 手机端变为 1 列,面板间距收敛。 +- 表格支持横向滚动或在小屏转为卡片列表。 +- AI 设置面板在手机端全屏或从底部滑出。 +- 装饰光斑和角标在小屏可减弱或隐藏。 + +## 9. 无障碍要求 + +- 正文和背景对比度达到可读标准。 +- 装饰元素设置 `aria-hidden="true"`。 +- 按钮和链接必须有可理解文本或 `aria-label`。 +- 输入框有关联 `label`。 +- `focus-visible` 使用清晰发光轮廓。 +- 错误、成功、警告不能只通过颜色区分,要配合图标或文字。 +- 弹窗、抽屉打开后焦点管理合理,关闭后返回触发元素。 + +## 10. 验收清单 + +页面改造完成后逐项检查: + +- [ ] 全局背景和面板变量已建立。 +- [ ] 导航、首页/仪表盘、表单、表格、登录页、AI 页、页脚风格统一。 +- [ ] 已通过组件库官方 Theme/ConfigProvider Token 启用暗色主题。 +- [ ] Modal、Drawer、Select、DatePicker、Popover 等浮层没有白底黑字。 +- [ ] Card、Table、Descriptions、Tabs、Pagination、Empty 没有外黑内白。 +- [ ] 至少有玻璃模糊、发光边框、渐变光效和悬浮反馈中的三种效果。 +- [ ] 主要信息没有被装饰元素遮挡。 +- [ ] 深色文字、浅色文字对比度均满足阅读要求。 +- [ ] 手机和平板布局正常。 +- [ ] 键盘导航和焦点样式正常。 +- [ ] `prefers-reduced-motion` 生效。 +- [ ] 构建没有因新增样式失败。 +- [ ] 原业务流程、接口请求和权限控制未被破坏。 +## 11. 阻断式自动审计(必须通过) + +面板风格改造完成后,必须运行技能内置脚本: + +```bash +python scripts/panel_ui_audit.py \ + --base-url http://localhost:<端口> \ + --hash-routes \ + --username <账号> \ + --password <密码> \ + --submit-text <登录按钮文本> \ + --routes /campusState,/alarms,/ai-chat \ + --open-selector '.drawer-open-button' \ + --wait-until domcontentloaded +``` + +脚本必须检查: + +- 普通正文:对比度至少 4.5:1。 +- 大号文字或粗体:对比度至少 3:1。 +- Card、Table、Descriptions、Tabs、Pagination、Empty、List 不允许出现深色外壳中的浅色孤立区域。 +- Modal、Drawer、Select、DatePicker、Popover、Tooltip、Cascader 不允许白底黑字或低对比。 +- Alert、Tag、Badge 文案、图标、关闭按钮和操作按钮必须使用暗色主题下可读的颜色。 +- 地图、视频、画布只豁免媒体内容本身,不豁免其悬浮控件、标签、空状态和提示信息。 + +含轮询、WebSocket、SSE 的单页应用使用 `--wait-until domcontentloaded`。只有退出码为 0、报告中 `0 violations`,健康验证才算完成。禁止为了通过审计而临时降低阈值、删除选择器或跳过页面。 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..496280f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# 面板 UI 阻断式审计脚本依赖(其余脚本仅用 Python 标准库) +playwright>=1.40.0 diff --git a/scripts/build_delivery_package.py b/scripts/build_delivery_package.py new file mode 100644 index 0000000..7410790 --- /dev/null +++ b/scripts/build_delivery_package.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +交付压缩包构建脚本(合同对齐)。 + +产物(位于 --out-dir,默认 .Codex/delivery/): + 1) 给甲方/终端使用的交付压缩包 + 2) 交付清单.md 与 zip 并列的单独文件(不打入包内) + +zip 内部固定结构: + README.md 包根目录唯一一份 README + docs/screenshots/ README 引用的全部配图(解压可显示) + 截图/界面截图/ 当前产品界面截图(中性文件名) + 录屏/ + 文档/环境依赖清单.md + 文档/部署注意事项.md + +用法见 --help。 +""" +from __future__ import annotations + +import argparse +import os +import re +import shutil +import sys +import tempfile +import zipfile +from pathlib import Path + + +def copy_file(src: Path, dst: Path): + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + +def main() -> int: + ap = argparse.ArgumentParser(description="构建交付压缩包与并列交付清单") + ap.add_argument("--project-dir", required=True) + ap.add_argument("--readme", default="README.md") + ap.add_argument("--out-dir", default=".Codex/delivery") + ap.add_argument("--assets-product", default=".Codex/delivery/assets/product") + ap.add_argument("--recordings", default=".Codex/delivery/recordings") + ap.add_argument("--dep-doc", default=".Codex/delivery/环境依赖清单.md") + ap.add_argument("--notes-doc", default=".Codex/delivery/部署注意事项.md") + ap.add_argument("--manifest-src", default=".Codex/delivery/manifest.md") + ap.add_argument("--zip-name", default=None, help="默认 smart-parking-delivery-.zip") + ap.add_argument("--video-name", default="full-feature-demo.mp4") + args = ap.parse_args() + + root = Path(args.project_dir).resolve() + out_dir = root / args.out_dir + out_dir.mkdir(parents=True, exist_ok=True) + + readme_path = root / args.readme + if not readme_path.exists(): + print("[FAIL] README 不存在:%s" % readme_path) + return 1 + + zip_name = args.zip_name or ("smart-parking-delivery-%s.zip" % _today()) + zip_path = out_dir / zip_name + + with tempfile.TemporaryDirectory() as td: + stage = Path(td) + + # 1) README(包根唯一一份) + copy_file(readme_path, stage / "README.md") + + # 2) README 引用图片 -> docs/...(保持相对路径) + readme_text = readme_path.read_text(encoding="utf-8", errors="ignore") + img_refs = re.findall(r"!\[[^\]]*\]\(([^)]+)\)", readme_text) + copied_img = set() + for ref in img_refs: + ref = ref.strip() + if "://" in ref or ref.startswith("#"): + continue + src_img = (readme_path.parent / ref).resolve() + if not src_img.exists(): + print("[FAIL] README 引用图片缺失:%s" % ref) + return 1 + rel = src_img.relative_to(root) + if str(rel) not in copied_img: + copy_file(src_img, stage / rel) + copied_img.add(str(rel)) + + # 3) 产品界面截图(中性命名,不体现版本对比或来源宣传) + product_assets = root / args.assets_product + if product_assets.is_dir(): + shutil.copytree(product_assets, stage / "截图" / "界面截图", dirs_exist_ok=True) + + # 4) 录屏(只取指定 mp4) + rec_dir = root / args.recordings + video = rec_dir / args.video_name + if video.exists(): + copy_file(video, stage / "录屏" / args.video_name) + + # 5) 文档目录:仅依赖清单 + 注意事项(无 README、无交付清单) + for d in (args.dep_doc, args.notes_doc): + p = root / d + if p.exists(): + copy_file(p, stage / "文档" / p.name) + + # 打包 + if zip_path.exists(): + zip_path.unlink() + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z: + for p in sorted(stage.rglob("*")): + if p.is_file(): + z.write(p, p.relative_to(stage).as_posix()) + + # 交付清单:复制为与 zip 并列的单独文件 + manifest_src = root / args.manifest_src + checklist_path = out_dir / "交付清单.md" + if manifest_src.exists(): + shutil.copy2(manifest_src, checklist_path) + + # 校验:解压核对 README 图片 + bad = _verify_zip(zip_path) + if bad: + print("[FAIL] 交付包校验未通过:%s" % bad) + return 1 + + print("交付包:%s (%.2f MB)" % (zip_path, zip_path.stat().st_size/1024/1024)) + if checklist_path.exists(): + print("并列交付清单:%s" % checklist_path) + return 0 + + +def _today() -> str: + from datetime import datetime + return datetime.now().strftime("%Y%m%d") + + +def _verify_zip(zip_path: Path) -> list[str]: + problems: list[str] = [] + with tempfile.TemporaryDirectory() as td: + with zipfile.ZipFile(zip_path) as z: + z.extractall(td) + v = Path(td) + names = [p.relative_to(v).as_posix() for p in v.rglob("*") if p.is_file()] + readmes = [n for n in names if n.lower().endswith("readme.md")] + if readmes != ["README.md"]: + problems.append("README 必须且只能在包根一份,实际:%s" % readmes) + if any("交付清单" in n for n in names): + problems.append("交付清单不得打入包内") + if any(re.search(r"(before|after|改造前|原版)", n, re.I) for n in names): + problems.append("包内不得出现对比期/before/after 文件命名") + docs = [n for n in names if n.startswith("文档/")] + if any(n.lower().endswith("readme.md") for n in docs): + problems.append("文档目录不得放 README") + if (v / "README.md").exists(): + txt = (v / "README.md").read_text(encoding="utf-8", errors="ignore") + for ref in re.findall(r"!\[[^\]]*\]\(([^)]+)\)", txt): + if "://" in ref or ref.startswith("#"): + continue + if not (v / ref).exists(): + problems.append("README 图片解压后缺失:%s" % ref) + forbidden_in_readme = [ + "致谢", "原始开源项目", "原项目链接", "原作者", "基于开源", + "He Rui", "CrazyHer", "campus-surveillance", + ] + for term in forbidden_in_readme: + if term.lower() in txt.lower(): + problems.append("README 出现不应主动展示的来源表述:%s" % term) + return problems + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/deliverables_check.py b/scripts/deliverables_check.py new file mode 100644 index 0000000..40168fd --- /dev/null +++ b/scripts/deliverables_check.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +交付物阻断式核查脚本(对齐软件改造部署外包交付规范)。 + +检查项: + A. 7 项交付物:源码 / 当前产品截图 / 全功能录屏 / 网盘归档 / README / Docker 部署 / 依赖与注意事项 + B. README 12 项必备内容(按标题关键词启发式匹配) + C. 痕迹/来源清理:对用户提供的商业标识与来源关键词做全文检索(交付物中不得命中) + D. 交付清单 .Codex/delivery/manifest.md 存在且 7 项条目齐全 + +退出码:0 全部通过;1 存在阻断项。 +用法见 --help。 +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from datetime import datetime +from pathlib import Path + +README_KEYWORDS = { + "一句话简介": ["简介", "项目介绍"], + "详细功能描述": ["功能", "功能特性", "功能特点"], + "行业痛点": ["痛点"], + "技术优势": ["技术优势", "核心优势"], + "应用场景": ["应用场景", "使用场景", "场景"], + "目标行业与人群": ["目标行业", "适用行业", "适配行业", "目标用户", "适用人群"], + "商业应用价值": ["商业价值", "应用价值"], + "技术架构(含拓扑图)": ["技术架构", "架构"], + "环境前置依赖": ["环境要求", "前置依赖", "依赖要求", "环境依赖"], + "快速开始": ["快速开始", "快速上手", "quick start"], + "安装部署教程": ["部署", "安装"], + "版权与使用说明": ["版权", "使用说明"], +} + +SCAN_SUFFIXES = { + ".ts", ".tsx", ".js", ".jsx", ".vue", ".java", ".py", ".go", ".json", + ".md", ".html", ".yml", ".yaml", ".less", ".css", ".scss", ".xml", +} + +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"} +VIDEO_SUFFIXES = {".mp4", ".mkv", ".mov", ".avi", ".webm"} + +SKIP_DIRS = { + "node_modules", ".git", "dist", "build", "target", ".next", ".umi", + "__pycache__", ".venv", "venv", +} + + +def iter_files(root: Path): + # os.walk 自顶向下并在目录层剪枝,避免遍历 node_modules/.git 等巨型目录 + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [ + d for d in dirnames + if d not in SKIP_DIRS and not d.lower().startswith(".codex") + ] + for name in filenames: + yield Path(dirpath) / name + + +def find_files(root: Path, suffixes: set[str]) -> list[Path]: + return [p for p in iter_files(root) if p.suffix.lower() in suffixes] + + +def main() -> int: + ap = argparse.ArgumentParser(description="开源改造项目交付物阻断式核查") + ap.add_argument("--project-dir", required=True, help="项目根目录") + ap.add_argument("--readme", default="README.md", help="README 相对路径") + ap.add_argument("--delivery-dir", default=".Codex/delivery", help="交付目录(相对项目)") + ap.add_argument("--assets-dir", default=".Codex/delivery/assets", + help="截图/录屏素材目录(相对项目)") + ap.add_argument("--trace-keywords", default="", + help="逗号分隔的商业标识与来源宣传关键词(原公司名、外部仓库、项目代号等)") + ap.add_argument("--allow-no-docker", action="store_true", + help="项目确实不适配 Docker 时,要求存在书面说明而非 Dockerfile") + ap.add_argument("--netdisk-link", default="", + help="网盘归档链接(manifest 中也可提供,二选一)") + ap.add_argument("--out", default=".Codex/reports/deliverables-check.json") + args = ap.parse_args() + + root = Path(args.project_dir).resolve() + violations: list[str] = [] + warnings: list[str] = [] + evidence: dict = {} + + # ---------- A1. 源码可构建产物线索 ---------- + # 多模块项目(如 frontend/backend 分离)清单可能在子目录,递归查找 + manifest_names = { + "package.json", "pom.xml", "build.gradle", "go.mod", + "requirements.txt", "pyproject.toml", "Cargo.toml", + } + manifest_hits = [p for p in iter_files(root) if p.name in manifest_names] + if not manifest_hits: + violations.append("未发现项目清单文件(package.json/pom.xml/go.mod 等),无法确认源码完整性") + else: + evidence["manifests"] = sorted(str(p.relative_to(root)) for p in manifest_hits) + + # ---------- A2/A3. 截图与录屏 ---------- + assets_dir = root / args.assets_dir + alt_dirs = [root / args.delivery_dir / "screenshots", root / args.delivery_dir / "recordings"] + images: list[Path] = [] + videos: list[Path] = [] + search_dirs = [d for d in [assets_dir, *alt_dirs] if d.exists()] + for d in search_dirs: + images.extend(find_files(d, IMAGE_SUFFIXES)) + videos.extend(find_files(d, VIDEO_SUFFIXES)) + # 对外只提供当前产品界面截图;交付素材目录不得含改造前/before 旧界面 + leaked_before = [p for p in images if re.search(r"(before|改造前|原版)", p.name, re.I)] + if leaked_before: + violations.append("交付素材中包含改造前/原版截图(不应随包交付):%s" + % "、".join(p.name for p in leaked_before[:8])) + if not images: + violations.append("缺少产品界面截图(应放在 %s 或交付目录)" % args.assets_dir) + else: + evidence["screenshots"] = len(images) + if not videos: + violations.append("缺少全功能操作演示录屏(mp4/mov 等)") + else: + evidence["recordings"] = len(videos) + + # ---------- A5. README ---------- + readme_path = root / args.readme + readme_text = "" + if not readme_path.exists(): + violations.append("缺少 README 文档:%s" % args.readme) + else: + readme_text = readme_path.read_text(encoding="utf-8", errors="ignore") + missing = [] + low = readme_text.lower() + for item, kws in README_KEYWORDS.items(): + if not any(k.lower() in low for k in kws): + missing.append(item) + if missing: + violations.append("README 缺少必备内容:%s" % "、".join(missing)) + # 架构图线索:mermaid 或图片引用 + if not re.search(r"```mermaid|!\[.*\]\(.*\)|架构图|拓扑图", readme_text): + warnings.append("README 未发现架构/拓扑图(mermaid 或图片)") + # 截图图文对应 + if not re.search(r"!\[.*\]\(.*\)", readme_text): + warnings.append("README 无图片引用,合同要求功能逐条对应截图") + + # ---------- A6. Docker / 书面说明 ---------- + docker_files = [p for p in [root / "Dockerfile", root / "docker-compose.yml", root / "docker-compose.yaml"] if p.exists()] + if not docker_files: + if args.allow_no_docker: + note = root / args.delivery_dir / "no-docker说明.md" + if not note.exists(): + violations.append("已声明不适配 Docker,但缺少书面说明(%s)" % note.relative_to(root)) + else: + violations.append("缺少 Dockerfile / docker-compose(不适配 Docker 请加 --allow-no-docker 并提供书面说明)") + + # ---------- A7. 依赖清单与部署注意事项 ---------- + dep_docs = list((root / args.delivery_dir).glob("*依赖*")) + list((root / args.delivery_dir).glob("*注意事项*")) + dep_docs += [root / "依赖清单.md", root / "部署注意事项.md"] + dep_docs = [p for p in dep_docs if p.exists()] + if not dep_docs: + # 若 README 已含环境依赖/注意事项章节,降级为警告 + if re.search(r"环境(要求|依赖|前置)|注意事项", readme_text): + warnings.append("未单独提供依赖清单/部署注意事项文档(README 中有相关章节,建议单独输出)") + else: + violations.append("缺少环境依赖清单与部署注意事项说明文档") + + # ---------- A4. 网盘链接 + D. manifest ---------- + manifest_path = root / args.delivery_dir / "manifest.md" + manifest_text = manifest_path.read_text(encoding="utf-8", errors="ignore") if manifest_path.exists() else "" + netdisk = args.netdisk_link or (re.search(r"https?://\S*(?:pan\.baidu|aliyundrive|alipan|123pan|cloud\.189)\S*", manifest_text) or [""])[0] + if not netdisk: + if not manifest_text: + violations.append("缺少网盘归档链接,且未提供交付清单 manifest.md") + else: + violations.append("未提供网盘素材归档链接(命令行 --netdisk-link 或写入 manifest)") + if not manifest_path.exists(): + violations.append("缺少交付清单:%s" % manifest_path.relative_to(root)) + else: + required_items = ["源码", "截图", "录屏", "网盘", "README", "Docker", "依赖"] + miss_items = [w for w in required_items if w not in manifest_text] + if miss_items: + violations.append("manifest.md 未覆盖交付物条目:%s" % "、".join(miss_items)) + + # ---------- C. 痕迹清理检索 ---------- + kws = [k.strip() for k in args.trace_keywords.split(",") if k.strip()] + traces: list[str] = [] + if kws: + pat = re.compile("|".join(re.escape(k) for k in kws), re.I) + for p in iter_files(root): + if p.suffix.lower() not in SCAN_SUFFIXES: + continue + rel = str(p.relative_to(root)) + if re.search(r"license|copying", rel, re.I): + continue + try: + txt = p.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + if pat.search(txt): + traces.append(rel) + if traces: + violations.append("痕迹清理不彻底,以下文件命中原标识关键词:%s" % "、".join(sorted(set(traces))[:20])) + + # ---------- 输出 ---------- + result = { + "timestamp": datetime.now().isoformat(), + "project_dir": str(root), + "violations": violations, + "warnings": warnings, + "evidence": evidence, + "passed": not violations, + } + out_path = root / args.out + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + + print("交付物核查:%d 个阻断项,%d 个警告" % (len(violations), len(warnings))) + for v in violations: + print(" [FAIL]", v) + for w in warnings: + print(" [WARN]", w) + print("report=%s" % out_path) + return 0 if not violations else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/panel_ui_audit.py b/scripts/panel_ui_audit.py new file mode 100644 index 0000000..a4168e0 --- /dev/null +++ b/scripts/panel_ui_audit.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""Blocking audit for dark glass-panel UIs. + +Requires Playwright: python -m pip install playwright && playwright install chromium. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any + +from playwright.sync_api import Page, sync_playwright + +AUDIT_JS = r""" +(() => { + function parseColor(value) { + const match = /rgba?\(([^)]+)\)/.exec(value || ''); + if (!match) return {r: 255, g: 255, b: 255, a: 1}; + const parts = match[1].split(',').map((x) => Number(x.trim())); + return {r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1}; + } + + function composite(top, bottom) { + const alpha = top.a + (1 - top.a) * bottom.a; + if (alpha === 0) return {r: bottom.r, g: bottom.g, b: bottom.b, a: 0}; + const channel = (t, b) => (top.a * t + (1 - top.a) * b) / alpha; + return {r: channel(top.r, bottom.r), g: channel(top.g, bottom.g), b: channel(top.b, bottom.b), a: alpha}; + } + + function splitTopLevel(text) { + const parts = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i += 1) { + if (text[i] === '(') depth += 1; + if (text[i] === ')') depth -= 1; + if (text[i] === ',' && depth === 0) { + parts.push(text.slice(start, i).trim()); + start = i + 1; + } + } + const last = text.slice(start).trim(); + if (last) parts.push(last); + return parts; + } + + function parseColorOrNull(value) { + const match = /rgba?\(([^)]*)\)/.exec(value || ''); + if (!match) return null; + const parts = match[1].split(',').map((x) => Number(x.trim())); + return {r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1}; + } + + function averageColors(colors) { + if (!colors.length) return null; + const total = colors.reduce((acc, color) => ({ + r: acc.r + color.r, + g: acc.g + color.g, + b: acc.b + color.b, + a: acc.a + color.a, + }), {r: 0, g: 0, b: 0, a: 0}); + return { + r: total.r / colors.length, + g: total.g / colors.length, + b: total.b / colors.length, + a: Math.min(1, total.a / colors.length), + }; + } + + function extractGradient(text, keyword) { + const start = text.search(new RegExp(`(?:^|[^a-z-])${keyword}\\(`, 'i')); + if (start < 0) return null; + const open = text.indexOf('(', start + keyword.length - 1); + let depth = 0; + for (let i = open; i < text.length; i += 1) { + if (text[i] === '(') depth += 1; + if (text[i] === ')') { + depth -= 1; + if (depth === 0) return text.slice(open + 1, i); + } + } + return null; + } + + function gradientAverage(backgroundImage, keyword) { + const content = extractGradient(backgroundImage, keyword); + if (!content) return null; + const colors = splitTopLevel(content) + .map((part) => parseColorOrNull(part)) + .filter(Boolean); + return averageColors(colors); + } + + function backgroundImageLayers(style) { + const image = style.backgroundImage; + if (!image || image === 'none') return []; + return ['conic-gradient', 'radial-gradient', 'linear-gradient'] + .map((keyword) => gradientAverage(image, keyword)) + .filter(Boolean); + } + + function effectiveBackground(startEl) { + const chain = []; + let el = startEl; + while (el && el.nodeType === 1) { + const style = getComputedStyle(el); + chain.unshift({ + color: parseColor(style.backgroundColor), + images: backgroundImageLayers(style), + }); + el = el.parentElement; + } + let bg = {r: 255, g: 255, b: 255, a: 1}; + for (const layer of chain) { + bg = composite(layer.color, bg); + for (const imageLayer of layer.images) bg = composite(imageLayer, bg); + } + return bg; + } + + function channelLuminance(value) { + const c = Math.max(0, Math.min(255, value)) / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + } + + function luminance(color) { + return 0.2126 * channelLuminance(color.r) + 0.7152 * channelLuminance(color.g) + 0.0722 * channelLuminance(color.b); + } + + function contrastRatio(first, second) { + const a = luminance(first); + const b = luminance(second); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + } + + function visible(el) { + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return rect.width >= 2 && rect.height >= 2 && + style.display !== 'none' && style.visibility !== 'hidden' && + Number(style.opacity) >= 0.1; + } + + function hasDirectText(el) { + return Array.from(el.childNodes).some((node) => node.nodeType === 3 && node.textContent.trim().length > 0); + } + + function isMedia(el) { + return Boolean(el.closest && el.closest( + 'img,picture,video,canvas,svg,iframe,.leaflet-container,[class*="leaflet"],[class*="map-container"],[class*="map-wrapper"],[data-map],[class*="player"]' + )); + } + + function inFloatingLayer(el) { + return Boolean(el.closest && el.closest( + '.ant-select-dropdown,.ant-picker-dropdown,.ant-modal-content,.ant-drawer-content,.ant-popover-inner,.ant-tooltip,.ant-cascader-dropdown,[role="dialog"],[role="alertdialog"]' + )); + } + + const bodyBg = effectiveBackground(document.body); + const bodyLuminance = luminance(bodyBg); + const issues = []; + const seen = new Set(); + const nodes = Array.from(document.querySelectorAll('body *')).filter(visible).slice(0, 1800); + + for (const el of nodes) { + const style = getComputedStyle(el); + const canCarryText = hasDirectText(el) || el.matches( + 'input,textarea,button,select,a,label,summary,option,[role="button"],[role="option"],[role="menuitem"],[role="tab"],[aria-label]' + ); + if (!canCarryText) continue; + + const text = (el.innerText || el.value || el.placeholder || el.getAttribute('aria-label') || '').trim(); + if (!text) continue; + + const bg = effectiveBackground(el); + const styledColor = parseColor(style.color); + const fg = composite(styledColor, bg); + const ratio = contrastRatio(fg, bg); + const fontSize = Number.parseFloat(style.fontSize); + const bold = Number.parseInt(style.fontWeight, 10) >= 600; + const large = fontSize >= 18.5 || (bold && fontSize >= 15); + const required = large ? 3 : 4.5; + const rect = el.getBoundingClientRect(); + + function push(reason, severity) { + const item = { + reason, + severity, + text: text.slice(0, 140), + tag: el.tagName.toLowerCase(), + className: String(el.className || '').slice(0, 180), + id: el.id || '', + background: `rgb(${Math.round(bg.r)}, ${Math.round(bg.g)}, ${Math.round(bg.b)})`, + color: `rgb(${Math.round(fg.r)}, ${Math.round(fg.g)}, ${Math.round(fg.b)})`, + contrast: Number(ratio.toFixed(2)), + requiredContrast: required, + backgroundLuminance: Number(luminance(bg).toFixed(3)), + bodyLuminance: Number(bodyLuminance.toFixed(3)), + size: `${Math.round(rect.width)}x${Math.round(rect.height)}`, + floating: inFloatingLayer(el), + }; + const key = JSON.stringify([item.reason, item.text, item.className, item.contrast, item.background]); + if (!seen.has(key)) { + seen.add(key); + issues.push(item); + } + } + + if (ratio < required) push('text contrast below threshold', 'P0'); + + const bgLum = luminance(bg); + if (!isMedia(el) && bgLum >= 0.62 && bodyLuminance <= 0.16 && rect.width >= 32 && rect.height >= 20) { + push('light component embedded in dark shell', 'P0'); + } + } + + return {bodyBg, bodyLuminance, issues}; +}) +""" + +FLOAT_OPENERS = [ + '.ant-select:not(.ant-select-disabled) .ant-select-selector', + '.ant-picker input', +] + + +def route_url(base_url: str, route: str, hash_routes: bool) -> str: + base = base_url.rstrip('/') + route = route if route.startswith('/') else f'/{route}' + return f'{base}/#{route}' if hash_routes else f'{base}{route}' + + +def audit_page(page: Page, route: str, extra_openers: list[str]) -> dict[str, Any]: + issues: list[dict[str, Any]] = [] + + data = page.evaluate(AUDIT_JS) + issues.extend(item for item in data['issues'] if not item.get('floating')) + + selectors = FLOAT_OPENERS + extra_openers + for selector in selectors: + try: + locator = page.locator(selector).first + if locator.count() == 0 or not locator.is_visible(): + continue + locator.click(timeout=1200) + page.wait_for_timeout(500) + opened = page.evaluate(AUDIT_JS) + issues.extend(item for item in opened['issues'] if item.get('floating')) + except Exception: + pass + finally: + for _ in range(2): + page.keyboard.press('Escape') + page.wait_for_timeout(120) + + return {'route': route, 'issues': issues} + + +def launch_chromium(playwright): + launch_options = {'headless': True} + channel = os.environ.get('PANEL_PLAYWRIGHT_CHANNEL') + executable_path = os.environ.get('PANEL_PLAYWRIGHT_EXECUTABLE_PATH') + if channel: + launch_options['channel'] = channel + elif executable_path: + launch_options['executable_path'] = executable_path + else: + local_app_data = os.environ.get('LOCALAPPDATA', '') + playwright_cache = Path(local_app_data) / 'ms-playwright' if local_app_data else None + has_downloaded_chromium = bool( + playwright_cache and list(playwright_cache.glob('chromium-*/chrome-win64/chrome.exe')) + ) + if not has_downloaded_chromium: + program_files_x86 = os.environ.get('ProgramFiles(x86)', '') + program_files = os.environ.get('ProgramFiles', '') + edge_candidates = [ + Path(program_files_x86) / 'Microsoft' / 'Edge' / 'Application' / 'msedge.exe', + Path(program_files) / 'Microsoft' / 'Edge' / 'Application' / 'msedge.exe', + ] + if any(edge.exists() for edge in edge_candidates): + launch_options['channel'] = 'msedge' + return playwright.chromium.launch(**launch_options) + +def csv_list(value: str) -> list[str]: + return [item.strip() for item in value.split(',') if item.strip()] + + +def main() -> int: + parser = argparse.ArgumentParser(description='Audit panel UI contrast and light-in-dark nesting.') + parser.add_argument('--base-url', required=True) + parser.add_argument('--routes', required=True, help='Comma-separated routes, for example /campusState,/alarms') + parser.add_argument('--hash-routes', action='store_true') + parser.add_argument('--login-route', default='/login') + parser.add_argument('--skip-login', action='store_true') + parser.add_argument('--username', default='') + parser.add_argument('--password', default='') + parser.add_argument('--username-selector', default='input[type="text"],input:not([type]),input[name="username"]') + parser.add_argument('--password-selector', default='input[type="password"]') + parser.add_argument('--submit-text', default='') + parser.add_argument('--submit-selector', default='button[type="submit"]') + parser.add_argument('--open-selector', action='append', default=[], help='Optional element that opens a drawer/modal; repeatable') + parser.add_argument('--viewport-width', type=int, default=1440) + parser.add_argument('--viewport-height', type=int, default=900) + parser.add_argument('--wait-until', default='domcontentloaded', choices=['load', 'domcontentloaded', 'networkidle', 'commit']) + parser.add_argument('--out', default='.Codex/reports/panel-ui-audit.json') + args = parser.parse_args() + + routes = csv_list(args.routes) + report: dict[str, Any] = {'baseUrl': args.base_url, 'pages': []} + + with sync_playwright() as playwright: + browser = launch_chromium(playwright) + page = browser.new_page(viewport={'width': args.viewport_width, 'height': args.viewport_height}) + + if not args.skip_login: + page.goto(route_url(args.base_url, args.login_route, args.hash_routes), wait_until=args.wait_until) + if args.username: + page.locator(args.username_selector).first.fill(args.username) + if args.password: + page.locator(args.password_selector).first.fill(args.password) + if args.submit_text: + page.get_by_role('button', name=args.submit_text).click() + else: + page.locator(args.submit_selector).first.click() + page.wait_for_load_state('networkidle') + page.wait_for_timeout(800) + + for route in routes: + page.goto(route_url(args.base_url, route, args.hash_routes), wait_until=args.wait_until) + page.wait_for_timeout(1200) + report['pages'].append(audit_page(page, route, args.open_selector)) + + browser.close() + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8') + + total = sum(len(page_report['issues']) for page_report in report['pages']) + print(f'audited {len(report["pages"])} routes; {total} violations') + print(f'report={out.resolve()}') + return 1 if total else 0 + + +if __name__ == '__main__': + sys.exit(main()) + diff --git a/scripts/workflow_manager.py b/scripts/workflow_manager.py new file mode 100644 index 0000000..4fe487f --- /dev/null +++ b/scripts/workflow_manager.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +项目初始化与部署工作流管理器 +提供状态管理、步骤执行和错误恢复功能 +""" + +import os +import sys +import json +import shutil +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass, asdict +from enum import Enum + +class StepStatus(Enum): + PENDING = "⏭️ 未开始" + IN_PROGRESS = "⏸️ 进行中" + COMPLETED = "✅ 已完成" + FAILED = "❌ 失败" + SKIPPED = "⏭️ 跳过" + +class ProjectType(Enum): + DOCKER_WEB = "docker_web" + DOCKER_API = "docker_api" + DOCKER_MICROSERVICES = "docker_microservices" + DESKTOP = "desktop" + MOBILE = "mobile" + CLI = "cli" + UNKNOWN = "unknown" + +class TechStack(Enum): + JAVA_SPRING = "java_spring" + NODE_EXPRESS = "node_express" + NODE_NEST = "node_nest" + PYTHON_DJANGO = "python_django" + PYTHON_FASTAPI = "python_fastapi" + GO_GIN = "go_gin" + GO_FIBER = "go_fiber" + REACT = "react" + VUE = "vue" + ANGULAR = "angular" + UNKNOWN = "unknown" + +@dataclass +class ProjectInfo: + """项目信息""" + name: str + directory: str + git_url: str + port_range: Optional[str] = None + project_type: ProjectType = ProjectType.UNKNOWN + tech_stack: List[TechStack] = None + + def __post_init__(self): + if self.tech_stack is None: + self.tech_stack = [] + +@dataclass +class Step: + """工作流步骤""" + id: int + name: str + description: str + applicable_types: List[ProjectType] + status: StepStatus = StepStatus.PENDING + start_time: Optional[str] = None + end_time: Optional[str] = None + notes: str = "" + error_info: Optional[str] = None + +class WorkflowManager: + """工作流管理器""" + + # 完整的20个核心步骤 + ALL_TYPES = [ProjectType.DOCKER_WEB, ProjectType.DOCKER_API, ProjectType.DOCKER_MICROSERVICES, ProjectType.DESKTOP, ProjectType.MOBILE, ProjectType.CLI] + DOCKER_TYPES = [ProjectType.DOCKER_WEB, ProjectType.DOCKER_API, ProjectType.DOCKER_MICROSERVICES] + + # 关键时序:初始基线启动(3,内部) → 初始界面截图(4,内部) → 面板改造(6-11) → 产品构建启动(15) + # → 健康验证(16) → 产品截图与录屏(17) → README/归档(18) → 交付核查(19) + CORE_STEPS = [ + Step(0, "克隆项目", "从Git仓库克隆项目到本地,不修改任何业务代码", ALL_TYPES), + Step(1, "项目完整度评估", "评估项目完整度、依赖情况与可运行性", ALL_TYPES), + Step(2, "项目整体分析", "分析项目结构、技术栈、启动方式和部署策略", ALL_TYPES), + Step(3, "初始版本基线启动(内部)", "在改动任何代码前,用项目现有启动方式跑通初始版本并确认可访问;仅用于内部质量核对", ALL_TYPES), + Step(4, "初始界面基线截图(内部)", "代码零改动状态下采集初始界面作为内部对比基线;严禁进入交付包或使用 before 等对外文件名", ALL_TYPES), + Step(5, "Docker配置生成", "生成/完善 Docker Compose 配置文件", DOCKER_TYPES), + Step(6, "页面信息与品牌名称修改", "替换页面品牌信息并建立科技感面板基础样式", ALL_TYPES), + Step(7, "Logo生成与替换", "生成并替换项目 Logo、Favicon,适配深色面板", ALL_TYPES), + Step(8, "版权与第三方标识规范", "清理依法可移除的公司名称、LOGO、商标、水印、联系方式等专属标识;MIT 等许可证要求保留的版权与许可文本必须随源码保留", ALL_TYPES), + Step(9, "页脚、导航与社区链接", "导航、页脚改为半透明悬浮面板风格,并放置开源社区与 GCC AI Coding 链接", ALL_TYPES), + Step(10, "登录页面", "改成居中玻璃面板、发光输入框、动态背景", ALL_TYPES), + Step(11, "AI对话功能", "实现前端AI对话界面与指定服务商API对接及使用教程", ALL_TYPES), + Step(12, "示例数据", "生成和导入示例数据,数据卡片/列表面板承载", DOCKER_TYPES), + Step(13, "CORS配置检查", "检查并配置 CORS 设置", DOCKER_TYPES), + Step(14, "端口检查与分配", "检查端口占用并分配端口", DOCKER_TYPES), + Step(15, "改造后构建与启动", "Docker项目构建镜像启动容器;非Docker项目手动构建并启动改造版", ALL_TYPES), + Step(16, "健康验证", "验证服务健康、面板UI审计0违规、全功能走查无报错", ALL_TYPES), + Step(17, "产品截图与全功能录屏", "采集当前产品全套高清界面截图(中性文件名)和全功能演示录屏;内部基线素材不进入交付物", ALL_TYPES), + Step(18, "标准化README与版本归档", "编写标准化 README(引用最终素材、含架构图与部署教程);不主动写来源/致谢宣传,并按授权决定是否提交", ALL_TYPES), + Step(19, "交付物核查", "按7项交付物清单与7条验收标准做交付前阻断式核查", ALL_TYPES), + ] + + # 旧版本步骤名 → 新版本步骤名(用于历史状态迁移) + STEP_NAME_ALIASES = { + "构建与启动": "改造后构建与启动", + "素材录制归档": "产品截图与全功能录屏", + "原始项目基线启动": "初始版本基线启动(内部)", + "改造前基线截图": "初始界面基线截图(内部)", + "原作者信息与版权痕迹合规处理": "版权与第三方标识规范", + "页脚与导航增强": "页脚、导航与社区链接", + "改造后截图与全功能录屏": "产品截图与全功能录屏", + "标准化README与提交": "标准化README与版本归档", + } + + def __init__(self, project_dir: str): + self.project_dir = Path(project_dir) + self.claude_dir = self.project_dir / ".Codex" + self.state_file = self.claude_dir / "workflow_state.json" + self.error_file = self.claude_dir / "error_log.md" + self.project_info: Optional[ProjectInfo] = None + self.steps: List[Step] = [] + + def init_project(self, name: str, git_url: str, port_range: Optional[str] = None): + """初始化项目信息""" + self.project_info = ProjectInfo( + name=name, + directory=str(self.project_dir), + git_url=git_url, + port_range=port_range + ) + self.steps = [Step(s.id, s.name, s.description, s.applicable_types) for s in self.CORE_STEPS] + self._ensure_directories() + self._save_state() + + def _ensure_directories(self): + """确保必要的目录存在""" + self.claude_dir.mkdir(exist_ok=True) + (self.claude_dir / 'reports').mkdir(exist_ok=True) + + def _save_state(self): + """保存状态到JSON文件""" + def enum_to_value(obj): + if isinstance(obj, Enum): + return obj.value + if isinstance(obj, list): + return [enum_to_value(item) for item in obj] + if isinstance(obj, dict): + return {k: enum_to_value(v) for k, v in obj.items()} + return obj + + project_info_dict = None + if self.project_info: + project_info_dict = asdict(self.project_info) + project_info_dict["project_type"] = project_info_dict["project_type"].value + project_info_dict["tech_stack"] = [ts.value for ts in project_info_dict["tech_stack"]] + + state = { + "project_info": project_info_dict, + "steps": [ + { + "id": s.id, + "name": s.name, + "status": s.status.value, + "start_time": s.start_time, + "end_time": s.end_time, + "notes": s.notes, + "error_info": s.error_info + } + for s in self.steps + ], + "last_updated": datetime.now().isoformat() + } + with open(self.state_file, 'w', encoding='utf-8') as f: + json.dump(state, f, ensure_ascii=False, indent=2) + + def load_state(self) -> bool: + """从JSON文件加载状态""" + if not self.state_file.exists(): + return False + + try: + with open(self.state_file, 'r', encoding='utf-8') as f: + state = json.load(f) + + if state.get("project_info"): + info = state["project_info"] + self.project_info = ProjectInfo( + name=info["name"], + directory=info["directory"], + git_url=info["git_url"], + port_range=info.get("port_range"), + project_type=ProjectType(info.get("project_type", "unknown")), + tech_stack=[TechStack(ts) for ts in info.get("tech_stack", [])] + ) + + # 以新 CORE_STEPS 为权威骨架;旧状态按“步骤名(含别名)”映射进度, + # 避免编号错位导致已完成的步骤被重置。 + old_steps = state.get("steps", []) + name_to_state: Dict[str, dict] = {} + for s in old_steps: + canon = self.STEP_NAME_ALIASES.get(s.get("name", ""), s.get("name", "")) + name_to_state[canon] = s + + self.steps = [] + for cs in self.CORE_STEPS: + prev = name_to_state.get(cs.name) + if prev is not None: + self.steps.append(Step( + id=cs.id, + name=cs.name, + description=cs.description, + applicable_types=cs.applicable_types, + status=StepStatus(prev.get("status", StepStatus.PENDING.value)), + start_time=prev.get("start_time"), + end_time=prev.get("end_time"), + notes=prev.get("notes", ""), + error_info=prev.get("error_info"), + )) + else: + # 新增步骤默认未开始 + self.steps.append(Step(cs.id, cs.name, cs.description, cs.applicable_types)) + + return True + except Exception as e: + self._log_error(f"加载状态失败: {e}") + return False + + def get_current_step(self) -> Optional[Step]: + """获取当前步骤""" + for step in self.steps: + if step.status == StepStatus.IN_PROGRESS: + return step + if step.status == StepStatus.PENDING: + return step + return None + + def start_step(self, step_id: int) -> bool: + """开始执行步骤""" + step = next((s for s in self.steps if s.id == step_id), None) + if not step: + return False + + # 检查步骤是否适用于当前项目类型 + if self.project_info and self.project_info.project_type != ProjectType.UNKNOWN: + if self.project_info.project_type not in step.applicable_types: + step.status = StepStatus.SKIPPED + step.notes = f"步骤不适用于项目类型: {self.project_info.project_type.value}" + self._save_state() + return True + + step.status = StepStatus.IN_PROGRESS + step.start_time = datetime.now().isoformat() + self._save_state() + return True + + def complete_step(self, step_id: int, notes: str = ""): + """完成步骤""" + step = next((s for s in self.steps if s.id == step_id), None) + if step: + step.status = StepStatus.COMPLETED + step.end_time = datetime.now().isoformat() + step.notes = notes + self._save_state() + + def fail_step(self, step_id: int, error_info: str): + """标记步骤失败""" + step = next((s for s in self.steps if s.id == step_id), None) + if step: + step.status = StepStatus.FAILED + step.end_time = datetime.now().isoformat() + step.error_info = error_info + self._save_state() + self._log_error(f"步骤 {step_id} ({step.name}) 失败: {error_info}") + + def skip_step(self, step_id: int, reason: str = ""): + """跳过步骤""" + step = next((s for s in self.steps if s.id == step_id), None) + if step: + step.status = StepStatus.SKIPPED + step.notes = reason + self._save_state() + + def _log_error(self, message: str): + """记录错误到错误日志""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + error_entry = f"\n## [{timestamp}]\n{message}\n\n" + + with open(self.error_file, 'a', encoding='utf-8') as f: + f.write(error_entry) + + def get_progress(self) -> Dict: + """获取进度信息""" + total = len(self.steps) + completed = sum(1 for s in self.steps if s.status == StepStatus.COMPLETED) + failed = sum(1 for s in self.steps if s.status == StepStatus.FAILED) + skipped = sum(1 for s in self.steps if s.status == StepStatus.SKIPPED) + pending = sum(1 for s in self.steps if s.status == StepStatus.PENDING) + + return { + "total": total, + "completed": completed, + "failed": failed, + "skipped": skipped, + "pending": pending, + "percentage": (completed / total * 100) if total > 0 else 0 + } + + def can_resume(self) -> Tuple[bool, Optional[int]]: + """检查是否可以恢复执行""" + if not self.state_file.exists(): + return False, None + + self.load_state() + + # 检查是否有失败的步骤 + failed_step = next((s for s in self.steps if s.status == StepStatus.FAILED), None) + if failed_step: + return True, failed_step.id + + # 检查是否有进行中的步骤 + in_progress = next((s for s in self.steps if s.status == StepStatus.IN_PROGRESS), None) + if in_progress: + return True, in_progress.id + + # 检查是否有未开始的步骤 + pending = next((s for s in self.steps if s.status == StepStatus.PENDING), None) + if pending: + return True, pending.id + + return False, None + + def generate_report(self) -> str: + """生成执行报告""" + progress = self.get_progress() + + report = f"""# 工作流执行报告 + +## 项目信息 +- **项目名称**: {self.project_info.name if self.project_info else 'N/A'} +- **项目目录**: {self.project_info.directory if self.project_info else 'N/A'} +- **Git地址**: {self.project_info.git_url if self.project_info else 'N/A'} +- **项目类型**: {self.project_info.project_type.value if self.project_info else 'N/A'} + +## 执行进度 +- **总步骤**: {progress['total']} +- **已完成**: {progress['completed']} +- **失败**: {progress['failed']} +- **跳过**: {progress['skipped']} +- **待执行**: {progress['pending']} +- **完成度**: {progress['percentage']:.1f}% + +## 步骤详情 +""" + for step in self.steps: + report += f"\n### 步骤 {step.id}: {step.name}\n" + report += f"- **状态**: {step.status.value}\n" + if step.start_time: + report += f"- **开始时间**: {step.start_time}\n" + if step.end_time: + report += f"- **完成时间**: {step.end_time}\n" + if step.notes: + report += f"- **说明**: {step.notes}\n" + if step.error_info: + report += f"- **错误**: {step.error_info}\n" + + return report + +# 技术栈检测工具 +class TechStackDetector: + """技术栈检测器""" + + DETECTION_RULES = { + TechStack.JAVA_SPRING: ["pom.xml", "build.gradle", "src/main/java"], + TechStack.NODE_EXPRESS: ["package.json", "app.js", "server.js"], + TechStack.NODE_NEST: ["nest-cli.json", "@nestjs"], + TechStack.PYTHON_DJANGO: ["manage.py", "django", "requirements.txt"], + TechStack.PYTHON_FASTAPI: ["fastapi", "main.py"], + TechStack.GO_GIN: ["go.mod", "gin"], + TechStack.GO_FIBER: ["go.mod", "fiber"], + TechStack.REACT: ["package.json", "react", "src/App.js", "src/App.tsx", "vite.config.ts", "vite.config.js"], + TechStack.VUE: ["package.json", "vue", "vue.config.js", "vite.config.ts", "vite.config.js"], + TechStack.ANGULAR: ["angular.json", "@angular"], + } + + @classmethod + def detect(cls, project_dir: str) -> List[TechStack]: + """检测项目技术栈""" + detected = [] + project_path = Path(project_dir) + + for tech, indicators in cls.DETECTION_RULES.items(): + found = False + for indicator in indicators: + # 递归检查文件存在 + for _ in project_path.rglob(indicator): + found = True + break + if found: break + + # 递归检查文件内容 + for file in project_path.rglob("*.json"): + try: + with open(file, 'r', encoding='utf-8') as f: + content = f.read() + if indicator in content: + found = True + break + except: + pass + if found: break + + if found and tech not in detected: + detected.append(tech) + + return detected if detected else [TechStack.UNKNOWN] + +# 项目类型判断器 +class ProjectTypeDetector: + """项目类型检测器""" + + @classmethod + def detect(cls, project_dir: str, tech_stacks: List[TechStack]) -> ProjectType: + """检测项目类型""" + project_path = Path(project_dir) + + # 检查桌面应用特征 + desktop_indicators = ["electron", "qt", "swing", "wpf", ".desktop"] + for indicator in desktop_indicators: + if any(indicator in str(tech.value).lower() for tech in tech_stacks): + return ProjectType.DESKTOP + + # 检查移动应用特征 + mobile_indicators = ["react-native", "flutter", "android", "ios"] + for indicator in mobile_indicators: + if any(indicator in str(tech.value).lower() for tech in tech_stacks): + return ProjectType.MOBILE + + # 检查Docker配置 + if (project_path / "Dockerfile").exists() or (project_path / "docker-compose.yml").exists(): + # 检查微服务特征 + if (project_path / "services").exists() or len(list(project_path.glob("**/Dockerfile"))) > 1: + return ProjectType.DOCKER_MICROSERVICES + return ProjectType.DOCKER_WEB + + # 检查API服务特征 + if any(tech in [TechStack.NODE_EXPRESS, TechStack.NODE_NEST, + TechStack.PYTHON_FASTAPI, TechStack.GO_GIN] for tech in tech_stacks): + return ProjectType.DOCKER_API + + return ProjectType.UNKNOWN + +def main(): + """命令行入口""" + if len(sys.argv) < 2: + print("Usage: workflow_manager.py [args...]") + print("Commands: init, status, resume, report") + sys.exit(1) + + command = sys.argv[1] + + if command == "init": + if len(sys.argv) < 5: + print("Usage: workflow_manager.py init [port_range]") + sys.exit(1) + project_dir = sys.argv[2] + name = sys.argv[3] + git_url = sys.argv[4] + port_range = sys.argv[5] if len(sys.argv) > 5 else None + + manager = WorkflowManager(project_dir) + manager.init_project(name, git_url, port_range) + print(f"项目已初始化: {name}") + + elif command == "start_step": + if len(sys.argv) < 4: + print("Usage: workflow_manager.py start_step ") + sys.exit(1) + project_dir = sys.argv[2] + step_id = int(sys.argv[3]) + + manager = WorkflowManager(project_dir) + if manager.load_state(): + if manager.start_step(step_id): + print(f"步骤 {step_id} 已开始") + else: + print(f"步骤 {step_id} 开始失败") + else: + print("未找到状态文件") + + elif command == "complete_step": + if len(sys.argv) < 4: + print("Usage: workflow_manager.py complete_step [notes]") + sys.exit(1) + project_dir = sys.argv[2] + step_id = int(sys.argv[3]) + notes = sys.argv[4] if len(sys.argv) > 4 else "" + + manager = WorkflowManager(project_dir) + if manager.load_state(): + manager.complete_step(step_id, notes) + print(f"步骤 {step_id} 已完成") + else: + print("未找到状态文件") + + elif command == "detect": + if len(sys.argv) < 3: + print("Usage: workflow_manager.py detect ") + sys.exit(1) + project_dir = sys.argv[2] + + manager = WorkflowManager(project_dir) + if manager.load_state(): + tech_stack = TechStackDetector.detect(project_dir) + project_type = ProjectTypeDetector.detect(project_dir, tech_stack) + + manager.project_info.tech_stack = tech_stack + manager.project_info.project_type = project_type + manager.start_step(2) + manager.complete_step(2, f"Detected tech stack: {[ts.value for ts in tech_stack]}, project type: {project_type.value}") + print(f"Tech stack: {[ts.value for ts in tech_stack]}, Project type: {project_type.value}") + else: + print("未找到状态文件") + + elif command == "status": + if len(sys.argv) < 3: + print("Usage: workflow_manager.py status ") + sys.exit(1) + project_dir = sys.argv[2] + + manager = WorkflowManager(project_dir) + if manager.load_state(): + progress = manager.get_progress() + print(f"进度: {progress['completed']}/{progress['total']} ({progress['percentage']:.1f}%)") + current = manager.get_current_step() + if current: + print(f"当前步骤: 步骤 {current.id} - {current.name}") + else: + print("未找到状态文件") + + elif command == "resume": + if len(sys.argv) < 3: + print("Usage: workflow_manager.py resume ") + sys.exit(1) + project_dir = sys.argv[2] + + manager = WorkflowManager(project_dir) + can_resume, step_id = manager.can_resume() + if can_resume: + print(f"可以从步骤 {step_id} 恢复执行") + else: + print("无法恢复,请重新初始化") + + elif command == "report": + if len(sys.argv) < 3: + print("Usage: workflow_manager.py report ") + sys.exit(1) + project_dir = sys.argv[2] + + manager = WorkflowManager(project_dir) + if manager.load_state(): + print(manager.generate_report()) + else: + print("未找到状态文件") + +if __name__ == "__main__": + main()