chore: add UI audits and reconcile release docs

This commit is contained in:
key
2026-07-29 16:10:46 +08:00
parent d08d3fb921
commit 6ba7df863f
16 changed files with 770 additions and 36 deletions
+2
View File
@@ -4,6 +4,8 @@
## Unreleased ## Unreleased
- **2026-07-29,资料处理媒体任务恢复已发布并完成生产验证**:修复历史图片/视频在视觉输入不兼容或未提取到文字时长期停在“等待解析”的问题;图片在送入视觉模型前会按方向纠正、限制像素和体积并统一编码,异步队列只有生成至少一个片段才进入完成态,零片段会保留为可重试失败。新增 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry`,可从当前租户原 OSS 对象重新暂存并入队;正式迁移 `aihr_20260729_media_reprocess_mysql8.sql` 增加 `source_attach_id` 和 `(tenant_id, source_attach_id, id)` 索引。生产已备份 schema、后端和完整站点,发布管理端与后端 JAR `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,移动 H5 未重发;发布脚本在重启后等待公开端点真实就绪再执行预检,服务为 `active`,根站、租户和移动首页均返回 HTTP 200。原卡住任务 `1133/1175/1308/1405` 从原文件重试为队列 `1185–1188`,四项均完成、各生成一个片段且向量存在;资料处理页显示“待重试 0”、Tika `1028/1028`、可引用资料 `1028`。该证据证明本次资料恢复和定向发布成功,不代表移动 H5 与当前工作树完整匹配,也不替代 Android 真机或正式试点验收。
- **2026-07-29,新增移动端 UI token 与渲染态审计,未发布静态资源**:`audit:ui-tokens` 以 `mobile-uni/src/styles/base.css` 为源扫描颜色、字阶、间距和圆角,`audit:ui-rendered` 在 390×844 的真实 Chrome 中审计 H5 构建的六个默认路由并保存截图。两项均把报告写入 Git 已忽略的 `output/ui-ux/`;本地实跑覆盖 53 个源码文件和 6 个渲染路由,渲染路由均无横向溢出或控制台错误,同时如实记录现有 palette 外颜色、孤立字号和非 token 圆角,不能把“脚本执行成功”解释为视觉债务已清零。
- **2026-07-29,移动端固定码认证与身份失败关闭收口,候选升级为 `0.1.13 (113)`,未发布生产**:生产验证只选择已存在 `app_user` 且双向唯一的在职组织主体;固定码请求、登录、非空项目范围、认证首页、训练历史/画像、五通道、我的反馈、大喇叭、考试、案例/全网只读入口、退出、旧令牌拒绝、等待防刷窗口后的再次获取和重新登录全部通过,应用日志确认两次验证码请求均命中“未真实发送短信”分支。脚本不输出手机号、固定码或令牌,不触发自动注册,不提交反馈、训练、附件、ASR/TTS 或 Agent 请求。后端 `/api/aihr/mobile/me` 与认证首页遇到身份缺失、组织查询异常、无在职身份或后台账号时改为失败关闭;前端角色和岗位加载失败清空缓存并展示明确错误,不再回退普通员工、陈旧岗位或公开首页。干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`:移动端 199 项、`ruoyi-aihr` 700 项、类型检查、H5/App、管理端构建、后端打包和 APK 深检全部通过,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`。DCloud 云端证书 APK 大小 `33,921,543` 字节、SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,API 35、v2 测试签名、禁止明文 HTTP、包内业务资源/登录法务/身份失败关闭和敏感值深检通过;候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,生产只读发布计划通过,现网仍为 `46C99CFF…`。当前双提交交付包 `bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip` 大小 `192,948,093` 字节、SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目及内部哈希逐项通过;严格门禁会校验固定码模式、5/5 场景、30/30 问题证据、五类负责人签认、每个直通角色至少两名有效处理人、生产场景版本/哈希、五通道正向授权/越权拒绝/一次正式回复,以及受控分发、法务例外和测试签名接受。包内 Go/No-Go 已指向当前包,旧 `ops9c53668c` ZIP 只作历史追溯。机器签认当前准确保持正式场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`,五个处理角色有效绑定均为 `0`。本项未发布后端、H5、数据库或生产静态资源;固定码、登录和重新登录不再列为功能缺口。 - **2026-07-29,移动端固定码认证与身份失败关闭收口,候选升级为 `0.1.13 (113)`,未发布生产**:生产验证只选择已存在 `app_user` 且双向唯一的在职组织主体;固定码请求、登录、非空项目范围、认证首页、训练历史/画像、五通道、我的反馈、大喇叭、考试、案例/全网只读入口、退出、旧令牌拒绝、等待防刷窗口后的再次获取和重新登录全部通过,应用日志确认两次验证码请求均命中“未真实发送短信”分支。脚本不输出手机号、固定码或令牌,不触发自动注册,不提交反馈、训练、附件、ASR/TTS 或 Agent 请求。后端 `/api/aihr/mobile/me` 与认证首页遇到身份缺失、组织查询异常、无在职身份或后台账号时改为失败关闭;前端角色和岗位加载失败清空缓存并展示明确错误,不再回退普通员工、陈旧岗位或公开首页。干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`:移动端 199 项、`ruoyi-aihr` 700 项、类型检查、H5/App、管理端构建、后端打包和 APK 深检全部通过,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`。DCloud 云端证书 APK 大小 `33,921,543` 字节、SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,API 35、v2 测试签名、禁止明文 HTTP、包内业务资源/登录法务/身份失败关闭和敏感值深检通过;候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,生产只读发布计划通过,现网仍为 `46C99CFF…`。当前双提交交付包 `bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip` 大小 `192,948,093` 字节、SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目及内部哈希逐项通过;严格门禁会校验固定码模式、5/5 场景、30/30 问题证据、五类负责人签认、每个直通角色至少两名有效处理人、生产场景版本/哈希、五通道正向授权/越权拒绝/一次正式回复,以及受控分发、法务例外和测试签名接受。包内 Go/No-Go 已指向当前包,旧 `ops9c53668c` ZIP 只作历史追溯。机器签认当前准确保持正式场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`,五个处理角色有效绑定均为 `0`。本项未发布后端、H5、数据库或生产静态资源;固定码、登录和重新登录不再列为功能缺口。
- **2026-07-29,Android 法务交互按企业内测体验收口,候选升级为 `0.1.12 (112)`,未发布生产**:取消启动阶段的 DCloud 原生隐私确认,统一由登录页协议勾选承担同意门槛;协议默认未勾选,服务协议与隐私政策可点击查看,未勾选时发送验证码和登录均在请求前失败关闭。两个公开 HTTPS 地址进入版本化 `.env`,npm 与 HBuilderX 云打包共用;源码、App-Plus 产物和 APK 门禁同时检查 `prompt=none`、登录法务链接、未勾选拦截、网络安全与敏感值。DCloud 独立内测 APK `com.yincheng.wygj 0.1.12 (112)` 大小 `33,921,458` 字节,SHA-256 `DD24E5F20BC7C064F5059E538A1FAB7518DF46CBAB502F59A3629F0B1B1F5EBB`,API 35、v2 测试签名、`usesCleartextTraffic=false`;本机 Android 15/API 35 虚拟机全新安装后确认原生隐私层不可见、四项敏感权限未授权、无 Crash/ANR。干净提交 `b9e08789` 的统一 RC 为 `releaseEligible=true`:移动端 199 项、后端 698 项、类型检查、H5/App、管理端、后端打包和 APK 深检全部通过,证据位于 `output/aug1-rc/0.1.12-112-b9e08789-frozen/`。双提交交付包 `bangdao-aug1-0.1.12-112-b9e08789-ops6bf365d5.zip` 大小 `192,926,061` 字节、SHA-256 `E8014A0C5BD56B66B198710E75E2B8DDEC86002D1E3BC6CAF56FB3454A199C33`,14 个条目及内部哈希逐条通过;新后端 JAR `3F9F4506…` 的生产只读计划也通过目标、服务、公开入口、64/64 schema 和生产禁 DDL 检查,现网 JAR 仍为 `46C99CFF…`,未执行部署。当前 AVD 为 `x86_64`,APK 只含 `arm64-v8a/armeabi-v7a`,DCloud Weex 在跨架构转译时实际报 `spinWaitPeer timeout`、`sender is null` 和 IPC timeout;390×844 Playwright 验证仅作为布局、链接和请求前拦截的补充证据,不替代 App 运行或认证业务验收,也不因此修改发布 ABI、增加降级链或启用兜底内容。证据位于 `output/playwright/aug1-login-consent/`。本项没有发布后端、H5、数据库或新的生产静态资源。 - **2026-07-29,Android 法务交互按企业内测体验收口,候选升级为 `0.1.12 (112)`,未发布生产**:取消启动阶段的 DCloud 原生隐私确认,统一由登录页协议勾选承担同意门槛;协议默认未勾选,服务协议与隐私政策可点击查看,未勾选时发送验证码和登录均在请求前失败关闭。两个公开 HTTPS 地址进入版本化 `.env`,npm 与 HBuilderX 云打包共用;源码、App-Plus 产物和 APK 门禁同时检查 `prompt=none`、登录法务链接、未勾选拦截、网络安全与敏感值。DCloud 独立内测 APK `com.yincheng.wygj 0.1.12 (112)` 大小 `33,921,458` 字节,SHA-256 `DD24E5F20BC7C064F5059E538A1FAB7518DF46CBAB502F59A3629F0B1B1F5EBB`,API 35、v2 测试签名、`usesCleartextTraffic=false`;本机 Android 15/API 35 虚拟机全新安装后确认原生隐私层不可见、四项敏感权限未授权、无 Crash/ANR。干净提交 `b9e08789` 的统一 RC 为 `releaseEligible=true`:移动端 199 项、后端 698 项、类型检查、H5/App、管理端、后端打包和 APK 深检全部通过,证据位于 `output/aug1-rc/0.1.12-112-b9e08789-frozen/`。双提交交付包 `bangdao-aug1-0.1.12-112-b9e08789-ops6bf365d5.zip` 大小 `192,926,061` 字节、SHA-256 `E8014A0C5BD56B66B198710E75E2B8DDEC86002D1E3BC6CAF56FB3454A199C33`,14 个条目及内部哈希逐条通过;新后端 JAR `3F9F4506…` 的生产只读计划也通过目标、服务、公开入口、64/64 schema 和生产禁 DDL 检查,现网 JAR 仍为 `46C99CFF…`,未执行部署。当前 AVD 为 `x86_64`,APK 只含 `arm64-v8a/armeabi-v7a`,DCloud Weex 在跨架构转译时实际报 `spinWaitPeer timeout`、`sender is null` 和 IPC timeout;390×844 Playwright 验证仅作为布局、链接和请求前拦截的补充证据,不替代 App 运行或认证业务验收,也不因此修改发布 ABI、增加降级链或启用兜底内容。证据位于 `output/playwright/aug1-login-consent/`。本项没有发布后端、H5、数据库或新的生产静态资源。
- **2026-07-28 至 2026-07-29,前一版 Android 受控内测候选包与法务链路,历史总体 No-Go**:补齐登录页协议默认不勾选与法务地址失败关闭、App-Plus 原生隐私配置生成/校验、APK 包名/版本/API/签名/哈希/包内资源一致性/敏感值门禁、DCloud 云证书自定义基座和独立测试包流程;《服务协议》《隐私政策》静态页已部署到 `/legal/`,发布前整站备份为 `/opt/wygj/backups/www-20260728224710`。客户端法务校验已移除 App-Plus 不保证支持的浏览器 `URL` 构造器,测试验证码也不再以源码常量进入安装包;五个文件选择服务与九个消费页面进一步统一“用户取消/平台失败”分流,取消后不再残留上传或转写错误;直通车首次弱网加载失败后可原位重试,失败时清空旧通道并禁止提交。当时独立 APK `com.yincheng.wygj 0.1.10 (110)` 的 `targetSdkVersion=35`,SHA-256 为 `20CB16B4E9D024DBF5EEEF577BFBA3B4DF5E5E78B597BABB99CB7450C7AE5034`;Android 原生网络安全配置经 APK 反编译确认 `usesCleartextTraffic=false`、`networkSecurityConfig` 引用和系统证书信任配置均已进入安装包。本机 `Medium_Phone` Android 15/API 35 虚拟机已完成从 `0.1.9` 保留数据覆盖升级和冷启动,真实显示原生隐私弹窗,四项敏感权限保持未授权且未见 FATAL/ANR;`0.1.6` 已在 vivo V2443A(Android 15)完成覆盖安装和冷启动。代码候选提交 `dc20b920` 已在干净工作区通过统一 RC 门禁,`releaseEligible=true`:移动端 198 项测试、类型检查、H5/App 构建、管理端构建、`ruoyi-aihr` 698 项全量测试、后端打包及 APK 强化门禁全部通过;最小交付包 SHA-256 为 `4BAA79B2CA2C4D486B6194288D90022D5F994A4D8C8E05D7C2DDD87F9BF5B3B7`,解压后 9 个文件、其中 8 个载荷逐文件复验通过。只读生产预检确认根站、租户接口、移动首页和 64/64 schema 门禁通过,但生产后端模块、管理端和移动 H5 与本地候选仍不一致;用户隐私选择、权限反例、认证态核心业务和正式内容也未完成,故当时 Go/No-Go 为 **No-Go**。本项没有新增生产发布、服务重启、数据库或静态资源变更。 - **2026-07-28 至 2026-07-29,前一版 Android 受控内测候选包与法务链路,历史总体 No-Go**:补齐登录页协议默认不勾选与法务地址失败关闭、App-Plus 原生隐私配置生成/校验、APK 包名/版本/API/签名/哈希/包内资源一致性/敏感值门禁、DCloud 云证书自定义基座和独立测试包流程;《服务协议》《隐私政策》静态页已部署到 `/legal/`,发布前整站备份为 `/opt/wygj/backups/www-20260728224710`。客户端法务校验已移除 App-Plus 不保证支持的浏览器 `URL` 构造器,测试验证码也不再以源码常量进入安装包;五个文件选择服务与九个消费页面进一步统一“用户取消/平台失败”分流,取消后不再残留上传或转写错误;直通车首次弱网加载失败后可原位重试,失败时清空旧通道并禁止提交。当时独立 APK `com.yincheng.wygj 0.1.10 (110)` 的 `targetSdkVersion=35`,SHA-256 为 `20CB16B4E9D024DBF5EEEF577BFBA3B4DF5E5E78B597BABB99CB7450C7AE5034`;Android 原生网络安全配置经 APK 反编译确认 `usesCleartextTraffic=false`、`networkSecurityConfig` 引用和系统证书信任配置均已进入安装包。本机 `Medium_Phone` Android 15/API 35 虚拟机已完成从 `0.1.9` 保留数据覆盖升级和冷启动,真实显示原生隐私弹窗,四项敏感权限保持未授权且未见 FATAL/ANR;`0.1.6` 已在 vivo V2443A(Android 15)完成覆盖安装和冷启动。代码候选提交 `dc20b920` 已在干净工作区通过统一 RC 门禁,`releaseEligible=true`:移动端 198 项测试、类型检查、H5/App 构建、管理端构建、`ruoyi-aihr` 698 项全量测试、后端打包及 APK 强化门禁全部通过;最小交付包 SHA-256 为 `4BAA79B2CA2C4D486B6194288D90022D5F994A4D8C8E05D7C2DDD87F9BF5B3B7`,解压后 9 个文件、其中 8 个载荷逐文件复验通过。只读生产预检确认根站、租户接口、移动首页和 64/64 schema 门禁通过,但生产后端模块、管理端和移动 H5 与本地候选仍不一致;用户隐私选择、权限反例、认证态核心业务和正式内容也未完成,故当时 Go/No-Go 为 **No-Go**。本项没有新增生产发布、服务重启、数据库或静态资源变更。
+2 -2
View File
@@ -11,9 +11,9 @@
- 品牌:管理端与员工端统一显示“帮道”,继续使用现有红色品牌图标。 - 品牌:管理端与员工端统一显示“帮道”,继续使用现有红色品牌图标。
- 基础链路:租户列表、验证码、`000000 / admin / admin123` 加密登录已验证。 - 基础链路:租户列表、验证码、`000000 / admin / admin123` 加密登录已验证。
- 管理端:除 AI 面试、训练、案例、SOP 和资料处理外,已包含大喇叭发布、单文件提炼与多岗位解读、五渠道直通反馈收件箱、内容/任务运营、问题榜运营、知识空间、全网 AI 配置和组织权限;员工个人训练、主管复盘、团队难题、成长证据与训练录音仅在认证 APP 按项目范围访问。 - 管理端:除 AI 面试、训练、案例、SOP 和资料处理外,已包含大喇叭发布、单文件提炼与多岗位解读、五渠道直通反馈收件箱、内容/任务运营、问题榜运营、知识空间、全网 AI 配置和组织权限;员工个人训练、主管复盘、团队难题、成长证据与训练录音仅在认证 APP 按项目范围访问。
- 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;“问”已改为数字师傅 Agent,文字、ASR 转写和图片/视频统一先做意图规划,再按服务端权限调用知识、训练、待办、记忆、媒体或全网工具,寒暄和不完整表达不再机械落入 RAG。现有能力还包括手机号登录、30 分钟短期多轮、原文件/视频受控交付、大喇叭公司文件摘要/下载/多岗位解读/追问、五渠道直通反馈、“记一下”确认卡、三级字号、训练/题目反馈、问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式。Agent 代码已随 2026-07-25 完整包部署;2026-07-29 当前 DCloud 云端证书内测 APK 为 `0.1.13 (113)`,SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,启动阶段不展示 DCloud 原生隐私确认,法务同意统一由登录页承担。该版进一步让认证身份、角色和组织岗位异常失败关闭,不再回退为普通员工或公开首页;移动端 199 项、`ruoyi-aihr` 700 项、类型检查、App-Plus 资源和 APK 深检已通过。干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`;候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,生产只读发布计划通过但未部署。当前双提交交付包为 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`,大小 `192,948,093` 字节,SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁会核对固定码模式、5/5 场景、30/30 问题证据、五类负责人签认、每个直通角色的主处理人/替补、五通道正向授权/越权拒绝/一次正式回复验收,以及受控分发、法务例外和测试签名接受,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` 及更早 ZIP 只作历史追溯。生产现有在职 APP 账号的固定码链路已经完整通过首次获取/登录、认证只读主路径、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录,应用日志确认两次均未真实发送短信;固定码、登录和重新登录不再列为功能缺口。当前 x86_64 AVD 对仅含 ARM ABI 的 DCloud Weex 运行时出现 IPC 超时;浏览器截图只作布局和交互补充证据,不替代 App 运行、认证业务或设备能力验收,也不因此修改发布 ABI、增加降级路径或启用兜底内容。机器签认当前准确显示正式场景 `0/5`、有据问题 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`,五个处理角色有效绑定均为 `0`;生产后端、业务签认和发布授权尚未闭环,当前 Go/No-Go 仍为 No-Go。旧 `mobile/` 只用于历史回溯。 - 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;“问”已改为数字师傅 Agent,文字、ASR 转写和图片/视频统一先做意图规划,再按服务端权限调用知识、训练、待办、记忆、媒体或全网工具,寒暄和不完整表达不再机械落入 RAG。现有能力还包括手机号登录、30 分钟短期多轮、原文件/视频受控交付、大喇叭公司文件摘要/下载/多岗位解读/追问、五渠道直通反馈、“记一下”确认卡、三级字号、训练/题目反馈、问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式。Agent 代码已随 2026-07-25 完整包部署;2026-07-29 当前 DCloud 云端证书内测 APK 为 `0.1.13 (113)`,SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,启动阶段不展示 DCloud 原生隐私确认,法务同意统一由登录页承担。该版进一步让认证身份、角色和组织岗位异常失败关闭,不再回退为普通员工或公开首页;移动端 199 项、`ruoyi-aihr` 700 项、类型检查、App-Plus 资源和 APK 深检已通过。干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`;冻结候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,其语义已包含在 2026-07-29 后续定向发布的生产 JAR `5376B67B…` 中。当前双提交交付包为 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`,大小 `192,948,093` 字节,SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁会核对固定码模式、5/5 场景、30/30 问题证据、五类负责人签认、每个直通角色的主处理人/替补、五通道正向授权/越权拒绝/一次正式回复验收,以及受控分发、法务例外和测试签名接受,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` 及更早 ZIP 只作历史追溯。生产现有在职 APP 账号的固定码链路已经完整通过首次获取/登录、认证只读主路径、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录,应用日志确认两次均未真实发送短信;固定码、登录和重新登录不再列为功能缺口。当前 x86_64 AVD 对仅含 ARM ABI 的 DCloud Weex 运行时出现 IPC 超时;浏览器截图只作布局和交互补充证据,不替代 App 运行、认证业务或设备能力验收,也不因此修改发布 ABI、增加降级路径或启用兜底内容。机器签认当前准确显示正式场景 `0/5`、有据问题 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`,五个处理角色有效绑定均为 `0`;生产后端定向发布已经完成,但业务签认、受控分发和真机门禁仍未闭环,当前 Go/No-Go 仍为 No-Go。旧 `mobile/` 只用于历史回溯。
- 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,ASR/TTS 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;生产短音频 ASR 当前为阿里云百炼 `qwen3-asr-flash`,SiliconFlow audio 接口保留兼容。SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。 - 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,ASR/TTS 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;生产短音频 ASR 当前为阿里云百炼 `qwen3-asr-flash`,SiliconFlow audio 接口保留兼容。SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。
- 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;任务列表按当前筛选结果前端分页(10/20/50 条,筛选或刷新回到第一页)。页面只保留“批量导入”,通过异步队列处理多文件或 ZIP,不提供浏览器目录选择和服务端目录导入入口。后端目录导入接口仅保留为运维/调试能力。 - 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示待重试、解析中、已完成、失败、片段数和向量化状态;任务列表按当前筛选结果前端分页(10/20/50 条,筛选或刷新回到第一页)。页面只保留“批量导入”,通过异步队列处理多文件或 ZIP,不提供浏览器目录选择和服务端目录导入入口。历史图片/视频若无片段会保持可重试失败,可直接从原 OSS 文件重新入队;2026-07-29 生产已用该链路恢复原卡住的 4 项资料,全部生成片段并入库。后端目录导入接口仅保留为运维/调试能力。
- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;生产通过 `POST /api/aihr/org/sync` 同步外部组织。2026-07-25 记录的生产快照为 3417 人、2938 人在职且均有姓名、3392 个手机号映射、175 名在职主管和 20 名项目经理;姓名通过稳定 `employee_id` 定向补齐,未执行全量覆盖。当前上游 `employee_number` 与既有主体不一致且手机号大量脱敏,修复契约前禁止强制全量同步;人数属于时点事实,发布或验收前应重新查询。 - 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;生产通过 `POST /api/aihr/org/sync` 同步外部组织。2026-07-25 记录的生产快照为 3417 人、2938 人在职且均有姓名、3392 个手机号映射、175 名在职主管和 20 名项目经理;姓名通过稳定 `employee_id` 定向补齐,未执行全量覆盖。当前上游 `employee_number` 与既有主体不一致且手机号大量脱敏,修复契约前禁止强制全量同步;人数属于时点事实,发布或验收前应重新查询。
- 正式试点:服务端提供按起止日期导出的受角色保护 CSV;当前管理端不展示个人训练或试点导出入口,正式试点需在明确独立后台契约后再开放。严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。组织身份同步和至少 20 名手机号映射门槛已具备;下一步仍需选定真实试点窗口,产生员工训练、人工校准和 SOP 评审样本后重跑严格门禁,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。 - 正式试点:服务端提供按起止日期导出的受角色保护 CSV;当前管理端不展示个人训练或试点导出入口,正式试点需在明确独立后台契约后再开放。严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。组织身份同步和至少 20 名手机号映射门槛已具备;下一步仍需选定真实试点窗口,产生员工训练、人工校准和 SOP 评审样本后重跑严格门禁,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。
- 侧栏:只展示当前 AIHR 业务菜单;过滤名单在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。 - 侧栏:只展示当前 AIHR 业务菜单;过滤名单在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。
@@ -295,5 +295,6 @@
- 生产服务保持 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP` 关闭,远端只读 schema 预检为 `64/64`,后端 JAR 与 AIHR 模块哈希和发布源一致;请求仍只能校验结构,不能执行 DDL。 - 生产服务保持 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP` 关闭,远端只读 schema 预检为 `64/64`,后端 JAR 与 AIHR 模块哈希和发布源一致;请求仍只能校验结构,不能执行 DDL。
- 2026-07-25 15:44:45,源码提交 `acd5fd3a` 增加常规陪练“建议回复”:服务端根据当前回合与教练提示生成可编辑但不自动发送的员工回复,失败时不复制提示。该提交已推送、尚未部署或生产验证。 - 2026-07-25 15:44:45,源码提交 `acd5fd3a` 增加常规陪练“建议回复”:服务端根据当前回合与教练提示生成可编辑但不自动发送的员工回复,失败时不复制提示。该提交已推送、尚未部署或生产验证。
- 2026-07-29,8 月 1 日 `0.1.13 (113)` 候选已在干净提交 `af8af2f6` 完成统一 RC,`releaseEligible=true`;199 项移动端测试、类型检查、H5/App、管理端构建、`ruoyi-aihr` 700 项测试、后端打包和 APK 深检通过,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`。生产固定码完成首次获取/登录、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录,两次均确认未真实发送短信;该项不再列为缺口。候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,生产只读发布计划通过但未获部署授权、未发布。当前双提交交付包 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip` 大小 `192,948,093` 字节,SHA-256 为 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` ZIP 只作历史追溯。当前机器签认仍为正式场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`。 - 2026-07-29,8 月 1 日 `0.1.13 (113)` 候选已在干净提交 `af8af2f6` 完成统一 RC,`releaseEligible=true`;199 项移动端测试、类型检查、H5/App、管理端构建、`ruoyi-aihr` 700 项测试、后端打包和 APK 深检通过,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`。生产固定码完成首次获取/登录、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录,两次均确认未真实发送短信;该项不再列为缺口。候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,生产只读发布计划通过但未获部署授权、未发布。当前双提交交付包 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip` 大小 `192,948,093` 字节,SHA-256 为 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` ZIP 只作历史追溯。当前机器签认仍为正式场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`。
- 2026-07-29 后续获得独立授权并通过 `release-backend.sh` 完成生产后端定向发布;当前 JAR SHA-256 为 `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,服务、三个公开入口和后端 + schema 发布后预检通过,冻结候选所含的内容语义与移动身份失败关闭不再是生产阻断项。同批还发布资料处理媒体重试 schema 和管理端,移动 H5 未重发;正式内容、五通道处理人、受控分发和 Android 真机门禁仍保持 No-Go。
当前只能确认 `0.1.13` 自动化候选已冻结、现网接口兼容且 schema 为 64/64;新后端尚未获授权部署,不能把历史完整包匹配外推为当前候选已上线。正式内容与五通道处理人签认、主管真实复盘、真机媒体和严格试点仍未完成,不能称“陪练模块完整交付完成”。 当前只能确认 `0.1.13` 自动化候选已冻结、现网接口兼容且 schema 为 64/64;新后端尚未获授权部署,不能把历史完整包匹配外推为当前候选已上线。正式内容与五通道处理人签认、主管真实复盘、真机媒体和严格试点仍未完成,不能称“陪练模块完整交付完成”。
+13 -8
View File
@@ -1,6 +1,6 @@
# BRD 功能审查与未完成项记录 # BRD 功能审查与未完成项记录
> 当前基线:2026-07-26。最新完整生产发布源仍为 `230dfb4d`,包含五岗位训练内容运营、M2—M3 训练闭环、数字师傅 Agent,以及公司消息 R1—R3 和多岗位解读;其后仅发布了阿里云百炼 ASR 后端热修复和生产模型配置,未重新发布静态资源或执行 schema 迁移。当前真相以本文件、[生产迁移 Runbook](BRD_PRODUCTION_MIGRATION_RUNBOOK.md)、[Changelog](../CHANGELOG.md)和每次发布的只读预检为准。 > 当前基线:2026-07-29。2026-07-25 的 `230dfb4d` 仍是最近一次管理端、H5、后端与 schema 同批完整发布源;其后先发布阿里云百炼 ASR 后端热修复,2026-07-29 又定向发布资料处理媒体恢复的 schema、管理端和后端 JAR `5376b67b…`,移动 H5 未重发。当前只能分别确认最新管理端、后端与媒体重试 schema,不能称当前工作树与线上完整包匹配。当前真相以本文件、[生产迁移 Runbook](BRD_PRODUCTION_MIGRATION_RUNBOOK.md)、[Changelog](../CHANGELOG.md)和每次发布的只读预检为准。
> >
> 数字师傅 Agent、`aihr_agent_run` 和对应移动端改造已随 2026-07-25 完整包部署;2026-07-26 已在一台 Android HBuilderX 标准基座跑通生产 ASR 请求,但 Agent 专项认证态业务回归及自定义基座完整真机验收仍未完成。 > 数字师傅 Agent、`aihr_agent_run` 和对应移动端改造已随 2026-07-25 完整包部署;2026-07-26 已在一台 Android HBuilderX 标准基座跑通生产 ASR 请求,但 Agent 专项认证态业务回归及自定义基座完整真机验收仍未完成。
> >
@@ -25,12 +25,18 @@
| 已实现 | 是 | 已有阶段一主功能,公司消息 R1—R3 已实现直通车与公司文件提炼闭环;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) | | 已实现 | 是 | 已有阶段一主功能,公司消息 R1—R3 已实现直通车与公司文件提炼闭环;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) |
| 本地自动化/API 验证 | 是 | 训练运营定向后端测试 `76` 项;公司消息新增后端 `30` 项、管理端 `2` 项、移动端 `2` 项;管理端/H5 构建通过 | | 本地自动化/API 验证 | 是 | 训练运营定向后端测试 `76` 项;公司消息新增后端 `30` 项、管理端 `2` 项、移动端 `2` 项;管理端/H5 构建通过 |
| 本地浏览器/视觉验证 | 部分 | 已检查管理端目录/任务、两轮动态对练、岗位不匹配拦截、直通处理和文件消息;线上已以认证态员工完成公司消息回归。Android 标准基座生产 ASR 已通过,训练完整写入和原生全项验收仍未完成 | | 本地浏览器/视觉验证 | 部分 | 已检查管理端目录/任务、两轮动态对练、岗位不匹配拦截、直通处理和文件消息;线上已以认证态员工完成公司消息回归。Android 标准基座生产 ASR 已通过,训练完整写入和原生全项验收仍未完成 |
| 已部署 | 是 | 管理端、H5、后端、公司消息 R1—R3、训练内容运营、M2 求助事件和租户组织治理迁移已发布;线上静态资源及 AIHR 模块与本地构建一致 | | 已部署 | 是 | 2026-07-25 完整包已包含公司消息 R1—R3、训练内容运营、M2 求助事件和租户组织治理;2026-07-29 又发布资料处理媒体恢复的 schema、管理端和后端,移动 H5 未重发 |
| 生产基础验证 | 是 | 服务 `active`;根站、H5、租户接口和员工首页 API 正常;远端完整预检为 `64/64`,且管理端/H5 资源、AIHR 模块与本地构建匹配 | | 生产基础验证 | 是 | 服务 `active`;根站、租户接口和移动首页 API 正常;媒体重试列/索引契约通过,4 个历史零片段任务均恢复为已完成且有片段/向量。当前未执行包含移动 H5 的完整包匹配 |
| 生产完整业务验收 | 部分 | 公司消息已完成认证态全员文件消息和直通车闭环,单台 Android 标准基座已完成生产 ASR;训练写入、主管复盘、定向人群正反例、正式处理人绑定、其他真机媒体与严格试点窗口仍未完成 | | 生产完整业务验收 | 部分 | 公司消息已完成认证态全员文件消息和直通车闭环,单台 Android 标准基座已完成生产 ASR;训练写入、主管复盘、定向人群正反例、正式处理人绑定、其他真机媒体与严格试点窗口仍未完成 |
“已部署”不等于“正式试点通过”。当前可以确认生产包和基础运行状态,不能把未执行的正式账号、真机和业务窗口验收写成已完成。 “已部署”不等于“正式试点通过”。当前可以确认生产包和基础运行状态,不能把未执行的正式账号、真机和业务窗口验收写成已完成。
### 2026-07-29 资料处理媒体恢复发布边界
历史图片/视频在视觉模型不接受原始分辨率/编码,或模型、ASR、关键帧链路没有返回可检索文字时,旧逻辑会留下状态 0、片段 0 的附件,页面长期显示“等待解析”,却没有从原件重新入队的入口。当前实现先对图片纠正方向、缩放和重编码;异步队列只有生成至少一个片段才进入完成态,零片段明确保持可重试失败。管理端可调用 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry`,服务端按当前租户从原 OSS 对象重新暂存,通过附件状态 CAS 防止重复入队,并用 `source_attach_id` 关联新队列条目。
正式迁移、管理端和后端已发布。生产后端 JAR SHA-256 为 `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,服务为 `active`,根站、租户和移动首页返回 HTTP 200。原卡住任务 `1133/1175/1308/1405` 已生成队列 `1185–1188`,四项均为完成、各一个片段且向量存在;页面“待重试”为 0。该验证只覆盖资料恢复链路和定向发布,不替代一般知识召回质量、移动 H5 完整包匹配、Android 真机或严格试点。
### 2026-07-24 公司消息 R1—R3 发布边界 ### 2026-07-24 公司消息 R1—R3 发布边界
当前公司消息能力包括全员公开频道、目标快照、必读、未读/已读、管理端发布/撤回、单文件异步提炼,以及以 `broadcastMessageId` 进入“问”的受控追问。公司文件多岗位解读已于 2026-07-25 部署并完成生产浏览器验证:AI 只保留能以原文短引用佐证的岗位视角,员工默认视角由认证手机号对应的在职岗位决定,同时允许切换查看全部已生成视角;生成失败不阻断摘要、原文件、发布或追问。服务端每轮重新校验租户、在职身份和消息状态,正文与附件内容均由服务端加载;消息撤回后续问和文件访问即被拒绝。本次验收生成四个视角,已验证员工端切换、原文依据、原文件下载一致性及撤回后不可访问;不据此替代真机或正式试点验收。 当前公司消息能力包括全员公开频道、目标快照、必读、未读/已读、管理端发布/撤回、单文件异步提炼,以及以 `broadcastMessageId` 进入“问”的受控追问。公司文件多岗位解读已于 2026-07-25 部署并完成生产浏览器验证:AI 只保留能以原文短引用佐证的岗位视角,员工默认视角由认证手机号对应的在职岗位决定,同时允许切换查看全部已生成视角;生成失败不阻断摘要、原文件、发布或追问。服务端每轮重新校验租户、在职身份和消息状态,正文与附件内容均由服务端加载;消息撤回后续问和文件访问即被拒绝。本次验收生成四个视角,已验证员工端切换、原文依据、原文件下载一致性及撤回后不可访问;不据此替代真机或正式试点验收。
@@ -101,12 +107,11 @@
4. BRD 终态仍缺员工对练图像/视频情境、服务录像回放、四川话/粤语真实样本和响应时延证据。 4. BRD 终态仍缺员工对练图像/视频情境、服务录像回放、四川话/粤语真实样本和响应时延证据。
5. 多租户知识平台完成正式租户 ID、空间、授权、外部应用、内容迁移和标准题签字后,才可称业务上线。 5. 多租户知识平台完成正式租户 ID、空间、授权、外部应用、内容迁移和标准题签字后,才可称业务上线。
## 5. 2026-07-25 当前生产发布证据 ## 5. 当前生产发布证据
- 发布源:`230dfb4d feat(broadcast): finish multi-role insight experience`,包含此前训练运营、数字师傅 Agent、公司消息 R1—R3 和多岗位解读;当前源码 `acd5fd3a` 的陪练建议回复未包含在本次生产包。 - 最新定向发布:2026-07-29 资料处理媒体恢复;schema 备份位于 `/opt/wygj/backups/media-reprocess-20260729151224/`,后端回滚目录为 `/opt/wygj/backups/backend-20260729152628-46c99cff75d2/`,管理端发布前完整站点备份为 `/opt/wygj/backups/frontend-20260729153140/`。
- 生产地址:`https://peilian.njzhmj.top/`、`https://peilian.njzhmj.top/h5/`;本次完整备份:`/opt/wygj/backups/broadcast-insights-20260725152349`。 - 最新后端:`5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`;服务 `active`,三个公开入口 HTTP 200,媒体重试 schema 契约及 4 个历史任务恢复通过。管理端已发布,移动 H5 未重发,因此不声称当前工作树完整包匹配。
- 数据库:训练、组织、Agent、`aihr_broadcast_attachment`、多岗位解读、`aihr_direct_feedback` 和五类处理角色迁移已执行;远端 `release-preflight` 对结构的只读核验为 `64/64`。 - 最近一次完整包发布源仍为 `230dfb4d feat(broadcast): finish multi-role insight experience`,完整备份位于 `/opt/wygj/backups/broadcast-insights-20260725152349`;当时管理端/H5、后端和 `64/64` schema 一致,包含训练运营、数字师傅 Agent、公司消息 R1—R3 和多岗位解读。
- 产物与服务:2026-07-25 完整包曾完成管理端/H5、后端和 schema 三项一致性核验;2026-07-26 后端 ASR 热修复后服务仍为 `active`、新 JAR 哈希匹配且生产 ASR/健康检查通过,但静态资源未重发,因此不得把该单项发布描述为当前工作树的完整包一致。
- 认证态生产已验证员工直通匿名提交、审计角色回复、员工查看回复,以及全员文件消息发布、摘要、多岗位解读、下载、内容追问和撤回;2026-07-26 另完成一台 Android 标准基座的生产 ASR。直通测试记录按审计要求保留,公司消息测试记录已撤回;该证据不包含 Agent 专项完整回归、完整训练写入、主管复盘、定向人群正反例或原生全项体验验收。 - 认证态生产已验证员工直通匿名提交、审计角色回复、员工查看回复,以及全员文件消息发布、摘要、多岗位解读、下载、内容追问和撤回;2026-07-26 另完成一台 Android 标准基座的生产 ASR。直通测试记录按审计要求保留,公司消息测试记录已撤回;该证据不包含 Agent 专项完整回归、完整训练写入、主管复盘、定向人群正反例或原生全项体验验收。
## 6. 当前事实源 ## 6. 当前事实源
+8 -1
View File
@@ -33,6 +33,13 @@
- 生产接口已获得正确转写和受控 `ossId`,Android HBuilderX 标准基座“按住说话”请求成功,服务日志无此前的 403/ASR 异常; - 生产接口已获得正确转写和受控 `ossId`,Android HBuilderX 标准基座“按住说话”请求成功,服务日志无此前的 403/ASR 异常;
- 回滚目录为 `/opt/wygj/backups/asr-hotfix-20260726210122`,包含原 JAR 与模型配置 SQL。本次未发布管理端/H5/App 静态资源、未变更 schema,不替代完整包预检、自定义基座权限验收、弱网/方言样本或 Agent 专项业务回归。 - 回滚目录为 `/opt/wygj/backups/asr-hotfix-20260726210122`,包含原 JAR 与模型配置 SQL。本次未发布管理端/H5/App 静态资源、未变更 schema,不替代完整包预检、自定义基座权限验收、弱网/方言样本或 Agent 专项业务回归。
2026-07-29 已完成资料处理媒体恢复的 schema、管理端与后端定向发布:
- 先备份 `aihr_knowledge_upload_item`、生产后端和完整 `/opt/wygj/www`,执行 `aihr_20260729_media_reprocess_mysql8.sql`,并通过 `source_attach_id` 列及 `(tenant_id, source_attach_id, id)` 索引契约;
- 生产后端 JAR SHA-256 为 `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,服务为 `active`;定向发布脚本等待根站、租户和移动首页真实就绪后再跑后端 + schema 预检,三个公开入口均返回 HTTP 200;
- 管理端资料处理页已发布,移动 H5 未重发。原卡住的 4 个图片任务已从 OSS 原件重试,最新队列条目全部完成、各有一个片段且向量存在,页面“待重试”为 0;
- 该次只证明资料恢复链路、管理端和后端定向发布成功。移动 H5 与当前工作树的完整包匹配、Android 真机和严格试点仍须分别验收。
2026-07-20 增量把“项目服务记忆最小切片”扩展为工作助手确认式统一采集:显式“记一下”生成候选,用户确认后按 `PRIVATE/COMPANY` 写入 `aihr_assistant_capture`;个人记录仅本人可见,公司记录先保持 `PENDING`,正式接收端和回执尚未实现。旧 `aihr_service_memory/version` 继续兼容项目记录;同批会话式“工作上报”继续使用既有 `aihr_work_report`,并补 `request_key/request_hash` 和唯一索引。 2026-07-20 增量把“项目服务记忆最小切片”扩展为工作助手确认式统一采集:显式“记一下”生成候选,用户确认后按 `PRIVATE/COMPANY` 写入 `aihr_assistant_capture`;个人记录仅本人可见,公司记录先保持 `PENDING`,正式接收端和回执尚未实现。旧 `aihr_service_memory/version` 继续兼容项目记录;同批会话式“工作上报”继续使用既有 `aihr_work_report`,并补 `request_key/request_hash` 和唯一索引。
2026-07-21 已在生产发布项目化工作助手与今日工作成果提交 `4536d607`,并发布确认卡首屏修正 `11250811`: 2026-07-21 已在生产发布项目化工作助手与今日工作成果提交 `4536d607`,并发布确认卡首屏修正 `11250811`:
@@ -64,7 +71,7 @@
## 发布前置条件 ## 发布前置条件
1. 确认发布窗口、DB 备份和回滚负责人;迁移脚本包含 `ALTER TABLE`,不能在无备份状态执行。 1. 确认发布窗口、DB 备份和回滚负责人;迁移脚本包含 `ALTER TABLE`,不能在无备份状态执行。
2. 发布前先执行 `scripts/release-preflight.sh` 的本地只读检查。定向后端发布先运行 `scripts/release-backend.sh plan`;只有负责人明确授权后才运行 `deploy`,由脚本固定目标、备份旧 JAR、校验候选、原子切换并在失败时恢复。发布后脚本会使用 `RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh`,只核对 AIHR 模块、必需表和生产运行时 schema 保护,不因本轮未发布的静态资源产生假失败;该结果必须明确记录为“后端定向发布”。完整包发布后,必须按 `docs/DEV_SETUP.md` 再启用 `RELEASE_VERIFY_REMOTE_MATCH=true`,同时核对线上静态资源、AIHR 模块和必需表;schema 检查只读、不执行迁移。定向后端或单项 H5 校验不能替代完整包复核。 2. 发布前先执行 `scripts/release-preflight.sh` 的本地只读检查。定向后端发布先运行 `scripts/release-backend.sh plan`;只有负责人明确授权后才运行 `deploy`,由脚本固定目标、备份旧 JAR、校验候选和原子切换。重启后最多等待 90 秒,只有根站、租户和移动首页真实就绪才执行后端 + schema 预检;激活、就绪等待或发布后预检失败都会恢复旧 JAR 并再次验证公开端点。发布后预检使用 `RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh`,只核对 AIHR 模块、必需表和生产运行时 schema 保护,不因本轮未发布的静态资源产生假失败;该结果必须明确记录为“后端定向发布”。完整包发布后,必须按 `docs/DEV_SETUP.md` 再启用 `RELEASE_VERIFY_REMOTE_MATCH=true`,同时核对线上静态资源、AIHR 模块和必需表;schema 检查只读、不执行迁移。定向后端或单项 H5 校验不能替代完整包复核。
3. 确认迁移目标库、租户和字符集为 MySQL 8 / `utf8mb4`;脚本只允许在目标业务库执行。 3. 确认迁移目标库、租户和字符集为 MySQL 8 / `utf8mb4`;脚本只允许在目标业务库执行。
4. 生产组织同步凭据、AI 供应商留存/费用口径和正式试点窗口仍未闭合时,不要把迁移完成宣称为 BRD 验收完成。 4. 生产组织同步凭据、AI 供应商留存/费用口径和正式试点窗口仍未闭合时,不要把迁移完成宣称为 BRD 验收完成。
+10 -5
View File
@@ -209,11 +209,16 @@ curl.exe -k -fsS http://127.0.0.1:6333/
npm --prefix mobile-uni run test:unit npm --prefix mobile-uni run test:unit
npm --prefix mobile-uni run typecheck npm --prefix mobile-uni run typecheck
npm --prefix mobile-uni run audit:ui-tokens
npm --prefix mobile-uni run build:h5
npm --prefix mobile-uni run audit:ui-rendered
npm --prefix frontend run build:dev npm --prefix frontend run build:dev
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -am -DskipTests=false test mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -am -DskipTests=false test
& $gitBash ./scripts/demo-check.sh & $gitBash ./scripts/demo-check.sh
``` ```
`audit:ui-tokens` 只读扫描 `src/styles/base.css` 与移动端源码,默认报告现有 palette、字阶、间距和圆角漂移但不因存量债务失败;需要把 palette 外颜色作为门禁时显式运行 `node mobile-uni/scripts/ui-token-audit.mjs --strict`。`audit:ui-rendered` 默认在 390×844 的真实 Chrome 中打开 H5 构建产物的登录、员工首页、问、练、候选人首页和主管首页,检查横向溢出、控制台错误和实际生效样式;报告与截图写入 Git 已忽略的 `output/ui-ux/`。默认静态服务只覆盖未登录/空态/错误态,不能替代 portless 认证态主路径、Android/iOS 真机或业务验收。
### 6. 停止 ### 6. 停止
先在后端、管理端和 `mobile-uni` 三个终端按 `Ctrl+C`。需要同时停止代理和依赖容器时执行: 先在后端、管理端和 `mobile-uni` 三个终端按 `Ctrl+C`。需要同时停止代理和依赖容器时执行:
@@ -328,15 +333,15 @@ Android 人工验收每完成一个步骤,用 `.\scripts\capture-android-accep
### 定向后端发布与回滚 ### 定向后端发布与回滚
8 月 1 日独立 APK 如只需补齐候选后端语义,使用固定拓扑的 `release-backend.sh`,不要手工 `scp` 后直接覆盖。脚本只允许 `YCWY:/opt/wygj/app/ruoyi-admin.jar` 和 `wygj-aihr.service`,默认 `plan` 与 `rollback-plan` 只读;`deploy`/`rollback` 必须使用只读计划输出的完整 SHA-256 授权串并保持 Git 工作区干净。计划阶段会核对冻结提交、JAR 哈希与 AIHR 模块、生产目标/服务/空间和 schema;部署阶段先保留旧 JAR 与清单,再在同目录校验并原子切换、重启、执行后端 + schema 预检,任何激活或发布后预检失败都会尝试恢复旧 JAR。回滚前还会额外备份当时正在运行的 JAR。脚本不删除备份,不发布管理端/H5,不执行数据库迁移。 8 月 1 日独立 APK 如只需补齐候选后端语义,使用固定拓扑的 `release-backend.sh`,不要手工 `scp` 后直接覆盖。脚本只允许 `YCWY:/opt/wygj/app/ruoyi-admin.jar` 和 `wygj-aihr.service`,默认 `plan` 与 `rollback-plan` 只读;`deploy`/`rollback` 必须使用只读计划输出的完整 SHA-256 授权串并保持 Git 工作区干净。计划阶段会核对冻结提交、JAR 哈希与 AIHR 模块、生产目标/服务/空间和 schema;部署阶段先保留旧 JAR 与清单,再在同目录校验并原子切换、重启,最多等待 90 秒直到根站、租户和移动首页真实恢复,再执行后端 + schema 预检。任何激活、就绪等待或发布后预检失败都会尝试恢复旧 JAR,并再次验证恢复后的公开端点。回滚前还会额外备份当时正在运行的 JAR。脚本不删除备份,不发布管理端/H5,不执行数据库迁移。
先只读生成计划: 先只读生成计划:
```bash ```bash
./scripts/release-backend.sh plan \ ./scripts/release-backend.sh plan \
--artifact backend/ruoyi-admin/target/ruoyi-admin.jar \ --artifact backend/ruoyi-admin/target/ruoyi-admin.jar \
--artifact-commit af8af2f6dd169e4616a4ca0ca724e8b809502ab3 \ --artifact-commit '<冻结提交完整哈希>' \
--expected-sha256 93ad272118f6a28f88c2bfa305f5c450e163cbe22577ff5be3e85b3bd3958dad --expected-sha256 '<候选JAR完整SHA-256>'
``` ```
只有负责人明确授权后,才能把计划输出的 `approval_token` 原样传给 `deploy`;仅运行 `plan` 不构成发布授权: 只有负责人明确授权后,才能把计划输出的 `approval_token` 原样传给 `deploy`;仅运行 `plan` 不构成发布授权:
@@ -344,8 +349,8 @@ Android 人工验收每完成一个步骤,用 `.\scripts\capture-android-accep
```bash ```bash
./scripts/release-backend.sh deploy \ ./scripts/release-backend.sh deploy \
--artifact backend/ruoyi-admin/target/ruoyi-admin.jar \ --artifact backend/ruoyi-admin/target/ruoyi-admin.jar \
--artifact-commit af8af2f6dd169e4616a4ca0ca724e8b809502ab3 \ --artifact-commit '<冻结提交完整哈希>' \
--expected-sha256 93ad272118f6a28f88c2bfa305f5c450e163cbe22577ff5be3e85b3bd3958dad \ --expected-sha256 '<候选JAR完整SHA-256>' \
--approval 'DEPLOY_BACKEND:<完整候选JAR-SHA256>' --approval 'DEPLOY_BACKEND:<完整候选JAR-SHA256>'
``` ```
+30 -2
View File
@@ -25,7 +25,7 @@ order by tenant_id;
### 2.2 备份与迁移 ### 2.2 备份与迁移
先备份数据库,依次执行 `backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql` 与 `backend/script/sql/update/aihr_20260722_knowledge_category_mysql8.sql`。迁移可重复执行,但生产仍应先在结构副本验证。执行后至少核对: 先备份数据库,依次执行 `backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql`、`backend/script/sql/update/aihr_20260722_knowledge_category_mysql8.sql`;需要从原 OSS 文件恢复历史零片段图片/视频时,还必须执行 `backend/script/sql/update/aihr_20260729_media_reprocess_mysql8.sql`。迁移可重复执行,但生产仍应先在结构副本验证。执行后至少核对:
```sql ```sql
show tables like 'aihr_knowledge_%'; show tables like 'aihr_knowledge_%';
@@ -33,6 +33,7 @@ show create table aihr_knowledge_info;
show create table aihr_knowledge_app; show create table aihr_knowledge_app;
show create table aihr_knowledge_space_grant; show create table aihr_knowledge_space_grant;
show create table aihr_knowledge_category; show create table aihr_knowledge_category;
show create table aihr_knowledge_upload_item;
``` ```
## 3. 幂等初始化 ## 3. 幂等初始化
@@ -123,7 +124,34 @@ left join aihr_knowledge_attach a
where a.id is null and o.ext1 in ('aihr-knowledge','aihr-knowledge-staging'); where a.id is null and o.ext1 in ('aihr-knowledge','aihr-knowledge-staging');
``` ```
### 5.2 细粒度 SOP 迁入 `yc_property_sop` ### 5.2 零片段图片/视频排障与重试
资料处理页把 `aihr_knowledge_attach.status in (0,3)` 且片段数为 0 的图片/视频列为“待重试”。先确认视觉模型、视频 ASR/ffmpeg 和原 OSS 对象仍可读取,再从页面点击“重试”;服务端只接受当前租户、状态为待处理/失败且仍有 OSS 原件的图片或视频,通过 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry` 重新暂存并写入异步队列。重复点击由附件状态 CAS 拒绝,不得直接把数据库状态改成完成或手工复制片段。
只读排障可使用:
```sql
select a.id, a.tenant_id, a.name, a.status, a.remark, a.doc_id,
count(f.id) fragment_count
from aihr_knowledge_attach a
left join aihr_knowledge_fragment f
on f.tenant_id = a.tenant_id
and f.knowledge_id = a.knowledge_id
and f.doc_id = a.doc_id
where a.id = <attachmentId>
group by a.id, a.tenant_id, a.name, a.status, a.remark, a.doc_id;
select id, batch_id, file_name, source_attach_id, status, error,
fragment_count, update_time
from aihr_knowledge_upload_item
where tenant_id = '<当前租户>'
and source_attach_id = <attachmentId>
order by id desc;
```
队列状态固定为 `0=待处理、1=处理中、2=完成、3=失败`。成功条件是最新队列条目为 2、附件为 2、片段数大于 0,并按当前向量配置确认相应片段已向量化;零片段必须保持失败态,不能显示为完成。普通失败条目只有在 72 小时暂存保留期内才可调用 `POST /api/knowledge/doc/upload-items/{id}/retry`;暂存已清理时应重新上传。历史附件重试不依赖旧暂存文件,而是从 OSS 原件重新入队。
### 5.3 细粒度 SOP 迁入 `yc_property_sop`
现有“投诉处理 SOP、催缴沟通 SOP、报修跟进 SOP”等 `legacy_*` 空间不直接改名。按以下顺序执行: 现有“投诉处理 SOP、催缴沟通 SOP、报修跟进 SOP”等 `legacy_*` 空间不直接改名。按以下顺序执行:
+4
View File
@@ -20,6 +20,10 @@ Android 每个人工步骤完成后,可在不改变设备状态的前提下保
每次手动操作后换一个简短的 `-Stage`。当前版本首次启动应记录为 `fresh-install-no-native-privacy`;登录页默认未勾选、两个法务链接可打开、未勾选发送验证码和登录均被阻止,可分别使用 `login-consent-default`、`login-legal-links`、`login-consent-required`。麦克风拒绝可使用 `microphone-denied`。脚本仍兼容识别旧包的 `first-choice`、`exit-confirmation` 和 `not-visible`,仅用于历史取证,不再是当前验收流程。脚本不把“拍到页面”自动判成业务通过;验收人仍须结合预期、截图和 `acceptance-evidence.json` 签认。 每次手动操作后换一个简短的 `-Stage`。当前版本首次启动应记录为 `fresh-install-no-native-privacy`;登录页默认未勾选、两个法务链接可打开、未勾选发送验证码和登录均被阻止,可分别使用 `login-consent-default`、`login-legal-links`、`login-consent-required`。麦克风拒绝可使用 `microphone-denied`。脚本仍兼容识别旧包的 `first-choice`、`exit-confirmation` 和 `not-visible`,仅用于历史取证,不再是当前验收流程。脚本不把“拍到页面”自动判成业务通过;验收人仍须结合预期、截图和 `acceptance-evidence.json` 签认。
### 自动化前置检查
先运行 `npm --prefix mobile-uni run audit:ui-tokens` 记录源码 token 收敛度,再完成 `npm --prefix mobile-uni run build:h5` 并运行 `npm --prefix mobile-uni run audit:ui-rendered`。后者默认以 390×844 打开六个未登录/空态路由,检查横向溢出、控制台错误和实际生效样式,并把报告与截图写入 Git 已忽略的 `output/ui-ux/`。审计发现的 palette 外颜色、孤立字号或非 token 圆角应作为视觉债务显式保留或修正;命令退出 0 只代表采集完成,不能替代下方 Android/iOS 系统权限、认证业务、前后台、弱网和真实媒体验收。
## 当前证据边界 ## 当前证据边界
2026-07-26 已在 vivo V2443A(Android 15)的 HBuilderX 5.22 标准基座完成手机号登录,并通过生产 `dashscope/qwen3-asr-flash` 跑通“按住说话 → ASR → 提交”链路。2026-07-28 同一机型再次由 HBuilderX CLI `5.22.2026072503-alpha` 完成标准基座编译、同步和启动;未配置正式法务地址时,登录页协议默认未勾选并明确阻止验证码与登录,点击协议入口不会打开占位地址。 2026-07-26 已在 vivo V2443A(Android 15)的 HBuilderX 5.22 标准基座完成手机号登录,并通过生产 `dashscope/qwen3-asr-flash` 跑通“按住说话 → ASR → 提交”链路。2026-07-28 同一机型再次由 HBuilderX CLI `5.22.2026072503-alpha` 完成标准基座编译、同步和启动;未配置正式法务地址时,登录页协议默认未勾选并明确阻止验证码与登录,点击协议入口不会打开占位地址。
@@ -6,7 +6,7 @@
> >
> 适用入口:员工端、主管端、候选人端的“问”能力;管理端只允许受限运营预览,不可代替移动用户身份。 > 适用入口:员工端、主管端、候选人端的“问”能力;管理端只允许受限运营预览,不可代替移动用户身份。
> >
> 本设计定义产品最终形态。当前本地已实现员工“问”的文字/媒体编排、知识与资源、本人待办/训练、主管训练概况、确认式记忆、全网同意、结构化响应和最小运行审计;主管/候选人统一入口及工作成果、指派、复盘、成果投稿等草稿工具仍是后续范围。该增量尚未部署,也未通过正式账号、真机或阶段一试点验收。 > 本设计定义产品最终形态。当前已实现员工“问”的文字/媒体编排、知识与资源、本人待办/训练、主管训练概况、确认式记忆、全网同意、结构化响应和最小运行审计;主管/候选人统一入口及工作成果、指派、复盘、成果投稿等草稿工具仍是后续范围。Agent 增量已随 2026-07-25 完整包部署,移动身份失败关闭语义又包含在 2026-07-29 定向发布的生产后端中;正式账号完整业务、真机和阶段一试点仍未验收。
## 1. 结论 ## 1. 结论
@@ -1,6 +1,6 @@
# 帮道 8 月 1 日 Android 内测 Go/No-Go # 帮道 8 月 1 日 Android 内测 Go/No-Go
> 评估时间:2026-07-29 12:20(CST) > 评估时间:2026-07-29 15:58(CST)
> >
> 评估对象:2026-08-01 受控内部测试 APK,不代表正式发布、应用商店上架或阶段一严格试点验收。 > 评估对象:2026-08-01 受控内部测试 APK,不代表正式发布、应用商店上架或阶段一严格试点验收。
> >
@@ -10,7 +10,7 @@
**当前为 No-Go,暂不能把现有 APK 标记为 8 月 1 日可分发版本。** **当前为 No-Go,暂不能把现有 APK 标记为 8 月 1 日可分发版本。**
`0.1.13` DCloud 云端证书 APK 已生成并通过深检,认证身份失败关闭和可重复固定码重新登录门禁已进入包体;干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`,新候选后端的生产只读发布计划也已通过。固定码、登录和重新登录不再是缺口;当前阻断点只剩生产后端发布授权与发布后匹配、正式业务内容/五通道处理人签认、企业发布签认,以及本轮明确暂缓的真机设备验收。 `0.1.13` DCloud 云端证书 APK 已生成并通过深检,认证身份失败关闭和可重复固定码重新登录门禁已进入包体;干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`。候选后端语义已包含在 2026-07-29 后续定向发布的生产 JAR 中,服务、公开入口、schema 和资料处理真实任务均已验证,生产后端发布不再是阻断项。当前阻断点只剩正式业务内容/五通道处理人签认、企业发布签认,以及本轮明确暂缓的真机设备验收。
## 已通过 ## 已通过
@@ -33,29 +33,28 @@
| 生产固定码认证 | 运行时确认固定码和生产显式开关同时启用;生产应用日志确认首次和重新登录前的两次验证码请求均命中“未真实发送短信”分支。只选择已存在 `app_user` 的双向唯一在职主体,不触发自动注册,不输出手机号、固定码或令牌 | | 生产固定码认证 | 运行时确认固定码和生产显式开关同时启用;生产应用日志确认首次和重新登录前的两次验证码请求均命中“未真实发送短信”分支。只选择已存在 `app_user` 的双向唯一在职主体,不触发自动注册,不输出手机号、固定码或令牌 |
| 认证后非真机主路径 | `scripts/verify-aug1-authenticated-production.sh --execute` 通过身份/非空项目范围、认证首页、训练历史/画像、五通道、我的反馈、大喇叭、考试、案例/全网能力只读入口、退出、旧令牌业务码 401、防刷窗口后的再次获取、重新登录、重新读取身份和再次退出;五通道返回数为 5。未提交业务写入、附件、ASR/TTS 或 Agent 请求 | | 认证后非真机主路径 | `scripts/verify-aug1-authenticated-production.sh --execute` 通过身份/非空项目范围、认证首页、训练历史/画像、五通道、我的反馈、大喇叭、考试、案例/全网能力只读入口、退出、旧令牌业务码 401、防刷窗口后的再次获取、重新登录、重新读取身份和再次退出;五通道返回数为 5。未提交业务写入、附件、ASR/TTS 或 Agent 请求 |
| 法务静态页 | `https://peilian.njzhmj.top/legal/terms.html` 与 `https://peilian.njzhmj.top/legal/privacy.html` 已部署并可访问;发布前站点备份为 `/opt/wygj/backups/www-20260728224710` | | 法务静态页 | `https://peilian.njzhmj.top/legal/terms.html` 与 `https://peilian.njzhmj.top/legal/privacy.html` 已部署并可访问;发布前站点备份为 `/opt/wygj/backups/www-20260728224710` |
| 生产 API 只读契约 | 可重复 GET-only 探针覆盖 20 个 APK 入口;固定码认证脚本另完成认证后只读主路径。`0.1.13` 新后端候选包含 `AihrMobileController` 身份失败关闭修复,现网后端仍为上一语义,因此只读兼容通过不代表新候选已部署匹配 | | 生产 API 只读契约 | 可重复 GET-only 探针覆盖 20 个 APK 入口;固定码认证脚本另完成认证后只读主路径。`0.1.13` 所需的 `AihrMobileController` 身份失败关闭语义已包含在 2026-07-29 定向发布的生产后端中;本次发布后根站、租户和移动首页再次返回 HTTP 200 |
| 定向发布预检 | `566f222e` 已支持只核对后端 + schema;`feece9bb` 进一步允许以冻结提交和解压后的候选 JAR 为准。两次实际只读运行均跳过管理端/H5,根站、租户、移动首页和生产禁 DDL 门禁通过;后端 + schema 模式另通过 64/64 schema。冻结物模式确认本地 JAR SHA-256 为 `8c676f2f…`,最终均仅因现网/候选模块哈希 `15f5513a…` / `c93460e8…` 不同而按预期失败 | | 定向发布预检 | `566f222e` 已支持只核对后端 + schema;`feece9bb` 进一步允许以冻结提交和解压后的候选 JAR 为准。两次实际只读运行均跳过管理端/H5,根站、租户、移动首页和生产禁 DDL 门禁通过;后端 + schema 模式另通过 64/64 schema。冻结物模式确认本地 JAR SHA-256 为 `8c676f2f…`,最终均仅因现网/候选模块哈希 `15f5513a…` / `c93460e8…` 不同而按预期失败 |
| 后端发布/回滚计划 | `release-backend.sh` 固定生产 JAR 与 systemd 服务,默认只读,部署/回滚分别要求完整哈希授权串和干净工作区;备份、原子切换、发布后预检、失败恢复及并发改包保护均有回归。`af8af2f6` 候选 JAR 实跑只读计划通过:候选 `93ad2721…`、现网 `46c99cff…`,服务 active、空间充足、三个公开入口、64/64 schema 和生产禁 DDL 通过,全程未备份、上传、重启或改库 | | 后端发布/回滚计划 | `release-backend.sh` 固定生产 JAR 与 systemd 服务,默认只读,部署/回滚分别要求完整哈希授权串和干净工作区;备份、原子切换、90 秒公开端点就绪等待、发布后预检、失败恢复及并发改包保护均有回归。2026-07-29 已用完整授权串完成定向发布,生产 JAR 为 `5376b67b…`、服务 active、三个公开入口 HTTP 200、媒体重试 schema 契约通过,后端回滚目录已保留 |
## No-Go 阻断项 ## No-Go 阻断项
1. **生产接口兼容,但当前后端修复未部署。** 生产固定码登录和现有认证只读接口可用,根站、租户接口、移动首页和 64/64 schema 门禁通过;当前源码除“不得冒充已创建/派发工单”和场景发布门禁外,又让移动身份缺失、组织查询异常、无在职身份和后台账号访问员工首页失败关闭。新后端 JAR 已冻结且只读发布计划通过,但尚无部署授权或发布后模块匹配结果,生产仍保留旧语义;获得单独授权前不得部署。管理端和移动 H5 不属于独立 APK 本轮必发单元,schema 本批不变。 1. **剩余认证问题已收敛为真机设备能力。** 固定码首次请求、登录、退出、旧令牌失效、等待防刷窗口后的再次请求和重新登录均已通过,两次请求都由日志确认没有真实短信发送。后端定向发布后公开接口再次通过;尚未完成的麦克风允许/拒绝、真实系统选择器取消、App 前后台和真实断网恢复均属于 Android 真机门禁,不能用 H5 结果代替。
2. **剩余认证问题已收敛为真机设备能力。** 固定码首次请求、登录、退出、旧令牌失效、等待防刷窗口后的再次请求和重新登录均已通过,两次请求都由日志确认没有真实短信发送。尚未完成的麦克风允许/拒绝、真实系统选择器取消、App 前后台和真实断网恢复均属于 Android 真机门禁,不能用 H5 结果代替。 2. **核心业务的非写入认证入口已通过,正式业务闭环仍受内容和处理人阻断。** 认证账号下身份、项目范围、首页、训练历史/画像、五通道列表、我的反馈、大喇叭、考试、案例和全网能力只读入口均已通过;正式“练”仍没有可发布的 5 个场景,五通道仍没有真实收件人,附件和 ASR/TTS 仍属真机/媒体验收。不得通过创建测试内容或绑定任意用户制造假闭环。
3. **核心业务的非写入认证入口已通过,正式业务闭环仍受内容和处理人阻断。** 认证账号下身份、项目范围、首页、训练历史/画像、五通道列表、我的反馈、大喇叭、考试、案例和全网能力只读入口均已通过;正式“练”仍没有可发布的 5 个场景,五通道仍没有真实收件人,附件和 ASR/TTS 仍属真机/媒体验收。不得通过创建测试内容或绑定任意用户制造假闭环。 3. **内容与业务验收不足。** 当前仅收到 1/24 份生活顾问访谈。技术侧已把调研整理为 30 道政策题候选和 5 个场景候选,并提供统一签认单,但全部保持待审/禁用,正式可发布场景仍为 0/5,正式有据标准答案仍为 0/30。生产只读审计确认 `direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 五个角色的有效用户绑定数均为 0,必须由业务负责人指定主处理人和替补后再验收。
4. **内容与业务验收不足。** 当前仅收到 1/24 份生活顾问访谈。技术侧已把调研整理为 30 道政策题候选和 5 个场景候选,并提供统一签认单,但全部保持待审/禁用,正式可发布场景仍为 0/5,正式有据标准答案仍为 0/30。生产只读审计确认 `direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 五个角色的有效用户绑定数均为 0,必须由业务负责人指定主处理人和替补后再验收。 4. **APK 分发与企业发布尚未签认。** 后端发布授权、备份、原子切换、发布后验证和回滚点已经闭环;当前 APK 已关闭明文流量但仍使用 DCloud 测试证书,只能用于受控内测。受控分发批准、测试签名接受、企业签名和公司法务正式签认仍是对应发布层级的门槛。
5. **发布与回滚尚未签认。** 固定目标、默认只读、显式授权、备份、原子切换、失败恢复和并发保护的后端工具已经落地,冻结候选的只读发布计划也已通过;但尚无负责人发布授权、实际发布后匹配结果或回滚演练记录。当前 APK 已关闭明文流量但仍使用 DCloud 测试证书,只能用于受控内测;企业签名和公司法务正式签认仍是正式发布门槛。
## 转为 Go 的最短路径 ## 转为 Go 的最短路径
| 顺序 | 必须完成 | 验收证据 | | 顺序 | 必须完成 | 验收证据 |
|---|---|---| |---|---|---|
| 已完成 | 确定 APK、后端、管理端/H5、数据库四个发布单元并生成 `0.1.13` DCloud 云端证书 APK | `0.1.13 (113)` APK SHA-256 `FA2D2A6D…`,API 35、v2 测试签名和深检通过;`0.1.12` ZIP 只作上一冻结基线 | | 已完成 | 确定 APK、后端、管理端/H5、数据库四个发布单元并生成 `0.1.13` DCloud 云端证书 APK | `0.1.13 (113)` APK SHA-256 `FA2D2A6D…`,API 35、v2 测试签名和深检通过;`0.1.12` ZIP 只作上一冻结基线 |
| 已完成 | 在干净提交上冻结 `0.1.13` APK 与后端 JAR,并执行生产只读发布计划 | `releaseEligible=true`,冻结目录 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`;候选 JAR `93AD2721…`、现网 JAR `46C99CFF…`,64/64 schema;未部署、未重启、未改库 | | 已完成 | 在干净提交上冻结 `0.1.13` APK 与候选后端 JAR,并执行生产只读发布计划 | `releaseEligible=true`,冻结目录 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`;候选 JAR `93AD2721…` 和当时现网 `46C99CFF…` 只保留为冻结/发布前基线 |
| 1 | 获得明确授权后才使用 `release-backend.sh deploy` 发布内容语义和移动身份失败关闭修复;管理端/H5 延期,schema 本批不变 | 远端备份目录、生产 `ruoyi-aihr` 模块匹配、64/64 schema、发布后固定码认证与回滚点;不得手工覆盖或绕过失败恢复 | | 已完成 | 获得明确授权后使用 `release-backend.sh deploy` 发布内容语义、移动身份失败关闭和后续资料处理恢复;保留回滚点并完成发布后验证 | 生产 JAR `5376B67B…`、服务 active、三个公开入口 HTTP 200、媒体重试 schema 契约通过;管理端同步发布,移动 H5 未重发 |
| 已完成 | 使用生产现有在职 APP 主体和固定码完成非真机认证、退出、重新获取、重新登录及认证后只读主路径;固定码请求不真实发送短信 | `scripts/verify-aug1-authenticated-production.sh --execute` 全部通过,五通道数为 5,旧令牌按业务码 401 拒绝,两次验证码请求均有“未真实发送”日志;执行过程不输出手机号、固定码或令牌 | | 已完成 | 使用生产现有在职 APP 主体和固定码完成非真机认证、退出、重新获取、重新登录及认证后只读主路径;固定码请求不真实发送短信 | `scripts/verify-aug1-authenticated-production.sh --execute` 全部通过,五通道数为 5,旧令牌按业务码 401 拒绝,两次验证码请求均有“未真实发送”日志;执行过程不输出手机号、固定码或令牌 |
| 2 | 业务方按统一签认单审核 30 道题和 5 个场景候选,补齐正式来源;同时指定五渠道主处理人和替补 | [业务内容与五通道签认单](帮道8月1日业务内容与五通道签认单-20260729.md)、正式内容版本、授权矩阵;正式可发布场景从 0/5 提升到 5/5、有据标准答案从 0/30 提升到 30/30,五个角色有效绑定均不少于 2 | | 1 | 业务方按统一签认单审核 30 道题和 5 个场景候选,补齐正式来源;同时指定五渠道主处理人和替补 | [业务内容与五通道签认单](帮道8月1日业务内容与五通道签认单-20260729.md)、正式内容版本、授权矩阵;正式可发布场景从 0/5 提升到 5/5、有据标准答案从 0/30 提升到 30/30,五个角色有效绑定均不少于 2 |
| 3 | 绑定获批处理人后完成五通道正例、跨通道反例和一次正式回复;用正式内容完成“查/练/问”业务写入闭环 | 每条用例的时间、角色、结果和脱敏证据;不记录手机号、验证码或反馈正文 | | 2 | 绑定获批处理人后完成五通道正例、跨通道反例和一次正式回复;用正式内容完成“查/练/问”业务写入闭环 | 每条用例的时间、角色、结果和脱敏证据;不记录手机号、验证码或反馈正文 |
| 4 | Android 真机完成麦克风、系统选择器、前后台、弱网、附件和 ASR/TTS 门禁 | 当前 APK 哈希对应的设备证据;不得用 H5 或模拟器空白 WebView 代替 | | 3 | Android 真机完成麦克风、系统选择器、前后台、弱网、附件和 ASR/TTS 门禁 | 当前 APK 哈希对应的设备证据;不得用 H5 或模拟器空白 WebView 代替 |
| 5 | 7 月 31 日复跑安装、升级、回滚和完整 Go/No-Go | RC APK 哈希、签认人、回滚点、最终结论 | | 4 | 7 月 31 日复跑安装、升级、回滚和完整 Go/No-Go | RC APK 哈希、签认人、回滚点、最终结论 |
公司法务签认与企业签名可在负责人明确接受“仅限小范围受控内部测试”的前提下不阻断技术验收,但不能省略登录页协议门禁、权限反例、版本冻结、生产依赖匹配和核心业务闭环。 公司法务签认与企业签名可在负责人明确接受“仅限小范围受控内部测试”的前提下不阻断技术验收,但不能省略登录页协议门禁、权限反例、版本冻结、生产依赖匹配和核心业务闭环。
@@ -111,6 +111,8 @@
同日当前源码候选继续升级为 `0.1.13 (113)`:认证身份、组织角色和岗位加载异常改为失败关闭,不再回退普通员工、陈旧岗位或公开首页;生产固定码的首次获取/登录、退出、旧令牌拒绝、等待防刷窗口后的再次获取和重新登录完整通过,两次请求均确认未真实发送短信。干净提交 `af8af2f6dd169e4616a4ca0ca724e8b809502ab3` 的统一 RC 已完成,证据位于 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`,`releaseEligible=true`;移动端 199 项、`ruoyi-aihr` 700 项、类型检查、H5/App、管理端构建、后端打包和 APK 深检通过。候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,生产只读发布计划通过但未执行部署。以 operations commit `46f4616bd4eff9bc068335758446c161ead38af1` 生成当前交付包 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`,大小 `192,948,093` 字节、SHA-256 为 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个 ZIP 条目和内部哈希已逐项复核;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包。旧 `ops9c53668c`、`0.1.12` 及更早安装包/交付 ZIP 只作历史追溯,不再作为当前候选。 同日当前源码候选继续升级为 `0.1.13 (113)`:认证身份、组织角色和岗位加载异常改为失败关闭,不再回退普通员工、陈旧岗位或公开首页;生产固定码的首次获取/登录、退出、旧令牌拒绝、等待防刷窗口后的再次获取和重新登录完整通过,两次请求均确认未真实发送短信。干净提交 `af8af2f6dd169e4616a4ca0ca724e8b809502ab3` 的统一 RC 已完成,证据位于 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`,`releaseEligible=true`;移动端 199 项、`ruoyi-aihr` 700 项、类型检查、H5/App、管理端构建、后端打包和 APK 深检通过。候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,生产只读发布计划通过但未执行部署。以 operations commit `46f4616bd4eff9bc068335758446c161ead38af1` 生成当前交付包 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`,大小 `192,948,093` 字节、SHA-256 为 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个 ZIP 条目和内部哈希已逐项复核;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包。旧 `ops9c53668c`、`0.1.12` 及更早安装包/交付 ZIP 只作历史追溯,不再作为当前候选。
同日后续已获得独立授权并通过 `release-backend.sh` 完成生产后端定向发布;生产 JAR SHA-256 为 `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,服务、三个公开入口和后端 + schema 发布后预检通过,后端发布不再是 8 月 1 日 APK 的阻断项。同批发布资料处理媒体恢复 schema 和管理端,移动 H5 未重发;这不改变正式场景/题目、五通道处理人、受控分发与真机设备门禁的 No-Go 状态。
生活顾问调研已转成 `docs/content-candidates/aug1-life-advisor-content-candidates-v0.1.json`:包含 5 个禁用场景候选和 30 道待审政策题,内容候选校验脚本通过。由于当前只收到 1/24 份访谈,且正式来源、业务审核和 Rubric 签认尚未完成,正式可发布场景与正式有据标准答案均为 `0`;交付包中的候选文件只供审核,不得直接导入或启用。 生活顾问调研已转成 `docs/content-candidates/aug1-life-advisor-content-candidates-v0.1.json`:包含 5 个禁用场景候选和 30 道待审政策题,内容候选校验脚本通过。由于当前只收到 1/24 份访谈,且正式来源、业务审核和 Rubric 签认尚未完成,正式可发布场景与正式有据标准答案均为 `0`;交付包中的候选文件只供审核,不得直接导入或启用。
业务回填入口已统一为 `docs/帮道8月1日业务内容与五通道签认单-20260729.md`。2026-07-29 生产只读审计确认五个直通角色均存在,但有效用户绑定数分别为 0;系统功能可用不等于真实收件人已配置,技术侧不得擅自绑定人员。 业务回填入口已统一为 `docs/帮道8月1日业务内容与五通道签认单-20260729.md`。2026-07-29 生产只读审计确认五个直通角色均存在,但有效用户绑定数分别为 0;系统功能可用不等于真实收件人已配置,技术侧不得擅自绑定人员。
@@ -11,7 +11,7 @@
> v1.7 文档收口:明确当前“工作上报”是四类成果投稿审核链,工作助手负责日常事实采集;项目名称选择、项目化会话、来源追溯和今日工作成果仍属后续迭代,不能从规划文档推断已实现。详见[迭代计划](工作助手与今日工作成果迭代计划-20260721.md)。 > v1.7 文档收口:明确当前“工作上报”是四类成果投稿审核链,工作助手负责日常事实采集;项目名称选择、项目化会话、来源追溯和今日工作成果仍属后续迭代,不能从规划文档推断已实现。详见[迭代计划](工作助手与今日工作成果迭代计划-20260721.md)。
> v1.8 发布前本地实施快照:项目名称选择、项目化会话、确认记录来源/状态、员工今日成果、主管项目成果和“成果投稿”界面名称已完成本地实现与 390×844 验证;该快照记录的是发布前状态,外部线索、工单和考勤投递仍保持 PENDING。 > v1.8 发布前本地实施快照:项目名称选择、项目化会话、确认记录来源/状态、员工今日成果、主管项目成果和“成果投稿”界面名称已完成本地实现与 390×844 验证;该快照记录的是发布前状态,外部线索、工单和考勤投递仍保持 PENDING。
> v1.9 发布与本轮回填: v1.8 所列多项目、项目化确认采集、来源/状态、员工今日成果、主管项目成果和“成果投稿”界面名称已于 2026-07-21 部署。成果历史日期选择、服务端拒绝未来日期及主管手机号授权兜底已于 2026-07-22 发布,并完成远端服务、schema 与产物匹配复核;外部线索、工单和考勤投递仍保持 PENDING。 > v1.9 发布与本轮回填: v1.8 所列多项目、项目化确认采集、来源/状态、员工今日成果、主管项目成果和“成果投稿”界面名称已于 2026-07-21 部署。成果历史日期选择、服务端拒绝未来日期及主管手机号授权兜底已于 2026-07-22 发布,并完成远端服务、schema 与产物匹配复核;外部线索、工单和考勤投递仍保持 PENDING。
> v2.0 本地实施快照:“问”改由 `/api/aihr/agent/**` 统一规划意图并执行受控工具,知识 RAG 退回底层能力;新增 `aihr_agent_run` 最小路由审计。该增量已完成本地自动化、H5 构建和部分 390×844 浏览器验证,尚未部署或完成正式账号/真机验收。 > v2.0 实施快照:“问”改由 `/api/aihr/agent/**` 统一规划意图并执行受控工具,知识 RAG 退回底层能力;新增 `aihr_agent_run` 最小路由审计。该增量已完成自动化、H5 构建和部分 390×844 浏览器验证,并随 2026-07-25 完整包部署;移动身份失败关闭语义又包含在 2026-07-29 定向发布的生产后端中。正式账号完整业务和真机验收仍未完成。
> 配套:需求见[《物业AI人力资源系统业务需求文档BRD》](物业AI人力资源系统业务需求文档BRD.md);2026-07 MVP 执行计划已归档到[《AI人力资源系统一期MVP版作战清单》](archive/2026-07-mvp-delivery/AI人力资源系统一期MVP版作战清单.md)。 > 配套:需求见[《物业AI人力资源系统业务需求文档BRD》](物业AI人力资源系统业务需求文档BRD.md);2026-07 MVP 执行计划已归档到[《AI人力资源系统一期MVP版作战清单》](archive/2026-07-mvp-delivery/AI人力资源系统一期MVP版作战清单.md)。
> **优先级图例**:`P0`=2026-07-05 MVP 演示必需 · `P1`=一期必需 · `P2`=二期/推迟。 > **优先级图例**:`P0`=2026-07-05 MVP 演示必需 · `P1`=一期必需 · `P2`=二期/推迟。
> 决策基线:若依基座 / 集中式前后端分离 / 本地登录 / 公有大模型API / 一期RAG / 组织人员外部同步(MVP 用快照) / 数据范围以项目为主体。 > 决策基线:若依基座 / 集中式前后端分离 / 本地登录 / 公有大模型API / 一期RAG / 组织人员外部同步(MVP 用快照) / 数据范围以项目为主体。
@@ -186,7 +186,7 @@ mobile/
| `/h5/#/pages/user/today/index` | 员工端首页,手机号登录后可“开始训练”并同步主管待复盘计数;旧 `/h5/user` 兼容重定向 | P0跑通 | | `/h5/#/pages/user/today/index` | 员工端首页,手机号登录后可“开始训练”并同步主管待复盘计数;旧 `/h5/user` 兼容重定向 | P0跑通 |
| `/h5/#/pages/candidate/index/index` | 候选人端首页,手机号登录后可面试练习、查岗位 SOP/案例、上传补充资料;旧 `/h5/candidate` 兼容重定向 | P0跑通 | | `/h5/#/pages/candidate/index/index` | 候选人端首页,手机号登录后可面试练习、查岗位 SOP/案例、上传补充资料;旧 `/h5/candidate` 兼容重定向 | P0跑通 |
| `/h5/#/pages/supervisor/index/index` | 主管端首页,查看团队、待复盘、案例/SOP 工具;旧 `/h5/supervisor` 兼容重定向 | P0跑通 | | `/h5/#/pages/supervisor/index/index` | 主管端首页,查看团队、待复盘、案例/SOP 工具;旧 `/h5/supervisor` 兼容重定向 | P0跑通 |
| `/h5/#/pages/user/sop/index` | 问·数字师傅 Agent;文字、语音、图片/视频自动路由知识、训练、待办、记忆、媒体或全网工具,项目按名称选择 | Agent 本地已实现,未部署;正式账号/真机验收未完成 | | `/h5/#/pages/user/sop/index` | 问·数字师傅 Agent;文字、语音、图片/视频自动路由知识、训练、待办、记忆、媒体或全网工具,项目按名称选择 | Agent 已部署;正式账号完整业务/真机验收未完成 |
| `/h5/#/pages/user/work-results/index` | 员工工作成果;按员工、项目、自然日汇总确认记录和待跟进 | 2026-07-22 已发布历史日期选择与未来日期拒绝 | | `/h5/#/pages/user/work-results/index` | 员工工作成果;按员工、项目、自然日汇总确认记录和待跟进 | 2026-07-22 已发布历史日期选择与未来日期拒绝 |
| `/h5/#/pages/supervisor/work-results/index` | 主管项目成果;按目标项目权限查看员工成果和高优问题 | 2026-07-22 已发布历史日期选择与手机号授权兜底 | | `/h5/#/pages/supervisor/work-results/index` | 主管项目成果;按目标项目权限查看员工成果和高优问题 | 2026-07-22 已发布历史日期选择与手机号授权兜底 |
| `/h5/#/pages/user/report/index` | 员工成果投稿;复用「问」的微信式语音会话,AI 整理确认后提交既有审核链 | 会话式页面和接口已部署;本地界面已正式改名;项目归属和媒体内容理解未实现 | | `/h5/#/pages/user/report/index` | 员工成果投稿;复用「问」的微信式语音会话,AI 整理确认后提交既有审核链 | 会话式页面和接口已部署;本地界面已正式改名;项目归属和媒体内容理解未实现 |
+2
View File
@@ -15,6 +15,8 @@
"verify:app-assets": "node scripts/verify-app-assets.mjs", "verify:app-assets": "node scripts/verify-app-assets.mjs",
"verify:android-apk": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-android-apk.ps1", "verify:android-apk": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-android-apk.ps1",
"verify:app-release": "node scripts/verify-app-assets.mjs --require-legal-consent --check-legal-urls", "verify:app-release": "node scripts/verify-app-assets.mjs --require-legal-consent --check-legal-urls",
"audit:ui-tokens": "node scripts/ui-token-audit.mjs",
"audit:ui-rendered": "node scripts/ui-rendered-audit.mjs",
"test:unit": "node --test tests/*.test.mjs", "test:unit": "node --test tests/*.test.mjs",
"typecheck": "vue-tsc --noEmit" "typecheck": "vue-tsc --noEmit"
}, },
+77
View File
@@ -0,0 +1,77 @@
/**
* ui-tokens.mjs — UI token 审计共享库
*
* 以 src/styles/base.css 为唯一 palette / 字阶 / 半径来源,
* 同时服务静态审计(ui-token-audit.mjs)与渲染审计(ui-rendered-audit.mjs)。
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const mobileRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
export const tokenFile = join(mobileRoot, 'src', 'styles', 'base.css');
export function normalizeHex(hex) {
let h = hex.toLowerCase();
if (h.length === 4 || h.length === 5) {
h = '#' + [...h.slice(1)].map((c) => c + c).join('');
}
return h;
}
export function normalizeRgba(fn) {
return fn.toLowerCase().replace(/\s+/g, '');
}
/** 渲染侧 computed style 给出的是 rgb()/rgba() 字符串,统一规整;不透明时折叠为 hex */
export function normalizeComputedColor(value) {
const v = value.toLowerCase().replace(/\s+/g, '');
const m = v.match(/^rgba?\((\d+),(\d+),(\d+)(?:,([\d.]+))?\)$/);
if (!m) return v;
const alpha = m[4] === undefined ? 1 : Number(m[4]);
if (alpha === 0) return 'transparent';
if (alpha === 1) {
return '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('');
}
return v;
}
export function hexToRgb(h) {
return [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
}
export function colorDist(a, b) {
const [r1, g1, b1] = hexToRgb(a);
const [r2, g2, b2] = hexToRgb(b);
return Math.round(Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2));
}
export const NEAR_MISS_MAX_DIST = 24; // RGB 欧氏距离 ≤24 视为"近色漂移",疑似 token 笔误
export function loadTokens() {
const baseCss = readFileSync(tokenFile, 'utf8');
const palette = new Set(['transparent', 'currentcolor', 'inherit']);
for (const m of baseCss.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) palette.add(normalizeHex(m[0]));
for (const m of baseCss.matchAll(/rgba?\([^)]*\)/g)) palette.add(normalizeRgba(m[0]));
const fontScale = new Set();
for (const m of baseCss.matchAll(/font-size:\s*([\d.]+)px/g)) fontScale.add(Number(m[1]));
const radiusTokens = new Set();
for (const m of baseCss.matchAll(/--[\w-]*radius[\w-]*:\s*([\d.]+)px/g)) radiusTokens.add(Number(m[1]));
for (const m of baseCss.matchAll(/border-radius:\s*([\d.]+)px/g)) radiusTokens.add(Number(m[1]));
const paletteHexes = [...palette].filter((v) => v.startsWith('#') && v.length === 7);
function nearestPalette(hex) {
let best = null;
for (const p of paletteHexes) {
const d = colorDist(hex, p);
if (!best || d < best.dist) best = { value: p, dist: d };
}
return best;
}
return { palette, paletteHexes, fontScale, radiusTokens, nearestPalette };
}
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env node
/**
* ui-rendered-audit.mjs — 渲染态视觉秩序审计(与静态审计互补)
*
* 静态脚本证明"源码写了什么",本脚本证明"用户实际看到什么":
* 在 390×844 移动视口的真实 Chromium 中逐页渲染 H5 构建产物,
* 用 getComputedStyle 收集每个可见元素的生效颜色/背景/字号/圆角,
* 与 base.css token palette(共享 lib/ui-tokens.mjs)比对;
* 同时检查横向溢出、控制台错误,并留存截图证据。
*
* 用法:
* node scripts/ui-rendered-audit.mjs # 静态服务 dist/build/h5(需先 build:h5)
* node scripts/ui-rendered-audit.mjs --url http://127.0.0.1:5175/h5/ # 审计 dev server
* node scripts/ui-rendered-audit.mjs --routes pages/auth/login/index,pages/user/today/index
*
* 输出:output/ui-ux/rendered-audit-latest.{md,json} + output/ui-ux/rendered-shots/*.png
* 注意:默认静态服务不带后端代理,页面以未登录/错误/空态渲染——这正是三态覆盖的一部分;
* 认证态渲染需 --url 指向带后端的入口并另行登录(见报告说明)。
*/
import http from 'node:http';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import {
mobileRoot,
normalizeComputedColor,
NEAR_MISS_MAX_DIST,
loadTokens,
} from './lib/ui-tokens.mjs';
const { palette, fontScale, radiusTokens, nearestPalette } = loadTokens();
// 已知的框架默认样式来源,照常统计但单独标注,不计入近色漂移
const KNOWN_FRAMEWORK_COLORS = {
'#000000': 'uni-app 内置组件默认色(uni-button/uni-picker 等,业务未定制时生效)',
};
// ---------- 参数 ----------
const args = process.argv.slice(2);
const argValue = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const VIEW_W = Number(argValue('--width') || 390);
const VIEW_H = Number(argValue('--height') || 844);
const outDir = path.resolve(argValue('--out') || path.join(mobileRoot, '..', 'output', 'ui-ux'));
const DEFAULT_ROUTES = [
'pages/auth/login/index',
'pages/user/today/index',
'pages/user/sop/index',
'pages/user/practice/index',
'pages/candidate/index/index',
'pages/supervisor/index/index',
];
const routes = (argValue('--routes') || DEFAULT_ROUTES.join(',')).split(',').map((s) => s.trim()).filter(Boolean);
// ---------- H5 静态服务(SPA 回退) ----------
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp',
'.svg': 'image/svg+xml', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
'.mp3': 'audio/mpeg', '.ico': 'image/x-icon',
};
function serveH5() {
const h5Dir = path.join(mobileRoot, 'dist', 'build', 'h5');
if (!existsSync(path.join(h5Dir, 'index.html'))) {
throw new Error(`未找到 H5 构建产物 ${h5Dir},请先 npm run build:h5,或用 --url 指向已有服务`);
}
const server = http.createServer((req, res) => {
let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
if (p.startsWith('/h5/')) p = p.slice(3);
if (p === '/' || p === '') p = '/index.html';
let file = path.join(h5Dir, p);
if (!file.startsWith(h5Dir) || !existsSync(file) || !file.includes('.')) {
file = path.join(h5Dir, 'index.html'); // SPA fallback
}
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
res.end(readFileSync(file));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}/h5/` }));
});
}
// ---------- Chrome 定位与启动 ----------
function findChrome() {
if (process.env.CHROME_BIN) return process.env.CHROME_BIN;
const candidates = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome',
];
for (const c of candidates) if (existsSync(c)) return c;
const pwDir = path.join(process.env.LOCALAPPDATA || '', 'ms-playwright');
if (existsSync(pwDir)) {
for (const entry of readdirSync(pwDir).filter((d) => d.startsWith('chromium_headless_shell')).sort().reverse()) {
const bin = path.join(pwDir, entry, 'chrome-win', 'headless_shell.exe');
if (existsSync(bin)) return bin;
}
for (const entry of readdirSync(pwDir).filter((d) => d.startsWith('chromium-')).sort().reverse()) {
const bin = path.join(pwDir, entry, 'chrome-win', 'chrome.exe');
if (existsSync(bin)) return bin;
}
}
throw new Error('未找到 Chrome/Chromium,请设置 CHROME_BIN');
}
async function launchChrome() {
const chromeBin = findChrome();
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'ui-rendered-audit-'));
const proc = spawn(chromeBin, [
'--headless=new', '--disable-gpu', '--no-first-run', '--ignore-certificate-errors',
'--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, 'about:blank',
], { stdio: ['ignore', 'ignore', 'pipe'] });
const port = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Chrome DevTools 启动超时')), 10000);
proc.stderr.on('data', (chunk) => {
const m = String(chunk).match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//);
if (m) { clearTimeout(timer); resolve(Number(m[1])); }
});
proc.on('exit', (code) => reject(new Error(`Chrome 退出: ${code}`)));
});
return { proc, port, userDataDir, chromeBin };
}
async function connectPage(port) {
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
const wsUrl = targets.find((t) => t.type === 'page')?.webSocketDebuggerUrl;
const ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let id = 0;
const pending = new Map();
const listeners = [];
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result || {});
return;
}
listeners.forEach((fn) => fn(msg));
};
return {
send(method, params = {}) {
const callId = ++id;
ws.send(JSON.stringify({ id: callId, method, params }));
return new Promise((resolve, reject) => pending.set(callId, { resolve, reject }));
},
onEvent(fn) {
listeners.push(fn);
return () => {
const index = listeners.indexOf(fn);
if (index >= 0) listeners.splice(index, 1);
};
},
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
close() { try { ws.close(); } catch {} },
};
}
// ---------- 页面内采集器 ----------
const COLLECTOR = `(() => {
const acc = { colors: {}, backgrounds: {}, fontSizes: {}, fontWeights: {}, radii: {}, elements: 0 };
const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };
const walk = (root) => {
for (const el of root.querySelectorAll('*')) {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
acc.elements++;
bump(acc.colors, cs.color);
bump(acc.backgrounds, cs.backgroundColor);
bump(acc.fontSizes, cs.fontSize);
bump(acc.fontWeights, cs.fontWeight);
for (const p of ['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius']) {
bump(acc.radii, cs[p]);
}
if (el.shadowRoot) walk(el.shadowRoot);
}
};
walk(document);
return {
...acc,
title: document.title,
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
bodyTextLength: document.body ? document.body.innerText.trim().length : 0,
};
})()`;
// ---------- 主流程 ----------
const served = argValue('--url') ? null : await serveH5();
const baseUrl = argValue('--url') || served.url;
console.log(`[setup] baseUrl=${baseUrl}`);
const shotsDir = path.join(outDir, 'rendered-shots');
mkdirSync(shotsDir, { recursive: true });
const chrome = await launchChrome();
console.log(`[setup] chrome pid=${chrome.proc.pid} port=${chrome.port} bin=${chrome.chromeBin}`);
const page = await connectPage(chrome.port);
console.log('[setup] page connected');
const pages = [];
try {
await page.send('Page.enable');
await page.send('Runtime.enable');
await page.send('Emulation.setDeviceMetricsOverride', {
width: VIEW_W, height: VIEW_H, deviceScaleFactor: 2, mobile: true,
});
await page.send('Emulation.setTouchEmulationEnabled', { enabled: true });
for (const route of routes) {
const consoleErrors = [];
const off = (msg) => {
if (msg.method === 'Runtime.exceptionThrown') {
consoleErrors.push(msg.params.exceptionDetails?.exception?.description || msg.params.exceptionDetails?.text || 'exception');
}
if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
consoleErrors.push(msg.params.args?.map((a) => a.value ?? a.description ?? '').join(' ').slice(0, 200));
}
};
const stopListening = page.onEvent(off);
// 该 H5 构建为 hash 路由(见 index.html 兼容重定向脚本),路由必须走 #/ 前缀
const url = `${baseUrl.replace(/\/$/, '')}/#/${route}`;
console.log(`[nav] ${url}`);
await page.send('Page.navigate', { url });
// 等 uni-page-body 出现再静置 1.5s 让异步渲染完成
const started = Date.now();
for (;;) {
const r = await page.send('Runtime.evaluate', {
expression: `!!document.querySelector('uni-page-body')`, returnByValue: true,
});
if (r.result?.value || Date.now() - started > 10000) break;
await page.sleep(300);
}
await page.sleep(1500);
const r = await page.send('Runtime.evaluate', { expression: COLLECTOR, returnByValue: true });
const stats = r.result?.value;
const shot = await page.send('Page.captureScreenshot', { format: 'png' });
const slug = route.replaceAll('/', '-');
writeFileSync(path.join(shotsDir, `${slug}.png`), Buffer.from(shot.data, 'base64'));
stopListening();
pages.push({ route, url, stats, consoleErrors: consoleErrors.filter(Boolean).slice(0, 10) });
console.log(`[rendered] ${route} elements=${stats?.elements ?? '?'} errors=${consoleErrors.filter(Boolean).length}`);
}
} finally {
page.close();
chrome.proc.kill('SIGTERM');
try { rmSync(chrome.userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); } catch {}
if (served) await new Promise((r) => served.server.close(r));
}
// ---------- 分析 ----------
function classifyColors(rawMap, { skipTransparent }) {
const agg = new Map();
for (const [raw, count] of Object.entries(rawMap)) {
const v = normalizeComputedColor(raw);
if (skipTransparent && v === 'transparent') continue;
agg.set(v, (agg.get(v) || 0) + count);
}
const onPalette = new Map();
const offPalette = new Map();
for (const [v, count] of agg) {
if (palette.has(v)) onPalette.set(v, count);
else offPalette.set(v, count);
}
const offDetailed = [...offPalette.entries()]
.sort((a, b) => b[1] - a[1])
.map(([v, count]) => {
const framework = KNOWN_FRAMEWORK_COLORS[v] || null;
const nearest = !framework && v.startsWith('#') && v.length === 7 ? nearestPalette(v) : null;
return { value: v, count, nearest, framework, nearMiss: !!nearest && nearest.dist > 0 && nearest.dist <= NEAR_MISS_MAX_DIST };
});
return {
onPaletteCount: [...onPalette.values()].reduce((a, b) => a + b, 0),
offPaletteCount: offDetailed.reduce((a, b) => a + b.count, 0),
nearMissCount: offDetailed.filter((d) => d.nearMiss).reduce((a, b) => a + b.count, 0),
offPalette: offDetailed.slice(0, 20),
};
}
function classifyFontSizes(rawMap) {
const dist = {};
const off = {};
for (const [raw, count] of Object.entries(rawMap)) {
const px = Number(raw.replace('px', ''));
if (!px) continue; // font-size:0 是消间隙/图标字体等合法手段,不计入字阶
dist[px] = (dist[px] || 0) + count;
if (!fontScale.has(px)) off[px] = (off[px] || 0) + count;
}
return { distribution: dist, offScale: off };
}
function classifyRadii(rawMap) {
const dist = {};
const off = {};
for (const [raw, count] of Object.entries(rawMap)) {
if (raw.includes('%')) { dist[raw] = (dist[raw] || 0) + count; continue; }
const px = Number(raw.replace('px', ''));
if (Number.isNaN(px) || px === 0 || px >= 999) continue;
dist[px] = (dist[px] || 0) + count;
if (!radiusTokens.has(px)) off[px] = (off[px] || 0) + count;
}
return { distribution: dist, offToken: off };
}
const analyzed = pages.map((p) => ({
route: p.route,
url: p.url,
elements: p.stats?.elements ?? 0,
bodyTextLength: p.stats?.bodyTextLength ?? 0,
horizontalOverflow: p.stats ? p.stats.scrollWidth > p.stats.innerWidth : null,
scrollWidth: p.stats?.scrollWidth,
innerWidth: p.stats?.innerWidth,
consoleErrors: p.consoleErrors,
colors: classifyColors(p.stats?.colors || {}, { skipTransparent: true }),
backgrounds: classifyColors(p.stats?.backgrounds || {}, { skipTransparent: true }),
fontSizes: classifyFontSizes(p.stats?.fontSizes || {}),
radii: classifyRadii(p.stats?.radii || {}),
}));
const fmtDist = (obj, n = 12) =>
Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, n).map(([v, c]) => `${v}×${c}`).join(',');
const lines = [];
lines.push('# mobile-uni 渲染态 UI 审计');
lines.push('');
lines.push(`- 生成时间:${new Date().toISOString()}`);
lines.push(`- 入口:${baseUrl}(${argValue('--url') ? '外部服务' : '本地静态服务 dist/build/h5'}),视口 ${VIEW_W}×${VIEW_H} @2x`);
lines.push(`- Chrome:${chrome.chromeBin}`);
lines.push(`- palette:${palette.size} 色;字阶:${[...fontScale].sort((a, b) => a - b).join('/')}px;半径 token:${[...radiusTokens].sort((a, b) => a - b).join('/')}px`);
lines.push(`- 口径:按可见元素的 getComputedStyle 计次(一个元素一次),非源码声明次数;默认未登录态渲染,异步接口失败的页面呈现错误/空态`);
lines.push('');
for (const p of analyzed) {
lines.push(`## ${p.route}`);
lines.push(`- 可见元素 ${p.elements};横向溢出:${p.horizontalOverflow ? `**是(scrollWidth ${p.scrollWidth} > ${p.innerWidth})**` : '否'};正文文本 ${p.bodyTextLength} 字符`);
lines.push(`- 控制台错误:${p.consoleErrors.length ? '**' + p.consoleErrors.length + ' 条**' : '0'}`);
for (const e of p.consoleErrors.slice(0, 5)) lines.push(` - ${e}`);
lines.push(`- 文字颜色:palette 内 ${p.colors.onPaletteCount} / 外 **${p.colors.offPaletteCount}**(近色 ${p.colors.nearMissCount})`);
if (p.colors.offPalette.length) {
lines.push('');
lines.push(' | 生效文字色(palette 外) | 元素数 | 最近 palette 色 (Δ) |');
lines.push(' |---|---:|---|');
for (const d of p.colors.offPalette.slice(0, 8)) {
const hint = d.framework ? `框架默认(${d.framework})` : d.nearest ? `${d.nearest.value} (Δ${d.nearest.dist})` : '—';
lines.push(` | ${d.value} | ${d.count} | ${hint} |`);
}
lines.push('');
}
lines.push(`- 背景色:palette 内 ${p.backgrounds.onPaletteCount} / 外 **${p.backgrounds.offPaletteCount}**(近色 ${p.backgrounds.nearMissCount})`);
const offBg = p.backgrounds.offPalette.slice(0, 5).map((d) => `${d.value}×${d.count}${d.nearest ? `(→${d.nearest.value} Δ${d.nearest.dist})` : ''}`).join(',');
if (offBg) lines.push(` - 头部:${offBg}`);
const offFs = Object.entries(p.fontSizes.offScale).sort((a, b) => b[1] - a[1]);
lines.push(`- 字号:分布 ${fmtDist(p.fontSizes.distribution)}`);
lines.push(`- 字阶外:${offFs.length ? offFs.map(([v, c]) => `${v}px×${c}`).join(',') : '无'}`);
const offR = Object.entries(p.radii.offToken).sort((a, b) => b[1] - a[1]);
lines.push(`- 圆角 token 外:${offR.length ? offR.map(([v, c]) => `${v}px×${c}`).join(',') : '无'}`);
lines.push('');
}
lines.push('## 截图证据');
for (const p of analyzed) lines.push(`- output/ui-ux/rendered-shots/${p.route.replaceAll('/', '-')}.png`);
mkdirSync(outDir, { recursive: true });
writeFileSync(path.join(outDir, 'rendered-audit-latest.json'), JSON.stringify({ generatedAt: new Date().toISOString(), baseUrl, viewport: [VIEW_W, VIEW_H], pages: analyzed }, null, 2));
writeFileSync(path.join(outDir, 'rendered-audit-latest.md'), lines.join('\n') + '\n');
console.log(`\n报告已写入 ${path.join(outDir, 'rendered-audit-latest.md')}`);
if (analyzed.some((p) => p.horizontalOverflow || p.consoleErrors.length)) {
console.log('提示:存在横向溢出或控制台错误的页面,详见报告。');
}
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env node
/**
* ui-token-audit.mjs — 视觉秩序(token 收敛度)审计
*
* 以 src/styles/base.css 的 :root 自定义属性与设计系统内置类为唯一 palette 来源,
* 扫描 src 下全部 .vue/.css/.scss(base.css 自身除外),统计:
* 1. 颜色:var() 引用 / palette 内字面值 / palette 外字面值(违规)
* 2. 字号:分布 + 不在 base.css 派生字阶内的"孤儿"值
* 3. 间距:margin/padding/gap 分布 + 非 2px 网格值
* 4. 圆角:分布 + 不在 token 半径集合内的值(0/百分比/胶囊值除外)
*
* 输出控制台摘要 + output/ui-ux/token-audit-latest.{json,md}。
* 默认只报告不失败;--strict 时存在 palette 外颜色则以 1 退出。
*/
import { readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import {
mobileRoot,
tokenFile,
normalizeHex,
normalizeRgba,
NEAR_MISS_MAX_DIST,
loadTokens,
} from './lib/ui-tokens.mjs';
const root = mobileRoot;
const srcDir = join(root, 'src');
const strict = process.argv.includes('--strict');
// ---------- 1. 从 base.css 提取 palette 与派生字阶(共享 lib,与渲染审计同源) ----------
const { palette, fontScale, radiusTokens, nearestPalette } = loadTokens();
// ---------- 2. 收集源文件 ----------
const exts = new Set(['.vue', '.css', '.scss']);
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(p));
else if (exts.has(entry.name.slice(entry.name.lastIndexOf('.')))) out.push(p);
}
return out;
}
const files = walk(srcDir).filter((f) => f !== tokenFile);
// ---------- 3. 扫描 ----------
const colors = { varRefs: 0, paletteLiteral: new Map(), offPalette: new Map() };
const fontSizes = new Map();
const fontSizesOffScale = new Map();
const spacings = new Map();
const spacingsOffGrid = new Map();
const radii = new Map();
const radiiOffToken = new Map();
function bump(map, key, loc) {
const cur = map.get(key) || { count: 0, locs: [] };
cur.count += 1;
if (cur.locs.length < 5) cur.locs.push(loc);
map.set(key, cur);
}
for (const file of files) {
const rel = relative(root, file).replaceAll('\\', '/');
const text = readFileSync(file, 'utf8');
const lineOf = (idx) => rel + ':' + (text.slice(0, idx).split('\n').length);
for (const m of text.matchAll(/var\(--[\w-]+\)/g)) colors.varRefs += 1;
for (const m of text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) {
const v = normalizeHex(m[0]);
if (palette.has(v)) bump(colors.paletteLiteral, v, lineOf(m.index));
else bump(colors.offPalette, v, lineOf(m.index));
}
for (const m of text.matchAll(/rgba?\([^)]*\)/g)) {
const v = normalizeRgba(m[0]);
if (palette.has(v)) bump(colors.paletteLiteral, v, lineOf(m.index));
else bump(colors.offPalette, v, lineOf(m.index));
}
for (const m of text.matchAll(/font-size:\s*([\d.]+)px/g)) {
const v = Number(m[1]);
bump(fontSizes, v, lineOf(m.index));
if (!fontScale.has(v)) bump(fontSizesOffScale, v, lineOf(m.index));
}
for (const m of text.matchAll(/(?:margin|padding|gap)(?:-[\w]+)?:\s*([^;}]+)/g)) {
for (const n of m[1].matchAll(/(-?[\d.]+)px/g)) {
const v = Number(n[1]);
if (v === 0) continue;
bump(spacings, v, lineOf(m.index));
if (v % 2 !== 0) bump(spacingsOffGrid, v, lineOf(m.index));
}
}
for (const m of text.matchAll(/border-radius:\s*([^;}]+)/g)) {
const raw = m[1].trim();
if (raw.includes('var(') || raw.includes('%')) continue;
for (const n of raw.matchAll(/([\d.]+)px/g)) {
const v = Number(n[1]);
if (v === 0 || v >= 999) continue; // 直角与胶囊/圆形属合法手段
bump(radii, v, lineOf(m.index));
if (!radiusTokens.has(v)) bump(radiiOffToken, v, lineOf(m.index));
}
}
}
// ---------- 4. 汇总 ----------
const sum = (map) => [...map.values()].reduce((a, b) => a + b.count, 0);
const top = (map, n = 12) =>
[...map.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, n);
const offPaletteDetailed = {};
let nearMissCount = 0;
for (const [v, d] of top(colors.offPalette, colors.offPalette.size)) {
const nearest = v.startsWith('#') && v.length === 7 ? nearestPalette(v) : null;
if (nearest && nearest.dist > 0 && nearest.dist <= NEAR_MISS_MAX_DIST) nearMissCount += d.count;
offPaletteDetailed[v] = { ...d, nearest };
}
const result = {
generatedAt: new Date().toISOString(),
scope: 'mobile-uni/src (excluding styles/base.css as token source)',
filesScanned: files.length,
tokenSource: {
paletteSize: palette.size,
fontScale: [...fontScale].sort((a, b) => a - b),
radiusTokens: [...radiusTokens].sort((a, b) => a - b),
},
colors: {
varRefs: colors.varRefs,
paletteLiteralCount: sum(colors.paletteLiteral),
offPaletteCount: sum(colors.offPalette),
offPaletteDistinct: colors.offPalette.size,
nearMissCount,
offPalette: Object.fromEntries(Object.entries(offPaletteDetailed).slice(0, 30)),
},
fontSizes: {
distinct: fontSizes.size,
total: sum(fontSizes),
offScaleDistinct: fontSizesOffScale.size,
offScaleCount: sum(fontSizesOffScale),
distribution: Object.fromEntries(top(fontSizes, 20)),
offScale: Object.fromEntries(top(fontSizesOffScale, 20)),
},
spacings: {
distinct: spacings.size,
total: sum(spacings),
offGridDistinct: spacingsOffGrid.size,
offGridCount: sum(spacingsOffGrid),
distribution: Object.fromEntries(top(spacings, 20)),
offGrid: Object.fromEntries(top(spacingsOffGrid, 20)),
},
radii: {
distribution: Object.fromEntries(top(radii, 20)),
offToken: Object.fromEntries(top(radiiOffToken, 20)),
},
};
// ---------- 5. 输出 ----------
const literalTotal = result.colors.paletteLiteralCount + result.colors.offPaletteCount;
const colorTotal = colors.varRefs + literalTotal;
const varRatio = colorTotal ? ((colors.varRefs / colorTotal) * 100).toFixed(1) : '0.0';
const lines = [];
lines.push('# mobile-uni UI token 审计');
lines.push('');
lines.push(`- 生成时间:${result.generatedAt}`);
lines.push(`- 扫描文件:${files.length} 个(token 源:src/styles/base.css)`);
lines.push(`- palette:${palette.size} 个颜色;字阶:${result.tokenSource.fontScale.join('/')}px;半径 token:${result.tokenSource.radiusTokens.join('/')}px`);
lines.push('');
lines.push('## 颜色');
lines.push(`- var() 引用 ${colors.varRefs} 次;字面值 ${literalTotal} 次(palette 内 ${result.colors.paletteLiteralCount},**palette 外 ${result.colors.offPaletteCount} 次 / ${colors.offPalette.size} 种**,其中近色漂移 ${nearMissCount} 次)`);
lines.push(`- token 引用占比(var / 全部颜色引用):${varRatio}%`);
if (colors.offPalette.size) {
lines.push('');
lines.push('| palette 外颜色 | 次数 | 最近 palette 色 (Δ) | 示例位置 |');
lines.push('|---|---:|---|---|');
for (const [v, d] of Object.entries(offPaletteDetailed).slice(0, 30)) {
const hint = d.nearest ? `${d.nearest.value} (Δ${d.nearest.dist})` : '—';
lines.push(`| ${v} | ${d.count} | ${hint} | ${d.locs.join('、')} |`);
}
}
lines.push('');
lines.push('## 字号');
lines.push(`- 共 ${result.fontSizes.total} 处、${fontSizes.size} 种;**字阶外 ${result.fontSizes.offScaleCount} 处 / ${fontSizesOffScale.size} 种**`);
lines.push(`- 分布:${[...top(fontSizes, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',')}`);
if (fontSizesOffScale.size) {
lines.push('');
lines.push('| 字阶外字号 | 次数 | 示例位置 |');
lines.push('|---|---:|---|');
for (const [v, d] of top(fontSizesOffScale, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`);
}
lines.push('');
lines.push('## 间距(margin/padding/gap)');
lines.push(`- 共 ${result.spacings.total} 处、${spacings.size} 种;**非 2px 网格 ${result.spacings.offGridCount} 处 / ${spacingsOffGrid.size} 种**`);
lines.push(`- 分布:${[...top(spacings, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',')}`);
if (spacingsOffGrid.size) {
lines.push('');
lines.push('| 非网格间距 | 次数 | 示例位置 |');
lines.push('|---|---:|---|');
for (const [v, d] of top(spacingsOffGrid, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`);
}
lines.push('');
lines.push('## 圆角');
lines.push(`- 分布:${[...top(radii, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',') || '(无字面值)'}`);
if (radiiOffToken.size) {
lines.push('');
lines.push('| token 外圆角 | 次数 | 示例位置 |');
lines.push('|---|---:|---|');
for (const [v, d] of top(radiiOffToken, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`);
}
const outDir = join(root, '..', 'output', 'ui-ux');
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'token-audit-latest.json'), JSON.stringify(result, null, 2));
writeFileSync(join(outDir, 'token-audit-latest.md'), lines.join('\n') + '\n');
console.log(lines.join('\n'));
console.log('');
console.log(`报告已写入 output/ui-ux/token-audit-latest.{md,json}`);
if (strict && result.colors.offPaletteCount > 0) {
console.error(`--strict:存在 ${result.colors.offPaletteCount} 处 palette 外颜色`);
process.exit(1);
}