diff --git a/.vitepress/config.mts b/.vitepress/config.mts index 1525518..2b5dfa9 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -84,8 +84,6 @@ export default defineConfig({ { text: '界面预览', link: '/#preview' }, // activeMatch:进入任一项目详情页时「项目」保持高亮 { text: '项目', link: '/projects/', activeMatch: '^/projects/' }, - // 教程视频:站内页,含各项目的录制清单与分镜 - { text: '教程', link: '/guide/videos', activeMatch: '^/guide/videos' }, // 官方文档直接跳文档站(外站),不在本站内做锚点停留 { text: '官方文档', @@ -100,7 +98,7 @@ export default defineConfig({ { text: '项目', collapsed: false, - // 每个项目带自己的子页面(快速开始 / 部署 / 教程等), + // 每个项目带自己的子页面(快速开始 / 部署 / 手册等), // 子页面清单来自注册表的 sections,不在此处重复维护。 items: projects.map((p) => ({ text: p.name, @@ -115,7 +113,6 @@ export default defineConfig({ text: '门户', collapsed: false, items: [ - { text: '教程中心', link: '/guide/videos' }, { text: '门户部署', link: '/guide/portal-deploy' }, ], }, diff --git a/data/projects.ts b/data/projects.ts index 0083760..ccca724 100644 --- a/data/projects.ts +++ b/data/projects.ts @@ -32,7 +32,7 @@ export interface Project { status: ProjectStatus /** 门户内的详情页路径 */ page: string - /** 详情页下的子页面(快速开始、部署、教程等) */ + /** 详情页下的子页面(快速开始、部署、方案对比等) */ sections: ProjectSection[] } @@ -51,7 +51,6 @@ export const projects: Project[] = [ sections: [ { text: '快速开始', link: '/projects/iotlcm/guide/quickstart' }, { text: '部署与运维', link: '/projects/iotlcm/guide/deploy' }, - { text: '教程视频', link: '/projects/iotlcm/guide/tutorial' }, { text: '方案对比', link: '/projects/iotlcm/compare' }, ], }, @@ -67,7 +66,6 @@ export const projects: Project[] = [ status: '已发布', page: '/projects/xiu-dtu/', sections: [ - { text: '教程视频', link: '/projects/xiu-dtu/guide/tutorial' }, ], }, { @@ -82,7 +80,6 @@ export const projects: Project[] = [ status: '已发布', page: '/projects/xiu-admin/', sections: [ - { text: '教程视频', link: '/projects/xiu-admin/guide/tutorial' }, ], }, ] diff --git a/pages/guide/videos.md b/pages/guide/videos.md deleted file mode 100644 index 5fee23a..0000000 --- a/pages/guide/videos.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: 教程中心 -description: 开源项目门户的全部教程视频清单与录制排期 -outline: deep ---- - -# 教程中心 - -门户下的教程视频按项目组织。视频是这个门户的主交付物之一,文档只作配套与字幕说明,不能替代视频。 - -## 视频总表 - -| # | 项目 | 类型 | 标题 | 时长 | 目标观众 | -| --- | --- | --- | --- | --- | --- | -| V0 | 门户 | 导览 | 雪羊科技开源门户导览:三个项目分别适合谁 | 3 分钟 | 第一次接触本项目的开发者、技术选型者 | -| V1 | iotLCM | 部署 | iotLCM 开源版部署教程:从零到可用 | 12 ~ 15 分钟 | 具备 Linux 与 Docker 基础的实施工程师 | -| V2 | iotLCM | 使用 | iotLCM 开源版使用教程:核心功能实操 | 15 ~ 20 分钟 | 已完成部署、准备给业务方上手的管理员 | -| V3 | iotLCM | 接口 | iotLCM OpenAPI 接入实战:从申请凭证到跑通调用 | 10 ~ 12 分钟 | 要把平台接进自己系统的第三方开发者 | -| V4 | 秀杰 DTU | 部署 | 秀杰 DTU 部署教程:源码起服务到设备接入 | 12 ~ 15 分钟 | 做 DTU 设备接入的开发者 | -| V5 | 秀杰 DTU | 设备 | 秀杰 DTU 设备实操:双通道接入、指令下发与 OTA | 15 ~ 18 分钟 | 设备端与平台端联调工程师 | -| V6 | XiuAdmin | 部署 | XiuAdmin 一键部署:三个容器跑起中后台 | 8 ~ 10 分钟 | 要用这个框架搭中后台的开发者 | -| V7 | XiuAdmin | 二开 | XiuAdmin 框架上手:代码生成到业务模块落地 | 15 分钟 | 要基于框架二次开发的工程师 | - -V0 是导览,可选;其余 7 支是必录。 - -## 分项目入口 - -- [iotLCM 教程视频](/projects/iotlcm/guide/tutorial) -- [秀杰 DTU 教程视频](/projects/xiu-dtu/guide/tutorial) -- [XiuAdmin 教程视频](/projects/xiu-admin/guide/tutorial) - -## 单支视频的录制要点(以 V2 使用教程为例) - -一支使用类视频要讲清三件事,缺一不可: - -1. **每段开头一句话说清业务问题**——观众得知道「为什么要做这个动作」,不然菜单再全也是流水账 -2. **完整走完一条真实链路**——从入口到结果,中间不跳步、不剪成片段拼接 -3. **交代异常分支**——比如批量动作为什么是异步、失败怎么看,这是使用者真正会卡住的地方 - -按后台真实菜单顺序组织,不按功能模块归类:观众是照着操作走的,菜单顺序就是他的心智路径。 - -以 V2 为例,最小可交付的 8 段是:工作台 → 卡信息 → 入库与库存 → 业务受理 → 批次与执行日志 → 套餐与流量 → 达量断网 → 智能诊断。 -其中「业务受理」「批次与执行日志」「智能诊断」是平台区别于普通 CRUD 的核心,不可省。 - -明确不录的内容也要说清,避免一支视频无限膨胀:OpenAPI 调用归接口那支、通道详细配置归实施方材料、系统管理页归组织与账号类材料。 - -## 通用录制规范 - -| 项 | 口径 | -| --- | --- | -| 录屏环境 | 干净的 Ubuntu 24.04 + Docker Compose V2,不在已有业务实例上录 | -| 画面内容 | 终端为主,浏览器演示为辅;关键步骤要有停顿与高亮 | -| 敏感信息 | 口令、令牌一律打码,示例用编排里的默认值 | -| 可复现 | 视频里出现的每条命令都必须能原样复制执行,与对应文档页严格一致 | -| 命名 | 小写连字符加版本号,如 `iotlcm-deploy-v1.mp4`,放 `website/public/videos/` | -| 字幕 | 每支视频配一份 Markdown 要点说明,随视频一起发布 | -| 发布位置 | 官网对应项目页 + 源码仓 README 顶部 + B 站 | - -## 排期建议 - -录制顺序按「依赖关系」而非项目分组:先录 V1(部署),因为 V2、V3 都以一套已起好的环境为背景;再录 V4、V5;最后 V6、V7。 - -建议批次: - -1. **第一批(3 支)**:V0、V1、V2 —— 覆盖门户与主力项目,官网可先上线 -2. **第二批(2 支)**:V3、V4 —— 补齐 iotLCM 接口面向与第二个项目 -3. **第三批(3 支)**:V5、V6、V7 —— 设备实操与框架两支 - -每批之间留一次审看:命令是否与文档一致、是否可复现、有没有打码遗漏。 - -## 验收口径 - -- 每支视频完整看一遍,步骤无断点 -- 视频里的命令在干净环境重跑能通 -- 官网对应项目页有该视频的入口,且能播放 -- 元数据齐备:标题、目标观众、时长、发布位置、关联文档与仓库路径 diff --git a/pages/projects/iotlcm/guide/quickstart.md b/pages/projects/iotlcm/guide/quickstart.md index 39a5756..658d4d5 100644 --- a/pages/projects/iotlcm/guide/quickstart.md +++ b/pages/projects/iotlcm/guide/quickstart.md @@ -1,6 +1,6 @@ --- title: 快速开始 -description: 三步把 iotLCM 开源版跑起来 +description: 三步把 Iotlcm 开源版跑起来 outline: deep --- @@ -78,4 +78,3 @@ docker compose down -v # 停止并删除数据卷(不可恢复) ## 下一步 - [部署与运维](/projects/iotlcm/guide/deploy) -- [教程视频](/projects/iotlcm/guide/tutorial) diff --git a/pages/projects/iotlcm/guide/tutorial.md b/pages/projects/iotlcm/guide/tutorial.md deleted file mode 100644 index 5e7d7b5..0000000 --- a/pages/projects/iotlcm/guide/tutorial.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -title: 教程视频 -description: iotLCM 开源版的部署、使用与接口接入录屏教程 -outline: deep ---- - -# 教程视频 - -iotLCM 分三支:怎么装起来、怎么用起来、怎么接进去。前两支面向实施与业务方,第三支面向第三方开发者。 - -## V1 部署教程:从零到可用 - -| 项 | 内容 | -| --- | --- | -| 标题 | iotLCM 开源版部署教程:从零到可用 | -| 目标观众 | 具备 Linux 与 Docker 基础的实施工程师 | -| 时长 | 12 ~ 15 分钟 | -| 发布位置 | 官网本页 + 源码仓 README 顶部 + B 站 | -| 关联文档 | [快速开始](/projects/iotlcm/guide/quickstart)、[部署与运维](/projects/iotlcm/guide/deploy) | -| 关联仓库路径 | `deploy/`、`docs/` | - -分镜: - -1. **环境准备**(1.5 分钟):Docker 与 Compose 版本检查、机器规格建议、端口占用确认 -2. **取代码**(0.5 分钟):`git clone` 公开仓库,展示目录结构 -3. **启动**(4 分钟):`cd deploy && docker compose up -d`,`docker compose logs -f mysql` 看初始化直到种子数据导入完成 -4. **验证**(3 分钟):登录管理端、打开文档站 `/docs/`、打开 OpenAPI 文档、跑一次卡状态查询 -5. **收尾**(2 分钟):改默认口令清单、`docker compose down` 与 `down -v` 的区别 -6. **踩坑预告**(1 分钟):MySQL 初始化失败要清卷、文档站必须带 `/docs/` 路径 - -## V2 使用教程:核心功能实操 - -| 项 | 内容 | -| --- | --- | -| 标题 | iotLCM 开源版使用教程:核心功能实操 | -| 目标观众 | 已完成部署、准备给业务方上手的管理员与运营人员 | -| 时长 | 15 ~ 20 分钟 | -| 发布位置 | 官网本页 + 源码仓 README 顶部 + B 站 | -| 关联文档 | [功能概览](/projects/iotlcm/) | -| 关联仓库路径 | `admin-ui/vue/apps/web-antd/src/views/iotlcm/`、`admin-svr/server/api/iot/v1/` | - -录制环境:Web 管理端 `http://<主机>:8009/`(部署视频里那套已起好的环境),数据用 mock 挡板或测试库, -不要在压测或生产环境上录。 - -### 分镜(按后台真实菜单顺序) - -**1. 登录与工作台**(1 分钟) - -- 打开管理端,用 admin 登录(口令打码) -- 进「工作台」看概览:卡总量、在网量、流量与告警概览 -- 一句话交代后台的菜单分组:生命周期、通信管理、资费定价、通道、预警中心、智能诊断、系统与组织 - -**2. 卡信息**(3 分钟)——菜单「通信管理 / 卡信息」 - -- 列表页看全量卡:分页、列筛选 -- 按 ICCID / MSISDN / IMSI 三种卡号任一检索(`cardValue` 支持单卡与批量两种输入) -- 按通道、卡状态、套餐编码再叠一层筛选,体现组合筛选能力 -- 点开一张卡的详情:基本信息、状态、有效期、绑定套餐 -- 说明列表右上角的导出(导出当前显示列) - -**3. 入库与库存**(2.5 分钟)——菜单「生命周期 / 入库管理」「库存卡信息」 - -- 入库管理:Excel 模板下载 → 批量导入卡 → 导入结果反馈(成功 / 失败条数) -- 库存卡信息:看在库卡、按状态与维度筛、看单卡出入库轨迹 -- 串一句:入库是所有下游动作(划拨、激活、续费)的前提 - -**4. 业务受理**(3 分钟)——菜单「通信管理 / 业务受理」 - -- 新建受理单:选卡 → 选动作(开卡 / 复机 / 停机 / 续费 / 变更套餐 / 销户)→ 提交 -- 提交前确认卡状态与套餐是否满足该动作的前置条件 -- 提交后到「批次管理」看任务生成 -- 顺带展示销户卡信息页与库存的差别 - -**5. 批次与执行日志**(3 分钟)——菜单「生命周期 / 批次管理」「执行日志」 - -- 批次管理:看批次列表、批次类型(开停复机 / 续费 / 变更套餐)、处理进度 -- 打开一个批次的卡明细:逐卡的成功 / 失败与失败原因 -- 执行日志:看异步执行记录与耗时,失败批次点详情看日志 -- 说明这里为什么是异步:批量动作走 worker 队列,页面只提交不等待 - -**6. 套餐与流量**(2.5 分钟)——菜单「资费定价」 - -- SIM 流量套餐:看套餐定义、有效期、生效与到期策略 -- 流量池:看池的总量与已用量 -- 流量池套餐配置:池与套餐的绑定关系 - -**7. 达量断网**(1.5 分钟)——菜单「通信管理 / 达量断网」 - -- 新建规则:选池 / 选套餐、设阈值与动作 -- 说明触发时机与卡状态的关系:这是运营商侧断网前的自保手段 - -**8. 智能诊断**(1.5 分钟)——菜单「智能诊断」 - -- 输入卡号,查诊断信息:基本信息 + 会话信息 + 套餐信息三块同屏 -- 查该卡的流量使用情况 -- 说明诊断给的是「当前结论 + 依据」,处置仍要回到对应菜单操作 - -### 录制注意 - -- 上面 8 段是**必须录到**的最小集合。若时间紧,4(业务受理)、5(批次与执行日志)、8(智能诊断)三段是平台区别于普通 CRUD 的核心,不可省 -- 每一段开头用一句话说清「这一步在业务上解决什么问题」,观众跟得上 -- 卡号、IMEI 用演示值;不要展示真实客户名与真实生产卡号 -- 批次那段的异步特性建议当场等一次执行结果(mock 环境秒级),让观众看到状态从「执行中」到「成功」 -- 全程不要出现真实口令与 token - -### 明确不录的内容 - -- OpenAPI 调用演示 → 属于 V3 接口那支 -- 通道与接入模板的详细配置 → 属于平台实施方内容,不是业务方日常操作 -- 租户、角色、菜单的系统管理页 → 与使用教程的受众不匹配,放进「组织与账号」类材料 - -## V3 OpenAPI 接入实战 - -| 项 | 内容 | -| --- | --- | -| 标题 | iotLCM OpenAPI 接入实战:从申请凭证到跑通调用 | -| 目标观众 | 要把平台接进自己系统的第三方开发者 | -| 时长 | 10 ~ 12 分钟 | -| 发布位置 | 官网本页 + 源码仓 README 顶部 + B 站 | -| 关联文档 | 文档站 OpenAPI 合同章节 | -| 关联仓库路径 | `openapi/`、`docs/` | - -分镜: - -1. **申请凭证**(2 分钟):管理端申请 API 凭证、看凭证与权限范围 -2. **鉴权方式**(2 分钟):JWT 令牌获取、令牌与权限的关系 -3. **第一次调用**(3 分钟):卡信息查询接口的请求参数、响应结构、逐字段解释 -4. **错误处理**(2 分钟):常见业务错误码与参数校验失败的处理路径 -5. **兼容接口**(1.5 分钟):旧版兼容接口与 V3 的差异,何时该用哪套 -6. **收尾**(1 分钟):接入自检清单 - -## 录制备忘 - -- 录屏环境用干净 Ubuntu + Compose V2,不在已有业务实例上录 -- 涉及口令的片段一律打码,用编排里的默认值 -- 步骤与[快速开始](/projects/iotlcm/guide/quickstart)严格一致,视频里出现的每条命令都必须能原样复制执行 -- 每个视频产出配套字幕或要点说明(Markdown),随视频一起发布 -- 视频文件命名 `iotlcm-deploy-v1.mp4` 这类,官网用相对路径引用;改投外部托管则不进仓库 diff --git a/pages/projects/iotlcm/index.md b/pages/projects/iotlcm/index.md index d2d742e..5a90db8 100644 --- a/pages/projects/iotlcm/index.md +++ b/pages/projects/iotlcm/index.md @@ -1,10 +1,10 @@ --- -title: iotLCM +title: Iotlcm description: 物联网卡全生命周期管理平台 outline: deep --- -# iotLCM +# Iotlcm 物联网卡全生命周期管理平台。卡片从运营商渠道入库 → 划拨给代理商 → 激活 / 续费 / 停机 → 流量与状态同步 → 销户,一个平台覆盖 SIM 卡管理全程。 @@ -50,9 +50,8 @@ outline: deep | --- | --- | | [快速开始](/projects/iotlcm/guide/quickstart) | 三步把整套跑起来 | | [部署与运维](/projects/iotlcm/guide/deploy) | 生产部署前的必改项、可选组件、离线部署与排错 | -| [教程视频](/projects/iotlcm/guide/tutorial) | 部署与使用的录屏教程 | | [方案对比](/projects/iotlcm/compare) | 与同类开源方案的可验证差异 | ## 边界 -开源版发布的是微服务家族,不含老平台的私有定制模块。运营商通道的生产凭据不随仓发布,需自行申请与配置。 +开源版发布的是微服务家族。运营商通道的生产凭据不随仓发布,需自行申请与配置。 diff --git a/pages/projects/xiu-admin/guide/tutorial.md b/pages/projects/xiu-admin/guide/tutorial.md deleted file mode 100644 index f0f0ede..0000000 --- a/pages/projects/xiu-admin/guide/tutorial.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: 教程视频 -description: XiuAdmin 中后台框架的一键部署与二开实操录屏教程 -outline: deep ---- - -# 教程视频 - -XiuAdmin 是开发底座而不是业务系统,两支视频分别回答「怎么跑起来」和「怎么用它写自己的业务」。 - -## V6 一键部署:三个容器跑起中后台 - -| 项 | 内容 | -| --- | --- | -| 标题 | XiuAdmin 一键部署:三个容器跑起中后台 | -| 目标观众 | 要用这个框架搭中后台的开发者 | -| 时长 | 8 ~ 10 分钟 | -| 发布位置 | 官网本页 + 源码仓 README 顶部 + B 站 | -| 关联文档 | 代码仓 `deploy/README.md` | -| 关联仓库路径 | `deploy/`、`build/` | - -分镜: - -1. **三个容器是什么**(1.5 分钟):MySQL 8.0、Redis 7.2、前后端一体应用,MySQL 与 Redis 只在内部网络可达、不映射宿主端口 -2. **一条命令启动**(3 分钟):`cd deploy && ./run.sh`,看它自动生成 `.env`(随机数据库口令、Redis 口令、JWT 密钥,权限 600)、拉镜像、起容器、等健康检查通过 -3. **访问与改密**(1.5 分钟):打开 `http://<服务器IP>:8199/`,初始账号 admin / 123456 登录后立即改密 -4. **常用命令**(1.5 分钟):`status` 看状态与健康检查、`logs` 跟日志、`restart` 改 `.env` 后重建应用容器、`down` 保留数据卷、`destroy` 清数据需二次确认 -5. **手工部署路线**(1 分钟):`cp .env.example .env` 后用 `docker compose up -d`,说明 `.env` 里各变量含义(镜像前缀、APP_TAG、APP_PORT、四个口令、JWT_SIGNING_KEY 必须够长且随机) - -## V7 框架上手:代码生成到业务模块落地 - -| 项 | 内容 | -| --- | --- | -| 标题 | XiuAdmin 框架上手:代码生成到业务模块落地 | -| 目标观众 | 要基于框架二次开发的工程师 | -| 时长 | 15 分钟 | -| 发布位置 | 官网本页 + 源码仓 README 顶部 + B 站 | -| 关联文档 | 代码仓 `README.md` 的内置功能清单与目录说明 | -| 关联仓库路径 | `server/`、`vue/` | - -分镜: - -1. **框架全貌**(2 分钟):GoFrame2 后端 + Vue3 前端(Vben Admin)+ Ant Design Vue,单仓结构(`server/`、`vue/`、`docs/`、`build/`、`deploy/`)与「前后端一体镜像」的思路 -2. **代码生成**(4 分钟):建表 → 简单配置 → 生成 CURD / 树表 / 消息队列 / 定时任务代码,表单控件勾选即生成 -3. **生成代码怎么接进系统**(3 分钟):生成的 API、控制器、菜单权限如何挂进路由,前端页面如何挂进菜单 -4. **多租户与数据权限**(2.5 分钟):租户与租户套餐、角色按机构或上下级关系划数据范围 -5. **异步与通知**(2 分钟):Asynq 消息队列怎么用、WebSocket 实时推送通知公告与私信 -6. **运维视图**(1.5 分钟):在线用户、定时任务、服务监控、缓存监控、附件大文件分片上传 - -## 录制备忘 - -- 录屏环境用干净 Ubuntu + Docker,不要连生产或预演环境 -- 口令一律打码,`run.sh` 生成随机口令这一步可展示但值要遮住 -- 代码生成那段用一个明显的示例表(如 demo_order),避免观众误以为是框架内置表 -- 步骤与代码仓 `deploy/README.md` 严格一致,视频里的每条命令都要能原样复制执行 diff --git a/pages/projects/xiu-dtu/guide/tutorial.md b/pages/projects/xiu-dtu/guide/tutorial.md deleted file mode 100644 index 95a2209..0000000 --- a/pages/projects/xiu-dtu/guide/tutorial.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: 教程视频 -description: 秀杰 DTU 管理平台的部署与设备实操录屏教程 -outline: deep ---- - -# 教程视频 - -秀杰 DTU 分两支:把服务起起来并让设备连上,然后在平台上真的操作一遍设备。 - -## V4 部署教程:源码起服务到设备接入 - -| 项 | 内容 | -| --- | --- | -| 标题 | 秀杰 DTU 部署教程:源码起服务到设备接入 | -| 目标观众 | 做 DTU 设备接入的开发者 | -| 时长 | 12 ~ 15 分钟 | -| 发布位置 | 官网本页 + 源码仓 README 顶部 + B 站 | -| 关联文档 | 代码仓 `docs_ui/` 文档站(快速上手、通信协议、控制台说明) | -| 关联仓库路径 | `server/`、`vue/`、`docs_ui/`、`docker/`、`deploy/` | - -分镜: - -1. **环境准备**(1.5 分钟):Go 1.25+、Node.js 24+ 与 pnpm 10+、MySQL 8、Redis 7 的版本检查 -2. **初始化数据库**(2 分钟):建库 `xiujie_dtu` 并按顺序导入四个初始化 SQL(基础表、业务表、菜单、角色) -3. **配置后端**(1.5 分钟):复制 `config_default.yaml` 为 `config.yaml`、改数据库与 Redis 连接、设置 JWT 签名密钥环境变量(长度不少于 32 位,未提供时服务拒绝签发令牌) -4. **启动后端**(2.5 分钟):`go run main.go`,说明三个监听端口的用途:控制台 5689、设备 HTTP 5692、设备 TCP 5690 -5. **启动前端与文档站**(2 分钟):`pnpm install && pnpm dev`(前端 5688)、`npm run docs:dev`(文档站 5687) -6. **登录与改密**(1 分钟):初始 admin 账号登录后立即改密 -7. **Docker 部署路线**(2 分钟):`docker/dtu_base` 初始化中间件 → `docker/dtu_iot` 部署业务服务、`make docker-build docker-push` 构建推送镜像 - -## V5 设备实操:双通道接入、指令下发与 OTA - -| 项 | 内容 | -| --- | --- | -| 标题 | 秀杰 DTU 设备实操:双通道接入、指令下发与 OTA | -| 目标观众 | 设备端与平台端联调工程师 | -| 时长 | 15 ~ 18 分钟 | -| 发布位置 | 官网本页 + 源码仓 README 顶部 + B 站 | -| 关联文档 | 代码仓文档站「通信协议」章节(HTTP API、TCP 帧、指令、属性上报、配置同步、OTA、UART 隧道) | -| 关联仓库路径 | `tools/`、`server/`、`docs_ui/` | - -分镜: - -1. **HTTP 通道联调**(3 分钟):用 `tools/test-device-http-v4.sh` 带 DEVICE_KEY / PRODUCT_KEY / PROJECT_KEY 打一次设备注册与上报,看平台侧设备列表出现 -2. **TCP 通道联调**(3 分钟):`tools/test-device-tcp-v4.py --heartbeat --listen-command` 建连、维持心跳、接收平台下发指令 -3. **指令下发**(2.5 分钟):从控制台对设备下指令,两端同时看帧与日志 -4. **属性上报与配置同步**(2.5 分钟):设备上报属性、平台侧改配置并下发,验证端到端一致性 -5. **OTA 升级**(2.5 分钟):研发中心建固件版本、发起 OTA 升级任务、看设备侧升级结果 -6. **UART 隧道**(1.5 分钟):透传串口数据的用法与适用场景 -7. **压测**(2 分钟):`tools/stress-2w-devices.py --count 20000` 演示 2 万设备压测怎么发起、看指标 - -## 录制备忘 - -- 录屏环境用干净 Ubuntu,MySQL / Redis 独立装,不要连生产或预演环境 -- DEVICE_KEY / PRODUCT_KEY / PROJECT_KEY 等示例值用文档里的测试值,画面出现即可 -- 2 万设备压测那段只演示命令与指标入口,不在录屏环境真压 2 万台 -- 步骤与代码仓文档站的「快速上手」严格一致,视频里的每条命令都要能原样复制执行 diff --git a/scripts/audit-band-offset.mjs b/scripts/audit-band-offset.mjs new file mode 100644 index 0000000..117b3d9 --- /dev/null +++ b/scripts/audit-band-offset.mjs @@ -0,0 +1,57 @@ +import { chromium } from 'playwright' + +/** + * 「整宽色块 + 内容被挤到右边」全站扫描 + * 症状:某条通栏 band(背景铺满视口)内,首个内容块的左边界离视口左边 > 20% 视口宽。 + * 这正是用户截图里页脚那条「左边一大片空白、内容挤在右边」的形态。 + */ +const BASE = 'http://127.0.0.1:18075' +const PAGES = [ + '/', '/demo/', '/about/', '/projects/', '/projects/iotlcm/', + '/projects/iotlcm/compare/', '/projects/iotlcm/guide/quickstart/', + '/projects/iotlcm/guide/deploy/', + '/projects/xiu-dtu/', '/projects/xiu-admin/', '/guide/portal-deploy/', +] + +const browser = await chromium.launch({ + executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', +}) + +for (const w of [1920, 1440, 1280]) { + const page = await browser.newPage({ viewport: { width: w, height: 950 } }) + for (const path of PAGES) { + await page.goto(BASE + path, { waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => !!document.querySelector('footer.pf-foot'), null, { timeout: 60000 }) + await page.waitForTimeout(900) + const bad = await page.evaluate(() => { + const vp = document.documentElement.clientWidth + const out = [] + const bands = document.querySelectorAll('section, footer, header, div.p-hero, div.p-section') + bands.forEach((el) => { + const cs = getComputedStyle(el) + const r = el.getBoundingClientRect() + const hasBg = cs.backgroundImage !== 'none' || (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)') + if (!hasBg) return + if (r.width < vp * 0.9) return + // 找该 band 内第一个「有内容的块」 + const probe = el.querySelector('.p-wrap, .pf-foot-main, .container') + if (!probe) return + const pr = probe.getBoundingClientRect() + const offset = pr.left - r.left + if (offset > vp * 0.2) { + out.push({ + tag: el.tagName.toLowerCase(), + cls: (el.className || '').toString().slice(0, 50), + offset: Math.round(offset), + pct: Math.round((offset / vp) * 100), + }) + } + }) + return out + }) + if (bad.length) console.log(`${w}\t${path}\t${JSON.stringify(bad)}`) + } + await page.close() +} +await browser.close() +console.log('scan done') diff --git a/scripts/audit-layout.mjs b/scripts/audit-layout.mjs new file mode 100644 index 0000000..1e69c2a --- /dev/null +++ b/scripts/audit-layout.mjs @@ -0,0 +1,161 @@ +import { chromium } from 'playwright' + +/** + * 全站布局审计 + * + * 查两类问题(都是「页脚被裁 / 页面被挤」的典型症状): + * 1) 横向溢出:文档元素 scrollWidth 超过 clientWidth,页面出现左右滚动条 + * 2) 页脚错位:footer 的左边界没有对齐内容区,或宽度小于视口(被侧边栏挤掉一块) + * 3) 页脚被遮挡:footer 宽度正常但被侧栏覆盖 + */ + +const BASE = 'http://127.0.0.1:8160/www' +const PAGES = [ + '/', + '/demo.html', + '/about.html', + '/projects/', + '/projects/iotlcm/', + '/projects/xiu-dtu/', + '/projects/xiu-admin/', + '/projects/iotlcm/compare.html', + '/projects/iotlcm/guide/quickstart.html', + '/projects/iotlcm/guide/deploy.html', + '/guide/portal-deploy.html', + '/public/screenshots/readme.html', +] + +const VIEWPORTS = [ + { w: 1440, h: 900, name: 'desktop' }, + { w: 1024, h: 800, name: 'laptop' }, + { w: 820, h: 900, name: 'tablet' }, + { w: 390, h: 844, name: 'mobile' }, +] + +const browser = await chromium.launch({ + executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', +}) + +const problems = [] + +for (const vp of VIEWPORTS) { + const page = await browser.newPage({ viewport: { width: vp.w, height: vp.h } }) + + for (const path of PAGES) { + try { + await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 30000 }) + await page.waitForTimeout(250) + + const r = await page.evaluate(() => { + const out = {} + + // 1) 横向溢出 + out.docScrollWidth = document.documentElement.scrollWidth + out.docClientWidth = document.documentElement.clientWidth + out.hOverflow = out.docScrollWidth - out.docClientWidth + + // 找出真正超宽的元素(排除 fixed 定位的装饰层) + const wide = [] + document.querySelectorAll('body *').forEach((el) => { + const cs = getComputedStyle(el) + if (cs.position === 'fixed' || cs.position === 'sticky') return + if (cs.overflowX !== 'visible') return + const rect = el.getBoundingClientRect() + if (rect.width === 0) return + if (rect.right > document.documentElement.clientWidth + 1) { + wide.push({ + tag: el.tagName.toLowerCase(), + cls: (el.className || '').toString().slice(0, 60), + right: Math.round(rect.right), + width: Math.round(rect.width), + }) + } + }) + out.wideEls = wide.slice(0, 5) + out.wideCount = wide.length + + // 2) 页脚位置 + const footer = document.querySelector('footer.pf-foot') + if (footer) { + const fr = footer.getBoundingClientRect() + const wrap = footer.querySelector('.p-wrap') + const wr = wrap ? wrap.getBoundingClientRect() : null + out.footer = { + left: Math.round(fr.left), + width: Math.round(fr.width), + vpWidth: document.documentElement.clientWidth, + wrapLeft: wr ? Math.round(wr.left) : null, + wrapWidth: wr ? Math.round(wr.width) : null, + // 页脚是否铺满视口宽度(允许 1px 误差) + spansVp: Math.abs(fr.width - document.documentElement.clientWidth) <= 1, + // 页脚是否从视口左边开始 + startsAtZero: Math.abs(fr.left) <= 1, + } + } else { + out.footer = null + } + + // 3) 侧边栏是否覆盖页脚(取侧栏底部与页脚顶部比较) + const aside = document.querySelector('.VPSidebar') + if (aside) { + const ar = aside.getBoundingClientRect() + const cs = getComputedStyle(aside) + out.sidebar = { + left: Math.round(ar.left), + right: Math.round(ar.right), + top: Math.round(ar.top), + bottom: Math.round(ar.bottom), + width: Math.round(ar.width), + position: cs.position, + visible: cs.display !== 'none' && ar.width > 0, + } + } else { + out.sidebar = null + } + + return out + }) + + const issues = [] + if (r.hOverflow > 2) { + issues.push(`横向溢出 ${r.hOverflow}px`) + } + if (r.footer) { + if (!r.footer.spansVp) issues.push(`页脚宽度 ${r.footer.width} ≠ 视口 ${r.footer.vpWidth}`) + if (!r.footer.startsAtZero) issues.push(`页脚左偏 ${r.footer.left}px`) + // 页脚内容区如果没从 0 附近开始,说明被侧栏挤了 + if (r.sidebar?.visible && r.footer.wrapLeft !== null) { + if (Math.abs(r.footer.wrapLeft - r.footer.left) > 40) { + issues.push(`页脚内容被挤:wrap.left=${r.footer.wrapLeft}, footer.left=${r.footer.left}`) + } + } + } else { + issues.push('页脚缺失') + } + + if (issues.length) { + problems.push({ vp: vp.name, path, issues, detail: r }) + } + } catch (e) { + problems.push({ vp: vp.name, path, issues: [`访问失败: ${e.message.slice(0, 80)}`] }) + } + } + + await page.close() +} + +await browser.close() + +if (!problems.length) { + console.log('✅ 未发现布局问题') +} else { + console.log(`❌ 发现 ${problems.length} 处问题\n`) + for (const p of problems) { + console.log(`[${p.vp}] ${p.path}`) + for (const i of p.issues) console.log(' - ' + i) + if (p.detail?.wideEls?.length) { + console.log(' 超宽元素: ' + JSON.stringify(p.detail.wideEls)) + } + console.log() + } +} diff --git a/scripts/audit-sidebar-footer.mjs b/scripts/audit-sidebar-footer.mjs new file mode 100644 index 0000000..eb474c3 --- /dev/null +++ b/scripts/audit-sidebar-footer.mjs @@ -0,0 +1,90 @@ +import { chromium } from 'playwright' + +/** + * 全站「页脚/侧栏」布局审计 + * 检查点: + * A) 页脚内容左边界是否与无侧栏页面(首页)一致 —— 不一致说明被侧栏挤了 + * B) 页脚可视区域是否被 fixed 层(.VPSidebar 等)遮住(按面积估算) + * C) 横向溢出 + */ +const BASE = process.env.AUDIT_BASE || 'http://127.0.0.1:18075' +const PAGES = [ + '/', + '/demo/', + '/about/', + '/projects/', + '/projects/iotlcm/', + '/projects/iotlcm/compare/', + '/projects/iotlcm/guide/quickstart/', + '/projects/iotlcm/guide/deploy/', + '/projects/xiu-dtu/', + '/projects/xiu-admin/', + '/guide/portal-deploy/', +] +const VPS = [1920, 1600, 1440, 1280, 1024, 820, 390] + +const browser = await chromium.launch({ + executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', +}) + +const rows = [] + +for (const w of VPS) { + const page = await browser.newPage({ viewport: { width: w, height: 900 } }) + for (const path of PAGES) { + await page.goto(BASE + path, { waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => !!document.querySelector('footer.pf-foot'), null, { timeout: 60000 }) + await page.waitForTimeout(900) + const r = await page.evaluate(() => { + const f = document.querySelector('footer.pf-foot') + const fr = f.getBoundingClientRect() + const wrap = f.querySelector('.p-wrap') + const wr = wrap.getBoundingClientRect() + const side = document.querySelector('.VPSidebar') + let sideR = null + if (side) { + const s = side.getBoundingClientRect() + const cs = getComputedStyle(side) + if (s.width > 0 && cs.display !== 'none' && cs.visibility !== 'hidden') + sideR = { left: s.left, right: s.right, width: s.width, z: cs.zIndex } + } + // 页脚与侧栏的横向重叠宽度(仅当侧栏层级高于页脚时才算遮挡) + const overlap = sideR ? Math.max(0, Math.min(sideR.right, fr.right) - Math.max(sideR.left, fr.left)) : 0 + return { + vp: document.documentElement.clientWidth, + footW: Math.round(fr.width), + wrapLeft: Math.round(wr.left), + wrapW: Math.round(wr.width), + cls: f.className, + sideW: sideR ? Math.round(sideR.width) : 0, + overlap: sideR && Number(sideR.z) > Number(getComputedStyle(f).zIndex) ? Math.round(overlap) : 0, + footZ: getComputedStyle(f).zIndex, + sideZ: sideR?.z ?? '-', + hOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + } + }) + rows.push({ w, path, ...r }) + } + await page.close() +} + +// 基线:每个宽度下无侧栏页面的 wrapLeft +const baseline = {} +for (const r of rows) if (!r.cls.includes('has-sidebar')) baseline[r.w] ??= r.wrapLeft + +let bad = 0 +console.log('w\tpath\twrapLeft\tbaseline\tsideW\toverlap\thOverflow') +for (const r of rows) { + const base = baseline[r.w] + const issues = [] + if (base !== undefined && Math.abs(r.wrapLeft - base) > 8) issues.push('页脚内容左偏') + if (r.overlap > 0) issues.push(`侧栏覆盖页脚 ${r.overlap}px`) + if (r.hOverflow > 2) issues.push(`横向溢出 ${r.hOverflow}px`) + if (issues.length) { + bad++ + console.log(`${r.w}\t${r.path}\t${r.wrapLeft}\t${base}\t${r.sideW}\t${r.overlap}\t${r.hOverflow}\t<< ${issues.join(' / ')}`) + } +} +console.log(`\n共 ${rows.length} 组检查,${bad} 组异常`) + +await browser.close() diff --git a/scripts/render-check.mjs b/scripts/render-check.mjs new file mode 100644 index 0000000..2b3d7c1 --- /dev/null +++ b/scripts/render-check.mjs @@ -0,0 +1,55 @@ +import { chromium } from 'playwright' +import { createServer } from 'http' +import { stat, readFile } from 'fs/promises' +import { extname, join, normalize } from 'path' + +const ROOT = process.argv[2] +if (!ROOT) { console.error('用法: node render-check.mjs '); process.exit(1) } + +const MIME = { '.html':'text/html', '.js':'text/javascript', '.css':'text/css', '.svg':'image/svg+xml', '.png':'image/png', '.jpg':'image/jpeg', '.json':'application/json', '.woff2':'font/woff2', '.woff':'font/woff', '.ico':'image/x-icon' } + +const server = createServer(async (req, res) => { + try { + let p = decodeURIComponent(req.url.split('?')[0]) + if (!p.startsWith('/')) p = '/' + p + let fp = normalize(join(ROOT, p)) + try { await stat(fp) } catch { + try { await stat(fp + '.html'); fp = fp + '.html' } + catch { try { await stat(join(fp, 'index.html')); fp = join(fp, 'index.html') } catch { fp = join(ROOT, '404.html') } } + } + if ((await stat(fp)).isDirectory()) fp = join(fp, 'index.html') + const buf = await readFile(fp) + res.writeHead(200, { 'content-type': MIME[extname(fp)] || 'application/octet-stream' }) + res.end(buf) + } catch { res.writeHead(404); res.end('nf') } +}) +await new Promise(r => server.listen(0, r)) +const port = server.address().port + +const browser = await chromium.launch({ channel: 'chrome', args: ['--no-sandbox'] }) +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, javaScriptEnabled: false }) +const page = await ctx.newPage() + +const pages = ['', 'demo', 'about', 'projects/', 'projects/iotlcm/', 'projects/xiu-dtu/', 'projects/xiu-admin/', 'guide/portal-deploy', 'projects/iotlcm/compare', 'projects/iotlcm/guide/deploy', 'projects/iotlcm/guide/quickstart'] +const results = [] +for (const p of pages) { + const resp = await page.goto(`http://127.0.0.1:${port}/${p}`, { waitUntil: 'load' }) + const info = await page.evaluate(() => ({ + is404: !!document.querySelector('.NotFound'), + h1: (document.querySelector('h1')?.textContent || '').trim().slice(0, 36), + hasNav: !!document.querySelector('.VPNav'), + hasFooter: !!document.querySelector('.pf-foot'), + })) + results.push({ p: p || '(home)', status: resp.status(), ...info }) +} +console.log(`\n=== ${ROOT} 渲染验收 ===`) +let ok = 0, fail = 0 +for (const r of results) { + const bad = r.is404 || r.status >= 400 || !r.hasNav + if (bad) fail++; else ok++ + console.log(`${bad ? '❌' : '✅'} ${String(r.status).padEnd(4)} ${r.p.padEnd(34)} ${r.is404 ? '[404页]' : ''} ${r.h1}`) +} +console.log(`\n通过 ${ok} / 失败 ${fail}`) +await browser.close() +server.close() +process.exit(fail ? 1 : 0)