s''
This commit is contained in:
@@ -49,27 +49,27 @@ export const CURRICULUM_CONFIG = {
|
|||||||
emoji: "🚀",
|
emoji: "🚀",
|
||||||
title: "基础篇 · 云手机入门",
|
title: "基础篇 · 云手机入门",
|
||||||
lessons: [
|
lessons: [
|
||||||
{ name: "01 什么是云手机?自建 vs 商业平台", duration: "08:32" },
|
{ name: "01 从0到1:安卓镜像安装与首次开机", duration: "08:32" },
|
||||||
{ name: "02 低成本工作室硬件选型", duration: "12:15" },
|
{ name: "02 性能翻倍:安卓存储扩容实操", duration: "12:15" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
emoji: "⚙️",
|
emoji: "⚙️",
|
||||||
title: "技术篇 · Redroid 与 AOSP",
|
title: "技术篇 · Redroid 与 AOSP",
|
||||||
lessons: [
|
lessons: [
|
||||||
{ name: "03 Docker 虚拟化安卓 - Redroid", duration: "15:20" },
|
{ name: "03 竖屏直播必备:系统显示方向设置", duration: "15:20" },
|
||||||
{ name: "04 ARM 开发板 AOSP 系统", duration: "18:00" },
|
{ name: "04 一次搞定 Root:Magisk + 模块安装", duration: "18:00" },
|
||||||
{ name: "05 相机与推流配置", duration: "14:30" },
|
{ name: "05 防风控第一步:机型参数改造", duration: "14:30" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
emoji: "📺",
|
emoji: "📺",
|
||||||
title: "直播篇 · 无人推流",
|
title: "直播篇 · 无人推流",
|
||||||
lessons: [
|
lessons: [
|
||||||
{ name: "06 ffmpeg 拉流转推", duration: "16:00" },
|
{ name: "06 降低风控命中:GPS 精准定位", duration: "16:00" },
|
||||||
{ name: "07 SRS 直播服务器", duration: "12:45" },
|
{ name: "07 无人直播入门:录播素材循环推流", duration: "12:45" },
|
||||||
{ name: "08 油猴脚本自动化", duration: "11:20" },
|
{ name: "08 TikTok 实战开播:完整流程演示", duration: "11:20" },
|
||||||
{ name: "09 TikTok/Youtube 实战", duration: "20:00" },
|
{ name: "09 TikTok 进阶优化:稳定推流与细节调优", duration: "20:00" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,49 +4,286 @@
|
|||||||
|
|
||||||
const SAMPLE_VIDEO = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
const SAMPLE_VIDEO = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||||
|
|
||||||
const SAMPLE_MARKDOWN = `# 课程概述
|
const LESSON_MARKDOWNS: Record<string, string> = {
|
||||||
|
"0-0": `# 01 从0到1:安卓镜像安装与首次开机
|
||||||
|
|
||||||
本小节将带你理解云手机的核心概念,掌握低成本工作室搭建技能。
|
对应电子书章节:\`硬件选择\` -> \`刷入安卓系统\`。
|
||||||
|
|
||||||
## 学习目标
|
## 你会完成什么
|
||||||
|
|
||||||
- 理解云手机的本质与自建方案
|
- 准备 TF 卡、读卡器、开发板电源与网线
|
||||||
- 掌握 Redroid、AOSP 等虚拟化技术
|
- 下载开发板安卓镜像(AOSP)
|
||||||
- 完成无人直播推流配置
|
- 用 Balena Etcher 刷写 img 并首次开机
|
||||||
|
|
||||||
## 核心要点
|
## 实操步骤
|
||||||
|
|
||||||
1. **定义**:云手机 = 虚拟化安卓 + 远程控制 + 自动化推流
|
1. 将 img 镜像写入 TF 卡
|
||||||
2. **技术**:Docker Redroid、ARM 开发板、ffmpeg、SRS
|
2. TF 卡插回开发板,接通电源和网线
|
||||||
3. **成本**:自建单台成本可控制在千元以内
|
3. 首次启动后确认系统已进入桌面并可调试
|
||||||
|
|
||||||
## 行动清单
|
## 避坑提醒
|
||||||
|
|
||||||
- [ ] 评估自己的硬件与网络环境
|
- 刷机前优先确认镜像版本与板型匹配
|
||||||
- [ ] 列出想实现的直播平台(TikTok/Youtube 等)
|
- 首次开机时间可能较长,不要中途断电
|
||||||
- [ ] 设定 1 个月内的搭建目标
|
`,
|
||||||
|
"0-1": `# 02 性能翻倍:安卓存储扩容实操
|
||||||
|
|
||||||
### 代码示例
|
对应电子书章节:\`硬件选择\` 与存储相关说明(TF/SSD 组合)。
|
||||||
|
|
||||||
|
## 核心目标
|
||||||
|
|
||||||
|
- 解决默认系统空间不足导致的安装失败
|
||||||
|
- 保证后续安装模块、素材、脚本有足够空间
|
||||||
|
|
||||||
|
## 推荐方案
|
||||||
|
|
||||||
|
- TF 卡至少 64GB
|
||||||
|
- 优先使用 USB3.0 读卡器提升刷写效率
|
||||||
|
- 有条件时搭配 SSD 作为素材盘
|
||||||
|
|
||||||
|
## 执行清单
|
||||||
|
|
||||||
|
- [ ] 检查系统可用容量
|
||||||
|
- [ ] 清理无用预装与缓存
|
||||||
|
- [ ] 预留直播素材和日志空间
|
||||||
|
`,
|
||||||
|
"1-0": `# 03 竖屏直播必备:系统显示方向设置
|
||||||
|
|
||||||
|
对应电子书章节:\`业务场景:无人直播\` 中摄像头与画面链路实践。
|
||||||
|
|
||||||
|
## 为什么先做竖屏
|
||||||
|
|
||||||
|
- TikTok 直播以竖屏为主
|
||||||
|
- 先固定方向,后续采集与推流才稳定
|
||||||
|
|
||||||
|
## 操作要点
|
||||||
|
|
||||||
|
1. 在系统中关闭自动旋转
|
||||||
|
2. 固定竖屏分辨率
|
||||||
|
3. 使用测试素材验证采集画面方向
|
||||||
|
|
||||||
|
## 验证标准
|
||||||
|
|
||||||
|
- 开播预览无横竖屏抖动
|
||||||
|
- 推流端与手机端画面方向一致
|
||||||
|
`,
|
||||||
|
"1-1": `# 04 一次搞定 Root:Magisk + 模块安装
|
||||||
|
|
||||||
|
对应电子书章节:\`获取root权限\` 与 \`安装lsposed环境\`。
|
||||||
|
|
||||||
|
## 实操流程
|
||||||
|
|
||||||
|
1. 从镜像提取 \`boot.img\`
|
||||||
|
2. 在安卓端用 Magisk 修补镜像
|
||||||
|
3. 通过刷写工具或 adb dd 回刷补丁镜像
|
||||||
|
4. 重启验证 Root 生效
|
||||||
|
|
||||||
|
## 关键命令(电子书同源)
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
# 启动 Redroid 容器
|
adb shell
|
||||||
docker run -itd --privileged \\
|
su
|
||||||
-v ~/data:/data \\
|
dd if=/sdcard/magisk_patched.img of=/dev/block/mmcblk0p9 bs=4M
|
||||||
redroid/redroid:13.0.0-latest
|
sync
|
||||||
|
reboot
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
行内代码示例:\`ffmpeg -re -i input.mp4 -c copy -f flv rtmp://server/live\` 可推流到 RTMP 服务器。
|
## 后续动作
|
||||||
|
|
||||||
## 注意事项
|
- 安装 LSPosed,准备模块化能力
|
||||||
|
- 记录可回滚镜像,避免系统损坏
|
||||||
|
`,
|
||||||
|
"1-2": `# 05 防风控第一步:机型参数改造
|
||||||
|
|
||||||
> 云手机自建需要一定技术基础,建议先完成前 2 节试看再决定是否购买。
|
对应电子书章节:\`修改机型\`。
|
||||||
|
|
||||||
| 项目 | 说明 |
|
## 目标
|
||||||
|------|------|
|
|
||||||
| 硬件 | 建议 ARM 开发板或 x86 主机 |
|
- 降低平台对异常设备指纹的识别概率
|
||||||
| 网络 | 需科学上网与静态 IP |
|
- 让账号环境更贴近真实移动设备
|
||||||
| 时间 | 完整搭建约 1-2 天 |
|
|
||||||
`;
|
## 建议流程
|
||||||
|
|
||||||
|
1. 安装改机工具并授予 Root 权限
|
||||||
|
2. 进入设备信息伪装,选择目标机型
|
||||||
|
3. 重启系统并重新检查参数是否生效
|
||||||
|
|
||||||
|
## 风险提示
|
||||||
|
|
||||||
|
- 机型参数、IP、GPS 需要协同一致
|
||||||
|
- 不建议频繁切换机型,避免触发异常行为
|
||||||
|
`,
|
||||||
|
"2-0": `# 06 降低风控命中:GPS 精准定位
|
||||||
|
|
||||||
|
对应电子书章节:\`GPS定位\`。
|
||||||
|
|
||||||
|
## 为什么必须做
|
||||||
|
|
||||||
|
- GPS 与 IP 国家/城市不一致,容易触发风控
|
||||||
|
- 直播账号稳定性与定位一致性强相关
|
||||||
|
|
||||||
|
## 操作步骤
|
||||||
|
|
||||||
|
1. 安装定位工具并配置 Mock Location
|
||||||
|
2. 通过地图搜索目标区域并手动校准
|
||||||
|
3. 启动模拟定位后再打开直播 App
|
||||||
|
|
||||||
|
## 检查清单
|
||||||
|
|
||||||
|
- [ ] GPS 城市与代理 IP 所在地区一致
|
||||||
|
- [ ] 时间时区与账号地区一致
|
||||||
|
- [ ] 开播前再次确认定位未漂移
|
||||||
|
`,
|
||||||
|
"2-1": `# 07 无人直播入门:录播素材循环推流
|
||||||
|
|
||||||
|
对应电子书章节:\`业务场景:无人直播\` 与 \`录播mp4素材\`。
|
||||||
|
|
||||||
|
## 核心链路
|
||||||
|
|
||||||
|
1. Linux 辅助板播放素材
|
||||||
|
2. HDMI 输出到采集卡
|
||||||
|
3. 采集卡通过 USB 输入安卓板,识别为摄像头
|
||||||
|
|
||||||
|
## 示例命令
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
ffplay -loop 0 -vf "transpose=2,scale=640:-1,format=yuv420p" -fs -threads 6 xxx.mp4
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 实战建议
|
||||||
|
|
||||||
|
- 先用短素材跑通链路,再换长素材
|
||||||
|
- 分辨率和码率不要一步拉满,先求稳定
|
||||||
|
`,
|
||||||
|
"2-2": `# 08 TikTok 实战开播:完整流程演示
|
||||||
|
|
||||||
|
对应电子书章节:\`业务场景:无人直播\` 与 \`辅助板linux配置\`。
|
||||||
|
|
||||||
|
## 开播前准备
|
||||||
|
|
||||||
|
- 辅助板已安装视频/音频驱动与 ffmpeg
|
||||||
|
- 安卓端已完成 Root、改机、定位
|
||||||
|
- 直播账号完成基础养号与资料设置
|
||||||
|
|
||||||
|
## 标准流程
|
||||||
|
|
||||||
|
1. 启动素材源或直播流
|
||||||
|
2. 在 TikTok 端预览并确认摄像头输入
|
||||||
|
3. 开播后观察前 10 分钟数据与风控提示
|
||||||
|
|
||||||
|
## 观察重点
|
||||||
|
|
||||||
|
- 是否弹验证窗口
|
||||||
|
- 是否出现明显限流
|
||||||
|
- 进房、停留、互动是否正常
|
||||||
|
`,
|
||||||
|
"2-3": `# 09 TikTok 进阶优化:稳定推流与细节调优
|
||||||
|
|
||||||
|
对应电子书章节:\`obs推流/srs拉流\`。
|
||||||
|
|
||||||
|
## 进阶目标
|
||||||
|
|
||||||
|
- 降低延迟与卡顿
|
||||||
|
- 提升长时段推流稳定性
|
||||||
|
|
||||||
|
## SRS 部署思路
|
||||||
|
|
||||||
|
- 在辅助板部署 SRS
|
||||||
|
- OBS 走 SRT 推流,辅助板本地拉流喂给安卓摄像头
|
||||||
|
|
||||||
|
## 拉流脚本示例
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
ffplay -fflags nobuffer -flags low_delay -framedrop \
|
||||||
|
-probesize 32 -analyzeduration 0 -sync ext -infbuf \
|
||||||
|
-vf "setpts=PTS-STARTPTS" \
|
||||||
|
'srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10'
|
||||||
|
\`\`\`
|
||||||
|
`,
|
||||||
|
"3-0": `# 10 软路由与科学上网
|
||||||
|
|
||||||
|
对应电子书章节:\`魔法上网\`。
|
||||||
|
|
||||||
|
## 为什么是软路由优先
|
||||||
|
|
||||||
|
- 电子书实测:代理 APK 在部分场景难过风控
|
||||||
|
- 软路由链路更稳定,适合长期业务运行
|
||||||
|
|
||||||
|
## 推荐组合
|
||||||
|
|
||||||
|
- 海外 VPS(中转)
|
||||||
|
- 静态住宅 IP(落地)
|
||||||
|
- Clash Meta(链式代理)
|
||||||
|
|
||||||
|
## 操作顺序
|
||||||
|
|
||||||
|
1. 购买 VPS 与域名
|
||||||
|
2. 购买静态住宅 IP
|
||||||
|
3. 配置链式代理与策略路由
|
||||||
|
`,
|
||||||
|
"3-1": `# 11 静态住宅 IP 配置
|
||||||
|
|
||||||
|
对应电子书章节:\`静态住宅ip\` 与 \`链式代理\`。
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
- 便宜节点通常不能国内直连,必须走中转
|
||||||
|
- IP、GPS、机型要形成一致的“环境画像”
|
||||||
|
|
||||||
|
## 配置要点
|
||||||
|
|
||||||
|
- 落地节点使用静态住宅 IP
|
||||||
|
- 中转与落地分层,避免单点故障
|
||||||
|
- 业务账号固定绑定线路,不频繁切换
|
||||||
|
|
||||||
|
## 验证指标
|
||||||
|
|
||||||
|
- DNS、出口国家、时区一致
|
||||||
|
- 连续开播无大规模验证弹窗
|
||||||
|
`,
|
||||||
|
"4-0": `# 12 HDMI 辅助板方案
|
||||||
|
|
||||||
|
对应电子书章节:\`如果需要直播\` 与 \`业务场景:无人直播\`。
|
||||||
|
|
||||||
|
## 方案价值
|
||||||
|
|
||||||
|
- 把外部视频信号稳定送进安卓端“摄像头”
|
||||||
|
- 满足 TikTok 对移动端直播形态的偏好
|
||||||
|
|
||||||
|
## 硬件清单
|
||||||
|
|
||||||
|
- Linux 辅助板(如 R3S)
|
||||||
|
- 视频采集卡
|
||||||
|
- HDMI 线
|
||||||
|
- 安卓开发板(主控)
|
||||||
|
|
||||||
|
## 连接示意
|
||||||
|
|
||||||
|
\`素材源 -> HDMI -> 采集卡 -> 安卓板 USB\`
|
||||||
|
|
||||||
|
确认安卓相机可预览即代表链路打通。
|
||||||
|
`,
|
||||||
|
"4-1": `# 13 多设备群控与远控
|
||||||
|
|
||||||
|
对应电子书章节:\`远程控制\`、\`内网穿透 frp\`、\`应用多开/系统多开\`。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 一台电脑管理多台安卓设备
|
||||||
|
- 降低人工重复操作成本
|
||||||
|
|
||||||
|
## 常见工具链
|
||||||
|
|
||||||
|
- qtscrcpy / escrcpy:图形远控与群控
|
||||||
|
- frp-panel:外网访问与设备穿透
|
||||||
|
- 安卓虚拟机:低成本扩展多开能力
|
||||||
|
|
||||||
|
## 交付标准
|
||||||
|
|
||||||
|
- 可以批量连接设备并执行统一操作
|
||||||
|
- 关键节点支持日志与异常回溯
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
export type LessonDetail = {
|
export type LessonDetail = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -57,82 +294,82 @@ export type LessonDetail = {
|
|||||||
|
|
||||||
export const LESSONS_MAP: Record<string, LessonDetail> = {
|
export const LESSONS_MAP: Record<string, LessonDetail> = {
|
||||||
"0-0": {
|
"0-0": {
|
||||||
name: "01 什么是云手机?自建 vs 商业平台",
|
name: "01 从0到1:安卓镜像安装与首次开机",
|
||||||
duration: "08:32",
|
duration: "08:32",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: "https://minioweb.hackrobot.cn/hackrobot/1. 安装安卓img_20260326_095335_848.mp4",
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "云手机定义与自建 vs 商业平台对比"),
|
markdown: LESSON_MARKDOWNS["0-0"],
|
||||||
},
|
},
|
||||||
"0-1": {
|
"0-1": {
|
||||||
name: "02 低成本工作室硬件选型",
|
name: "02 性能翻倍:安卓存储扩容实操",
|
||||||
duration: "12:15",
|
duration: "12:15",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: "https://minioweb.hackrobot.cn/hackrobot/2. 安卓扩容_20260326_095426_1002.mp4",
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "开发板、采集卡、配件选型"),
|
markdown: LESSON_MARKDOWNS["0-1"],
|
||||||
},
|
},
|
||||||
"1-0": {
|
"1-0": {
|
||||||
name: "03 Docker 虚拟化安卓 - Redroid",
|
name: "03 竖屏直播必备:系统显示方向设置",
|
||||||
duration: "15:20",
|
duration: "15:20",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: "https://minioweb.hackrobot.cn/hackrobot/3. 设置竖屏_20260326_095414_979.mp4",
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "Redroid 安装与配置"),
|
markdown: LESSON_MARKDOWNS["1-0"],
|
||||||
},
|
},
|
||||||
"1-1": {
|
"1-1": {
|
||||||
name: "04 ARM 开发板 AOSP 系统",
|
name: "04 一次搞定 Root:Magisk + 模块安装",
|
||||||
duration: "18:00",
|
duration: "18:00",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: "https://minioweb.hackrobot.cn/hackrobot/4. 安装magisk以及模块_20260326_095427_1018.mp4",
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "AOSP 刷机与系统定制"),
|
markdown: LESSON_MARKDOWNS["1-1"],
|
||||||
},
|
},
|
||||||
"1-2": {
|
"1-2": {
|
||||||
name: "05 相机与推流配置",
|
name: "05 防风控第一步:机型参数改造",
|
||||||
duration: "14:30",
|
duration: "14:30",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: "https://minioweb.hackrobot.cn/hackrobot/5. 修改机型参数_20260326_095438_1051.mp4",
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "虚拟相机与 RTMP 推流"),
|
markdown: LESSON_MARKDOWNS["1-2"],
|
||||||
},
|
},
|
||||||
"2-0": {
|
"2-0": {
|
||||||
name: "06 ffmpeg 拉流转推",
|
name: "06 降低风控命中:GPS 精准定位",
|
||||||
duration: "16:00",
|
duration: "16:00",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: "https://minioweb.hackrobot.cn/hackrobot/6. gps定位_20260326_095430_1031.mp4",
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "ffmpeg 拉流与转推"),
|
markdown: LESSON_MARKDOWNS["2-0"],
|
||||||
},
|
},
|
||||||
"2-1": {
|
"2-1": {
|
||||||
name: "07 SRS 直播服务器",
|
name: "07 无人直播入门:录播素材循环推流",
|
||||||
duration: "12:45",
|
duration: "12:45",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: "https://minioweb.hackrobot.cn/hackrobot/7无人直播-录播_20260326_100335_2814.mp4",
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "SRS 部署与推拉流"),
|
markdown: LESSON_MARKDOWNS["2-1"],
|
||||||
},
|
},
|
||||||
"2-2": {
|
"2-2": {
|
||||||
name: "08 油猴脚本自动化",
|
name: "08 TikTok 实战开播:完整流程演示",
|
||||||
duration: "11:20",
|
duration: "11:20",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: "https://minioweb.hackrobot.cn/hackrobot/8. tiktok直播_20260326_095441_1064.mp4",
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "油猴脚本实现自动化操作"),
|
markdown: LESSON_MARKDOWNS["2-2"],
|
||||||
},
|
},
|
||||||
"2-3": {
|
"2-3": {
|
||||||
name: "09 TikTok/Youtube 实战",
|
name: "09 TikTok 进阶优化:稳定推流与细节调优",
|
||||||
duration: "20:00",
|
duration: "20:00",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: "https://minioweb.hackrobot.cn/hackrobot/9. tiktok直播-2_20260326_095403_943.mp4",
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "TikTok/Youtube 无人直播实战"),
|
markdown: LESSON_MARKDOWNS["2-3"],
|
||||||
},
|
},
|
||||||
"3-0": {
|
"3-0": {
|
||||||
name: "10 软路由与科学上网",
|
name: "10 软路由与科学上网",
|
||||||
duration: "13:00",
|
duration: "13:00",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: SAMPLE_VIDEO,
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "软路由配置与科学上网"),
|
markdown: LESSON_MARKDOWNS["3-0"],
|
||||||
},
|
},
|
||||||
"3-1": {
|
"3-1": {
|
||||||
name: "11 静态住宅 IP 配置",
|
name: "11 静态住宅 IP 配置",
|
||||||
duration: "10:30",
|
duration: "10:30",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: SAMPLE_VIDEO,
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "静态住宅 IP 与代理配置"),
|
markdown: LESSON_MARKDOWNS["3-1"],
|
||||||
},
|
},
|
||||||
"4-0": {
|
"4-0": {
|
||||||
name: "12 HDMI 辅助板方案",
|
name: "12 HDMI 辅助板方案",
|
||||||
duration: "15:00",
|
duration: "15:00",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: SAMPLE_VIDEO,
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "HDMI 采集与辅助板"),
|
markdown: LESSON_MARKDOWNS["4-0"],
|
||||||
},
|
},
|
||||||
"4-1": {
|
"4-1": {
|
||||||
name: "13 多设备群控与远控",
|
name: "13 多设备群控与远控",
|
||||||
duration: "12:00",
|
duration: "12:00",
|
||||||
videoUrl: SAMPLE_VIDEO,
|
videoUrl: SAMPLE_VIDEO,
|
||||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "群控与远程控制方案"),
|
markdown: LESSON_MARKDOWNS["4-1"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const EBOOKS_DISPLAY = [
|
|||||||
|
|
||||||
/** 视频教程展示 - 参考 digital.hackrobot.cn 资源导航专题,至少 4 个 */
|
/** 视频教程展示 - 参考 digital.hackrobot.cn 资源导航专题,至少 4 个 */
|
||||||
export const VIDEO_COURSES_DISPLAY = [
|
export const VIDEO_COURSES_DISPLAY = [
|
||||||
{ id: "cloudphone", icon: "☁️", title: "云手机", desc: "低成本自建云手机,TikTok/YouTube 运营搭建指南", href: "/cloudphone" },
|
{ id: "cloudphone", icon: "☁️", title: "云手机", desc: "低成本自建云手机,TikTok/YouTube 无人直播实战指南", href: "/cloudphone" },
|
||||||
{ id: "livestream", icon: "📺", title: "无人直播", desc: "TikTok/YouTube 无人直播,24 小时自动化", href: "/topic/livestream" },
|
{ id: "livestream", icon: "📺", title: "无人直播", desc: "TikTok/YouTube 无人直播,24 小时自动化", href: "/topic/livestream" },
|
||||||
{ id: "indie", icon: "🛠️", title: "独立开发", desc: "从想法到产品,独立开发者全流程指南", href: "/topic/indie" },
|
{ id: "indie", icon: "🛠️", title: "独立开发", desc: "从想法到产品,独立开发者全流程指南", href: "/topic/indie" },
|
||||||
{ id: "aiagent", icon: "🤖", title: "AI Agent", desc: "AIGC 与智能体,Vibe Coding 跨界创造", href: "/topic/aiagent" },
|
{ id: "aiagent", icon: "🤖", title: "AI Agent", desc: "AIGC 与智能体,Vibe Coding 跨界创造", href: "/topic/aiagent" },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTopicCurriculum } from "../config";
|
import { getTopicCurriculum } from "../config";
|
||||||
import { canWatchLesson } from "@/app/cloudphone/lib/payment";
|
import { canWatchLesson, isPaid } from "@/app/cloudphone/lib/payment";
|
||||||
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/cloudphone/lib/learning";
|
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/cloudphone/lib/learning";
|
||||||
|
|
||||||
type TopicCurriculumProps = {
|
type TopicCurriculumProps = {
|
||||||
@@ -14,19 +14,26 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
|||||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||||
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set());
|
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set());
|
||||||
const [completed, setCompleted] = useState<Set<string>>(new Set());
|
const [completed, setCompleted] = useState<Set<string>>(new Set());
|
||||||
|
const [paid, setPaid] = useState(false);
|
||||||
|
const [showUpdatingModal, setShowUpdatingModal] = useState(false);
|
||||||
|
const [updatingLessonName, setUpdatingLessonName] = useState("");
|
||||||
|
|
||||||
const curriculum = getTopicCurriculum(topicId);
|
const curriculum = getTopicCurriculum(topicId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBookmarks(getBookmarks());
|
setBookmarks(getBookmarks());
|
||||||
setCompleted(getCompletedLessons());
|
setCompleted(getCompletedLessons());
|
||||||
|
setPaid(isPaid());
|
||||||
const onB = () => setBookmarks(getBookmarks());
|
const onB = () => setBookmarks(getBookmarks());
|
||||||
const onP = () => setCompleted(getCompletedLessons());
|
const onP = () => setCompleted(getCompletedLessons());
|
||||||
|
const onPayUpdate = () => setPaid(isPaid());
|
||||||
window.addEventListener("learning:bookmarks", onB);
|
window.addEventListener("learning:bookmarks", onB);
|
||||||
window.addEventListener("learning:progress", onP);
|
window.addEventListener("learning:progress", onP);
|
||||||
|
window.addEventListener("pay:updated", onPayUpdate);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("learning:bookmarks", onB);
|
window.removeEventListener("learning:bookmarks", onB);
|
||||||
window.removeEventListener("learning:progress", onP);
|
window.removeEventListener("learning:progress", onP);
|
||||||
|
window.removeEventListener("pay:updated", onPayUpdate);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -43,6 +50,20 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
|||||||
}
|
}
|
||||||
}, [topicId]);
|
}, [topicId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showUpdatingModal) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setShowUpdatingModal(false);
|
||||||
|
};
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [showUpdatingModal]);
|
||||||
|
|
||||||
const toggle = (i: number) => {
|
const toggle = (i: number) => {
|
||||||
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
|
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
|
||||||
};
|
};
|
||||||
@@ -50,6 +71,17 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
|||||||
if (!curriculum?.hasContent || !curriculum.modules.length) return null;
|
if (!curriculum?.hasContent || !curriculum.modules.length) return null;
|
||||||
|
|
||||||
const modules = curriculum.modules;
|
const modules = curriculum.modules;
|
||||||
|
const isLivestreamTopic = topicId === "livestream";
|
||||||
|
const isLivestreamUpdating = isLivestreamTopic && paid;
|
||||||
|
|
||||||
|
const openUpdatingModal = (lessonName: string) => {
|
||||||
|
setUpdatingLessonName(lessonName);
|
||||||
|
setShowUpdatingModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeUpdatingModal = () => {
|
||||||
|
setShowUpdatingModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-t border-[var(--border)] py-12 md:py-16 lg:py-20">
|
<section className="border-t border-[var(--border)] py-12 md:py-16 lg:py-20">
|
||||||
@@ -92,12 +124,50 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
|||||||
const key = `${origMod}-${li}`;
|
const key = `${origMod}-${li}`;
|
||||||
const bookmarked = isBookmarked(origMod, li);
|
const bookmarked = isBookmarked(origMod, li);
|
||||||
const isCompleted = completed.has(key);
|
const isCompleted = completed.has(key);
|
||||||
|
const textClass = unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]";
|
||||||
return (
|
return (
|
||||||
<li key={key}>
|
<li key={key}>
|
||||||
<Link
|
{isLivestreamUpdating ? (
|
||||||
href={`/cloudphone/course/${origMod}/${li}`}
|
<div
|
||||||
className="flex flex-col gap-1 px-4 py-3 transition hover:bg-[var(--muted)]/50 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3"
|
role="button"
|
||||||
>
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
if (paid) openUpdatingModal(lesson.name);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (paid) openUpdatingModal(lesson.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex w-full flex-col gap-1 px-4 py-3 text-left transition hover:bg-[var(--muted)]/50 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 truncate sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleBookmark(origMod, li);
|
||||||
|
}}
|
||||||
|
className="shrink-0 text-xs"
|
||||||
|
aria-label={bookmarked ? "取消收藏" : "收藏"}
|
||||||
|
>
|
||||||
|
{bookmarked ? "⭐" : "☆"}
|
||||||
|
</button>
|
||||||
|
<span className={textClass}>{lesson.name}</span>
|
||||||
|
<span className="shrink-0 text-xs text-amber-500">🛠️</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2 text-xs text-[var(--muted-foreground)] sm:text-sm">
|
||||||
|
{lesson.duration}
|
||||||
|
<span>···</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/cloudphone/course/${origMod}/${li}`}
|
||||||
|
className="flex flex-col gap-1 px-4 py-3 transition hover:bg-[var(--muted)]/50 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3"
|
||||||
|
>
|
||||||
<span className="flex items-center gap-2 truncate sm:gap-3">
|
<span className="flex items-center gap-2 truncate sm:gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -111,13 +181,7 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
|||||||
>
|
>
|
||||||
{bookmarked ? "⭐" : "☆"}
|
{bookmarked ? "⭐" : "☆"}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span className={textClass}>{lesson.name}</span>
|
||||||
className={
|
|
||||||
unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{lesson.name}
|
|
||||||
</span>
|
|
||||||
{free && (
|
{free && (
|
||||||
<span className="shrink-0 rounded bg-[var(--accent)]/20 px-1.5 py-0.5 text-xs text-[var(--accent)]">
|
<span className="shrink-0 rounded bg-[var(--accent)]/20 px-1.5 py-0.5 text-xs text-[var(--accent)]">
|
||||||
试看
|
试看
|
||||||
@@ -136,7 +200,8 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
|||||||
{lesson.duration}
|
{lesson.duration}
|
||||||
<span className="text-[var(--accent)]">→</span>
|
<span className="text-[var(--accent)]">→</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -147,6 +212,49 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showUpdatingModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[70] flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="课程更新提示"
|
||||||
|
onClick={closeUpdatingModal}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[2px] animate__animated animate__fadeIn" />
|
||||||
|
<div
|
||||||
|
className="animate__animated animate__fadeInUp relative w-full max-w-md rounded-2xl border border-amber-400/30 bg-[var(--card)] p-6 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-amber-500/15 text-2xl text-amber-500">
|
||||||
|
🚧
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-xl font-bold text-[var(--foreground)]">正在更新</h3>
|
||||||
|
<p className="mb-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
<span className="font-medium text-[var(--foreground)]">{updatingLessonName}</span> 正在升级内容,
|
||||||
|
将在完成后第一时间开放。
|
||||||
|
</p>
|
||||||
|
<p className="mb-6 text-sm text-[var(--muted-foreground)]">
|
||||||
|
现在可以先学习「云手机」课程,后续会自动同步最新章节。
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeUpdatingModal}
|
||||||
|
className="rounded-full border border-[var(--border)] px-4 py-2 text-sm text-[var(--muted-foreground)] transition hover:bg-[var(--muted)]"
|
||||||
|
>
|
||||||
|
稍后再看
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeUpdatingModal}
|
||||||
|
className="rounded-full bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
我知道了
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
|||||||
{ value: "100+", label: "分钟视频" },
|
{ value: "100+", label: "分钟视频" },
|
||||||
{ value: "2", label: "大模块" },
|
{ value: "2", label: "大模块" },
|
||||||
],
|
],
|
||||||
moduleIndices: [1, 2],
|
|
||||||
payUrl: "/",
|
payUrl: "/",
|
||||||
},
|
},
|
||||||
indie: {
|
indie: {
|
||||||
@@ -75,6 +74,36 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LIVESTREAM_CURRICULUM = [
|
||||||
|
{
|
||||||
|
emoji: "🎯",
|
||||||
|
title: "策略篇 · 账号定位",
|
||||||
|
lessons: [
|
||||||
|
{ name: "01 赛道选择与账号人设设计", duration: "09:30" },
|
||||||
|
{ name: "02 起号期内容与直播节奏", duration: "11:20" },
|
||||||
|
],
|
||||||
|
originalModuleIndex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emoji: "🧩",
|
||||||
|
title: "执行篇 · 素材与脚本",
|
||||||
|
lessons: [
|
||||||
|
{ name: "03 直播素材筛选与版权避坑", duration: "12:10" },
|
||||||
|
{ name: "04 开播话术与互动脚本模板", duration: "10:45" },
|
||||||
|
],
|
||||||
|
originalModuleIndex: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emoji: "📈",
|
||||||
|
title: "增长篇 · 复盘优化",
|
||||||
|
lessons: [
|
||||||
|
{ name: "05 流量数据复盘与调优闭环", duration: "13:15" },
|
||||||
|
{ name: "06 多账号矩阵扩展方法", duration: "14:00" },
|
||||||
|
],
|
||||||
|
originalModuleIndex: 3,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function getTopicConfig(topicId: string): TopicConfig | null {
|
export function getTopicConfig(topicId: string): TopicConfig | null {
|
||||||
return TOPIC_CONFIGS[topicId] ?? null;
|
return TOPIC_CONFIGS[topicId] ?? null;
|
||||||
}
|
}
|
||||||
@@ -83,6 +112,9 @@ export function getTopicCurriculum(topicId: string) {
|
|||||||
const config = getTopicConfig(topicId);
|
const config = getTopicConfig(topicId);
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
if (config.externalUrl) return { modules: [], hasContent: false };
|
if (config.externalUrl) return { modules: [], hasContent: false };
|
||||||
|
if (topicId === "livestream") {
|
||||||
|
return { modules: LIVESTREAM_CURRICULUM, hasContent: LIVESTREAM_CURRICULUM.length > 0 };
|
||||||
|
}
|
||||||
const indices = config.moduleIndices ?? CURRICULUM_CONFIG.modules.map((_, i) => i);
|
const indices = config.moduleIndices ?? CURRICULUM_CONFIG.modules.map((_, i) => i);
|
||||||
const modules = indices
|
const modules = indices
|
||||||
.map((i) => ({ module: CURRICULUM_CONFIG.modules[i], originalIndex: i }))
|
.map((i) => ({ module: CURRICULUM_CONFIG.modules[i], originalIndex: i }))
|
||||||
|
|||||||
Reference in New Issue
Block a user