Initial panel project deploy skill snapshot
This commit is contained in:
+91
@@ -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 <Git 仓库地址>”
|
||||
- “克隆并改造这个开源项目,Docker 部署”
|
||||
- “项目页面改版成科技感大屏风格”
|
||||
|
||||
Agent 会自动读取 `SKILL.md` 并按 18 步工作流执行,最终必须通过阻断式 UI 审计(0 violations)。
|
||||
|
||||
## 七、注意事项
|
||||
|
||||
- 技能脚本**不含任何硬编码本机路径**,可跨设备/跨平台直接迁移。
|
||||
- 是否 `git push`、是否本地提交,严格以用户授权为准;用户要求“不推送代码”时禁止推送。
|
||||
- 默认账号密码必须来自项目实际初始化逻辑,禁止猜测。
|
||||
- 合同相关交付规范见 `references/delivery-contract.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 <project_dir> "<项目名称>" "<git地址>" "[端口范围]"
|
||||
python scripts/workflow_manager.py status <project_dir>
|
||||
python scripts/workflow_manager.py resume <project_dir>
|
||||
python scripts/workflow_manager.py report <project_dir>
|
||||
```
|
||||
|
||||
## 技术栈检测
|
||||
|
||||
- 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 提交信息描述具体业务变化,不出现“工作流”。
|
||||
+10
@@ -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\"
|
||||
+12
@@ -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/"
|
||||
@@ -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 时,禁止声明“已交付”。
|
||||
@@ -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`,健康验证才算完成。禁止为了通过审计而临时降低阈值、删除选择器或跳过页面。
|
||||
@@ -0,0 +1,2 @@
|
||||
# 面板 UI 阻断式审计脚本依赖(其余脚本仅用 Python 标准库)
|
||||
playwright>=1.40.0
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
交付压缩包构建脚本(合同对齐)。
|
||||
|
||||
产物(位于 --out-dir,默认 .Codex/delivery/):
|
||||
1) <zip-name> 给甲方/终端使用的交付压缩包
|
||||
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-<YYYYMMDD>.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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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 <command> [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 <project_dir> <name> <git_url> [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 <project_dir> <step_id>")
|
||||
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 <project_dir> <step_id> [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 <project_dir>")
|
||||
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 <project_dir>")
|
||||
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 <project_dir>")
|
||||
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 <project_dir>")
|
||||
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()
|
||||
Reference in New Issue
Block a user