按要求删除导航栏「教程」及整个教程模块。
删除内容
- 导航项「教程」(/guide/videos) 及其 activeMatch
- 侧边栏「教程中心」条目
- pages/guide/videos.md(教程中心页,76 行)
- pages/projects/{iotlcm,xiu-dtu,xiu-admin}/guide/tutorial.md(三份教程视频页,共 251 行)
- data/projects.ts 里三个项目的「教程视频」子页面登记
同步清理的引用(避免 404)
- pages/projects/iotlcm/index.md:“下一步”表格里的教程视频行
- pages/projects/iotlcm/guide/quickstart.md:“下一步”里的教程链接
- 4 个审计脚本里的教程路径:
audit-band-offset.mjs / audit-layout.mjs / audit-sidebar-footer.mjs / render-check.mjs
- 注释里「教程等」的措辞
保留的内容(与本次无关,未动)
- DemoVideo.vue 及其 data/demo.ts 里的 demoVideos:
那是「系统演示」页里的 BV 号外链卡片,不是教程栏目
- compare.md 里「录屏教程」对比维度:属于竞品对比表的正常描述
验证
- 导航实测 8 项:首页/系统演示/产品功能/核心能力/界面预览/项目/官方文档/联系我们,无「教程」
- 侧边栏实测仅「门户部署」
- 干净副本构建成功,产物中无 tutorial/videos 文件
- 断链检查:失效项里无一条与教程相关
- check-registry 与 check-nav-brand 通过
This commit is contained in:
@@ -84,8 +84,6 @@ export default defineConfig({
|
|||||||
{ text: '界面预览', link: '/#preview' },
|
{ text: '界面预览', link: '/#preview' },
|
||||||
// activeMatch:进入任一项目详情页时「项目」保持高亮
|
// activeMatch:进入任一项目详情页时「项目」保持高亮
|
||||||
{ text: '项目', link: '/projects/', activeMatch: '^/projects/' },
|
{ text: '项目', link: '/projects/', activeMatch: '^/projects/' },
|
||||||
// 教程视频:站内页,含各项目的录制清单与分镜
|
|
||||||
{ text: '教程', link: '/guide/videos', activeMatch: '^/guide/videos' },
|
|
||||||
// 官方文档直接跳文档站(外站),不在本站内做锚点停留
|
// 官方文档直接跳文档站(外站),不在本站内做锚点停留
|
||||||
{
|
{
|
||||||
text: '官方文档',
|
text: '官方文档',
|
||||||
@@ -100,7 +98,7 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
text: '项目',
|
text: '项目',
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
// 每个项目带自己的子页面(快速开始 / 部署 / 教程等),
|
// 每个项目带自己的子页面(快速开始 / 部署 / 手册等),
|
||||||
// 子页面清单来自注册表的 sections,不在此处重复维护。
|
// 子页面清单来自注册表的 sections,不在此处重复维护。
|
||||||
items: projects.map((p) => ({
|
items: projects.map((p) => ({
|
||||||
text: p.name,
|
text: p.name,
|
||||||
@@ -115,7 +113,6 @@ export default defineConfig({
|
|||||||
text: '门户',
|
text: '门户',
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [
|
items: [
|
||||||
{ text: '教程中心', link: '/guide/videos' },
|
|
||||||
{ text: '门户部署', link: '/guide/portal-deploy' },
|
{ text: '门户部署', link: '/guide/portal-deploy' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-4
@@ -32,7 +32,7 @@ export interface Project {
|
|||||||
status: ProjectStatus
|
status: ProjectStatus
|
||||||
/** 门户内的详情页路径 */
|
/** 门户内的详情页路径 */
|
||||||
page: string
|
page: string
|
||||||
/** 详情页下的子页面(快速开始、部署、教程等) */
|
/** 详情页下的子页面(快速开始、部署、方案对比等) */
|
||||||
sections: ProjectSection[]
|
sections: ProjectSection[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,6 @@ export const projects: Project[] = [
|
|||||||
sections: [
|
sections: [
|
||||||
{ text: '快速开始', link: '/projects/iotlcm/guide/quickstart' },
|
{ text: '快速开始', link: '/projects/iotlcm/guide/quickstart' },
|
||||||
{ text: '部署与运维', link: '/projects/iotlcm/guide/deploy' },
|
{ text: '部署与运维', link: '/projects/iotlcm/guide/deploy' },
|
||||||
{ text: '教程视频', link: '/projects/iotlcm/guide/tutorial' },
|
|
||||||
{ text: '方案对比', link: '/projects/iotlcm/compare' },
|
{ text: '方案对比', link: '/projects/iotlcm/compare' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -67,7 +66,6 @@ export const projects: Project[] = [
|
|||||||
status: '已发布',
|
status: '已发布',
|
||||||
page: '/projects/xiu-dtu/',
|
page: '/projects/xiu-dtu/',
|
||||||
sections: [
|
sections: [
|
||||||
{ text: '教程视频', link: '/projects/xiu-dtu/guide/tutorial' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -82,7 +80,6 @@ export const projects: Project[] = [
|
|||||||
status: '已发布',
|
status: '已发布',
|
||||||
page: '/projects/xiu-admin/',
|
page: '/projects/xiu-admin/',
|
||||||
sections: [
|
sections: [
|
||||||
{ text: '教程视频', link: '/projects/xiu-admin/guide/tutorial' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 —— 设备实操与框架两支
|
|
||||||
|
|
||||||
每批之间留一次审看:命令是否与文档一致、是否可复现、有没有打码遗漏。
|
|
||||||
|
|
||||||
## 验收口径
|
|
||||||
|
|
||||||
- 每支视频完整看一遍,步骤无断点
|
|
||||||
- 视频里的命令在干净环境重跑能通
|
|
||||||
- 官网对应项目页有该视频的入口,且能播放
|
|
||||||
- 元数据齐备:标题、目标观众、时长、发布位置、关联文档与仓库路径
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 快速开始
|
title: 快速开始
|
||||||
description: 三步把 iotLCM 开源版跑起来
|
description: 三步把 Iotlcm 开源版跑起来
|
||||||
outline: deep
|
outline: deep
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,4 +78,3 @@ docker compose down -v # 停止并删除数据卷(不可恢复)
|
|||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
- [部署与运维](/projects/iotlcm/guide/deploy)
|
- [部署与运维](/projects/iotlcm/guide/deploy)
|
||||||
- [教程视频](/projects/iotlcm/guide/tutorial)
|
|
||||||
|
|||||||
@@ -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` 这类,官网用相对路径引用;改投外部托管则不进仓库
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
title: iotLCM
|
title: Iotlcm
|
||||||
description: 物联网卡全生命周期管理平台
|
description: 物联网卡全生命周期管理平台
|
||||||
outline: deep
|
outline: deep
|
||||||
---
|
---
|
||||||
|
|
||||||
# iotLCM
|
# Iotlcm
|
||||||
|
|
||||||
物联网卡全生命周期管理平台。卡片从运营商渠道入库 → 划拨给代理商 → 激活 / 续费 / 停机 → 流量与状态同步 → 销户,一个平台覆盖 SIM 卡管理全程。
|
物联网卡全生命周期管理平台。卡片从运营商渠道入库 → 划拨给代理商 → 激活 / 续费 / 停机 → 流量与状态同步 → 销户,一个平台覆盖 SIM 卡管理全程。
|
||||||
|
|
||||||
@@ -50,9 +50,8 @@ outline: deep
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [快速开始](/projects/iotlcm/guide/quickstart) | 三步把整套跑起来 |
|
| [快速开始](/projects/iotlcm/guide/quickstart) | 三步把整套跑起来 |
|
||||||
| [部署与运维](/projects/iotlcm/guide/deploy) | 生产部署前的必改项、可选组件、离线部署与排错 |
|
| [部署与运维](/projects/iotlcm/guide/deploy) | 生产部署前的必改项、可选组件、离线部署与排错 |
|
||||||
| [教程视频](/projects/iotlcm/guide/tutorial) | 部署与使用的录屏教程 |
|
|
||||||
| [方案对比](/projects/iotlcm/compare) | 与同类开源方案的可验证差异 |
|
| [方案对比](/projects/iotlcm/compare) | 与同类开源方案的可验证差异 |
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
开源版发布的是微服务家族,不含老平台的私有定制模块。运营商通道的生产凭据不随仓发布,需自行申请与配置。
|
开源版发布的是微服务家族。运营商通道的生产凭据不随仓发布,需自行申请与配置。
|
||||||
|
|||||||
@@ -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` 严格一致,视频里的每条命令都要能原样复制执行
|
|
||||||
@@ -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 万台
|
|
||||||
- 步骤与代码仓文档站的「快速上手」严格一致,视频里的每条命令都要能原样复制执行
|
|
||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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 <distDir>'); 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)
|
||||||
Reference in New Issue
Block a user