104 Commits

Author SHA1 Message Date
dff257c3f3 更换包名+app名字更换 2026-11-12 10:17:40 +08:00
6cf6bea082 对接用户协议和隐私政策 2026-11-11 17:00:51 +08:00
985bcf4b55 对接连续任务列表接口和连续任务下发,便捷操作中暂停任务的对接,底部导航tab项的设置根据用户角色显示和设置 2026-11-11 16:51:21 +08:00
47f518f94c 重整多余的UI组件和未定功能的逻辑链路的修正 2026-11-11 16:33:04 +08:00
34cb9d2805 自主任务接口接入和bug修复和工作记录页面 2026-11-06 08:50:51 +08:00
4e92398349 机器状态数据和地图切换,系统配置页面 2026-11-06 08:49:21 +08:00
84eec7da96 机器列表和远程喊话等ui 2026-11-06 08:47:36 +08:00
e6165b180b 修改名字接口 2026-11-06 08:44:47 +08:00
8eea42b75d 分区相关 2026-11-06 08:44:05 +08:00
85c83ec143 作业首页和机器状态的数据 2026-11-06 08:41:44 +08:00
d882db7995 远程遥控相关 2026-11-06 08:34:19 +08:00
eb8dec2868 页面UI替换1远程遥控和各项弹窗和作业主页面和子页面+路径规划的主页面重构等 2026-10-28 14:38:24 +08:00
8a41113c92 绑定页新UI+逻辑链路的适配+设备的首页无设备页面和详情页面 2026-10-24 17:18:42 +08:00
e3af8cf617 新版UI-登录页面-我的页面(简单适配)-设备的部分(无设备的+有设备) 2026-10-24 09:08:33 +08:00
81dae187ac +ios端的配置修改 2026-10-22 14:11:42 +08:00
56d5d55d6e +ios端的配置修改 2026-10-22 13:22:35 +08:00
615d76a8ce USB 摄像头适配iPadOS17+ios端的配置 2026-10-22 12:54:08 +08:00
58a23ef218 USB 摄像头适配iPadOS17+ 2026-10-22 12:53:24 +08:00
582242e9aa USB 摄像头+远程遥控+中英的适配+topic的集成和状态的使用到相应页面 2026-10-20 17:03:37 +08:00
178cd2533c 安卓权限添加 2026-10-20 16:45:51 +08:00
68984aa7d2 优化视频延迟 2026-10-13 14:39:01 +08:00
e4f7cc7a90 本地视频延迟优化+视频的获取链路播放 2026-10-13 12:40:16 +08:00
9671798f29 多语言切换+首次设置语言 2026-10-12 14:21:09 +08:00
dc006c2935 修复personal退出登录误弹登录过期弹窗 2026-10-10 10:12:11 +08:00
167229371f 帮助与反馈页面开发 2026-10-10 08:47:30 +08:00
208147cdf2 启动动画和登录页面的动画 2026-09-23 16:52:18 +08:00
b3a700491a 启动动画和登录页面的动画 2026-09-23 16:50:48 +08:00
d230279f4f 启动动画和登录页面的动画 2026-09-23 16:48:48 +08:00
32397f8899 AIR版的功能添加 2026-09-20 16:31:15 +08:00
c8b597f683 蓝牙和上位机和非上位机的功能的适配 2026-09-16 15:38:41 +08:00
a1e01da241 多角色的配置适配 2026-09-14 12:35:20 +08:00
3547f80383 修改UI 2026-09-11 16:12:44 +08:00
4b88a291a5 ios蓝牙修复写配置 2026-09-11 15:37:37 +08:00
877ff932d4 修复iOS蓝牙扫描问题和权限问题
- iOS 跳过 Android 专属权限检查(bluetoothConnect/bluetoothScan)
- iOS 跳过定位服务检查(iOS 蓝牙扫描不需要定位)
- Info.plist 添加 NSBluetoothAlwaysUsageDescription 蓝牙权限
- Podfile 修复被污染内容并添加 CDN 源
- project.pbxproj 临时修改 Bundle ID 和 Team(适配当前开发环境)
2026-09-11 15:22:53 +08:00
43c0b68e1a 遗漏的文件 2026-09-11 09:07:18 +08:00
356c2a1925 蓝牙增强+个更换绑定逻辑接口 2026-09-11 09:06:20 +08:00
3ba480f63f config文件 2026-09-10 09:43:27 +08:00
85e9632326 退出登陆的重新设计到我的页面;优化高清地图的加载和显示(冷启动稍慢,再次秒开);声网通用组件遗漏文件; 2026-09-10 09:02:23 +08:00
b4bedd9737 修复路径规划任务的取消接口的入参的结构去掉deviceid,测试通过。 2026-09-10 08:58:04 +08:00
d76bcdcdf2 声网通用组件开发测试通过 2026-09-10 08:55:56 +08:00
b468e577a6 远程遥控视频的显示bug修复;恢复远程遥控中的前置推送检查和动态实时信息的展示。 2026-09-04 15:02:43 +08:00
db81816b78 修复高清地图(贴图)不支持手动滑动地理位置显示问题;修复地图显示上次设备位置的异常情况;修复打点保存少携带mode +orgid参数问题。 2026-09-04 08:43:43 +08:00
5141d6e83f 蓝牙绑定设备+场站切换支持高清图层的一对一显示+设备实时状态的部分显示 2026-09-02 16:58:45 +08:00
61bce2c1cf 绑定设备+覆盖模式+控制显示bug+在线更新 2026-09-01 08:59:20 +08:00
0fa67a127b Merge remote-tracking branch 'origin/feature/next-arch' into feature/next-arch 2026-08-26 14:25:53 +08:00
7c01229b9e 1:修复远程遥控控制状态显示不正确的问题
2:更换端口映射为测试服的8081-59003,9001-59004
2026-08-26 14:23:20 +08:00
a48778bc8a 删除 _fix_impl.py 2026-08-25 03:44:22 +00:00
e8070b5183 删除 _fix_json_data.py 2026-08-25 03:44:13 +00:00
fc3cf4de0f 删除 _fix_overflow.py 2026-08-25 03:44:01 +00:00
db323a26ce 删除 _fix_pause_stop.py 2026-08-25 03:43:51 +00:00
11509d1aa6 删除 _fix_startwork.py 2026-08-25 03:43:41 +00:00
97a2126fcf 删除 _fix_taskid2.py 2026-08-25 03:43:33 +00:00
0978f387fd 删除 _fix_taskid3.py 2026-08-25 03:43:23 +00:00
e9532ddafa 删除 _fix_taskid.py 2026-08-25 03:43:14 +00:00
43fbd0af19 删除 _patch_save.py 2026-08-25 03:43:06 +00:00
4b03da8142 删除 flutter_01.log 2026-08-25 03:42:53 +00:00
d0fcc067ca 删除 fix_dup.py 2026-08-25 03:42:42 +00:00
ca805ee957 删除 flutter_02.log 2026-08-25 03:42:33 +00:00
d119407866 删除 flutter_04.log 2026-08-25 03:42:21 +00:00
5a02522bb4 删除 flutter_03.log 2026-08-25 03:42:12 +00:00
54dac30915 删除 flutter_05.log 2026-08-25 03:42:03 +00:00
875766cc28 删除 flutter_06.log 2026-08-25 03:41:54 +00:00
e8a79168e8 删除 flutter_07.log 2026-08-25 03:41:45 +00:00
7773fe7a60 删除 flutter_08.log 2026-08-25 03:41:37 +00:00
b6e2a40aa7 删除 _fix_flow.py 2026-08-25 03:41:25 +00:00
bb2becdf7b 删除 _fix_dup.py 2026-08-25 03:41:16 +00:00
ca616faf11 迁移提交 2026-08-25 11:34:32 +08:00
83220869da 高清地图+无人机实时信息显示异常问题修复 2026-08-19 14:57:24 +08:00
ab75e8c5b0 and so on 2026-08-18 08:52:33 +08:00
87609c7dc4 优化ios 2026-08-10 16:26:15 +08:00
9dc445eeb5 优化+摄像头+扫码绑定 2026-08-10 10:00:52 +08:00
db70665ced 一次大的提交 2026-08-07 08:49:29 +08:00
bf2fc49c19 手误 2026-07-11 16:49:55 +08:00
1a4d0aedfd 优化和路径规划新适配 2026-07-11 16:46:53 +08:00
2124f3bc02 1.优化了无人的轨迹的绘制的过程中的 飞机不动路线自己动的不和谐现象,实现了飞机实时根据推送的数据绘制珍格格飞机任务的过程,同时也优化了地图加载慢 白底色的问题。
2.完善了遗留一些页面的多语言支持的。
2026-07-03 13:39:18 +08:00
0db64a96e7 1.优化补足了首页下拉刷新中对获取场站接口的加入,确保能够实时获取数据并渲染。
2.优化修改了tcp重连机制,具体操作:断开重连时候不再建立新链接 而是将旧链接重置,并重新发送数据。
3.大面积的给项目中的各个页面 手动接入了多语言的支持。同时也调整了相应带来的问题英文长度较大影响原有的样式的问题,已解决了对应的样式不和谐问题。保证了外观的统一性。
4.给项目中的主要操作加了几个核心的操作比如远程遥控和路径规划添加了双层的保护,设置里安全模式,即在此期间对服务器推送的重复登录操作后的自动退出登录做一个拦截 保证操作的安全性。
2026-07-02 16:04:30 +08:00
d5a83c1221 1.测试和修改了无人机在线实时信息的展示数据集的展示。使其能够全面展示。
2.添加了在无人机设备摄像头的为空时候,通过新增的接口获取无人机的摄像头的列表。
3.替换原先依照tcp推送消息绘制机器运动作业的轨迹过程,先用订阅mqtt特定话题推送的信息做动画过程的绘制。
2026-06-24 14:44:16 +08:00
b7ea788298 1优化改动设备栏中全部标签栏的设备列表中数据的整合,又数据合并解耦成使用已有组件各自维护,使其样式和逻辑一致。
2接入了无人机机场的实时信息和无人机的实时信息,并创建了对应的组件,并测试。
3.实现了机器人的详情页的视频显示的逻辑为当机器人在线,就在下方同步出来机器人的各个方位视频,并且支持切换。
4.实现了全部设备中对类型的区分和实现了点击进入对应的详情页。
5.修复了部分bug,优化了代码结构,提高了代码的可读性和可维护性。
6更改了机器人列表项中的信息展示为机器序列号和别名和id(优先展示别名)
7.更改了无人机场的信息展示上位 无人机及序列号下位机场序列号。
8优化了无人机机场信息只能是的为明文展示。便于观看理解。
2026-06-23 16:20:51 +08:00
06718ed957 集成双 MQTT 客户端实现设备实时数据监控(未测试)
1.集成两个独立的 MQTT 客户端,支持 WebSocket 和 TCP 协议
2.实现无人机/机场 OSD 实时监控(ws://1.95.137.212:8083/mqtt)
3.实现任务状态消息订阅(tcp://1.95.137.212:59020)
4.采用清洁架构设计,分层清晰(Domain → Data → Presentation)
2026-06-18 16:41:02 +08:00
a6143f2ba7 遗漏文件的提交 2026-06-17 13:47:24 +08:00
443ce601a0 1更新无人机的摄像头视频显示的条件为在离线情况下的自动刷新+下拉刷新的机制。
2更新优化去除原有路径规划的初始化阶段的获取设备的接口请求,改为使用新的 targetDevice。
3修改无人机机场的在线状态简化为在线和离线。去掉机场二字。
4.更新同一账号同时段登录,被强制下线的友好提示!
2026-06-17 13:46:30 +08:00
71aea48d9f 集成接口 开始执行任务的领域层和数据层和UI层的开发啊(待测试)
集成功能 暂停 取消功能 恢复功能的接口的领域层和数据层的开发 下一步待集成到页面上
优化功能,优化了接口异常和未知异常对页面渲染的影响对用户的体验的不好情况。具体通过弹窗友好提示!
优化更新了tcp指示灯点击出现机器状态中添加选中设备的编号 方便用户使用的明白明了!。
优化更新关闭了tcp重连操作内链条中的获取用户设备和切换设备操作项。
调整了获取无人机状态信息的更新频率 为14秒一次
2026-06-16 17:34:00 +08:00
52fe17c0d5 构建初步的设备运行悬浮状态栏,已实现初步的的悬浮和开关功能。
优化了登录链条适配多种用户的登录操作
2026-06-12 15:00:44 +08:00
b08e1e0b57 补充 2026-06-11 17:31:10 +08:00
8eb878dd81 无人机视频对接+支持无人机未在线时候5S执行一下查询操作。便于视频正常展示。
无人机视频的摄像头切换功能和参数切换功能(广角和红外 和等)
无人机航线任务创建页面的开发和接口对接
无人机航线任务搜索接口(支持时间过滤)
无人机执行航线任务功能开发和接口对接
无人机暂停执行任务功能开发和接口对接
无人机返航任务和取消返航功能开发和接口对接
无人机机场详情页中的展示信息的更正。
2026-06-11 17:30:15 +08:00
058a5d9588 对接路径规划的列表(根据场站的下的)
优化无人机机场的监控视频的显示视频流畅度提升80%
添加首页tcp指示灯的机器状态信息展示功能。(暂不支持在此页面上设备的切换显示)
2026-06-09 14:39:55 +08:00
ff4a3836c7 优化了tcp实时更新对UI线程的压力和对用弹窗的重复弹窗的影响。
更换了路径规划的的经纬度的字段名和取操作的名字更换。
2026-06-08 13:35:56 +08:00
354f7d6290 优化了tcp实时更新对UI线程的压力和对用弹窗的重复弹窗的影响。
更换了路径规划的的经纬度的字段名和取操作的名字更换。
2026-06-08 13:35:36 +08:00
7c9734d918 对接远程遥控 2026-06-05 19:06:21 +08:00
d8879dad62 告警中心的二级详情页面+信息中心的页面+对接机器人接口 2026-06-02 08:40:50 +08:00
9eab402551 遗漏 2026-06-02 08:31:13 +08:00
b6480778ed 遗漏 2026-06-01 17:33:40 +08:00
93e69a5e4b 机器人的列表接口 2026-06-01 17:33:16 +08:00
9154174614 遗漏提交 2026-05-29 14:14:19 +08:00
335c468aac 修复版本管理整包更新无法弹出的问题
完成集成新的登录接口的更换使用
完成集成用户的场站列表接口
完成开发设计选择场站项为全局属性
完成我的页面的个人信息的集成和详情页面的开发
完成设备页面的中无人机机场的列表接口对接入和个项页面的布局的优化
完成设备页面的中无人机机场的详情接口的对接和使用和页面的更新布局
2026-05-29 14:13:34 +08:00
938aac2db9 添加修复大包更新安装覆盖的-待测试
添加修复了更新接口的令狐适配
添加了新的服务端地址-
2026-05-27 16:05:21 +08:00
afef5c4ef1 所有一级页面和不分二级页面 2026-05-25 12:58:29 +08:00
b9f747de05 v2版本登录 2026-05-19 08:46:35 +08:00
07b6b19b34 v2版本登录修改 2026-05-19 08:34:35 +08:00
f1a279cf57 v2版本的首页界面开发 2026-05-18 17:18:58 +08:00
03f797e717 底部tab的灵活可控 2026-05-14 15:08:19 +08:00
a4ac503abd -提交-bug已处理 2026-05-14 14:18:37 +08:00
fb529b2485 -暂提交-bug未处理 2026-05-13 10:34:07 +08:00
4b3ad72627 解耦登录中TCP连接的必要,使其能够快速响应登录 2026-05-06 16:28:43 +08:00
819 changed files with 159725 additions and 2914 deletions

135
.app-report-build/build.mjs Normal file
View File

@@ -0,0 +1,135 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
const root = process.cwd();
const stage = path.join(root,'.app-report-build');
const out = path.join(root,'汇报成果');
const rt = 'C:/Users/jsmbz/.cache/codex-runtimes/codex-primary-runtime/dependencies';
const nm = rt + '/node/node_modules';
const sk = 'C:/Users/jsmbz/.codex/plugins/cache/openai-primary-runtime/presentations/26.1104.12130/skills/presentations';
const pkgDir = nm + '/@oai/artifact-tool';
const pkg = JSON.parse(await fs.readFile(pkgDir+'/package.json','utf8'));
const ex = pkg.exports?.['.'] ?? pkg.exports;
const entry = (typeof ex === 'string' ? ex : ex?.import?.default ?? ex?.import ?? ex?.default) ?? pkg.module ?? pkg.main;
const { Presentation, PresentationFile } = await import(pathToFileURL(path.join(pkgDir,entry)).href);
const deck = Presentation.create({slideSize:{width:1280,height:720}});
const font = 'Microsoft YaHei';
const color = {bg:'#F6F8F4', ink:'#18342E', quiet:'#4C655E', accent:'#127555', dark:'#12382F', pale:'#D1EAE0', white:'#FFFFFF', gold:'#986320', subtle:'#D8E3DB'};
const noLine = {fill:'none',width:0};
function tx(s, value, x, y, w, h, opts={}) {
const shape = s.shapes.add({name:opts.name??value.slice(0,40),geometry:'textbox',position:{left:x,top:y,width:w,height:h},fill:'none',line:noLine});
shape.text = value;
shape.text.style = {typeface:font,fontSize:opts.size??26,color:opts.color??color.ink,bold:opts.bold??false,verticalAlignment:'middle',alignment:opts.align??'left',insets:{left:0,right:0,top:0,bottom:0}};
return shape;
}
function line(s,y) {
s.shapes.add({name:'行分隔线',geometry:'rect',position:{left:72,top:y,width:1136,height:1},fill:color.subtle,line:noLine});
}
function base(title, intro, page) {
const s = deck.slides.add();
s.background.fill = color.bg;
tx(s,title,72,54,1136,68,{size:48,bold:true});
tx(s,intro,72,130,1136,40,{size:25,color:color.quiet});
tx(s,page,1148,663,60,26,{size:16,color:color.quiet,align:'right'});
return s;
}
function notes(slide, text) {
slide.speakerNotes?.textFrame?.setText?.(text);
}
async function picture(slide, f, alt, left,top,width,height) {
const b = await fs.readFile(f);
slide.images.add({blob:b.buffer.slice(b.byteOffset,b.byteOffset+b.byteLength),contentType:f.endsWith('.png')?'image/png':'image/jpeg',alt,fit:'contain',position:{left,top,width,height}});
}
const cover = deck.slides.add();
cover.background.fill = color.dark;
tx(cover,'SATABOT',76,90,600,46,{size:27,bold:true,color:color.pale});
tx(cover,'草坪作业规划 App',72,193,1136,91,{size:72,bold:true,color:color.white});
tx(cover,'功能进度汇报',72,286,1136,78,{size:59,bold:true,color:color.white});
tx(cover,'核心流程已有实现,新增任务功能待联调',76,412,1130,52,{size:31,color:color.pale});
tx(cover,'2026 年 11 月 9 日',76,600,650,44,{size:24,color:color.pale});
notes(cover,'汇报依据为当前 Flutter 工作区、Git 提交记录、Android 调试产物和使用手册。范围为草坪机器人核心作业流程。');
const features = base('已实现的核心功能','覆盖设备管理、遥控、作业规划和记录查看。','02');
tx(features,'功能',72,196,245,34,{size:22,bold:true,color:color.accent});
tx(features,'用户能做什么',335,196,873,34,{size:22,bold:true,color:color.accent});
const rows = [
['设备管理','扫码绑定或切换设备,查看在线状态、电量和位置。'],
['远程遥控','查看实时视频,使用摇杆控制设备,支持急停操作。'],
['分区与作业规划','在地图上编辑作业区,生成或选择路线,发起自主作业。'],
['作业记录','查看历史作业和轨迹,回顾任务执行情况。'],
];
rows.forEach(([k,v],i)=>{
const y=240+i*83;
line(features,y);
tx(features,k,72,y+10,250,61,{size:26,bold:true});
tx(features,v,335,y+10,873,61,{size:26});
});
tx(features,'以上为代码中已有实现的能力,当前版本的实机效果以验收为准。',72,600,1136,38,{size:21,color:color.quiet});
notes(features,'来源:lib/features/v2/device_list/presentation/pages、lib/features/remote_control、lib/features/regions、lib/features/autonomous_work、lib/features/v2/work/presentation/pages。核心功能按工作区实现归纳,未将页面存在等同于真机验收通过。');
const demo = base('地图上看路线,遥控时看画面','使用手册界面示例','03');
tx(demo,'路线规划',72,190,396,44,{size:27,bold:true,color:color.accent});
tx(demo,'视频遥控',475,190,731,44,{size:27,bold:true,color:color.accent});
await picture(demo,'temp_docx_gen/path_generated.jpg','手册截图中的路线规划效果',72,243,360,329);
await picture(demo,'temp_docx_gen/remote_control.jpg','手册截图中的视频遥控界面',475,243,733,329);
tx(demo,'下发前可预览地图上的规划路线。',72,592,392,48,{size:23});
tx(demo,'在同一界面查看画面并操作摇杆。',475,592,733,48,{size:23});
notes(demo,'截图来源:temp_docx_gen/path_generated.jpg 与 temp_docx_gen/remote_control.jpg。均为仓库使用手册的示意截图,当前验收包的界面及设备实测结果以验收为准。');
const progress = base('主体流程已实现,真机验收待完成','当前已有 Android 调试包,最新代码仍需重新打包联调。','04');
const stages = [
['已实现','设备管理、实时状态、遥控、分区和路线规划已有实现。',color.accent],
['接入中','连续作业、返航方案和作业记录接口仍需联调。',color.gold],
['待验收','验证任务下发与状态回传、断连与急停、轨迹与记录一致性。',color.ink],
];
stages.forEach(([k,v,c],i)=>{
const y=208+i*123;
line(progress,y);
tx(progress,k,72,y+18,250,70,{size:33,bold:true,color:c});
tx(progress,v,335,y+18,873,70,{size:26});
});
notes(progress,'来源:当前 Git 状态、34cb9d28 提交记录、lib/features/consecutive_tasks、lib/features/site_task_plans、lib/features/work_history 及 return_plan_library_cubit.dart。调试包路径为 build/app/outputs/flutter-apk/app-debug.apk。尚未核对调试包与工作区的一致性,也没有本次全流程真机验收记录。');
const timing = base('预计 11 月 27 日提交 Android 验收包','按三周估算,前提是接口与测试设备在 11 月 13 日前就绪。','05');
const milestones = [
['11 月 13 日','完成接口联调','核对连续作业、返航和作业记录的数据。'],
['11 月 20 日','完成真机回归','跑通作业全流程,检查断连与急停并复测关键问题。'],
['11 月 27 日','提交验收包','修复阻塞问题,提供安装包和验收清单。'],
];
milestones.forEach(([d,k,v],i)=>{
const y=210+i*105;
line(timing,y);
tx(timing,d,72,y+14,227,65,{size:30,bold:true,color:color.accent});
tx(timing,k,311,y+14,242,65,{size:26,bold:true});
tx(timing,v,563,y+14,645,65,{size:25});
});
tx(timing,'正式发布时间在验收通过后确认。',72,596,1136,39,{size:24,color:color.quiet});
notes(timing,'三周排期是以 2026 年 11 月 9 日为基准的规划估算,未获得团队排期确认。交付指 Android 验收包。前提为接口可联调、设备到位且关键安全用例通过。');
await fs.mkdir(stage,{recursive:true});
await fs.mkdir(out,{recursive:true});
const candidate = path.join(stage,'candidate.pptx');
const final = path.join(out,'草坪作业规划App功能进度汇报.pptx');
await (await PresentationFile.exportPptx(deck)).save(candidate);
try {
const {finalizePresentation} = await import(pathToFileURL(sk+'/container_tools/artifact_tool_utils.mjs').href);
await finalizePresentation({workspaceDir:root,candidatePath:candidate,finalPath:final,
pythonExecutable:rt+'/python/python.exe',
integrityValidatorPath:sk+'/container_tools/inspect_presentation_package_integrity.py',
layoutValidatorPath:sk+'/container_tools/inspect_presentation_layout_geometry.py',
layoutArgs:['--expected-slide-size-emu','12192000,6858000','--validate-heading-fit'],
fontPolicy:{basis:'design',families:[font]},verifyArtifactToolImport:true,
receiptPath:path.join(stage,'validation.json')});
} catch(e) {
console.error('Finalizer:',e.message);
await fs.copyFile(candidate,final);
}
console.log('OUTPUT',final,(await fs.stat(final)).size);
for (let i=0;i<deck.slides.items.length;i++) {
try {
const png = await deck.export({slide:deck.slides.items[i],format:'png',scale:0.75});
await fs.writeFile(path.join(stage,'slide-'+(i+1)+'.png'),new Uint8Array(await png.arrayBuffer()));
} catch(e) {console.error('Render',i+1,e.message);}
}

Binary file not shown.

View File

@@ -0,0 +1,78 @@
{"kind":"deck","id":"pr/rsjjns","name":"Deck"}
{"kind":"layout","id":"ly/jep0za58","editable":false,"name":"Master","type":"master"}
{"kind":"layout","id":"ly/kb214bmt","editable":false,"name":"Title Slide","type":"title","placeholders":[{"name":"title","type":"title"},{"name":"subtitle","type":"subtitle"}]}
{"kind":"layout","id":"ly/07ylsjqp","editable":false,"name":"Section Header","type":"sectionHeader","placeholders":[{"name":"title","type":"title"}]}
{"kind":"layout","id":"ly/94ra5gfq","editable":false,"name":"Title and Body","type":"tx","placeholders":[{"name":"title","type":"title"},{"name":"body","type":"body"}]}
{"kind":"layout","id":"ly/i9cnexgj","editable":false,"name":"Title and Two Column","type":"twoColTx","placeholders":[{"name":"title","type":"title"},{"name":"Left column","type":"body"},{"name":"Right column","type":"body"}]}
{"kind":"layout","id":"ly/yh8va14r","editable":false,"name":"Title and One Column","type":"tx","placeholders":[{"name":"title","type":"title"},{"name":"body","type":"body"}]}
{"kind":"layout","id":"ly/87ix832p","editable":false,"name":"Blank","type":"blank"}
{"kind":"slide","id":"sl/5ms4nr","slideIndex":0,"layout":{"id":"ly/87ix832p","name":"Blank"},"title":"SATABOT","textShapes":5,"backgroundColor":"rgba(18,56,47,1)"}
{"kind":"textbox","id":"sh/9oja5ov6","slideIndex":0,"position":{"left":76,"top":90,"width":600,"height":46},"name":"SATABOT","text":"SATABOT","geometry":"rect","lineWidth":0,"style":{"fontSize":27,"typeface":"Microsoft YaHei","color":"#D1EAE0","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/m1sbu9wf","slideIndex":0,"position":{"left":72,"top":193,"width":1136,"height":91},"name":"草坪作业规划 App","text":"草坪作业规划 App","geometry":"rect","lineWidth":0,"style":{"fontSize":72,"typeface":"Microsoft YaHei","color":"#FFFFFF","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/nm1s3ed0","slideIndex":0,"position":{"left":72,"top":286,"width":1136,"height":78},"name":"功能进度汇报","text":"功能进度汇报","geometry":"rect","lineWidth":0,"style":{"fontSize":59,"typeface":"Microsoft YaHei","color":"#FFFFFF","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/wrut03eh","slideIndex":0,"position":{"left":76,"top":412,"width":1130,"height":52},"name":"核心流程已有实现,新增任务功能待联调","text":"核心流程已有实现,新增任务功能待联调","geometry":"rect","lineWidth":0,"style":{"fontSize":31,"typeface":"Microsoft YaHei","color":"#D1EAE0","alignment":"left"}}
{"kind":"textbox","id":"sh/xs3a9ov2","slideIndex":0,"position":{"left":76,"top":600,"width":650,"height":44},"name":"2026 年 11 月 9 日","text":"2026 年 11 月 9 日","geometry":"rect","lineWidth":0,"style":{"fontSize":24,"typeface":"Microsoft YaHei","color":"#D1EAE0","alignment":"left"}}
{"kind":"notes","id":"nt/5ms4nr","slideIndex":0,"text":"汇报依据为当前 Flutter 工作区、Git 提交记录、Android 调试产物和使用手册。范围为草坪机器人核心作业流程。"}
{"kind":"slide","id":"sl/4t5sqh","slideIndex":1,"layout":{"id":"ly/87ix832p","name":"Blank"},"title":"已实现的核心功能","textShapes":14,"backgroundColor":"rgba(246,248,244,1)"}
{"kind":"textbox","id":"sh/p0zuhkfu","slideIndex":1,"position":{"left":72,"top":54,"width":1136,"height":68},"name":"已实现的核心功能","text":"已实现的核心功能","geometry":"rect","lineWidth":0,"style":{"fontSize":48,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/2xobm5g3","slideIndex":1,"position":{"left":72,"top":130,"width":1136,"height":40},"name":"覆盖设备管理、遥控、作业规划和记录查看。","text":"覆盖设备管理、遥控、作业规划和记录查看。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"textbox","id":"sh/3yxcfaxo","slideIndex":1,"position":{"left":1148,"top":663,"width":60,"height":26},"name":"02","text":"02","geometry":"rect","lineWidth":0,"style":{"fontSize":16,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"right"}}
{"kind":"textbox","id":"sh/c3qtszy5","slideIndex":1,"position":{"left":72,"top":196,"width":245,"height":34},"name":"功能","text":"功能","geometry":"rect","lineWidth":0,"style":{"fontSize":22,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/d4zulkfq","slideIndex":1,"position":{"left":335,"top":196,"width":873,"height":34},"name":"用户能做什么","text":"用户能做什么","geometry":"rect","lineWidth":0,"style":{"fontSize":22,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"shape","id":"sh/q18bqpgz","slideIndex":1,"position":{"left":72,"top":240,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/b2hcjuxk","slideIndex":1,"position":{"left":72,"top":250,"width":250,"height":61},"name":"设备管理","text":"设备管理","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/07atwzeh","slideIndex":1,"position":{"left":335,"top":250,"width":873,"height":61},"name":"扫码绑定或切换设备,查看在线状态、电量和位置。","text":"扫码绑定或切换设备,查看在线状态、电量和位置。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/l8jup4f2","slideIndex":1,"position":{"left":72,"top":323,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/xw361ojq","slideIndex":1,"position":{"left":72,"top":333,"width":250,"height":61},"name":"远程遥控","text":"远程遥控","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/wvups325","slideIndex":1,"position":{"left":335,"top":333,"width":873,"height":61},"name":"查看实时视频,使用摇杆控制设备,支持急停操作。","text":"查看实时视频,使用摇杆控制设备,支持急停操作。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/vulozy1k","slideIndex":1,"position":{"left":72,"top":406,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/atc7qtkf","slideIndex":1,"position":{"left":72,"top":416,"width":250,"height":61},"name":"分区与作业规划","text":"分区与作业规划","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/5knq58j2","slideIndex":1,"position":{"left":335,"top":416,"width":873,"height":61},"name":"在地图上编辑作业区,生成或选择路线,发起自主作业。","text":"在地图上编辑作业区,生成或选择路线,发起自主作业。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/kjupw32h","slideIndex":1,"position":{"left":72,"top":489,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/jilo3y1w","slideIndex":1,"position":{"left":72,"top":499,"width":250,"height":61},"name":"作业记录","text":"作业记录","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/ihc7utkb","slideIndex":1,"position":{"left":335,"top":499,"width":873,"height":61},"name":"查看历史作业和轨迹,回顾任务执行情况。","text":"查看历史作业和轨迹,回顾任务执行情况。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"textbox","id":"sh/loj6t43i","slideIndex":1,"position":{"left":72,"top":600,"width":1136,"height":38},"name":"以上为代码中已有实现的能力,当前版本的实机效果以验收为准。","text":"以上为代码中已有实现的能力,当前版本的实机效果以验收为准。","geometry":"rect","lineWidth":0,"style":{"fontSize":21,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"notes","id":"nt/4t5sqh","slideIndex":1,"text":"来源:lib/features/v2/device_list/presentation/pages、lib/features/remote_control、lib/features/regions、lib/features/autonomous_work、lib/features/v2/work/presentation/pages。核心功能按工作区实现归纳,未将页面存在等同于真机验收通过。"}
{"kind":"slide","id":"sl/bpwt6i","slideIndex":2,"layout":{"id":"ly/87ix832p","name":"Blank"},"title":"地图上看路线,遥控时看画面","textShapes":7,"backgroundColor":"rgba(246,248,244,1)"}
{"kind":"textbox","id":"sh/ep8fqxcf","slideIndex":2,"position":{"left":72,"top":54,"width":1136,"height":68},"name":"地图上看路线,遥控时看画面","text":"地图上看路线,遥控时看画面","geometry":"rect","lineWidth":0,"style":{"fontSize":48,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/1szylsb6","slideIndex":2,"position":{"left":72,"top":130,"width":1136,"height":40},"name":"使用手册界面示例","text":"使用手册界面示例","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"textbox","id":"sh/0rqxsnul","slideIndex":2,"position":{"left":1148,"top":663,"width":60,"height":26},"name":"03","text":"03","geometry":"rect","lineWidth":0,"style":{"fontSize":16,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"right"}}
{"kind":"textbox","id":"sh/rmxgfit4","slideIndex":2,"position":{"left":72,"top":190,"width":396,"height":44},"name":"路线规划","text":"路线规划","geometry":"rect","lineWidth":0,"style":{"fontSize":27,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/qlofmdcj","slideIndex":2,"position":{"left":475,"top":190,"width":731,"height":44},"name":"视频遥控","text":"视频遥控","geometry":"rect","lineWidth":0,"style":{"fontSize":27,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/3ixwbyts","slideIndex":2,"position":{"left":72,"top":592,"width":392,"height":48},"name":"下发前可预览地图上的规划路线。","text":"下发前可预览地图上的规划路线。","geometry":"rect","lineWidth":0,"style":{"fontSize":23,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"textbox","id":"sh/2hofidc7","slideIndex":2,"position":{"left":475,"top":592,"width":733,"height":48},"name":"在同一界面查看画面并操作摇杆。","text":"在同一界面查看画面并操作摇杆。","geometry":"rect","lineWidth":0,"style":{"fontSize":23,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"image","id":"im/w7u1wr21","slideIndex":2,"position":{"left":72,"top":243,"width":360,"height":329},"fit":"contain","name":"","alt":"手册截图中的路线规划效果"}
{"kind":"image","id":"im/x8ni5wjm","slideIndex":2,"position":{"left":475,"top":243,"width":733,"height":329},"fit":"contain","name":"","alt":"手册截图中的视频遥控界面"}
{"kind":"notes","id":"nt/bpwt6i","slideIndex":2,"text":"截图来源:temp_docx_gen/path_generated.jpg 与 temp_docx_gen/remote_control.jpg。均为仓库使用手册的示意截图,当前验收包的界面及设备实测结果以验收为准。"}
{"kind":"slide","id":"sl/pfl994","slideIndex":3,"layout":{"id":"ly/87ix832p","name":"Blank"},"title":"主体流程已实现,真机验收待完成","textShapes":9,"backgroundColor":"rgba(246,248,244,1)"}
{"kind":"textbox","id":"sh/yxo32pkj","slideIndex":3,"position":{"left":72,"top":54,"width":1136,"height":68},"name":"主体流程已实现,真机验收待完成","text":"主体流程已实现,真机验收待完成","geometry":"rect","lineWidth":0,"style":{"fontSize":48,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/l0zmdk3a","slideIndex":3,"position":{"left":72,"top":130,"width":1136,"height":40},"name":"当前已有 Android 调试包,最新代码仍需重新打包联调。","text":"当前已有 Android 调试包,最新代码仍需重新打包联调。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"textbox","id":"sh/0zql4z2p","slideIndex":3,"position":{"left":1148,"top":663,"width":60,"height":26},"name":"04","text":"04","geometry":"rect","lineWidth":0,"style":{"fontSize":16,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"right"}}
{"kind":"shape","id":"sh/baxk7al8","slideIndex":3,"position":{"left":72,"top":208,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/q9o3y5kn","slideIndex":3,"position":{"left":72,"top":226,"width":250,"height":70},"name":"已实现","text":"已实现","geometry":"rect","lineWidth":0,"style":{"fontSize":33,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/dwf29k3y","slideIndex":3,"position":{"left":335,"top":226,"width":873,"height":70},"name":"设备管理、实时状态、遥控、分区和路线规划已有实现。","text":"设备管理、实时状态、遥控、分区和路线规划已有实现。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/cb6l0f2t","slideIndex":3,"position":{"left":72,"top":331,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/v614jelc","slideIndex":3,"position":{"left":72,"top":349,"width":250,"height":70},"name":"接入中","text":"接入中","geometry":"rect","lineWidth":0,"style":{"fontSize":33,"typeface":"Microsoft YaHei","color":"#986320","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/a5s3a9kr","slideIndex":3,"position":{"left":335,"top":349,"width":873,"height":70},"name":"连续作业、返航方案和作业记录接口仍需联调。","text":"连续作业、返航方案和作业记录接口仍需联调。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/o7mhcfix","slideIndex":3,"position":{"left":72,"top":454,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/p8fi50ji","slideIndex":3,"position":{"left":72,"top":472,"width":250,"height":70},"name":"待验收","text":"待验收","geometry":"rect","lineWidth":0,"style":{"fontSize":33,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/2l4za50r","slideIndex":3,"position":{"left":335,"top":472,"width":873,"height":70},"name":"验证任务下发与状态回传、断连与急停、轨迹与记录一致性。","text":"验证任务下发与状态回传、断连与急停、轨迹与记录一致性。","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"notes","id":"nt/pfl994","slideIndex":3,"text":"来源:当前 Git 状态、34cb9d28 提交记录、lib/features/consecutive_tasks、lib/features/site_task_plans、lib/features/work_history 及 return_plan_library_cubit.dart。调试包路径为 build/app/outputs/flutter-apk/app-debug.apk。尚未核对调试包与工作区的一致性,也没有本次全流程真机验收记录。"}
{"kind":"slide","id":"sl/6beug4","slideIndex":4,"layout":{"id":"ly/87ix832p","name":"Blank"},"title":"预计 11 月 27 日提交 Android 验收包","textShapes":13,"backgroundColor":"rgba(246,248,244,1)"}
{"kind":"textbox","id":"sh/lw3m1g7i","slideIndex":4,"position":{"left":72,"top":54,"width":1136,"height":68},"name":"预计 11 月 27 日提交 Android 验收包","text":"预计 11 月 27 日提交 Android 验收包","geometry":"rect","lineWidth":0,"style":{"fontSize":48,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/ytcn6187","slideIndex":4,"position":{"left":72,"top":130,"width":1136,"height":40},"name":"按三周估算,前提是接口与测试设备在 11 月 13 日前就绪。","text":"按三周估算,前提是接口与测试设备在 11 月 13 日前就绪。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"textbox","id":"sh/zul4z6pc","slideIndex":4,"position":{"left":1148,"top":663,"width":60,"height":26},"name":"05","text":"05","geometry":"rect","lineWidth":0,"style":{"fontSize":16,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"right"}}
{"kind":"shape","id":"sh/8ze5cvq9","slideIndex":4,"position":{"left":72,"top":210,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/90nm5g7u","slideIndex":4,"position":{"left":72,"top":224,"width":227,"height":65},"name":"11 月 13 日","text":"11 月 13 日","geometry":"rect","lineWidth":0,"style":{"fontSize":30,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/mxwnal83","slideIndex":4,"position":{"left":311,"top":224,"width":242,"height":65},"name":"完成接口联调","text":"完成接口联调","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/7y543qpo","slideIndex":4,"position":{"left":563,"top":224,"width":645,"height":65},"name":"核对连续作业、返航和作业记录的数据。","text":"核对连续作业、返航和作业记录的数据。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/g3e5gv65","slideIndex":4,"position":{"left":72,"top":315,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/h476907q","slideIndex":4,"position":{"left":72,"top":329,"width":227,"height":65},"name":"11 月 20 日","text":"11 月 20 日","geometry":"rect","lineWidth":0,"style":{"fontSize":30,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/lwruloby","slideIndex":4,"position":{"left":311,"top":329,"width":242,"height":65},"name":"完成真机回归","text":"完成真机回归","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/kvitcjad","slideIndex":4,"position":{"left":563,"top":329,"width":645,"height":65},"name":"跑通作业全流程,检查断连与急停并复测关键问题。","text":"跑通作业全流程,检查断连与急停并复测关键问题。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"shape","id":"sh/ju9cjet8","slideIndex":4,"position":{"left":72,"top":420,"width":1136,"height":1},"name":"行分隔线","geometry":"rect","fillColor":"#D8E3DB","lineWidth":0}
{"kind":"textbox","id":"sh/yt0ba9sn","slideIndex":4,"position":{"left":72,"top":434,"width":227,"height":65},"name":"11 月 27 日","text":"11 月 27 日","geometry":"rect","lineWidth":0,"style":{"fontSize":30,"typeface":"Microsoft YaHei","color":"#127555","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/t0bup8ba","slideIndex":4,"position":{"left":311,"top":434,"width":242,"height":65},"name":"提交验收包","text":"提交验收包","geometry":"rect","lineWidth":0,"style":{"fontSize":26,"typeface":"Microsoft YaHei","color":"#18342E","bold":true,"alignment":"left"}}
{"kind":"textbox","id":"sh/8z2tg3ap","slideIndex":4,"position":{"left":563,"top":434,"width":645,"height":65},"name":"修复阻塞问题,提供安装包和验收清单。","text":"修复阻塞问题,提供安装包和验收清单。","geometry":"rect","lineWidth":0,"style":{"fontSize":25,"typeface":"Microsoft YaHei","color":"#18342E","alignment":"left"}}
{"kind":"textbox","id":"sh/7ytcnyt4","slideIndex":4,"position":{"left":72,"top":596,"width":1136,"height":39},"name":"正式发布时间在验收通过后确认。","text":"正式发布时间在验收通过后确认。","geometry":"rect","lineWidth":0,"style":{"fontSize":24,"typeface":"Microsoft YaHei","color":"#4C655E","alignment":"left"}}
{"kind":"notes","id":"nt/6beug4","slideIndex":4,"text":"三周排期是以 2026 年 11 月 9 日为基准的规划估算,未获得团队排期确认。交付指 Android 验收包。前提为接口可联调、设备到位且关键安全用例通过。"}

View File

@@ -0,0 +1,18 @@
const fs = require('node:fs/promises');
const sharp = require('C:/Users/jsmbz/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/sharp');
const JSZip = require('jszip');
const root = require('node:path').join(__dirname,'..');
async function main() {
const files = await fs.readdir(root+'/汇报成果');
const final = files.find(n => n.endsWith('.pptx'));
const zip = await JSZip.loadAsync(await fs.readFile(root+'/汇报成果/'+final),{checkCRC32:true});
const names = Object.keys(zip.files);
const slides = names.filter(f=>/^ppt\/slides\/slide\d+\.xml$/.test(f));
if (slides.length!==5) throw new Error('Unexpected slide count '+slides.length);
console.log('VERIFIED',final,slides.length,'slides',names.filter(f=>/^ppt\/media\/.+/.test(f)).length,'images');
for (let i=1;i<=5;i++) {
const img = await sharp(__dirname+'/slide-'+i+'.png').resize({width:768}).jpeg({quality:72}).toBuffer();
console.log('IMAGE '+img.toString('base64'));
}
}
main().catch(e=>{console.error(e);process.exitCode=1});

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

42
.gitignore vendored
View File

@@ -20,7 +20,9 @@ build/
gen/
local.properties
.gradle/
.kotlin/
captures/
hs_err_pid*.log
.idea/modules.xml
.idea/workspace.xml
@@ -79,6 +81,25 @@ Debug/
# ----------------------------------------------------------------------------
lib/generated_plugin_registrant.dart
# ----------------------------------------------------------------------------
# Node.js 依赖目录(前端/工具链产物,不需要提交)
# ----------------------------------------------------------------------------
node_modules/
**/node_modules/
# ----------------------------------------------------------------------------
# 日志与崩溃转储(本地调试产物,不需要提交)
# ----------------------------------------------------------------------------
flutter_*.log
test_run.log
*.log
.sentry-native/
# ----------------------------------------------------------------------------
# Office 临时锁文件(Word/Excel 打开时生成,不需要提交)
# ----------------------------------------------------------------------------
~$*
# ----------------------------------------------------------------------------
# 编辑器与系统文件
# ----------------------------------------------------------------------------
@@ -93,4 +114,23 @@ lib/generated_plugin_registrant.dart
Thumbs.db
# 环境变量敏感信息
.env*
.env*
# ----------------------------------------------------------------------------
# 备份文件与一次性调试产物(不需要提交)
# ----------------------------------------------------------------------------
*.bak
*.bak-*
pubspec.lock.bak*
fix_lock.py
git_lock_versions.json
# ----------------------------------------------------------------------------
# 临时文档 / 软著手册生成物料(不需要提交)
# ----------------------------------------------------------------------------
temp_*.md
temp_*.txt
temp_*.py
temp_*
temp_docx_gen/
*.docx

View File

@@ -9,6 +9,10 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- packages/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`

View File

@@ -0,0 +1,45 @@
kotlin version: 2.2.20
error message: Failed connecting to the daemon in 4 retries
error message: Daemon compilation failed: Could not connect to Kotlin compile daemon
java.lang.RuntimeException: Could not connect to Kotlin compile daemon
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:31)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)

View File

@@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,8 +8,15 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// 🔥 加载签名配置
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.maibu.maibu_satabot_v2"
namespace = "com.maibu.maibusatabotv2"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -21,7 +31,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.maibu.maibu_satabot_v2"
applicationId = "com.maibu.maibusatabotv2"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
@@ -30,11 +40,20 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}

View File

@@ -6,3 +6,68 @@
# 如果你用 dio + json_serializable / gson
-keepattributes Signature
-keepattributes *Annotation*
# ===== 火山引擎 RTC SDK 保留规则 =====
# 保留所有火山引擎相关类
-dontwarn com.ss.bytertc.**
-keep class com.ss.bytertc.** { *; }
# 荣耀音频相关类
-dontwarn com.hihonor.android.magicx.media.audio.interfaces.**
-keep class com.hihonor.android.magicx.media.audio.interfaces.** { *; }
# ===== 声网 Agora RTC SDK 保留规则 =====
# 保留所有声网相关类
-dontwarn io.agora.**
-keep class io.agora.** { *; }
# 保留native方法
-keepclasseswithmembernames class * {
native <methods>;
}
# ===== FastJSON 保留规则 =====
# AWT 相关
-dontwarn java.awt.**
-keep class java.awt.Color { *; }
-keep class java.awt.Font { *; }
-keep class java.awt.Point { *; }
-keep class java.awt.Rectangle { *; }
# Javax Money
-dontwarn javax.money.**
-keep class javax.money.CurrencyUnit { *; }
-dontwarn org.javamoney.moneta.**
-keep class org.javamoney.moneta.Money { *; }
# JAX-RS
-dontwarn javax.ws.rs.**
-keep class javax.ws.rs.Consumes { *; }
-keep class javax.ws.rs.Produces { *; }
-keep class javax.ws.rs.core.Response { *; }
-keep class javax.ws.rs.core.StreamingOutput { *; }
-keep class javax.ws.rs.ext.MessageBodyReader { *; }
-keep class javax.ws.rs.ext.MessageBodyWriter { *; }
-keep class javax.ws.rs.ext.Provider { *; }
# Jersey
-dontwarn org.glassfish.jersey.internal.spi.**
-keep class org.glassfish.jersey.internal.spi.AutoDiscoverable { *; }
# Joda Time
-dontwarn org.joda.time.**
-keep class org.joda.time.DateTime { *; }
-keep class org.joda.time.DateTimeZone { *; }
-keep class org.joda.time.Duration { *; }
-keep class org.joda.time.Instant { *; }
-keep class org.joda.time.LocalDate { *; }
-keep class org.joda.time.LocalDateTime { *; }
-keep class org.joda.time.LocalTime { *; }
-keep class org.joda.time.Period { *; }
-keep class org.joda.time.ReadablePartial { *; }
-keep class org.joda.time.format.DateTimeFormat { *; }
-keep class org.joda.time.format.DateTimeFormatter { *; }
# Springfox
-dontwarn springfox.documentation.spring.web.json.**
-keep class springfox.documentation.spring.web.json.Json { *; }

View File

@@ -1,85 +1,55 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <!-- 关键! -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 如果还需要扫描闪光灯控制,可选添加 -->
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.maibu.maibusatabotv2">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-feature android:name="android.hardware.bluetooth" android:required="false"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
<application
android:label="飒沓机器人"
android:label="飒沓·星枢"
android:name="${applicationName}"
android:usesCleartextTraffic="true"
android:largeHeap="true"
android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/launcher_icon">
<meta-data android:name="android.max_aspect" android:value="2.1" />
android:icon="@mipmap/launcher_icon"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!--
USB 设备插入时,Android 直接弹出"允许 [App名] 访问此USB设备"权限对话框,
而不再弹出"用哪个APP打开"的选择对话框(避免与其他 UVC 应用竞争)。
用户勾选"总是允许"后,以后插入同类型设备将不再弹窗。
-->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- 高德 Key -->
<meta-data
android:name="com.amap.api.v2.apikey"
android:value="bbb1f0f20eed6bf679eddf2625630aba" />
android:value="2"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -1,5 +0,0 @@
package com.maibu.maibu_satabot_v2
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,43 @@
package com.maibu.maibusatabotv2
import android.os.Build
import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
private const val WINDOW_CHANNEL = "satabot/window"
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WINDOW_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
// 横屏全屏页(如自主作业中心)需要把内容铺到前置摄像头挖孔那一侧,
// 否则系统会在短边留一条黑边(WINDOW 不把该区域交给 App 绘制)。
"setCutoutMode" -> {
val shortEdges = call.argument<Boolean>("shortEdges") ?: false
applyCutoutMode(shortEdges)
result.success(null)
}
else -> result.notImplemented()
}
}
}
private fun applyCutoutMode(shortEdges: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
val attrs = window.attributes
attrs.layoutInDisplayCutoutMode = if (shortEdges) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
}
window.attributes = attrs
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -3,10 +3,10 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<!-- 居中显示 App Logo,消除冷启动白屏观感 -->
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/launch_image" />
</item>
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

View File

@@ -3,10 +3,10 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<!-- 居中显示 App Logo,消除冷启动白屏观感 -->
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/launch_image" />
</item>
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2e2e2e</color>
<color name="ic_launcher_background">#FF8500</color>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
USB 设备过滤器:告诉 Android "我们 App 能处理这些 USB 设备"。
插入匹配的设备时,系统会直接弹出"允许 XX App 访问"权限对话框,
而不再弹出"用哪个 APP 打开"的Chooser对话框。
class=14 (0x0E): USB Video Class (UVC),即采集卡/USB摄像头
subclass=1: Video Control 接口
subclass=2: Video Streaming 接口
-->
<resources>
<!-- UVC 视频采集设备(采集卡、USB摄像头) -->
<usb-device class="14" subclass="1" protocol="0" />
<usb-device class="14" subclass="2" protocol="0" />
<!-- 部分采集卡以 Misc / Command 类上报,顺带匹配 -->
<usb-device class="239" subclass="2" protocol="1" />
</resources>

View File

@@ -1,7 +1,16 @@
allprojects {
repositories {
// 使用阿里云镜像加速(优先)
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
google()
mavenCentral()
// 火山引擎 RTC 仓库
maven { url = uri("https://artifact.bytedance.com/repository/Volcengine/") }
// BytePlus 公共仓库(包含火山引擎依赖)
maven { url = uri("https://artifact.byteplus.com/repository/public/") }
}
}

View File

@@ -1,2 +1,21 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dhttps.protocols=TLSv1.3,TLSv1.2,TLSv1.1 -Djava.net.preferIPv4Stack=true -Djavax.net.ssl.trustStoreType=JKS
# 限制 Kotlin 编译守护进程内存,避免与 Gradle 守护进程叠加挤占系统内存
kotlin.daemon.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=512m
android.useAndroidX=true
# 使用阿里云镜像加速
android.enableJetifier=true
# 禁用代理(覆盖全局配置)
systemProp.http.proxyHost=
systemProp.http.proxyPort=
systemProp.https.proxyHost=
systemProp.https.proxyPort=
# 放行SSL证书与协议,解决TLS握手异常
systemProp.http.ssl.insecure=true
systemProp.http.ssl.allowall=true
systemProp.http.ssl.ignore.validity.dates=true
# Android SDK 镜像配置(使用清华大学镜像)
systemProp.android.sdkmanager.channel=stable

View File

@@ -11,6 +11,8 @@ pluginManagement {
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
google()
mavenCentral()
gradlePluginPortal()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
assets/images/chucao.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
assets/images/dianzhan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

BIN
assets/images/huawei.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
assets/images/img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

BIN
assets/images/img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
assets/images/logo_sata.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
assets/images/qinxi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

BIN
assets/images/shebei_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

BIN
assets/images/xunjian.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

52
build_patch.bat Normal file
View File

@@ -0,0 +1,52 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
echo ============================================
echo Re Build Patch
echo ============================================
echo.
set PATCH_VERSION=1.0.2
set TARGET_VERSION_CODE=1
echo [1/3] Building Release APK ...
echo.
call flutter build apk --release
if %errorlevel% neq 0 (
echo.
echo APK build failed!
pause
exit /b 1
)
echo.
echo APK build done
echo.
echo [2/3] Generating patch ...
echo.
call dart run flutter_patcher:pack ^
--apk build/app/outputs/flutter-apk/app-release.apk ^
--version %PATCH_VERSION% ^
--target-version-code %TARGET_VERSION_CODE%
if %errorlevel% neq 0 (
echo.
echo Patch generation failed!
pause
exit /b 1
)
echo.
echo Patch generation done
echo.
echo [3/3] Output:
echo.
echo APK: build\app\outputs\flutter-apk\app-release.apk
echo Patch: dist\libapp.so
echo Manifest: dist\manifest.json
echo.
echo ============================================
echo Next: Upload dist\libapp.so to server
echo ============================================
echo.
pause

View File

@@ -2,4 +2,5 @@ description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- get_it: true
- provider: true
- provider: true
- shared_preferences: true

3
dist/download/.gitkeep vendored Normal file
View File

@@ -0,0 +1,3 @@
# 完整安装包目录
将 app-release.apk 放在此目录下用于测试

6
dist/manifest.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"version": "1.0.10",
"md5": "416e3f0e2a701bb8b4a0856a29dba597",
"targetVersionCode": 1,
"abi": "arm64-v8a"
}

3
dist/patch/.gitkeep vendored Normal file
View File

@@ -0,0 +1,3 @@
# 差量更新包目录
将生成的 .patch 文件放在此目录下

9
dist/version/check.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"hasUpdate": true,
"patch": {
"version": "1.0.2",
"patchUrl": "http://127.0.0.1:8080/libapp.so",
"md5": "",
"targetVersionCode": 1
}
}

View File

@@ -0,0 +1,213 @@
# DeviceTaskCubit 使用指南
## 功能概述
DeviceTaskCubit 提供了设备任务管理的完整功能:
- 获取任务池并过滤出当前设备的任务
- 取消任务
- 暂停任务
- 恢复任务
- 全局管理 taskId
## 在页面中集成
### 1. 在 BlocProvider 中注册
```dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../presentation/bloc/device_task_cubit.dart';
class YourPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => GetIt.I<DeviceTaskCubit>(),
child: YourPageContent(),
);
}
}
```
### 2. 获取任务池(选择航线时调用)
```dart
// 当用户选择航线后,获取该设备的任务
final deviceId = targetDevice?.deviceId ?? '';
if (deviceId.isNotEmpty) {
context.read<DeviceTaskCubit>().fetchAndFilterTask(deviceId);
}
```
### 3. 监听状态变化(自动显示错误弹窗)
```dart
BlocConsumer<DeviceTaskCubit, DeviceTaskState>(
listener: (context, state) {
// 🔥 自动显示错误弹窗(2秒后自动消失)
if (state.shouldShowError && state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage!),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
}
// 操作成功提示
if (state.operationType == DeviceTaskOperationType.cancel &&
!state.isLoading) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('取消任务成功')),
);
}
},
builder: (context, state) {
// 显示加载状态
if (state.isLoading) {
return const CircularProgressIndicator();
}
// 显示当前任务ID
final taskId = state.currentTaskId;
return Text('当前任务ID: $taskId');
},
)
```
**关键点:**
- `shouldShowError` 为 true 时,表示发生了错误,需要显示弹窗
- 弹窗会自动在 2 秒后消失
- **不会影响页面展示**,页面继续正常运行
### 4. 执行任务操作
#### 取消任务
```dart
final deviceId = targetDevice?.deviceId ?? '';
context.read<DeviceTaskCubit>().cancelTask(deviceId);
```
#### 暂停任务
```dart
final deviceId = targetDevice?.deviceId ?? '';
context.read<DeviceTaskCubit>().pauseTask(deviceId);
```
#### 恢复任务
```dart
final deviceId = targetDevice?.deviceId ?? '';
context.read<DeviceTaskCubit>().recoveryTask(deviceId);
```
### 5. 手动更新 taskId(选择新航线时)
```dart
// 如果需要在选择航线时手动设置 taskId
context.read<DeviceTaskCubit>().updateCurrentTaskId(newTaskId);
```
### 6. 清除当前任务
```dart
// 退出页面或切换设备时清除
context.read<DeviceTaskCubit>().clearCurrentTask();
```
## 注意事项
1. **自动获取用户信息和场站ID**
- Cubit 内部会自动从 AppUserCubit 和 SiteCubit 获取所需参数
- 无需手动传递 userId、orgId、siteId
2. **统一错误处理**
- 所有错误都会通过 ErrorHandler 转换为友好提示
- 不会显示原始错误信息(如 HTTP 404、连接超时等)
3. **taskId 全局管理**
- fetchAndFilterTask 会自动过滤并保存当前设备的 taskId
- 所有操作接口都会使用这个全局保存的 taskId
4. **状态监听**
- operationType 可以区分当前正在进行的操作类型
- isLoading 表示是否正在执行网络请求
## 完整示例
```dart
class RoutePlanningPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => GetIt.I<DeviceTaskCubit>(),
child: Scaffold(
appBar: AppBar(title: const Text('路径规划')),
body: BlocConsumer<DeviceTaskCubit, DeviceTaskState>(
listener: (context, state) {
if (state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage!)),
);
}
},
builder: (context, state) {
return Column(
children: [
// 显示当前任务ID
Text('当前任务ID: ${state.currentTaskId ?? "无"}'),
// 操作按钮
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () {
final deviceId = 'YOUR_DEVICE_ID';
context.read<DeviceTaskCubit>().pauseTask(deviceId);
},
child: const Text('暂停'),
),
ElevatedButton(
onPressed: () {
final deviceId = 'YOUR_DEVICE_ID';
context.read<DeviceTaskCubit>().recoveryTask(deviceId);
},
child: const Text('恢复'),
),
ElevatedButton(
onPressed: () {
final deviceId = 'YOUR_DEVICE_ID';
context.read<DeviceTaskCubit>().cancelTask(deviceId);
},
child: const Text('取消'),
),
],
),
// 加载指示器
if (state.isLoading)
const CircularProgressIndicator(),
],
);
},
),
),
);
}
}
```
## 错误处理优化
项目中已实现统一的错误处理机制:
- 所有接口错误都会转换为友好的中文提示
- 不会显示原始的错误信息(如 "HTTP 404"、"Connection timeout" 等)
- 提示会自动消失(2秒后)
- 页面不会崩溃
示例错误提示:
- "网络连接超时,请检查网络设置"
- "登录已过期,请重新登录"
- "操作失败,请稍后重试"
- "未知错误,请稍后重试"

View File

@@ -0,0 +1,296 @@
# 无人机机场 OSD 实时数据展示功能
## 📋 功能概述
在无人机机场详情页面添加了 **OSD 实时数据卡片**,通过 MQTT 订阅 `thing/product/${gatewaySn}/osd` topic,实时展示机场的遥测数据。
## 🎯 核心特性
### 1. 滑动窗口设计
- **左右滑动切换**:用户可以左右滑动查看不同的 OSD 数据字段
- **底部指示器**:显示当前页码和总页数
- **滑动提示**:左右箭头提示用户可以滑动
### 2. 智能状态管理
- **在线时**:显示实时 OSD 数据(电量、温度、风速等)
- **离线时**:显示"机场离线,暂无实时数据"提示
- **加载中**:显示加载动画
### 3. 数据字段展示
目前展示的 OSD 字段包括:
| 字段 | 图标 | 颜色规则 | 示例值 |
|------|------|---------|--------|
| 电量 | 🔋 battery_full | >50% 绿色, 20-50% 橙色, <20% 红色 | 85% |
| 温度 | 🌡️ thermostat | 蓝色 | 25°C |
| 风速 | 💨 air | 绿色 | 3.5 m/s |
| 降雨量 | 💧 water_drop | 蓝色 | 小雨 / 12 mm |
| 网络状态 | 📶 network_check | 灰色 | 等级 4 |
| GPS | 🛰️ gps_fixed | 紫色 | 12 颗 |
## 📁 文件结构
```
lib/features/v2/device_list/
├── presentation/
│ ├── pages/
│ │ └── drone_station_detail_page.dart # 详情页(集成 OSD 卡片)
│ └── widgets/
│ └── drone_station_osd_card.dart # OSD 实时数据卡片组件
```
## 🔧 技术实现
### 1. MQTT 数据订阅
```dart
// 订阅机场 OSD topic
final stationTopic = 'thing/product/$gatewaySn/osd';
// 监听数据流
_subscription = _dataSource.stationOsdStream.listen((osd) {
setState(() {
_currentOsd = osd;
_parseOsdFields(osd); // 解析并更新 UI
});
});
```
### 2. 数据解析
```dart
void _parseOsdFields(DroneOsdEntity osd) {
final data = osd.rawData;
_osdFields = [
{
'icon': Icons.battery_full_rounded,
'label': '电量',
'value': data['battery'] != null ? '${data['battery']}%' : '未知',
'color': _getBatteryColor(data['battery']),
},
// ... 其他字段
];
}
```
### 3. 滑动卡片实现
使用 `PageView.builder` 实现左右滑动:
```dart
PageView.builder(
itemCount: _osdFields.length,
onPageChanged: (index) {
setState(() {
_currentIndex = index;
});
},
itemBuilder: (context, index) {
final field = _osdFields[index];
return _buildOsdPage(field, index);
},
)
```
## 🎨 UI 设计
### 卡片布局
```
┌─────────────────────────────┐
│ [图标] 电量 ◀ ▶ │
│ │
│ 85% │
│ │
│ 1 / 6 │
│ │
│ ● ○ ○ ○ ○ ○ │
└─────────────────────────────┘
```
### 状态指示器
- **当前页**:长条蓝色圆点(16px)
- **其他页**:短条灰色圆点(6px)
## 📊 数据映射规则
### 降雨量格式化
```dart
String _formatRainfall(dynamic rainfall) {
if (rainfall is String) {
switch (rainfall.toLowerCase()) {
case 'no_rain': return '无降雨';
case 'light_rain': return '小雨';
case 'moderate_rain': return '中雨';
case 'heavy_rain': return '大雨';
default: return '$rainfall mm';
}
}
return '$rainfall mm';
}
```
### 电量颜色规则
```dart
Color _getBatteryColor(dynamic battery) {
final value = battery is num ? battery.toDouble() : 0.0;
if (value > 50) return Color(0xFF00B42A); // 绿色
if (value > 20) return Color(0xFFFF7D00); // 橙色
return Color(0xFFF53F3F); // 红色
}
```
## 🔄 生命周期管理
### 启动监听
```dart
@override
void initState() {
super.initState();
_startListening(); // 开始监听 MQTT
}
```
### 停止监听
```dart
@override
void dispose() {
_stopListening(); // 取消订阅,释放资源
super.dispose();
}
```
### 动态更新
```dart
@override
void didUpdateWidget(DroneStationOsdCard oldWidget) {
super.didUpdateWidget(oldWidget);
// gatewaySn 或 isOnline 变化时重新订阅
if (oldWidget.gatewaySn != widget.gatewaySn ||
oldWidget.isOnline != widget.isOnline) {
_stopListening();
_startListening();
}
}
```
## 🚀 使用方法
### 在详情页中集成
```dart
DroneStationOsdCard(
gatewaySn: widget.station.gatewaySn,
isOnline: detail.isOnline,
)
```
### 参数说明
| 参数 | 类型 | 说明 |
|------|------|------|
| `gatewaySn` | String | 机场网关序列号(用于 MQTT topic) |
| `isOnline` | bool | 机场是否在线(决定是否显示实时数据) |
## 📝 扩展建议
### 1. 添加更多 OSD 字段
在 `_parseOsdFields` 方法中添加新字段:
```dart
{
'icon': Icons.new_icon,
'label': '新字段名称',
'value': data['newField'] != null ? '${data['newField']}' : '未知',
'color': const Color(0xFF165DFF),
},
```
### 2. 自定义滑动方向
修改 `PageView` 的 `scrollDirection`:
```dart
PageView.builder(
scrollDirection: Axis.vertical, // 改为上下滑动
// ...
)
```
### 3. 自动轮播
添加定时器自动切换卡片:
```dart
Timer.periodic(Duration(seconds: 3), (timer) {
if (_osdFields.isNotEmpty) {
setState(() {
_currentIndex = (_currentIndex + 1) % _osdFields.length;
});
_pageController.animateToPage(_currentIndex, ...);
}
});
```
### 4. 手势优化
添加双击放大、长按复制等功能:
```dart
GestureDetector(
onDoubleTap: () {
// 双击放大
},
onLongPress: () {
// 长按复制数值
Clipboard.setData(ClipboardData(text: field['value']));
},
child: _buildOsdPage(field, index),
)
```
## ⚠️ 注意事项
1. **MQTT 连接**:确保应用已连接到 MQTT 服务器
2. **topic 格式**:必须为 `thing/product/${gatewaySn}/osd`
3. **数据格式**:MQTT 消息 payload 必须是 JSON 格式
4. **内存管理**:页面销毁时必须取消订阅,避免内存泄漏
5. **在线判断**:只有机场在线时才订阅 MQTT,离线时不订阅
## 🐛 调试技巧
### 查看 MQTT 日志
```dart
debugPrint('🔊 [DroneStationOsdCard] 开始监听机场 OSD: ${widget.gatewaySn}');
debugPrint('✅ [DroneStationOsdCard] 收到机场 OSD 数据');
```
### 检查数据解析
```dart
debugPrint('📊 [DroneStationOsdCard] OSD 原始数据: ${osd.rawData}');
debugPrint('📊 [DroneStationOsdCard] 解析后字段数: ${_osdFields.length}');
```
## 📦 依赖项
- `flutter_bloc`: 状态管理
- `mqtt_client`: MQTT 通信
- `equatable`: 实体类比较
- `get_it`: 依赖注入
## ✅ 完成清单
- [x] 创建 `DroneStationOsdCard` 组件
- [x] 实现 MQTT 数据订阅
- [x] 实现滑动窗口 UI
- [x] 实现数据解析和格式化
- [x] 集成到详情页
- [x] 添加状态管理(在线/离线/加载中)
- [x] 添加生命周期管理
- [x] 添加调试日志

View File

@@ -0,0 +1,303 @@
# Flutter 项目统一错误处理规范
## 核心原则
**所有接口异常都不应该影响页面展示,只显示友好提示弹窗(2秒后自动消失)**
## 实现方案
### 1. Cubit/Bloc 层处理
#### ✅ 正确做法
```dart
class MyCubit extends Cubit<MyState> {
Future<void> fetchData() async {
try {
emit(state.copyWith(isLoading: true));
final result = await useCase.call(params);
result.fold(
(failure) {
// 🔥 设置错误信息 + 标记需要显示弹窗
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 关键!
));
},
(data) {
emit(state.copyWith(
isLoading: false,
data: data,
));
},
);
} catch (e) {
// 🔥 捕获所有异常
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
shouldShowError: true, // 关键!
));
}
}
}
```
#### ❌ 错误做法
```dart
// ❌ 不要抛出异常到页面层
throw Exception('网络错误');
// ❌ 不要显示原始错误信息
emit(state.copyWith(errorMessage: e.toString()));
// ❌ 不要让页面崩溃
if (error) throw error;
```
### 2. State 设计
```dart
class MyState extends Equatable {
final bool isLoading;
final String? errorMessage;
final bool shouldShowError; // 🔥 关键字段
const MyState({
this.isLoading = false,
this.errorMessage,
this.shouldShowError = false, // 默认 false
});
MyState copyWith({
bool? isLoading,
String? errorMessage,
bool? shouldShowError,
}) {
return MyState(
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage,
shouldShowError: shouldShowError ?? false, // 🔥 默认重置为 false
);
}
}
```
**关键点:**
- `shouldShowError` 默认为 `false`
- 每次 emit 时如果不传,会自动重置为 `false`
- 这样可以确保错误弹窗只显示一次
### 3. 页面层监听
```dart
BlocConsumer<MyCubit, MyState>(
listener: (context, state) {
// 🔥 自动显示错误弹窗
if (state.shouldShowError && state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage!),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
// 页面正常渲染,不受错误影响
if (state.isLoading) {
return const CircularProgressIndicator();
}
return YourContent();
},
)
```
### 4. ErrorHandler 工具类
```dart
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
class ErrorHandler {
/// 获取友好的错误消息
static String getErrorMessage(Object error) {
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return '网络连接超时,请检查网络设置';
case DioExceptionType.connectionError:
return '网络连接失败,请检查网络';
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
if (statusCode == 401) {
return '登录已过期,请重新登录';
} else if (statusCode == 403) {
return '没有权限执行此操作';
} else if (statusCode == 404) {
return '请求的资源不存在';
} else if (statusCode == 500) {
return '服务器异常,请稍后重试';
} else {
return '服务器响应异常';
}
default:
return '请求失败,请稍后重试';
}
} else {
return '操作失败,请稍后重试';
}
}
}
```
## 完整示例
### DeviceTaskCubit 示例
```dart
class DeviceTaskCubit extends Cubit<DeviceTaskState> {
Future<void> cancelTask(String deviceId) async {
final taskId = state.currentTaskId;
if (taskId == null) {
emit(state.copyWith(errorMessage: '无可用任务'));
return;
}
emit(state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.cancel,
));
try {
final result = await _cancelTaskUseCase.call(params);
result.fold(
(failure) {
_logger.logWithLevel('❌ 取消任务失败: ${failure.message}');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示弹窗
));
},
(success) {
_logger.logWithLevel('✅ 取消任务成功');
emit(state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
));
},
);
} catch (e) {
_logger.logWithLevel('❌ 取消任务异常: $e');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示弹窗
));
}
}
}
```
### 页面使用示例
```dart
class TaskPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => GetIt.I<DeviceTaskCubit>(),
child: Scaffold(
appBar: AppBar(title: const Text('任务管理')),
body: BlocConsumer<DeviceTaskCubit, DeviceTaskState>(
listener: (context, state) {
// 🔥 自动显示错误弹窗
if (state.shouldShowError && state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage!),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
}
},
builder: (context, state) {
return Column(
children: [
// 页面内容不受错误影响
Text('当前任务ID: ${state.currentTaskId ?? "无"}'),
ElevatedButton(
onPressed: () {
context.read<DeviceTaskCubit>().cancelTask(deviceId);
},
child: const Text('取消任务'),
),
if (state.isLoading)
const CircularProgressIndicator(),
],
);
},
),
),
);
}
}
```
## 错误提示文案规范
| 错误类型 | 提示文案 |
|---------|---------|
| 网络超时 | "网络连接超时,请检查网络设置" |
| 连接失败 | "网络连接失败,请检查网络" |
| 401 未授权 | "登录已过期,请重新登录" |
| 403 禁止访问 | "没有权限执行此操作" |
| 404 资源不存在 | "请求的资源不存在" |
| 500 服务器错误 | "服务器异常,请稍后重试" |
| 其他业务错误 | "操作失败,请稍后重试" |
| 未知错误 | "未知错误,请稍后重试" |
**注意:**
- ✅ 使用友好的中文提示
- ❌ 不要显示 HTTP 状态码
- ❌ 不要显示技术术语(如 "DioException"、"timeout")
- ❌ 不要显示完整的错误堆栈
## 优势
1. **页面不崩溃** - 所有异常都被捕获
2. **用户体验好** - 友好的中文提示
3. **自动消失** - 2秒后弹窗自动关闭
4. **不影响操作** - 用户可以继续使用页面
5. **统一规范** - 全项目统一的错误处理方式
## 检查清单
在开发新功能时,确保:
- [ ] Cubit 中所有 try-catch 都使用了 `ErrorHandler.getErrorMessage()`
- [ ] State 中有 `shouldShowError` 字段
- [ ] 错误时设置 `shouldShowError: true`
- [ ] copyWith 中 `shouldShowError` 默认为 `false`
- [ ] 页面 BlocConsumer listener 中监听 `shouldShowError`
- [ ] 使用 SnackBar 显示错误(2秒自动消失)
- [ ] 不显示原始错误信息

388
docs/ERROR_HANDLING_FIX.md Normal file
View File

@@ -0,0 +1,388 @@
# 统一错误处理修复记录
## 问题描述
之前项目中存在多个地方直接将原始错误信息(如 `DioException [connection error]...`)显示在页面上,严重影响用户体验。这些错误包括:
- 网络连接失败
- HTTP 状态码错误
- 超时错误
- 其他技术异常
**问题表现:**
- 全屏显示错误页面
- 直接展示技术错误信息(如 `DioException`、`HTTP 500` 等)
- 用户无法继续操作
- 错误信息不友好,普通用户看不懂
## 解决方案
采用统一的错误处理机制,确保:
1. ✅ **所有错误都被拦截**,不会直接显示原始错误
2. ✅ **友好的中文提示**,使用 SnackBar 浮动显示 2 秒后自动消失
3. ✅ **不影响页面渲染**,错误发生时页面保持不变,用户可以继续操作
4. ✅ **统一的错误处理模式**,所有 BLoC/Cubit 都遵循相同规范
## 修复的文件列表
### 1. DeviceStatusBloc (设备状态)
**文件路径:** `lib/features/v2/device_list/presentation/bloc/device_status_bloc.dart`
**修改内容:**
- ✅ 添加 `ErrorHandler` 导入
- ✅ 将 `emit(DeviceStatusError(e.toString()))` 改为使用 `ErrorHandler.getErrorMessage(e)`
- ✅ 设置 `shouldShowError: true` 标记
**State 更新:**
```dart
class DeviceStatusError extends DeviceStatusState {
final String message;
final bool shouldShowError; // 🔥 新增
const DeviceStatusError({
required this.message,
this.shouldShowError = false,
});
}
```
**页面更新:**
```dart
// device_status_page.dart
BlocConsumer<DeviceStatusBloc, DeviceStatusState>(
listener: (context, state) {
if (state is DeviceStatusError && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.orange,
),
);
}
},
builder: (context, state) {
if (state is DeviceStatusError) {
return const SizedBox.shrink(); // 🔥 不再显示全屏错误
}
// ... 正常内容
},
)
```
---
### 2. DroneStationBloc (无人机机场)
**文件路径:** `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart`
**修改内容:**
- ✅ 添加 `ErrorHandler` 导入
- ✅ 更新所有错误状态:
- `DroneStationError`
- `UAVDetailError`
- `VideoStreamError`
- `UavVideoStreamError`
- ✅ 所有错误都设置 `shouldShowError: true`
**State 更新:**
```dart
// lib/features/v2/device_list/presentation/bloc/drone_station_state.dart
class DroneStationError extends DroneStationState {
final String message;
final bool shouldShowError; // 🔥 新增
const DroneStationError({
required this.message,
this.shouldShowError = false,
});
}
```
**页面更新:**
```dart
// device_status_page.dart - _buildDroneStationList
BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is DroneStationError && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(...);
}
},
builder: (context, state) {
if (state is DroneStationError) {
return const SizedBox.shrink(); // 🔥 保持页面
}
// ... 正常内容
},
)
```
---
### 3. RobotListBloc (机器人列表)
**文件路径:** `lib/features/v2/device_list/presentation/bloc/robot_list_bloc.dart`
**修改内容:**
- ✅ 添加 `ErrorHandler` 导入
- ✅ 将 `emit(RobotListError(e.toString()))` 改为使用 `ErrorHandler`
- ✅ 设置 `shouldShowError: true`
**State 更新:**
```dart
class RobotListError extends RobotListState {
final String message;
final bool shouldShowError; // 🔥 新增
const RobotListError({
required this.message,
this.shouldShowError = false,
});
}
```
**页面更新:**
```dart
// robot_list_page.dart
BlocConsumer<RobotListBloc, RobotListState>(
listener: (context, state) {
if (state is RobotListError && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(...);
}
},
builder: (context, state) {
if (state is RobotListError) {
return const SizedBox.shrink();
}
// ... 正常内容
},
)
```
---
### 4. HomeV2Bloc (首页 V2)
**文件路径:** `lib/features/v2/home/presentation/bloc/home_v2_bloc.dart`
**修改内容:**
- ✅ 添加 `ErrorHandler` 导入
- ✅ 将所有 `emit(HomeV2Error(failure.message))` 改为使用 `ErrorHandler`
- ✅ 设置 `shouldShowError: true`
**State 更新:**
```dart
class HomeV2Error extends HomeV2State {
final String message;
final bool shouldShowError; // 🔥 新增
const HomeV2Error({
required this.message,
this.shouldShowError = false,
});
}
```
**页面更新:**
```dart
// home_v2_page.dart
BlocConsumer<HomeV2Bloc, HomeV2State>(
listener: (context, state) {
if (state is HomeV2Error && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(...);
}
},
builder: (context, state) {
if (state is HomeV2Error) {
return const SizedBox.shrink();
}
// ... 正常内容
},
)
```
---
## 统一错误处理流程
### 1. BLoC/Cubit 层
```dart
try {
// 业务逻辑
final result = await useCase.call(params);
result.fold(
(failure) => emit(ErrorState(
message: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 🔥 关键:标记需要显示弹窗
)),
(data) => emit(SuccessState(data)),
);
} catch (e) {
emit(ErrorState(
message: ErrorHandler.getErrorMessage(e),
shouldShowError: true, // 🔥 关键:标记需要显示弹窗
));
}
```
### 2. State 层
```dart
class ErrorState extends SomeState {
final String message;
final bool shouldShowError; // 🔥 关键:用于标记是否显示弹窗
const ErrorState({
required this.message,
this.shouldShowError = false, // 默认不显示
});
}
```
### 3. Page 层
```dart
BlocConsumer<SomeBloc, SomeState>(
listener: (context, state) {
// 🔥 监听错误并显示友好提示
if (state is ErrorState && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.orange,
),
);
}
},
builder: (context, state) {
if (state is ErrorState) {
// 🔥 返回空容器,保持当前页面不变
return const SizedBox.shrink();
}
// ... 正常内容
},
)
```
---
## ErrorHandler 工具类
**文件路径:** `lib/core/network/error_handler.dart`
**功能:**
- 将所有 Dio 异常转换为友好的中文提示
- 支持多种错误类型:
- 连接超时 → "网络连接超时,请检查网络设置"
- 连接失败 → "网络连接失败,请检查网络"
- 401 → "登录已过期,请重新登录"
- 403 → "没有权限执行此操作"
- 404 → "请求的资源不存在"
- 500 → "服务器异常,请稍后重试"
- 其他 → "操作失败,请稍后重试"
**使用方法:**
```dart
// 在 BLoC/Cubit 中
String friendlyMessage = ErrorHandler.getErrorMessage(error);
// 或在页面中直接显示
ErrorHandler.handleError(context, error);
```
---
## 修复效果对比
### 修复前 ❌
```
┌─────────────────────────────┐
│ │
│ ⚠️ Error Icon │
│ │
│ DioException [connection │
│ error]: The connection │
│ errored: Failed host │
│ lookup... │
│ │
│ [ 重试 ] │
│ │
└─────────────────────────────┘
```
- 全屏错误页面
- 显示原始技术错误
- 用户无法继续操作
- 体验极差
### 修复后 ✅
```
┌─────────────────────────────┐
│ │
│ [正常页面内容] │
│ │
│ 用户可以看到页面 │
│ 可以继续操作 │
│ │
└─────────────────────────────┘
↓
┌──────────────┐
│ 网络连接失败 │ ← SnackBar 浮动显示
│ 请检查网络 │ 2秒后自动消失
└──────────────┘
```
- 页面保持不变
- 友好的中文提示
- SnackBar 浮动显示
- 2秒自动消失
- 用户可以继续操作
---
## 最佳实践
### ✅ DO - 应该这样做
1. 所有 BLoC/Cubit 的错误处理都使用 `ErrorHandler.getErrorMessage()`
2. 所有错误状态都设置 `shouldShowError: true`
3. 页面使用 `BlocConsumer` 监听错误
4. 错误时使用 `SizedBox.shrink()` 保持页面
5. SnackBar 设置为 `floating` 和 `orange` 背景
### ❌ DON'T - 不要这样做
1. ❌ 直接使用 `e.toString()` 作为错误消息
2. ❌ 在页面中显示全屏错误页面
3. ❌ 直接展示原始技术错误(DioException、HTTP 状态码等)
4. ❌ 让用户点击"重试"按钮才能恢复
5. ❌ 不同页面使用不同的错误处理方式
---
## 后续工作
### 待迁移的 BLoC/Cubit
以下 BLoC/Cubit 可能还需要迁移到统一的错误处理模式:
- [ ] 其他未检查的 BLoC
- [ ] 第三方库相关的错误处理
- [ ] WebSocket 连接的错误处理
### 优化建议
1. 考虑为不同类型的错误设置不同的 SnackBar 颜色
2. 可以添加错误日志上报功能
3. 可以考虑添加错误重试机制(在后台自动重试)
4. 可以为特定场景定制错误提示文案
---
## 总结
通过本次修复,我们实现了:
✅ 统一的错误处理机制
✅ 友好的用户提示
✅ 不影响页面渲染
✅ 提升用户体验
✅ 代码规范统一
**核心原则:**
> 任何接口异常都不应该影响页面的展示,给个几秒弹窗就行!
---
**修复日期:** 2026-06-15
**修复人员:** AI Assistant
**影响范围:** 设备管理、首页相关的所有 BLoC/Cubit

520
docs/SOP_使用文档.md Normal file
View File

@@ -0,0 +1,520 @@
# 迈步 Satabot App 使用说明书
> 版本:1.0.0 | 适用对象:终端用户(运维人员)
---
## 目录
1. [应用简介](#1-应用简介)
2. [安装与登录](#2-安装与登录)
3. [首页](#3-首页)
4. [设备管理](#4-设备管理)
5. [机器人控制](#5-机器人控制)
6. [无人机巡检](#6-无人机巡检)
7. [告警中心](#7-告警中心)
8. [工单管理](#8-工单管理)
9. [问题上报](#9-问题上报)
10. [我的](#10-我的)
11. [蓝牙设备绑定](#11-蓝牙设备绑定)
12. [常见问题](#12-常见问题)
---
## 1. 应用概述
### 1.1 产品简介
迈步 Satabot 是一款面向光伏电站的智能管理移动应用,为电站运维人员提供一站式的设备监控、远程控制、告警处理和工单管理能力。通过本 App,运维人员可以随时随地掌握电站运行状况,快速响应设备异常,提升运维效率。
### 1.2 适用人群
| 角色 | 说明 |
|------|------|
| 电站运维人员 | 日常巡检、设备监控、告警处理、工单执行 |
| 设备管理人员 | 设备绑定、参数配置、运行状态查看 |
| 管理人员 | 电站发电数据查看、工单统计 |
### 1.3 核心功能
| 功能模块 | 说明 |
|---------|------|
| 首页 | 电站发电概览、实时功率、设备统计、快捷入口 |
| 设备管理 | 设备列表、分类筛选、搜索、蓝牙扫描、设备详情 |
| 机器人控制 | 远程方向控制、任务管理、运行参数设置 |
| 无人机巡检 | 机场状态、实时监控、视频直播、飞行任务管理 |
| 告警中心 | 告警统计、告警处理、派发工单、AI 诊断 |
| 工单管理 | 工单全流程(新建→执行→完成/挂起/转派) |
| 问题上报 | 问题类型选择、媒体上传、设备关联 |
| 个人中心 | 个人信息、系统设置、夜间模式、导航自定义 |
### 1.4 设备类型
本 App 管理以下类型的设备:
| 设备类型 | 说明 |
|---------|------|
| 光伏逆变器 | 将直流电转换为交流电的核心设备 |
| 汇流箱 | 汇集多个光伏组件的直流电 |
| 光伏组件 | 将太阳能转化为直流电的组件 |
| 监控设备 | 数据采集与传输设备 |
| 清洁除草机器人 | MC700 系列,光伏面板清洁除草机器人 |
| 无人机机场 | 无人机自动机巢,含无人机巡检 |
### 1.5 底部导航栏
App 底部有以下功能页签,可在"我的 → 底部导航设置"中自定义显示和排序:
| 页签 | 名称 | 说明 |
|------|------|------|
| 首页 | 电站概览 | 发电数据、设备统计、快捷入口 |
| 设备 | 设备管理 | 设备列表、机器人、无人机 |
| 告警 | 告警中心 | 查看和处理设备告警 |
| 工单 | 工单管理 | 工单全流程管理 |
| 上报 | 问题上报 | 上报设备问题 |
| 我的 | 个人中心 | 个人信息、系统设置 |
---
## 2. 安装与登录
### 2.1 安装
- **Android**:扫描二维码下载 APK 安装
- **iOS**:扫描二维码跳转 App Store 安装
- 安装时如提示"未知来源",请在手机设置中允许安装
### 2.2 登录
1. 打开 App,进入登录页面
2. 输入**用户名**和**密码**
3. 勾选"已阅读并同意用户协议"
4. 点击**登录**按钮
5. 登录成功后自动跳转到首页
> **提示**:如果忘记密码,请联系管理员重置。
### 2.3 注册
1. 在登录页面点击**注册账号**
2. 填写用户名、密码等信息
3. 提交后等待管理员审核
### 2.4 修改密码
1. 登录后进入"我的"页面
2. 点击"修改密码"
3. 输入旧密码和新密码
4. 提交完成修改
---
## 3. 首页
### 3.1 页面说明
首页展示当前选中电站的整体运行情况。
### 3.2 切换电站
- 页面顶部左侧显示当前电站名称
- 点击电站名称可下拉切换其他电站
- 切换后所有数据自动刷新
### 3.3 功能区域
| 区域 | 说明 |
|------|------|
| 天气信息 | 显示电站所在地天气 |
| 电站概览 | 装机容量、今日发电量等 |
| 功率卡片 | 实时功率、今日累计发电量、功率趋势图 |
| 统计网格 | 设备总数、在线数、离线数、异常数 |
| 工单卡片 | 待处理工单数量,点击进入工单页 |
| 快捷入口 | 设备管理、告警、工单等快速跳转 |
| TCP 状态 | 右上角圆点显示通信连接状态(绿色=正常) |
### 3.4 下拉刷新
- 在首页任意位置**下拉**可刷新所有数据
---
## 4. 设备管理
### 4.1 进入设备页
点击底部导航栏的**设备**页签。
### 4.2 切换电站
- 页面顶部左侧可切换电站,切换后设备列表自动刷新
### 4.3 搜索设备
- 页面上方搜索框中输入设备名称
- 点击搜索按钮或按确认键进行搜索
### 4.4 设备分类筛选
页面中部有设备类型筛选条:
| 筛选项 | 说明 |
|--------|------|
| 全部 | 显示所有设备 |
| 逆变器 | 仅显示逆变器设备 |
| 汇流箱 | 仅显示汇流箱设备 |
| 组件 | 仅显示光伏组件 |
| 监控 | 仅显示监控设备 |
| 机器人 | 仅显示清洁除草机器人 |
| 无人机机场 | 仅显示无人机机巢 |
### 4.5 添加设备
- 点击页面右上角 **+** 按钮
- 可选择**扫一扫**(扫码绑定)或**蓝牙**(蓝牙搜索绑定)
### 4.6 蓝牙扫描
- 点击搜索框旁边的**蓝牙图标**
- App 开始扫描附近蓝牙设备
- 扫描结果中,名称为纯数字的设备(时间戳格式)自动**置顶**显示
- 可在搜索框中输入关键词过滤设备
- 点击设备可进入设备详情页
### 4.7 设备详情
点击设备列表中的设备,进入设备详情页:
- **光伏设备**:显示设备运行参数(功率、电压、电流、温度等)
- **机器人**:进入机器人控制页(详见第 5 章)
- **无人机机场**:进入机场详情页(详见第 6 章)
---
## 5. 机器人控制
### 5.1 进入方式
设备页 → 筛选"机器人" → 点击机器人设备
### 5.2 机器人详情页
| 区域 | 说明 |
|------|------|
| 设备信息卡 | 显示机器人名称、序列号、在线状态、电量 |
| 状态栏 | 运行状态、工作模式、当前位置 |
| 控制面板 | 方向控制(前进/后退/左转/右转/停止) |
| 检查清单 | 任务执行前的检查项 |
| 任务信息 | 当前任务详情 |
| 操作按钮 | 启动任务、暂停、取消等 |
### 5.3 远程控制
1. 进入机器人详情页后,App 自动申请远程控制权限
2. 获得权限后可使用控制面板上的方向按钮
3. 点击**停止**按钮停止移动
4. 退出页面时自动释放控制权限
### 5.4 任务管理
| 操作 | 说明 |
|------|------|
| 创建任务 | 设置工作区域参数后创建清洁任务 |
| 启动任务 | 选择任务后点击启动 |
| 暂停任务 | 执行中点击暂停 |
| 恢复任务 | 暂停后点击恢复 |
| 取消任务 | 取消当前任务 |
### 5.5 运行参数设置
1. 在机器人详情页点击设置图标
2. 可调整运行参数(速度、切割高度等)
3. 点击保存生效
---
## 6. 无人机巡检
### 6.1 进入方式
设备页 → 筛选"无人机机场" → 点击机场设备
### 6.2 机场详情页
| 信息 | 说明 |
|------|------|
| 机场状态 | 在线状态、仓内温度 |
| 无人机状态 | 电量、位置、飞行状态 |
| 视频监控 | 仓内监控画面 |
### 6.3 实时监控
- 点击"实时监控"进入无人机监控页
- 显示无人机 OSD 数据:经纬度、高度、速度、电量、航向等
### 6.4 视频直播
1. 点击"视频直播"进入直播页面
2. 可切换摄像头:广角 / 变焦 / 红外
3. 支持画面全屏、方向控制
### 6.5 飞行任务
| 操作 | 说明 |
|------|------|
| 查看任务 | 查看已有飞行任务列表 |
| 创建任务 | 选择航线、设置参数后创建 |
| 执行任务 | 选择任务后点击执行 |
| 暂停任务 | 飞行中可暂停 |
| 返航 | 一键返航 |
---
## 7. 告警中心
### 7.1 进入方式
点击底部导航栏的**告警**页签。
### 7.2 告警统计
- 页面上方显示告警数量统计卡片
- 按状态分类:待处理、处理中、已处理、已关闭
### 7.3 告警列表
- 按状态 Tab 分类展示
- 下拉刷新,上拉加载更多
- 点击告警条目进入详情页
### 7.4 告警详情
| 信息 | 说明 |
|------|------|
| 告警信息 | 告警名称、级别、时间、设备 |
| 告警详情 | 详细描述、可能原因 |
| AI 诊断 | AI 辅助分析告警原因和建议 |
### 7.5 处理告警
1. 在告警详情页点击"处理"
2. 选择处理方式(确认/关闭)
3. 填写处理说明
4. 提交完成处理
### 7.6 派发工单
1. 在告警详情页点击"派发工单"
2. 选择工单模型
3. 填写派发信息
4. 提交后自动生成工单
---
## 8. 工单管理
### 8.1 进入方式
点击底部导航栏的**工单**页签。
### 8.2 工单统计
- 页面上方显示各状态工单数量统计
- 点击数字可快速筛选对应状态的工单
### 8.3 工单列表
- 按状态 Tab 分类展示:待处理、处理中、已完成、已挂起
- 下拉刷新,上拉加载更多
- 点击工单条目进入详情页
### 8.4 工单详情
显示工单编号、类型、设备、描述、处理记录等信息。
### 8.5 工单操作
| 操作 | 说明 |
|------|------|
| 开始执行 | 接收工单后开始处理 |
| 完成 | 处理完毕后标记完成 |
| 挂起 | 暂时无法处理,挂起工单 |
| 恢复 | 挂起的工单恢复处理 |
| 转派 | 将工单转给其他人员 |
### 8.6 新建工单
1. 在工单页面点击"上报"按钮
2. 选择工单类型
3. 填写问题描述
4. 关联设备
5. 提交生成工单
---
## 9. 问题上报
### 9.1 进入方式
点击底部导航栏的**上报**页签。
### 9.2 上报流程
1. **选择上报类型**:故障 / 建议 / 投诉等
2. **选择严重等级**:紧急 / 严重 / 一般 / 轻微
3. **选择设备**:从设备列表中关联相关设备
4. **上传媒体**:拍照、录像或选择图片/视频
5. **填写描述**:文字描述问题详情
6. 点击**提交**完成上报
### 9.3 媒体上传
- 点击拍照按钮直接拍照
- 或从手机相册选择图片/视频
- 点击已上传的媒体可预览大图/视频
---
## 10. 我的
### 10.1 进入方式
点击底部导航栏的**我的**页签。
### 10.2 功能列表
| 功能 | 说明 |
|------|------|
| 用户信息卡 | 头像、昵称、联系方式,点击进入编辑页 |
| 快捷操作 | 常用功能快速入口 |
| 个人资料 | 修改昵称、头像等 |
| 系统设置 | 夜间模式、语言切换、清除缓存 |
| 底部导航设置 | 自定义底部 Tab 显示/隐藏和排序 |
| 版本更新 | 检查并下载新版本 |
| 退出登录 | 清除登录状态返回登录页 |
### 10.3 夜间模式
- 我的 → 系统设置 → 夜间模式开关
- 开启后 App 切换为深色主题
### 10.4 底部导航设置
1. 我的 → 底部导航设置(或首页 → 设置入口)
2. 可开关每个 Tab 的显示/隐藏
3. 至少保留一个 Tab
4. 可调整 Tab 排列顺序
5. 设置即时生效
---
## 11. 蓝牙设备绑定
### 11.1 方式一:扫码绑定
1. 设备页 → 点击右上角 **+**
2. 选择"扫一扫"
3. 扫描设备上的二维码
4. App 获取设备名称(如 `MC700AIR-CN-JS-1777272534998-0000000A-75`)
5. 进入绑定页面
6. 选择所属组织和场站
7. 点击提交完成绑定
### 11.2 方式二:蓝牙搜索绑定
1. 设备页 → 点击搜索框旁的**蓝牙图标**
2. App 扫描附近蓝牙设备
3. 在搜索框输入关键词可过滤设备
4. 蓝牙名为纯数字的设备(时间戳格式)自动置顶
5. 点击目标设备进入设备详情页
6. 点击**连接设备**按钮连接蓝牙
7. 连接成功后,**绑定设备**按钮变为可点击状态
8. 未连接时"绑定设备"按钮为灰色,不可点击
9. 点击"绑定设备"
- App 自动获取全部设备列表
- 用蓝牙名(时间戳,如 `1777272534998`)匹配设备
- 匹配成功后自动跳转到绑定页面
10. 选择所属组织和场站
11. 点击提交完成绑定
### 11.3 设备名说明
| 位置 | 名称示例 | 说明 |
|------|---------|------|
| 蓝牙广播名 | `1777272534998` | 纯数字时间戳 |
| 设备完整名 | `MC700AIR-CN-JS-1777272534998-0000000A-75` | 设备序列号 |
| 匹配方式 | 完整名包含蓝牙名 | 蓝牙名是完整名的一部分 |
---
## 12. 常见问题
### Q1:登录提示"登录失败"?
- 检查用户名和密码是否正确
- 检查网络是否正常
- 联系管理员确认账号是否已开通
### Q2:蓝牙扫描不到设备?
- 确认手机蓝牙已开启
- 确认已授予 App 蓝牙权限和定位权限
- 靠近设备后重试
- 安卓手机需开启"位置信息"开关
### Q3:蓝牙连接失败?
- 确认设备已通电
- 确认设备未被其他手机连接
- 靠近设备重试
- 重启手机蓝牙后重试
### Q4:绑定设备按钮灰色不可点击?
- "绑定设备"按钮需要**先连接蓝牙**后才能使用
- 请先点击"连接设备"按钮,连接成功后按钮变为可点击
### Q5:点击绑定设备后提示"未匹配到设备"?
- 确认设备已录入后台系统
- 联系管理员确认设备名中是否包含当前蓝牙名(时间戳)
### Q6:首页数据显示"加载失败"?
- 检查网络连接
- 点击"重试"按钮
- 确认当前账号有权限访问该电站
### Q7:机器人控制无反应?
- 确认机器人在线
- 确认已获得远程控制权限
- 确认机器人未在执行其他任务
- 退出页面重新进入
### Q8:无人机视频不显示?
- 确认无人机和机场在线
- 确认网络畅通
- 尝试切换摄像头
### Q9:TCP 状态显示异常?
- 首页右上角圆点显示 TCP 连接状态
- 红色表示未连接,绿色表示正常
- 如持续异常,检查网络或联系管理员
### Q10:App 版本过旧无法使用?
- 进入"我的"→"版本更新"检查新版本
- 下载并安装最新版本
---
## 联系与反馈
如遇到本说明书未涵盖的问题,请通过以下方式联系:
- **客服电话**:请联系管理员获取
- **问题反馈**:App 内"上报"页面提交问题反馈
- **技术支持**:请联系管理员获取联系方式

View File

@@ -0,0 +1,197 @@
# Tab 配置系统开发规范
## 📋 功能说明
Tab 配置系统支持动态开关控制底部导航栏的 Tab 项显示/隐藏,配置自动保存到 SharedPreferences。
## 🔧 添加新 Tab 项的步骤
当需要添加新的 Tab 项时,必须按以下顺序修改 **3 个文件**:
### 1️⃣ 修改 `tab_config.dart` - 添加默认配置
**文件路径:** `lib/features/main_container/domain/tab_config.dart`
在 `TabConfig.defaultConfig()` 方法的 `items` 列表中添加新的 `TabConfigItem`:
```dart
TabConfigItem(
id: 'your_tab_id', // 唯一标识符(小写+下划线)
name: '显示名称', // 中文名称(简洁,建议2-4个字)
nameEn: 'Display Name', // 英文名称
icon: 'icon_name_rounded', // 图标名称(Material Icons)
isEnabled: true, // 默认是否启用(true/false)
order: 7, // 排序序号(从1开始递增)
),
```
**注意事项:**
- `id` 必须唯一,建议使用有意义的英文标识
- `name` 要简洁,符合 TAB 命名规范
- `order` 决定 Tab 在导航栏中的显示顺序
- `isEnabled` 控制默认是否显示该 Tab
---
### 2️⃣ 修改 `custom_main_container.dart` - 添加页面映射
**文件路径:** `lib/features/main_container/presentation/pages/custom_main_container.dart`
#### 2.1 导入新页面
在文件顶部添加新页面的 import:
```dart
import 'package:maibu_satabot_v2/features/xxx/presentation/pages/your_page.dart';
```
#### 2.2 在 `_buildPages` 方法中添加 case
```dart
List<Widget> _buildPages(List<dynamic> enabledTabs) {
return enabledTabs.map((tab) {
switch (tab.id) {
case 'home_v2':
return const HomeV2Page();
case 'home':
return const HomePage();
case 'device':
return const DeviceStatusPage();
case 'ai':
return const AiPage();
case 'warning':
return const WarningCenterPage();
case 'my':
return const MyPage();
case 'your_tab_id': // 👈 新增这个 case
return const YourPage();
default:
return const HomePage();
}
}).toList();
}
```
---
### 3️⃣ 修改 `tab_settings_page.dart` - 添加图标映射
**文件路径:** `lib/features/main_container/presentation/pages/tab_settings_page.dart`
在 `_getIconData` 方法中添加新图标的映射:
```dart
IconData _getIconData(String iconName) {
switch (iconName) {
case 'home_rounded':
return Icons.home_rounded;
case 'grid_view_rounded':
return Icons.grid_view_rounded;
case 'devices_rounded':
return Icons.devices_rounded;
case 'auto_awesome_rounded':
return Icons.auto_awesome_rounded;
case 'warning_amber_rounded':
return Icons.warning_amber_rounded;
case 'person_rounded':
return Icons.person_rounded;
case 'your_icon_name': // 👈 新增这个 case
return Icons.your_icon_name;
default:
return Icons.home_rounded;
}
}
```
**注意:** 如果使用的是 Material Icons 标准图标,通常不需要修改此文件,除非使用了特殊图标。
---
## ✅ 检查清单
添加新 Tab 后,请确认:
- [ ] 已在 `tab_config.dart` 中添加 `TabConfigItem` 配置
- [ ] 已在 `custom_main_container.dart` 中导入新页面
- [ ] 已在 `custom_main_container.dart` 的 `switch` 中添加 case 分支
- [ ] 已在 `tab_settings_page.dart` 中添加图标映射(如需要)
- [ ] `id`、`order` 没有与其他 Tab 冲突
- [ ] 测试开关功能是否正常
- [ ] 测试切换 Tab 是否正常显示对应页面
---
## 🚀 完整示例:添加"消息"Tab
假设要添加一个"消息"Tab,显示消息中心页面:
### 步骤 1:修改 `tab_config.dart`
```dart
TabConfigItem(
id: 'message',
name: '消息',
nameEn: 'Messages',
icon: 'notifications_rounded',
isEnabled: true,
order: 7,
),
```
### 步骤 2:修改 `custom_main_container.dart`
```dart
// 顶部添加导入
import 'package:maibu_satabot_v2/features/message/presentation/pages/message_page.dart';
// 在 _buildPages 方法中添加
case 'message':
return const MessagePage();
```
### 步骤 3:修改 `tab_settings_page.dart`
```dart
case 'notifications_rounded':
return Icons.notifications_rounded;
```
---
## ⚠️ 常见问题
### Q1: 添加新 Tab 后看不到?
**A:** 点击 Tab 配置页面的"重置为默认"按钮,或清除应用数据重新运行。
### Q2: 点击 Tab 后页面不显示?
**A:** 检查 `custom_main_container.dart` 中的 `switch-case` 是否正确添加了新 Tab 的映射。
### Q3: 图标显示不正确?
**A:** 检查 `tab_settings_page.dart` 中的 `_getIconData` 方法是否添加了对应的图标映射。
### Q4: Tab 顺序不对?
**A:** 检查 `tab_config.dart` 中各 Tab 的 `order` 值,确保按期望顺序排列。
---
## 📝 核心原理
1. **配置管理:** `TabConfigCubit` 管理所有 Tab 的配置和选中状态
2. **持久化:** 配置自动保存到 SharedPreferences,重启应用后保持
3. **动态渲染:** 底部导航栏根据 `enabledItems` 动态渲染启用的 Tab
4. **页面切换:** 使用 `IndexedStack` 保持各页面状态,通过 `selectedIndex` 切换
---
## 🎯 最佳实践
1. **Tab 命名:** 保持简洁,中文 2-4 个字,避免过长
2. **默认状态:** 新功能建议设置 `isEnabled: false`,通过灰度发布逐步开放
3. **排序规划:** 预留 order 间隔(如 10, 20, 30),方便后续插入新 Tab
4. **图标选择:** 优先使用 Material Icons 的 `_rounded` 版本,风格统一
5. **测试验证:** 每次修改后测试开关、切换、持久化等功能
---
**最后更新:** 2026-05-20
**维护者:** 开发团队

View File

@@ -0,0 +1,195 @@
# 无人机实时视频接口对接完成总结
## ✅ 已完成工作
### 1. API 层
- [x] 添加 API 常量 `changeUAVLens` 到 `HttpApiConsts`
### 2. 实体层 (Domain/Entities)
- [x] 创建 `UavVideoStreamEntity` 实体类
- [x] 定义 `UavLensType` 枚举(广角、变焦、红外)
- [x] 定义 `VideoQualityType` 枚举(自适应、低、中、高清晰度)
- [x] 实现 RTC 参数解析方法(appId、roomId、token、userId)
### 3. 数据源层 (Data/Datasources)
- [x] 扩展 `DroneStationDataSource` 接口,添加 `getUavVideoStream` 方法
- [x] 实现 `DroneStationDataSourceImpl.getUavVideoStream`
- [x] 支持可选的镜头类型参数
- [x] 支持自定义清晰度和 Token 有效期
- [x] 添加详细的日志输出用于调试
### 4. 仓库层 (Data/Repositories)
- [x] 扩展 `DroneStationRepository` 接口
- [x] 实现 `DroneStationRepositoryImpl.getUavVideoStream`
- [x] 使用 `Either<Failure, T>` 模式处理错误
### 5. 用例层 (Domain/UseCases)
- [x] 创建 `GetUavVideoStreamUseCase`
- [x] 封装业务逻辑,供 BLoC 调用
- [x] 提供合理的默认值
### 6. 状态管理层 (Presentation/BLoC)
- [x] 添加 `UavVideoStreamLoad` 事件
- [x] 添加 `UavVideoStreamLoading` 状态
- [x] 添加 `UavVideoStreamLoaded` 状态
- [x] 添加 `UavVideoStreamError` 状态
- [x] 在 `DroneStationBloc` 中实现事件处理逻辑
### 7. 依赖注入 (DI)
- [x] 导入 `GetUavVideoStreamUseCase`
- [x] 注册 `GetUavVideoStreamUseCase` 为单例
- [x] 更新 `DroneStationBloc` 工厂,注入新的 UseCase
### 8. UI 层 (Presentation/Pages)
- [x] 创建示例页面 `UavLiveVideoPage`
- [x] 实现镜头切换功能(PopupMenuButton)
- [x] 实现加载状态显示
- [x] 实现错误处理和重试机制
- [x] 预留 RTC SDK 集成位置
### 9. 测试
- [x] 编写单元测试 `get_uav_video_stream_usecase_test.dart`
- [x] 测试成功场景
- [x] 测试失败场景
- [x] 测试默认参数
### 10. 文档
- [x] 创建集成指南 `UAV_VIDEO_INTEGRATION_GUIDE.md`
- [x] 创建使用示例 `UAV_VIDEO_USAGE_EXAMPLES.md`
## 📁 文件清单
### 新增文件(7个)
1. `lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart` - 实体类
2. `lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart` - 用例
3. `lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart` - 示例页面
4. `test/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase_test.dart` - 单元测试
5. `docs/UAV_VIDEO_INTEGRATION_GUIDE.md` - 集成指南
6. `docs/UAV_VIDEO_USAGE_EXAMPLES.md` - 使用示例
7. `docs/UAV_VIDEO_COMPLETION_SUMMARY.md` - 本文件
### 修改文件(9个)
1. `lib/core/consts/http_api_consts.dart` - 添加 API 常量
2. `lib/features/v2/device_list/data/datasources/drone_station_datasource.dart` - 添加接口方法
3. `lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart` - 实现接口
4. `lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart` - 实现仓库
5. `lib/features/v2/device_list/domain/repositories/drone_station_repository.dart` - 添加仓库接口
6. `lib/features/v2/device_list/presentation/bloc/drone_station_event.dart` - 添加事件
7. `lib/features/v2/device_list/presentation/bloc/drone_station_state.dart` - 添加状态
8. `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart` - 添加事件处理
9. `lib/core/di/injection.dart` - 注册依赖
## 核心特性
### 清洁架构设计
- **分层清晰**:Entity → UseCase → Repository → DataSource → BLoC → UI
- **依赖倒置**:高层模块不依赖低层模块的具体实现
- **可测试性**:每层都可以独立测试
### 可扩展性
- **多镜头支持**:通过枚举轻松扩展更多镜头类型
- **多清晰度支持**:支持自适应、低、中、高四种清晰度
- **多 RTC SDK 支持**:兼容火山引擎和声网两种 RTC SDK
- **可复用**:可在任何页面中使用,无需重复实现
### 健壮性
- **错误处理**:使用 `Either<Failure, T>` 统一处理错误
- **默认值**:提供合理的默认参数,简化调用
- **日志记录**:详细的日志输出,方便调试
- **重试机制**:支持手动重试和自动刷新
## 快速开始
### 方式一:直接使用示例页面
```dart
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: '1581F8HGX253U00A063U',
cameraIndex: '176-0-0',
),
),
);
```
### 方式二:在自定义页面中集成
参考 `docs/UAV_VIDEO_USAGE_EXAMPLES.md` 中的详细示例。
## 📋 API 说明
### 请求参数
```json
{
"sn": "1581F8HGX253U00A063U", // 必填:设备序列号
"lensType": "", // 可选:wide/zoom/ir
"cameraIndex": "176-0-0", // 必填:摄像头编号
"qualityType": "adaptive", // 可选:adaptive/low/medium/high
"videoExpire": 720000000 // 可选:Token有效期(毫秒)
}
```
### 返回数据
```json
{
"msg": "操作成功",
"code": 200,
"data": {
"sn": "1581F8HGX253U00A063U",
"camera_index": "176-0-0",
"url": "app_id=xxx&room_id=xxx&token=xxx&user_id=xxx",
"expire_ts": 1781155234,
"url_type": "volc" // volc 或 agora
}
}
```
## 🔧 后续工作
1. [ ] 集成 RTC SDK 显示实际视频画面
- 根据 `urlType` 选择火山引擎或声网 SDK
- 初始化 RTC 引擎并加入房间
- 渲染远程视频流
2. [ ] 添加视频录制功能
- 开始/停止录制
- 保存录制文件
3. [ ] 添加截图功能
- 截取当前帧
- 保存到相册
4. [ ] 优化视频加载超时处理
- 设置合理的超时时间
- 提供友好的超时提示
5. [ ] 支持多路视频同时观看
- 分屏显示多个摄像头
- 切换主视图
## 📚 相关文档
- [完整集成指南](./UAV_VIDEO_INTEGRATION_GUIDE.md)
- [使用示例](./UAV_VIDEO_USAGE_EXAMPLES.md)
- [API 接口定义](../lib/core/consts/http_api_consts.dart)
- [实体类定义](../lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart)
- [BLoC 状态管理](../lib/features/v2/device_list/presentation/bloc/)
## ✅ 验证结果
- [x] 所有文件编译通过,无错误
- [x] 单元测试编写完成
- [x] 依赖注入配置正确
- [x] 文档齐全
## 💡 注意事项
1. **不要在页面上直接调用 API**:必须通过 BLoC 管理状态
2. **及时释放资源**:在 `dispose()` 中关闭 BLoC
3. **合理设置 Token 有效期**:建议设置为较长的时间,避免频繁刷新
4. **检查 RTC 参数**:确保 AppId、RoomId、Token、UserId 正确解析
5. **根据 urlType 选择 SDK**:volc 使用火山引擎,agora 使用声网
## 🎉 总结
无人机实时视频接口已成功对接,采用清洁架构设计,具有良好的可扩展性和可维护性。所有代码已通过编译检查,单元测试覆盖主要场景,文档齐全。下一步只需集成 RTC SDK 即可显示实际视频画面。

View File

@@ -0,0 +1,323 @@
# 无人机实时视频接口对接说明
## 概述
本模块实现了无人机实时视频流的获取功能,采用清洁架构设计,支持多种镜头类型切换(广角、变焦、红外),并兼容火山引擎和声网两种RTC SDK。
## API 接口
### 接口地址
```
POST http://1.95.137.212:59015/iot/UAV/changeLens
```
### 请求参数
```json
{
"sn": "1581F8HGX253U00A063U", // 无人机设备序列号(必填)
"lensType": "", // 镜头类型:wide(广角)、zoom(变焦)、ir(红外),可为空
"cameraIndex": "176-0-0", // 摄像头编号(必填)
"qualityType": "adaptive", // 清晰度:adaptive(自适应)、low、medium、high(默认adaptive)
"videoExpire": 720000000 // Token有效期(毫秒,默认720000000)
}
```
### 返回数据
```json
{
"msg": "操作成功",
"code": 200,
"data": {
"sn": "1581F8HGX253U00A063U",
"camera_index": "176-0-0",
"url": "app_id=xxx&expire_time=xxx&room_id=xxx&token=xxx&user_id=xxx",
"expire_ts": 1781155234,
"url_type": "volc" // volc(火山引擎) 或 agora(声网)
}
}
```
## 架构设计
### 目录结构
```
lib/features/v2/device_list/
├── domain/
│ ├── entities/
│ │ └── uav_video_stream_entity.dart # 实体类
│ ├── repositories/
│ │ ── drone_station_repository.dart # 仓库接口
│ └── usecases/
│ └── get_uav_video_stream_usecase.dart # 用例
├── data/
│ ├── datasources/
│ │ ├── drone_station_datasource.dart # 数据源接口
│ │ └── drone_station_datasource_impl.dart # 数据源实现
│ └── repositories/
│ └── drone_station_repository_impl.dart # 仓库实现
── presentation/
├── bloc/
│ ├── drone_station_bloc.dart # BLoC状态管理
│ ├── drone_station_event.dart # 事件定义
│ └── drone_station_state.dart # 状态定义
└── pages/
── uav_live_video_page.dart # 示例页面
```
### 核心组件
#### 1. 实体类 (Entity)
**文件**: `domain/entities/uav_video_stream_entity.dart`
定义了枚举类型和实体类:
- `UavLensType`: 镜头类型枚举(wide、zoom、ir)
- `VideoQualityType`: 视频质量枚举(adaptive、low、medium、high)
- `UavVideoStreamEntity`: 视频流实体,包含解析RTC参数的方法
#### 2. 数据源 (DataSource)
**文件**: `data/datasources/drone_station_datasource_impl.dart`
实现了API调用逻辑:
- 发送POST请求到 `/iot/UAV/changeLens`
- 处理响应并转换为实体对象
- 包含详细的日志输出用于调试
#### 3. 仓库 (Repository)
**文件**: `data/repositories/drone_station_repository_impl.dart`
使用 `fpdart` 的 `Either` 类型处理错误:
- 成功时返回 `Right(UavVideoStreamEntity)`
- 失败时返回 `Left(Failure)`
#### 4. 用例 (UseCase)
**文件**: `domain/usecases/get_uav_video_stream_usecase.dart`
封装业务逻辑,供BLoC调用。
#### 5. BLoC 状态管理
**文件**: `presentation/bloc/drone_station_bloc.dart`
新增事件和状态:
- **事件**: `UavVideoStreamLoad` - 加载视频流
- **状态**:
- `UavVideoStreamLoading` - 加载中
- `UavVideoStreamLoaded` - 加载成功
- `UavVideoStreamError` - 加载失败
#### 6. 依赖注入
**文件**: `core/di/injection.dart`
已注册以下单例:
```dart
sl.registerLazySingleton<GetUavVideoStreamUseCase>(
() => GetUavVideoStreamUseCase(sl()),
);
sl.registerFactory<DroneStationBloc>(
() => DroneStationBloc(sl(), sl(), sl(), sl()),
);
```
## 使用方式
### 方式一:直接使用示例页面
```dart
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/uav_live_video_page.dart';
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: '1581F8HGX253U00A063U',
cameraIndex: '176-0-0',
),
),
);
```
### 方式二:在现有页面中使用
#### 1. 导入必要的类
```dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../core/di/injection.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
import '../bloc/drone_station_bloc.dart';
import '../bloc/drone_station_event.dart';
import '../bloc/drone_station_state.dart';
```
#### 2. 初始化 BLoC
```dart
late DroneStationBloc _bloc;
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
// 加载视频流
_loadVideoStream(UavLensType.wide);
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
```
#### 3. 加载视频流
```dart
void _loadVideoStream(UavLensType lensType) {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: lensType,
qualityType: VideoQualityType.adaptive,
videoExpire: 720000000,
),
);
}
```
#### 4. 监听状态变化
```dart
BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is UavVideoStreamLoaded) {
final videoStream = state.videoStream;
debugPrint('URL Type: ${videoStream.urlType}');
debugPrint('AppId: ${videoStream.appId}');
debugPrint('RoomId: ${videoStream.roomId}');
debugPrint('UserId: ${videoStream.userId}');
// TODO: 根据 urlType 初始化对应的 RTC 引擎
if (videoStream.urlType.toLowerCase() == 'volc') {
// 使用火山引擎 RTC SDK
} else if (videoStream.urlType.toLowerCase() == 'agora') {
// 使用声网 RTC SDK
}
} else if (state is UavVideoStreamError) {
setState(() {
_errorMessage = state.message;
});
}
},
builder: (context, state) {
// 根据状态渲染UI
if (state is UavVideoStreamLoading) {
return const CircularProgressIndicator();
}
if (state is UavVideoStreamLoaded) {
// 显示视频画面
return Container();
}
if (state is UavVideoStreamError) {
return Text('错误: ${state.message}');
}
return Container();
},
)
```
#### 5. 切换镜头类型
```dart
// 切换到广角镜头
_loadVideoStream(UavLensType.wide);
// 切换到变焦镜头
_loadVideoStream(UavLensType.zoom);
// 切换到红外镜头
_loadVideoStream(UavLensType.ir);
```
## RTC 集成
获取到视频流后,需要根据 `urlType` 选择对应的 RTC SDK:
### 火山引擎 (volc)
```dart
final appId = videoStream.appId;
final roomId = videoStream.roomId;
final token = videoStream.token;
final userId = videoStream.userId;
// 使用 volc_engine_rtc SDK
final engine = await RTCEngine.createRTCEngine(
RTCVideoContext(appId: appId, eventHandler: handler),
);
final room = await engine.createRTCRoom(roomId);
await room.joinRoom(token: token, userId: userId);
```
### 声网 (agora)
```dart
final appId = videoStream.appId;
final channelId = videoStream.roomId;
final token = videoStream.token;
final uid = int.tryParse(videoStream.userId) ?? 0;
// 使用 agora_rtc_engine SDK
final engine = createAgoraRtcEngine();
await engine.initialize(RtcEngineContext(appId: appId));
await engine.joinChannel(
token: token,
channelId: channelId,
uid: uid,
options: ChannelMediaOptions(...),
);
```
## 注意事项
1. **可扩展性**: 本模块采用清洁架构设计,所有业务逻辑与UI分离,便于在其他页面复用。
2. **错误处理**: 使用 `Either<Failure, T>` 模式统一处理错误,确保异常不会直接抛出。
3. **日志记录**: 数据源层包含详细的日志输出,方便调试和问题排查。
4. **默认值**:
- `lensType` 可为空,后端会根据实际情况选择默认镜头
- `qualityType` 默认为 `adaptive`(自适应)
- `videoExpire` 默认为 `720000000` 毫秒
5. **Token 有效期**: `videoExpire` 参数单位为毫秒,建议设置较长的有效期以避免频繁刷新。
6. **多镜头支持**: 通过 `UavLensType` 枚举可以轻松扩展更多镜头类型。
## 后续工作
1. [ ] 集成 RTC SDK 显示实际视频画面
2. [ ] 添加视频录制功能
3. [ ] 添加截图功能
4. [ ] 优化视频加载超时处理
5. [ ] 添加视频质量切换功能
6. [ ] 支持多路视频同时观看
## 相关文件清单
### 新增文件
- `lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart`
- `lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart`
- `lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart`
### 修改文件
- `lib/core/consts/http_api_consts.dart` - 添加 API 常量
- `lib/features/v2/device_list/data/datasources/drone_station_datasource.dart` - 添加接口方法
- `lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart` - 实现接口
- `lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart` - 实现仓库
- `lib/features/v2/device_list/domain/repositories/drone_station_repository.dart` - 添加仓库接口
- `lib/features/v2/device_list/presentation/bloc/drone_station_event.dart` - 添加事件
- `lib/features/v2/device_list/presentation/bloc/drone_station_state.dart` - 添加状态
- `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart` - 添加事件处理
- `lib/core/di/injection.dart` - 注册依赖

View File

@@ -0,0 +1,298 @@
# 无人机视频页面集成完成
## 📋 更新内容
已将新的无人机实时视频接口集成到现有的**无人机视频回传/远程控制**页面中。
---
## 🎯 集成位置
### 1. 无人机详情页面 → 无人机状态卡片
**文件**: `lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart`
**修改内容**:
```dart
// 点击"无人机状态"卡片时,传递设备序列号和摄像头索引
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DroneVideoControlPage(
droneSn: detail.deviceSn, // ✅ 传递设备序列号
cameraIndex: detail.gatewayCameraList != null &&
detail.gatewayCameraList!.isNotEmpty
? detail.gatewayCameraList!.first.cameraIndex
: '176-0-0', // 默认值
),
),
);
```
---
### 2. 无人机视频控制页面
**文件**: `lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart`
**新增功能**:
#### ✅ 接收参数
```dart
class DroneVideoControlPage extends StatefulWidget {
final String droneSn; // 无人机设备序列号
final String cameraIndex; // 摄像头编号
const DroneVideoControlPage({
super.key,
required this.droneSn,
required this.cameraIndex,
});
}
```
#### ✅ 自动加载视频流
```dart
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
// 默认加载广角镜头的视频流
_loadVideoStream(UavLensType.wide);
}
```
#### ✅ 镜头切换功能
在视频播放器右下角添加了镜头类型选择器:
- **广角** (wide)
- **变焦** (zoom)
- **红外** (ir)
点击可快速切换不同镜头的视频流。
#### ✅ 加载状态显示
```
┌─────────────────────────┐
│ │
│ ⏳ 正在加载... │
│ │
└─────────────────────────┘
```
#### ✅ 错误处理和重试
```
┌─────────────────────────┐
│ │
│ ❌ 加载失败 │
│ 错误信息: xxxxx │
│ │
│ [ 重试 ] │
│ │
└─────────────────────────┘
```
---
## 🔄 工作流程
```mermaid
graph LR
A[无人机详情页] --> B[点击无人机状态卡片]
B --> C[打开视频控制页面]
C --> D[自动调用changeLens接口]
D --> E{加载结果}
E -->|成功| F[显示视频画面]
E -->|失败| G[显示错误+重试按钮]
F --> H[用户切换镜头]
H --> D
G -->|点击重试| D
```
---
## 📱 UI 展示
### 视频播放器区域
```
┌──────────────────────────────────────┐
│ ● REC 00:12:36 │
│ │
│ │
│ [视频画面/加载状态] │
│ │
│ │
│ ┌────────┐ │
│ │广角 ▼│ │ ← 镜头切换
│ └────────┘ │
└──────────────────────────────────────┘
```
### 镜头切换菜单
```
┌──────────┐
│ 广角 │ ← 当前选中
│ 变焦 │
│ 红外 │
└──────────┘
```
---
## 🔧 技术实现
### 1. BLoC 状态管理
```dart
// 监听视频流状态
BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is UavVideoStreamLoaded) {
// 获取视频流数据
_videoStream = state.videoStream;
// TODO: 初始化 RTC 引擎并显示视频
debugPrint('AppId: ${state.videoStream.appId}');
debugPrint('RoomId: ${state.videoStream.roomId}');
debugPrint('UserId: ${state.videoStream.userId}');
} else if (state is UavVideoStreamError) {
// 显示错误信息
_errorMessage = state.message;
}
},
// ...
)
```
### 2. 视频流加载
```dart
void _loadVideoStream(UavLensType lensType) {
setState(() {
_isLoading = true;
_errorMessage = null;
_currentLensType = lensType;
});
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: lensType,
qualityType: VideoQualityType.adaptive,
videoExpire: 720000000,
),
);
}
```
### 3. 镜头切换
```dart
PopupMenuButton<UavLensType>(
icon: Text(_getLensTypeName(_currentLensType)),
onSelected: (UavLensType lensType) {
if (lensType != _currentLensType) {
_loadVideoStream(lensType); // 重新加载视频流
}
},
itemBuilder: (context) => [
PopupMenuItem(value: UavLensType.wide, child: Text('广角')),
PopupMenuItem(value: UavLensType.zoom, child: Text('变焦')),
PopupMenuItem(value: UavLensType.ir, child: Text('红外')),
],
)
```
---
## ✅ 已完成的功能
- ✅ 从无人机详情页传递设备序列号和摄像头索引
- ✅ 自动调用 `changeLens` 接口获取视频流
- ✅ 支持三种镜头类型切换(广角/变焦/红外)
- ✅ 加载状态显示
- ✅ 错误处理和重试机制
- ✅ 打印视频流参数(AppId、RoomId、UserId等)
- ✅ 清洁架构设计,易于扩展和维护
---
## 🔜 下一步工作(TODO)
### 1. 集成 RTC SDK 显示实际视频
在 `_buildVideoPlayer()` 方法中,当 `_videoStream != null` 时:
```dart
if (_videoStream != null) {
// 根据 urlType 选择对应的 RTC SDK
if (_videoStream!.urlType == 'volc') {
// 使用火山引擎 RTC SDK
return VolcEngineRtcView(
appId: _videoStream!.appId,
roomId: _videoStream!.roomId,
token: _videoStream!.token,
userId: _videoStream!.userId,
);
} else if (_videoStream!.urlType == 'agora') {
// 使用声网 RTC SDK
return AgoraRtcView(
appId: _videoStream!.appId,
channelId: _videoStream!.roomId,
token: _videoStream!.token,
uid: int.parse(_videoStream!.userId),
);
}
}
```
### 2. 参考现有实现
可以参考 `drone_station_detail_page.dart` 中的以下方法:
- `_initFloatingVolcEngine()` - 火山引擎初始化
- `_initFloatingAgoraEngine()` - 声网引擎初始化
- `_disposeVolcEngine()` / `_disposeAgoraEngine()` - 资源释放
### 3. 添加更多控制功能
- 云台控制(上下左右)
- 变焦倍数调节
- 拍照/录像
- AI识别结果展示
---
## 📝 测试步骤
1. **进入无人机详情页**
- 确保无人机在线且有摄像头列表
2. **点击"无人机状态"卡片**
- 应该跳转到视频控制页面
3. **观察加载过程**
- 看到 "正在加载视频流..." 提示
- 成功后显示视频画面(当前为占位图)
4. **测试镜头切换**
- 点击右下角的镜头类型按钮
- 选择"广角"、"变焦"或"红外"
- 观察是否重新加载并切换成功
5. **测试错误处理**
- 断开网络连接
- 应该显示错误信息和重试按钮
- 点击重试应该重新加载
---
## 🎉 总结
✅ **集成完成!**
现在无人机视频控制页面已经:
- 正确接收设备参数
- 自动调用新的视频流接口
- 支持多镜头切换
- 完善的加载和错误处理
只需最后一步:**集成 RTC SDK 显示实际视频画面**,即可完整实现无人机实时视频监控功能!

View File

@@ -0,0 +1,642 @@
# 无人机实时视频接口使用示例
## 快速开始
本指南展示如何在你的项目中快速集成无人机实时视频功能。
## 1. 基础用法 - 直接导航到视频页面
最简单的方式是直接导航到 `UavLiveVideoPage`:
```dart
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/uav_live_video_page.dart';
// 在任意地方调用
void _openVideo() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: '1581F8HGX253U00A063U', // 无人机序列号
cameraIndex: '176-0-0', // 摄像头编号
),
),
);
}
```
## 2. 高级用法 - 在自定义页面中集成
如果你需要在自己的页面中集成视频功能,可以按照以下步骤操作:
### 步骤 1: 创建页面并初始化 BLoC
```dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../core/di/injection.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
import '../bloc/drone_station_bloc.dart';
import '../bloc/drone_station_event.dart';
import '../bloc/drone_station_state.dart';
class MyCustomVideoPage extends StatefulWidget {
final String droneSn;
final String cameraIndex;
const MyCustomVideoPage({
super.key,
required this.droneSn,
required this.cameraIndex,
});
@override
State<MyCustomVideoPage> createState() => _MyCustomVideoPageState();
}
class _MyCustomVideoPageState extends State<MyCustomVideoPage> {
late DroneStationBloc _bloc;
UavVideoStreamEntity? _videoStream;
bool _isLoading = false;
String? _errorMessage;
UavLensType? _currentLensType;
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
// 默认加载广角镜头
_loadVideoStream(UavLensType.wide);
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
}
```
### 步骤 2: 实现加载视频流方法
```dart
/// 加载视频流
void _loadVideoStream(UavLensType lensType) {
setState(() {
_isLoading = true;
_errorMessage = null;
_currentLensType = lensType;
});
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: lensType,
qualityType: VideoQualityType.adaptive, // 自适应清晰度
videoExpire: 720000000, // Token有效期(毫秒)
),
);
}
```
### 步骤 3: 构建 UI 并监听状态变化
```dart
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _bloc,
child: Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'无人机实时视频',
style: TextStyle(color: Colors.white),
),
centerTitle: true,
actions: [
// 镜头切换按钮
PopupMenuButton<UavLensType>(
icon: const Icon(Icons.videocam, color: Colors.white),
tooltip: '切换镜头',
onSelected: (lensType) {
_loadVideoStream(lensType);
},
itemBuilder: (context) => [
const PopupMenuItem(
value: UavLensType.wide,
child: Row(
children: [
Icon(Icons.videocam, size: 20),
SizedBox(width: 8),
Text('广角镜头'),
],
),
),
const PopupMenuItem(
value: UavLensType.zoom,
child: Row(
children: [
Icon(Icons.zoom_in, size: 20),
SizedBox(width: 8),
Text('变焦镜头'),
],
),
),
const PopupMenuItem(
value: UavLensType.ir,
child: Row(
children: [
Icon(Icons.thermostat, size: 20),
SizedBox(width: 8),
Text('红外镜头'),
],
),
),
],
),
],
),
body: BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
// 监听状态变化
if (state is UavVideoStreamLoaded) {
setState(() {
_videoStream = state.videoStream;
_isLoading = false;
});
debugPrint('=== 视频流加载成功 ===');
debugPrint('URL Type: ${state.videoStream.urlType}');
debugPrint('AppId: ${state.videoStream.appId}');
debugPrint('RoomId: ${state.videoStream.roomId}');
debugPrint('UserId: ${state.videoStream.userId}');
// TODO: 这里可以初始化 RTC 引擎并显示视频
_initRtcEngine(state.videoStream);
} else if (state is UavVideoStreamError) {
setState(() {
_errorMessage = state.message;
_isLoading = false;
});
}
},
builder: (context, state) {
// 根据状态渲染不同的 UI
// 加载中状态
if (_isLoading || state is UavVideoStreamLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text(
'正在加载视频流...',
style: TextStyle(color: Colors.white),
),
],
),
);
}
// 错误状态
if (_errorMessage != null || state is UavVideoStreamError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
_errorMessage ?? (state as UavVideoStreamError).message,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_currentLensType != null) {
_loadVideoStream(_currentLensType!);
}
},
child: const Text('重试'),
),
],
),
);
}
// 无视频信号
if (_videoStream == null) {
return const Center(
child: Text(
'暂无视频信号',
style: TextStyle(color: Colors.white),
),
);
}
// 视频已加载,显示视频画面
return Container(
width: double.infinity,
height: double.infinity,
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.videocam_off,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
'视频流已获取\nURL Type: ${_videoStream!.urlType}\nCamera: $_currentLensType',
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'请集成 RTC SDK 后在此处显示视频画面',
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
),
);
},
),
),
);
}
```
### 步骤 4: 初始化 RTC 引擎(可选)
```dart
/// 初始化 RTC 引擎
Future<void> _initRtcEngine(UavVideoStreamEntity videoStream) async {
final appId = videoStream.appId;
final roomId = videoStream.roomId;
final token = videoStream.token;
final userId = videoStream.userId.isNotEmpty
? videoStream.userId
: 'user_${DateTime.now().millisecondsSinceEpoch}';
if (appId.isEmpty || roomId.isEmpty || token.isEmpty) {
debugPrint('RTC 参数缺失');
return;
}
debugPrint('=== RTC 初始化 ===');
debugPrint('AppId: $appId');
debugPrint('RoomId: $roomId');
debugPrint('UserId: $userId');
debugPrint('URL Type: ${videoStream.urlType}');
// 根据 urlType 选择不同的 RTC SDK
final sdkType = videoStream.urlType.toLowerCase() == 'agora'
? RtcSdkType.agora
: RtcSdkType.volcengine;
if (sdkType == RtcSdkType.agora) {
await _initAgoraEngine(appId, roomId, token, userId);
} else {
await _initVolcEngine(appId, roomId, token, userId);
}
}
// TODO: 实现具体的 RTC 引擎初始化逻辑
// 参考 drone_station_detail_page.dart 中的实现
```
## 3. 常见场景示例
### 场景 1: 从列表页跳转到视频页
```dart
// 在设备列表中点击某个设备
void _onDeviceTap(Device device) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: device.sn,
cameraIndex: device.cameraIndex,
),
),
);
}
```
### 场景 2: 支持多个摄像头切换
```dart
class MultiCameraVideoPage extends StatefulWidget {
final String droneSn;
final List<String> cameraIndices;
const MultiCameraVideoPage({
super.key,
required this.droneSn,
required this.cameraIndices,
});
@override
State<MultiCameraVideoPage> createState() => _MultiCameraVideoPageState();
}
class _MultiCameraVideoPageState extends State<MultiCameraVideoPage> {
late DroneStationBloc _bloc;
int _currentCameraIndex = 0;
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
_loadCurrentCamera();
}
void _loadCurrentCamera() {
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndices[_currentCameraIndex],
lensType: UavLensType.wide,
),
);
}
void _switchCamera(int index) {
setState(() {
_currentCameraIndex = index;
});
_loadCurrentCamera();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('摄像头 ${_currentCameraIndex + 1}/${widget.cameraIndices.length}'),
actions: [
// 切换摄像头按钮
IconButton(
icon: const Icon(Icons.switch_camera),
onPressed: () {
final nextIndex = (_currentCameraIndex + 1) % widget.cameraIndices.length;
_switchCamera(nextIndex);
},
),
],
),
body: BlocBuilder<DroneStationBloc, DroneStationState>(
builder: (context, state) {
// ... 根据状态渲染UI
},
),
);
}
}
```
### 场景 3: 自动刷新视频流(Token 过期时)
```dart
class AutoRefreshVideoPage extends StatefulWidget {
final String droneSn;
final String cameraIndex;
const AutoRefreshVideoPage({
super.key,
required this.droneSn,
required this.cameraIndex,
});
@override
State<AutoRefreshVideoPage> createState() => _AutoRefreshVideoPageState();
}
class _AutoRefreshVideoPageState extends State<AutoRefreshVideoPage> {
late DroneStationBloc _bloc;
Timer? _refreshTimer;
static const _tokenRefreshInterval = Duration(minutes: 55); // 每55分钟刷新一次(Token有效期约1小时)
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
_loadVideoStream();
// 启动定时刷新
_startAutoRefresh();
}
void _startAutoRefresh() {
_refreshTimer?.cancel();
_refreshTimer = Timer.periodic(_tokenRefreshInterval, (timer) {
debugPrint('⏰ 自动刷新视频流 Token');
_loadVideoStream();
});
}
void _loadVideoStream() {
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: UavLensType.wide,
),
);
}
@override
void dispose() {
_refreshTimer?.cancel();
_bloc.close();
super.dispose();
}
}
```
## 4. 最佳实践
### ✅ 推荐做法
1. **始终检查错误状态**
```dart
if (state is UavVideoStreamError) {
// 显示友好的错误提示
showSnackBar(context, '视频加载失败: ${state.message}');
}
```
2. **提供重试机制**
```dart
ElevatedButton(
onPressed: () => _loadVideoStream(_currentLensType!),
child: const Text('重试'),
)
```
3. **记录关键日志**
```dart
debugPrint('视频流加载成功: URL Type=${videoStream.urlType}');
```
4. **合理设置 Token 有效期**
```dart
videoExpire: 720000000, // 约8天,避免频繁刷新
```
5. **及时释放资源**
```dart
@override
void dispose() {
_bloc.close();
super.dispose();
}
```
### ❌ 避免的做法
1. **不要在页面外直接调用 API**
```dart
// 错误:绕过 BLoC 直接调用
final result = await repository.getUavVideoStream(...);
// ✅ 正确:通过 BLoC 管理状态
_bloc.add(UavVideoStreamLoad(...));
```
2. **不要忘记处理加载状态**
```dart
// ❌ 错误:没有加载指示器
if (state is UavVideoStreamLoaded) { ... }
// ✅ 正确:显示加载状态
if (state is UavVideoStreamLoading) {
return CircularProgressIndicator();
}
```
3. **不要硬编码参数**
```dart
// 错误:硬编码
sn: '1581F8HGX253U00A063U',
// ✅ 正确:使用变量
sn: widget.droneSn,
```
## 5. 故障排查
### 问题 1: 视频流加载失败
**可能原因:**
- 网络问题
- 设备序列号错误
- 摄像头编号错误
- Token 过期
**解决方案:**
1. 检查网络连接
2. 验证 `droneSn` 和 `cameraIndex` 是否正确
3. 查看控制台日志输出
4. 尝试重新加载
### 问题 2: 视频画面不显示
**可能原因:**
- RTC SDK 未正确初始化
- RTC 参数解析失败
- SDK 版本不兼容
**解决方案:**
1. 检查 `urlType` 字段,确认使用正确的 SDK
2. 验证 AppId、RoomId、Token、UserId 是否正确解析
3. 查看 RTC SDK 的日志输出
4. 参考 `drone_station_detail_page.dart` 中的实现
### 问题 3: 镜头切换无效
**可能原因:**
- 后端不支持该镜头类型
- 摄像头不支持指定的镜头
**解决方案:**
1. 检查后端返回的错误信息
2. 尝试其他镜头类型
3. 联系后端确认支持的镜头类型
## 6. 扩展功能
### 添加视频录制功能
```dart
// TODO: 集成 RTC SDK 的录制功能
Future<void> _startRecording() async {
// 根据使用的 RTC SDK 调用相应的录制 API
}
Future<void> _stopRecording() async {
// 停止录制并保存文件
}
```
### 添加截图功能
```dart
// TODO: 集成 RTC SDK 的截图功能
Future<void> _takeScreenshot() async {
// 根据使用的 RTC SDK 调用相应的截图 API
// 保存截图到相册
}
```
### 添加视频质量切换
```dart
void _changeQuality(VideoQualityType quality) {
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: _currentLensType,
qualityType: quality, // 切换清晰度
),
);
}
```
## 7. 相关文档
- [完整集成指南](./UAV_VIDEO_INTEGRATION_GUIDE.md)
- [API 接口文档](../lib/core/consts/http_api_consts.dart)
- [实体类定义](../lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart)
- [BLoC 状态管理](../lib/features/v2/device_list/presentation/bloc/)
## 8. 技术支持
如遇到问题,请:
1. 查看控制台日志输出
2. 参考示例代码 `uav_live_video_page.dart`
3. 查阅集成指南文档
4. 联系开发团队

12
fix_json.ps1 Normal file
View File

@@ -0,0 +1,12 @@
$zhPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\zh-CN.json'
$enPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\en-US.json'
$zhContent = [System.IO.File]::ReadAllText($zhPath)
$zhContent = $zhContent.Replace('"bow_mode": "弓字模式"', '"bow_mode": "覆盖模式"')
[System.IO.File]::WriteAllText($zhPath, $zhContent)
$enContent = [System.IO.File]::ReadAllText($enPath)
$enContent = $enContent.Replace('"bow_mode": "Bow Mode"', '"bow_mode": "Coverage Mode"')
[System.IO.File]::WriteAllText($enPath, $enContent)
Write-Host "Done!"

106
flutter_01.log Normal file
View File

@@ -0,0 +1,106 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.48:37689 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [1,623ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [9.3s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [14.1s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [20ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [17ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [8.2s]
• PKB110 (mobile) • 192.168.2.48:37689 • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168]
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93
[✓] Network resources [1,268ms]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

109
flutter_02.log Normal file
View File

@@ -0,0 +1,109 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.39:42831 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [841ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [4.4s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [9.0s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [11ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [8ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (7 available) [11.6s]
• 21091116AC (mobile) • 192.168.2.16:35367 • android-arm64 • Android 13 (API 33)
• PKB110 (mobile) • 192.168.2.39:42831 • android-arm64 • Android 16 (API 36)
• PKB110 (wireless) (mobile) • adb-4TRKWOEE4HR8AMEA-ti69Ed._adb-tls-connect._tcp • android-arm64 • Android 16 (API 36)
• 21091116AC (wireless) (mobile) • adb-QK4LCEO78TWOVKOB-v9ALzk._adb-tls-connect._tcp • android-arm64 • Android 13 (API 33)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168]
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93
[✓] Network resources [939ms]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

View File

@@ -1,5 +1,7 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
platform :ios, '13.0'
source 'https://github.com/volcengine/volcengine-specs.git'
source 'https://cdn.cocoapods.org/'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -1,7 +1,58 @@
PODS:
- agora_rtc_engine (6.5.3):
- AgoraIrisRTC_iOS (= 4.5.2-build.1)
- AgoraRtcEngine_iOS (= 4.5.2)
- Flutter
- AgoraInfra_iOS (1.2.13.1)
- AgoraIrisRTC_iOS (4.5.2-build.1)
- AgoraRtcEngine_iOS (4.5.2):
- AgoraRtcEngine_iOS/AIAEC (= 4.5.2)
- AgoraRtcEngine_iOS/AIAECLL (= 4.5.2)
- AgoraRtcEngine_iOS/AINS (= 4.5.2)
- AgoraRtcEngine_iOS/AINSLL (= 4.5.2)
- AgoraRtcEngine_iOS/AudioBeauty (= 4.5.2)
- AgoraRtcEngine_iOS/ClearVision (= 4.5.2)
- AgoraRtcEngine_iOS/ContentInspect (= 4.5.2)
- AgoraRtcEngine_iOS/FaceCapture (= 4.5.2)
- AgoraRtcEngine_iOS/FaceDetection (= 4.5.2)
- AgoraRtcEngine_iOS/LipSync (= 4.5.2)
- AgoraRtcEngine_iOS/ReplayKit (= 4.5.2)
- AgoraRtcEngine_iOS/RtcBasic (= 4.5.2)
- AgoraRtcEngine_iOS/SpatialAudio (= 4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecDec (= 4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecEnc (= 4.5.2)
- AgoraRtcEngine_iOS/VideoCodecDec (= 4.5.2)
- AgoraRtcEngine_iOS/VideoCodecEnc (= 4.5.2)
- AgoraRtcEngine_iOS/VirtualBackground (= 4.5.2)
- AgoraRtcEngine_iOS/VQA (= 4.5.2)
- AgoraRtcEngine_iOS/AIAEC (4.5.2)
- AgoraRtcEngine_iOS/AIAECLL (4.5.2)
- AgoraRtcEngine_iOS/AINS (4.5.2)
- AgoraRtcEngine_iOS/AINSLL (4.5.2)
- AgoraRtcEngine_iOS/AudioBeauty (4.5.2)
- AgoraRtcEngine_iOS/ClearVision (4.5.2)
- AgoraRtcEngine_iOS/ContentInspect (4.5.2)
- AgoraRtcEngine_iOS/FaceCapture (4.5.2)
- AgoraRtcEngine_iOS/FaceDetection (4.5.2)
- AgoraRtcEngine_iOS/LipSync (4.5.2)
- AgoraRtcEngine_iOS/ReplayKit (4.5.2)
- AgoraRtcEngine_iOS/RtcBasic (4.5.2):
- AgoraInfra_iOS (= 1.2.13.1)
- AgoraRtcEngine_iOS/SpatialAudio (4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecDec (4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecEnc (4.5.2)
- AgoraRtcEngine_iOS/VideoCodecDec (4.5.2)
- AgoraRtcEngine_iOS/VideoCodecEnc (4.5.2)
- AgoraRtcEngine_iOS/VirtualBackground (4.5.2)
- AgoraRtcEngine_iOS/VQA (4.5.2)
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- flutter_webrtc (1.4.0):
- Flutter
- WebRTC-SDK (= 144.7559.01)
@@ -44,6 +95,8 @@ PODS:
- GTMSessionFetcher/Core (2.3.0)
- image_picker_ios (0.0.1):
- Flutter
- iris_method_channel (0.0.1):
- Flutter
- isar_community_flutter_libs (1.0.0):
- Flutter
- MLImage (1.0.0-beta4)
@@ -72,6 +125,8 @@ PODS:
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- open_file_ios (1.0.3):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- permission_handler_apple (9.3.0):
@@ -89,29 +144,67 @@ PODS:
- Flutter
- vibration (3.0.0):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- volc_engine_rtc (3.60.4):
- Flutter
- VolcApiEngine (= 1.7.0)
- VolcEngineRTC (= 3.60.103.2100)
- VolcApiEngine (1.7.0)
- VolcEngineRTC (3.60.103.2100):
- VolcEngineRTC/BMF (= 3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoDenoiseExtension (= 3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSharpenExtension (= 3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSRExtension (= 3.60.103.2100)
- VolcEngineRTC/Core (= 3.60.103.2100)
- VolcEngineRTC/CVByteNN (= 3.60.103.2100)
- VolcEngineRTC/RealXBase (= 3.60.103.2100)
- VolcEngineRTC/RTCFFmpeg (= 3.60.103.2100)
- VolcEngineRTC/BMF (3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoDenoiseExtension (3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSharpenExtension (3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSRExtension (3.60.103.2100)
- VolcEngineRTC/Core (3.60.103.2100)
- VolcEngineRTC/CVByteNN (3.60.103.2100)
- VolcEngineRTC/RealXBase (3.60.103.2100)
- VolcEngineRTC/RTCFFmpeg (3.60.103.2100)
- WebRTC-SDK (144.7559.01)
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- agora_rtc_engine (from `.symlinks/plugins/agora_rtc_engine/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- iris_method_channel (from `.symlinks/plugins/iris_method_channel/ios`)
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- vibration (from `.symlinks/plugins/vibration/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- volc_engine_rtc (from `.symlinks/plugins/volc_engine_rtc/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
https://github.com/volcengine/volcengine-specs.git:
- VolcApiEngine
- VolcEngineRTC
trunk:
- AgoraInfra_iOS
- AgoraIrisRTC_iOS
- AgoraRtcEngine_iOS
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
@@ -128,20 +221,30 @@ SPEC REPOS:
- WebRTC-SDK
EXTERNAL SOURCES:
agora_rtc_engine:
:path: ".symlinks/plugins/agora_rtc_engine/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
flutter_blue_plus_darwin:
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
iris_method_channel:
:path: ".symlinks/plugins/iris_method_channel/ios"
isar_community_flutter_libs:
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/ios"
open_file_ios:
:path: ".symlinks/plugins/open_file_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
@@ -154,12 +257,22 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
vibration:
:path: ".symlinks/plugins/vibration/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
volc_engine_rtc:
:path: ".symlinks/plugins/volc_engine_rtc/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
agora_rtc_engine: 0c7d50312967c4dc31c3c45e50589ce48f57e08a
AgoraInfra_iOS: 3691b2b277a1712a35ae96de25af319de0d73d08
AgoraIrisRTC_iOS: eab58c126439adf5ec99632828a558ea216860da
AgoraRtcEngine_iOS: 97e2398a2addda9057815a2a583a658e36796ff6
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_blue_plus_darwin: ee2377ea51a6c9e481f2ed76ec597717ef5d287d
flutter_webrtc: ec91d94b484ad49cf191ef93413f64a40ffd3b4c
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
@@ -169,6 +282,7 @@ SPEC CHECKSUMS:
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
iris_method_channel: 7d661cf3259b3009ae423508470dbeb9374446ee
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
MLImage: 7bb7c4264164ade9bf64f679b40fb29c8f33ee9b
MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505
@@ -176,6 +290,7 @@ SPEC CHECKSUMS:
MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49
mobile_scanner: b67191637a5ea7ba15652ca208069d2bae1900ab
nanopb: 438bc412db1928dac798aa6fd75726007be04262
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
@@ -184,9 +299,13 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
volc_engine_rtc: 1bc697fba3c7a2f6513a18ec2abfb629188ce6ff
VolcApiEngine: e1f8b47ec5ef64bf6b48e3600be132c42643f8f0
VolcEngineRTC: 889b981b13621208b25942263f0fd5817c93b9bc
WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
PODFILE CHECKSUM: 2da7455b7ebb4d9567497e4f758b13986b3bb736
COCOAPODS: 1.16.2

View File

@@ -494,14 +494,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = R5KTW5WPS3;
DEVELOPMENT_TEAM = 93X3AN3P79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -519,7 +519,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -537,7 +537,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -553,7 +553,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -680,14 +680,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = R5KTW5WPS3;
DEVELOPMENT_TEAM = 93X3AN3P79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -706,14 +706,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = R5KTW5WPS3;
DEVELOPMENT_TEAM = 93X3AN3P79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibusatabotv2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";

View File

@@ -1,10 +1,10 @@
import Flutter
import UIKit
@main
@mainlication(
_ application
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
override func app: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Maibu Satabot V2</string>
<string>飒沓·星枢</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>maibu_satabot_v2</string>
<string>飒沓·星枢</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -66,5 +66,35 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<!-- 相机权限 -->
<key>NSCameraUsageDescription</key>
<string>需要使用相机拍照和录像</string>
<!-- 相册权限 -->
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以选择图片和视频</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要保存拍摄的照片和视频到相册</string>
<!-- 麦克风权限(录像需要) -->
<key>NSMicrophoneUsageDescription</key>
<string>录像时需要使用麦克风录制声音</string>
<!-- 允许 HTTP 连接(火山引擎视频流) -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- 蓝牙权限 -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接设备</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要使用蓝牙连接设备</string>
<!-- 本地网络权限(iOS 14+:ONVIF 组播发现 / 局域网 RTSP 摄像头访问必需) -->
<key>NSLocalNetworkUsageDescription</key>
<string>需要访问本地网络以发现并连接局域网摄像头(ONVIF / RTSP)。</string>
<key>NSBonjourServices</key>
<array>
<string>_wsdiscovery._udp</string>
<string>_http._tcp</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,947 @@
import 'dart:async';
import 'package:agora_rtc_engine/agora_rtc_engine.dart' as agora;
import 'package:flutter/material.dart';
/// 声网视频播放状态
enum AgoraPlayerState {
/// 空闲:未连接或已主动断开
idle,
/// 连接中:初始化引擎 / 加入频道 / 等待远端首帧
connecting,
/// 播放中:已收到远端首帧
playing,
/// 失败:参数缺失、加入频道失败、超时或 SDK 报错
error,
}
/// 通用声网(Agora RTC)视频展示组件
///
/// 只需要 appId / channelId / token 三个值即可拉流播放,组件内部自行完成
/// 引擎创建、加入频道、远端首帧监听、渲染与资源释放,调用方不用管生命周期。
///
/// 两种用法:
///
/// 1)直接当普通 Widget 嵌进布局:
/// ```dart
/// AgoraVideoPlayer(appId: 'x', channelId: 'y', token: 'z')
/// ```
///
/// 2)一行代码弹出(推荐,关闭弹窗即自动释放引擎):
/// ```dart
/// onTap: () => AgoraVideoPlayer.show(
/// context,
/// appId: 'x',
/// channelId: 'y',
/// token: 'z',
/// ),
/// ```
class AgoraVideoPlayer extends StatefulWidget {
/// 声网 App ID
final String appId;
/// 频道名(后端接口里的 channel / room_id)
final String channelId;
/// 频道 Token,未开启鉴权时可传空串
final String token;
/// 本端 uid,纯观看传 0 由 SDK 自动分配
final int uid;
/// 频道模式,默认直播模式
final agora.ChannelProfileType channelProfile;
/// 本端角色,默认观众(只拉流不推流,不占用相机麦克风)
final agora.ClientRoleType clientRole;
/// 画面填充模式
final agora.RenderModeType renderMode;
/// 是否订阅音频,默认只订阅视频
final bool subscribeAudio;
/// Android 使用 SurfaceView 渲染(出现黑屏 / 层级遮挡时可切 true)
final bool useAndroidSurfaceView;
/// iOS、桌面端使用 Flutter Texture 渲染
final bool useFlutterTexture;
/// 是否在 initState 自动连接
final bool autoConnect;
/// 连接超时:超过该时长仍未收到远端首帧判定为失败
final Duration connectTimeout;
/// 状态变化回调
final ValueChanged<AgoraPlayerState>? onStateChanged;
/// 首帧解码成功回调,参数为远端 uid
final ValueChanged<int>? onFirstFrame;
/// 错误回调
final ValueChanged<String>? onError;
/// 视频区背景色
final Color backgroundColor;
/// 正在建连时的提示文案
final String loadingText;
/// 已进房但未收到画面时的提示文案
final String waitingText;
/// 失败时是否显示内置重试按钮
final bool showRetryButton;
/// 自定义失败态 UI,返回 null 则使用内置样式
final Widget Function(BuildContext context, String message)? errorBuilder;
/// 重连令牌:值变化即强制重新连接,即使 appId / channelId / token 完全相同
/// (供「重新加载」按钮使用)
final int reloadToken;
const AgoraVideoPlayer({
super.key,
required this.appId,
required this.channelId,
required this.token,
this.uid = 0,
this.channelProfile =
agora.ChannelProfileType.channelProfileLiveBroadcasting,
this.clientRole = agora.ClientRoleType.clientRoleAudience,
this.renderMode = agora.RenderModeType.renderModeFit,
this.subscribeAudio = false,
this.useAndroidSurfaceView = false,
this.useFlutterTexture = false,
this.autoConnect = true,
this.connectTimeout = const Duration(seconds: 15),
this.onStateChanged,
this.onFirstFrame,
this.onError,
this.backgroundColor = Colors.black,
this.loadingText = '视频加载中...',
this.waitingText = '等待视频流...',
this.showRetryButton = true,
this.errorBuilder,
this.reloadToken = 0,
});
/// 从后端返回的 `app_id=xx&channel=yy&token=zz&user_id=0` 形式参数串构建
factory AgoraVideoPlayer.fromUrl({
Key? key,
required String url,
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
Duration connectTimeout = const Duration(seconds: 15),
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
final p = parseParams(url);
return AgoraVideoPlayer(
key: key,
appId: p['app_id'] ?? p['appid'] ?? '',
channelId:
p['channel'] ?? p['channel_id'] ?? p['room_id'] ?? p['roomid'] ?? '',
token: p['token'] ?? '',
uid: int.tryParse(p['user_id'] ?? p['uid'] ?? '') ?? 0,
renderMode: renderMode,
connectTimeout: connectTimeout,
onStateChanged: onStateChanged,
);
}
/// 解析 `k=v&k=v` 参数串
static Map<String, String> parseParams(String url) {
final params = <String, String>{};
for (final pair in url.split('&')) {
final kv = pair.split('=');
if (kv.length == 2) {
params[kv[0].trim()] = Uri.decodeComponent(kv[1]);
}
}
return params;
}
/// 【核心入口】一行代码弹出居中视频弹窗(16:9)
///
/// 关闭弹窗时组件被移出 widget 树,自动触发 dispose →
/// leaveChannel + release,调用方不需要手动清理任何东西。
static Future<void> show(
BuildContext context, {
required String appId,
required String channelId,
required String token,
int uid = 0,
String title = '实时视频',
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
bool barrierDismissible = true,
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
return showDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: Colors.black87,
builder: (dialogContext) => Dialog(
backgroundColor: Colors.black,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 48,
padding: const EdgeInsets.only(left: 16),
color: const Color(0xFF2A2E34),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(
Icons.close,
color: Colors.white70,
size: 20,
),
onPressed: () => Navigator.pop(dialogContext),
),
],
),
),
AspectRatio(
aspectRatio: 16 / 9,
child: AgoraVideoPlayer(
appId: appId,
channelId: channelId,
token: token,
uid: uid,
renderMode: renderMode,
onStateChanged: onStateChanged,
),
),
],
),
),
),
);
}
/// 以底部弹层形式弹出(适合列表页预览,不遮挡整屏)
static Future<void> showBottomSheet(
BuildContext context, {
required String appId,
required String channelId,
required String token,
int uid = 0,
double heightFactor = 0.42,
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.black,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => SizedBox(
height: MediaQuery.of(sheetContext).size.height * heightFactor,
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: AgoraVideoPlayer(
appId: appId,
channelId: channelId,
token: token,
uid: uid,
renderMode: renderMode,
onStateChanged: onStateChanged,
),
),
],
),
),
);
}
/// 【手动参数入口】弹出带 App ID / Channel / Token 三个输入框的弹窗
///
/// 填完点「重新加载」开始拉流;即使参数一模一样也会强制重连。
/// 适用于接口未就绪、需要手工验证频道参数的场景。
/// 关闭弹窗同样会自动释放引擎。
static Future<void> showManualInput(
BuildContext context, {
String title = '实时视频',
String initialAppId = '',
String initialChannelId = '',
String initialToken = '',
bool barrierDismissible = true,
}) {
return showDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: Colors.black87,
builder: (_) => _AgoraManualParamsDialog(
title: title,
initialAppId: initialAppId,
initialChannelId: initialChannelId,
initialToken: initialToken,
),
);
}
@override
State<AgoraVideoPlayer> createState() => _AgoraVideoPlayerState();
}
class _AgoraVideoPlayerState extends State<AgoraVideoPlayer> {
agora.RtcEngine? _engine;
agora.RtcEngineEventHandler? _handler;
AgoraPlayerState _state = AgoraPlayerState.idle;
String? _errorMessage;
int? _remoteUid;
bool _isJoined = false;
bool _isDisposed = false;
bool _firstFrameNotified = false;
Timer? _connectTimer;
/// 连接序号:参数快速变化时用于丢弃过期的异步流程
int _connectSeq = 0;
@override
void initState() {
super.initState();
if (widget.autoConnect) {
_connect();
}
}
@override
void didUpdateWidget(covariant AgoraVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
final changed =
oldWidget.appId != widget.appId ||
oldWidget.channelId != widget.channelId ||
oldWidget.token != widget.token ||
oldWidget.uid != widget.uid ||
oldWidget.reloadToken != widget.reloadToken;
if (changed) {
_log('参数变化,重新连接: ${widget.channelId}');
_connect();
}
}
@override
void dispose() {
_isDisposed = true;
_cancelConnectTimer();
_release();
super.dispose();
}
void _log(String msg) => debugPrint('🎥 [Agora] $msg');
void _updateState(AgoraPlayerState next, {String? message}) {
if (_state == next && message == _errorMessage) return;
_state = next;
_errorMessage = message;
if (next == AgoraPlayerState.playing) {
_cancelConnectTimer();
}
if (mounted) {
setState(() {});
}
widget.onStateChanged?.call(next);
if (next == AgoraPlayerState.error && message != null) {
widget.onError?.call(message);
}
}
void _startConnectTimer() {
_cancelConnectTimer();
_connectTimer = Timer(widget.connectTimeout, () {
if (_isDisposed || _state == AgoraPlayerState.playing) return;
_log('⏰ 连接超时(${widget.connectTimeout.inSeconds}s 未收到首帧)');
_updateState(AgoraPlayerState.error, message: '视频连接超时,请重试');
});
}
void _cancelConnectTimer() {
_connectTimer?.cancel();
_connectTimer = null;
}
/// 建立连接:先释放旧引擎,再创建 → 初始化 → 注册回调 → 加入频道
Future<void> _connect() async {
final seq = ++_connectSeq;
_cancelConnectTimer();
await _release();
if (_isDisposed || seq != _connectSeq) return;
if (widget.appId.isEmpty || widget.channelId.isEmpty) {
_updateState(AgoraPlayerState.error, message: 'appId / channelId 不能为空');
return;
}
_remoteUid = null;
_isJoined = false;
_firstFrameNotified = false;
_updateState(AgoraPlayerState.connecting);
_startConnectTimer();
try {
final engine = agora.createAgoraRtcEngine();
_engine = engine;
await engine.initialize(
agora.RtcEngineContext(
appId: widget.appId,
channelProfile: widget.channelProfile,
),
);
if (_isDisposed || seq != _connectSeq) return;
await engine.enableVideo();
if (_isDisposed || seq != _connectSeq) return;
_handler = _buildEventHandler();
engine.registerEventHandler(_handler!);
_log('加入频道: ${widget.channelId}');
await engine.joinChannel(
token: widget.token,
channelId: widget.channelId,
uid: widget.uid,
options: agora.ChannelMediaOptions(
channelProfile: widget.channelProfile,
clientRoleType: widget.clientRole,
autoSubscribeVideo: true,
autoSubscribeAudio: widget.subscribeAudio,
),
);
if (_isDisposed || seq != _connectSeq) return;
_log('✅ joinChannel 调用成功,等待远端首帧...');
} catch (e) {
_log('❌ 初始化失败: $e');
_updateState(AgoraPlayerState.error, message: '视频连接失败: $e');
}
}
agora.RtcEngineEventHandler _buildEventHandler() {
return agora.RtcEngineEventHandler(
onJoinChannelSuccess: (agora.RtcConnection connection, int elapsed) {
_log('✅ 加入频道成功: ${connection.channelId}, 耗时 ${elapsed}ms');
if (!mounted) return;
setState(() => _isJoined = true);
},
onUserJoined: (agora.RtcConnection connection, int rUid, int elapsed) {
_log('👤 远端用户加入: uid=$rUid');
if (!mounted) return;
// VideoViewController.remote 内部断言 uid != 0,uid 为 0 时构建会崩
if (rUid == 0) {
_log('⚠️ 远端 uid=0,无法用 remote View 渲染,已忽略');
return;
}
setState(() => _remoteUid ??= rUid);
},
onUserOffline: (
agora.RtcConnection connection,
int rUid,
agora.UserOfflineReasonType reason,
) {
_log('👋 远端用户离开: uid=$rUid, reason=$reason');
if (!mounted || _remoteUid != rUid) return;
setState(() => _remoteUid = null);
// 推流端掉线,回到等待态并重新计时
_updateState(AgoraPlayerState.connecting);
_startConnectTimer();
},
onRemoteVideoStateChanged: (
agora.RtcConnection connection,
int rUid,
agora.RemoteVideoState state,
agora.RemoteVideoStateReason reason,
int elapsed,
) {
if (!mounted) return;
_log('📹 远端视频状态: uid=$rUid, state=$state, reason=$reason');
if (state == agora.RemoteVideoState.remoteVideoStateDecoding) {
if (rUid != 0 && _remoteUid == null) {
setState(() => _remoteUid = rUid);
}
_updateState(AgoraPlayerState.playing);
if (!_firstFrameNotified) {
_firstFrameNotified = true;
widget.onFirstFrame?.call(rUid);
}
} else if (state == agora.RemoteVideoState.remoteVideoStateFrozen) {
_updateState(AgoraPlayerState.connecting);
} else if (state == agora.RemoteVideoState.remoteVideoStateFailed) {
_updateState(AgoraPlayerState.error, message: '远端视频流异常: $reason');
}
},
onLeaveChannel: (agora.RtcConnection connection, agora.RtcStats stats) {
_log('已离开频道: ${connection.channelId}');
if (!mounted) return;
setState(() {
_isJoined = false;
_remoteUid = null;
});
},
onError: (agora.ErrorCodeType err, String msg) {
_log('❌ SDK 错误: $err - $msg');
_updateState(AgoraPlayerState.error, message: '声网错误($err): $msg');
},
);
}
Future<void> _release() async {
final engine = _engine;
final handler = _handler;
_engine = null;
_handler = null;
if (engine == null) return;
try {
if (handler != null) {
engine.unregisterEventHandler(handler);
}
await engine.leaveChannel();
await engine.release();
_log('🗑️ 引擎已释放');
} catch (e) {
_log('⚠️ 释放引擎失败: $e');
}
}
@override
Widget build(BuildContext context) {
// 关键:远端 uid 未知时绝不能构建 remote View(SDK 内部有 uid != 0 断言)
final canRender = _engine != null && _isJoined && _remoteUid != null;
return Container(
color: widget.backgroundColor,
child: Stack(
fit: StackFit.expand,
children: [
if (canRender) _buildVideoView(),
if (_state != AgoraPlayerState.playing) _buildOverlay(canRender),
],
),
);
}
Widget _buildVideoView() {
return agora.AgoraVideoView(
controller: agora.VideoViewController.remote(
rtcEngine: _engine!,
canvas: agora.VideoCanvas(
uid: _remoteUid!,
renderMode: widget.renderMode,
),
connection: agora.RtcConnection(channelId: widget.channelId),
useAndroidSurfaceView: widget.useAndroidSurfaceView,
useFlutterTexture: widget.useFlutterTexture,
),
);
}
Widget _buildOverlay(bool canRender) {
if (_state == AgoraPlayerState.error) {
final message = _errorMessage ?? '视频加载失败';
if (widget.errorBuilder != null) {
return widget.errorBuilder!(context, message);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
message,
style: const TextStyle(color: Colors.white70, fontSize: 13),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (widget.showRetryButton) ...[
const SizedBox(height: 16),
ElevatedButton(
onPressed: _connect,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
),
child: const Text('重试'),
),
],
],
),
);
}
final text = canRender ? widget.waitingText : widget.loadingText;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
),
const SizedBox(height: 12),
Text(text, style: const TextStyle(color: Colors.white70, fontSize: 13)),
],
),
);
}
}
/// 手动填写声网参数的弹窗,由 [AgoraVideoPlayer.showManualInput] 弹出
///
/// 结构:标题栏 + 三个输入框 + 「重新加载」按钮 + 16:9 视频区。
/// 点「重新加载」会递增 reloadToken,即使参数未变也会强制断开重连。
class _AgoraManualParamsDialog extends StatefulWidget {
final String title;
final String initialAppId;
final String initialChannelId;
final String initialToken;
const _AgoraManualParamsDialog({
required this.title,
this.initialAppId = '',
this.initialChannelId = '',
this.initialToken = '',
});
@override
State<_AgoraManualParamsDialog> createState() =>
_AgoraManualParamsDialogState();
}
class _AgoraManualParamsDialogState extends State<_AgoraManualParamsDialog> {
static const Color _primary = Color(0xFF165DFF);
static const Color _danger = Color(0xFFF53F3F);
static const Color _success = Color(0xFF00B42A);
late final TextEditingController _appIdCtrl = TextEditingController(
text: widget.initialAppId,
);
late final TextEditingController _channelCtrl = TextEditingController(
text: widget.initialChannelId,
);
late final TextEditingController _tokenCtrl = TextEditingController(
text: widget.initialToken,
);
/// 已生效的参数(点「重新加载」才同步,输入过程中不触发重连)
String _appId = '';
String _channelId = '';
String _token = '';
/// 递增即强制重连
int _reloadToken = 0;
/// 是否已发起过加载(未发起时视频区只放占位文案)
bool _started = false;
String? _hint;
AgoraPlayerState _state = AgoraPlayerState.idle;
@override
void initState() {
super.initState();
_appId = widget.initialAppId.trim();
_channelId = widget.initialChannelId.trim();
_token = widget.initialToken.trim();
// 调用方已经把参数给齐时直接开播,不用手点
_started = _appId.isNotEmpty && _channelId.isNotEmpty;
}
@override
void dispose() {
_appIdCtrl.dispose();
_channelCtrl.dispose();
_tokenCtrl.dispose();
super.dispose();
}
void _reload() {
FocusScope.of(context).unfocus();
final appId = _appIdCtrl.text.trim();
final channelId = _channelCtrl.text.trim();
if (appId.isEmpty || channelId.isEmpty) {
setState(() {
_hint = 'App ID 与 Channel 不能为空';
_started = false;
});
return;
}
setState(() {
_appId = appId;
_channelId = channelId;
_token = _tokenCtrl.text.trim();
_reloadToken++;
_started = true;
_hint = null;
});
debugPrint('🎥 [Agora] 手动重新加载: channel=$_channelId, seq=$_reloadToken');
}
String get _stateText {
switch (_state) {
case AgoraPlayerState.idle:
return '未连接';
case AgoraPlayerState.connecting:
return '连接中…';
case AgoraPlayerState.playing:
return '播放中';
case AgoraPlayerState.error:
return '连接失败';
}
}
Color get _stateColor {
switch (_state) {
case AgoraPlayerState.idle:
return Colors.white38;
case AgoraPlayerState.connecting:
return Colors.amberAccent;
case AgoraPlayerState.playing:
return _success;
case AgoraPlayerState.error:
return _danger;
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.black,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildInput(
controller: _appIdCtrl,
label: 'App ID',
hint: '声网控制台的应用 ID',
),
_buildInput(
controller: _channelCtrl,
label: 'Channel',
hint: '频道名 / room_id',
),
_buildInput(
controller: _tokenCtrl,
label: 'Token',
hint: '未开启鉴权可留空',
maxLines: 2,
),
if (_hint != null) ...[
const SizedBox(height: 2),
Text(
_hint!,
style: const TextStyle(color: _danger, fontSize: 12),
),
],
const SizedBox(height: 10),
ElevatedButton.icon(
onPressed: _reload,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重新加载'),
style: ElevatedButton.styleFrom(
backgroundColor: _primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 10),
_buildStatusBar(),
],
),
),
AspectRatio(aspectRatio: 16 / 9, child: _buildVideoArea()),
const SizedBox(height: 8),
],
),
),
),
);
}
Widget _buildHeader() {
return Container(
height: 48,
padding: const EdgeInsets.only(left: 16),
color: const Color(0xFF2A2E34),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white70, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
);
}
Widget _buildStatusBar() {
return Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: _stateColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
'状态:$_stateText',
style: TextStyle(color: _stateColor, fontSize: 12),
),
const SizedBox(width: 12),
Flexible(
child: Text(
_started ? 'channel: $_channelId' : '未发起连接',
style: const TextStyle(color: Colors.white24, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildVideoArea() {
if (!_started) {
return Container(
color: Colors.black,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 24),
child: const Text(
'填写 App ID / Channel 后点「重新加载」',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white38, fontSize: 13),
),
);
}
return AgoraVideoPlayer(
appId: _appId,
channelId: _channelId,
token: _token,
reloadToken: _reloadToken,
onStateChanged: (s) {
if (!mounted) return;
setState(() => _state = s);
},
);
}
Widget _buildInput({
required TextEditingController controller,
required String label,
String? hint,
int maxLines = 1,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: TextField(
controller: controller,
maxLines: maxLines,
style: const TextStyle(color: Colors.white, fontSize: 13),
cursorColor: _primary,
decoration: InputDecoration(
labelText: label,
labelStyle: const TextStyle(color: Colors.white54, fontSize: 13),
hintText: hint,
hintStyle: const TextStyle(color: Colors.white24, fontSize: 12),
isDense: true,
filled: true,
fillColor: const Color(0xFF1A1D21),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.white12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.white12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: _primary),
),
),
),
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
class AppLogo extends StatelessWidget {
const AppLogo({super.key, required this.size});
final double size;
@override
Widget build(BuildContext context) {
return Image.asset(
'assets/images/app_logo.png',
width: size,
height: size,
fit: BoxFit.contain,
cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)).ceil(),
);
}
}

Some files were not shown because too many files have changed in this diff Show More