231 lines
8.3 KiB
Dart
231 lines
8.3 KiB
Dart
import 'package:cupertino_ui/cupertino_ui.dart';
|
||
import 'package:ttstd_family_care/app/constants/app_constants.dart';
|
||
import 'package:ttstd_family_care/core/network/api_exception.dart';
|
||
import 'package:ttstd_family_care/core/network/dio_client.dart';
|
||
import 'package:ttstd_family_care/features/device/domain/device_models.dart';
|
||
|
||
/// 客户端(家属端)设备接口访问层。
|
||
///
|
||
/// 后端接口前缀为 `/api/v1/client/sn`(与登录接口所在的 `/api/v1/open` 不同),
|
||
/// 由于 Dio 在 path 以 `/` 开头时会替换 baseUrl 的完整 path 段(保留 host),
|
||
/// 这里直接使用绝对路径即可正确命中 client 接口。
|
||
/// [DioClient.get] 已统一解析为后台返回体中的 `data` 字段;业务失败时抛出
|
||
/// [ApiException],本层将其降级为「空数据」以适配家属端展示。
|
||
class DeviceApi {
|
||
const DeviceApi();
|
||
|
||
/// 获取当前用户已绑定的设备列表。
|
||
Future<List<DeviceBrief>> getMyDevices() async {
|
||
try {
|
||
final data = await DioClient.get(
|
||
'v1/client/sn/my-devices',
|
||
);
|
||
return _parseList(data, DeviceBrief.fromJson);
|
||
} on ApiException {
|
||
return const [];
|
||
}
|
||
}
|
||
|
||
/// 绑定设备(手动输入 SN 或扫码得到 SN 后调用)。
|
||
///
|
||
/// 成功返回 null;失败返回后台错误提示。
|
||
Future<String?> bindDevice(String sn, {String? remark}) async {
|
||
try {
|
||
final data = await DioClient.post(
|
||
'v1/client/sn/bind',
|
||
queryParameters: {'sn': sn},
|
||
data: remark != null && remark.isNotEmpty ? {'remark': remark} : null,
|
||
);
|
||
debugPrint('[DeviceApi] bindDevice sn=$sn data=$data');
|
||
return null;
|
||
} on ApiException catch (e) {
|
||
debugPrint('[DeviceApi] bindDevice sn=$sn failed: ${e.message}');
|
||
return e.message;
|
||
}
|
||
}
|
||
|
||
/// 解绑设备。
|
||
///
|
||
/// 成功返回 null;失败返回后台错误提示。
|
||
Future<String?> unbindDevice(String sn) async {
|
||
try {
|
||
final data = await DioClient.post(
|
||
'v1/client/sn/unbind',
|
||
queryParameters: {'sn': sn},
|
||
);
|
||
debugPrint('[DeviceApi] unbindDevice sn=$sn data=$data');
|
||
return null;
|
||
} on ApiException catch (e) {
|
||
debugPrint('[DeviceApi] unbindDevice sn=$sn failed: ${e.message}');
|
||
return e.message;
|
||
}
|
||
}
|
||
|
||
/// 获取已绑定设备基本信息(无上报数据时返回 null)。
|
||
Future<DeviceSystemInfo?> getDeviceInfo(String sn) async {
|
||
try {
|
||
final data = await DioClient.get(
|
||
'v1/client/sn/device-info',
|
||
queryParameters: {'sn': sn},
|
||
);
|
||
return data is Map<String, dynamic> ? DeviceSystemInfo.fromJson(data) : null;
|
||
} on ApiException {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// 获取已绑定设备最新定位信息(无上报数据时返回 null)。
|
||
Future<DeviceLocation?> getLocation(String sn) async {
|
||
try {
|
||
final data = await DioClient.get(
|
||
'v1/client/sn/location',
|
||
queryParameters: {'sn': sn},
|
||
);
|
||
return data is Map<String, dynamic> ? DeviceLocation.fromJson(data) : null;
|
||
} on ApiException {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// 获取已绑定设备已安装应用列表。
|
||
Future<List<DeviceApkInfo>> getApks(String sn) async {
|
||
try {
|
||
final data = await DioClient.get(
|
||
'v1/client/sn/apks',
|
||
queryParameters: {'sn': sn},
|
||
);
|
||
return _parseList(data, DeviceApkInfo.fromJson);
|
||
} on ApiException {
|
||
return const [];
|
||
}
|
||
}
|
||
|
||
/// 向设备下发操作指令(重启/关机/截屏/刷新/定位等)。
|
||
///
|
||
/// [op] 为后端操作标识,如 `reboot` / `shutdown` / `screenshot` / `refresh` / `locate`,
|
||
/// 对应接口路径 `/api/v1/client/sn/ops/{op}?sn=xxx`。
|
||
/// 成功返回 null;失败返回后台错误提示。
|
||
Future<String?> sendDeviceOp(String sn, String op) async {
|
||
try {
|
||
final data = await DioClient.post(
|
||
'v1/client/sn/ops/$op',
|
||
queryParameters: {'sn': sn},
|
||
);
|
||
debugPrint('[DeviceApi] sendDeviceOp op=$op sn=$sn data=$data');
|
||
return null;
|
||
} on ApiException catch (e) {
|
||
debugPrint('[DeviceApi] sendDeviceOp op=$op sn=$sn failed: ${e.message}');
|
||
return e.message;
|
||
}
|
||
}
|
||
|
||
/// 对指定应用执行操作(打开/停止/卸载/清除数据)。
|
||
///
|
||
/// [op] 为后端操作标识:`launch` / `stop` / `uninstall` / `clear_data`,
|
||
/// 对应接口路径 `/api/v1/client/sn/ops/app/{op}?sn=xxx`,请求体携带应用包名。
|
||
/// 成功返回 null;失败返回后台错误提示。
|
||
Future<String?> sendAppOp(String sn, String op, String packageName) async {
|
||
try {
|
||
final data = await DioClient.post(
|
||
'v1/client/sn/ops/app/$op',
|
||
queryParameters: {'sn': sn},
|
||
data: {'packageName': packageName},
|
||
);
|
||
debugPrint('[DeviceApi] sendAppOp op=$op sn=$sn pkg=$packageName data=$data');
|
||
return null;
|
||
} on ApiException catch (e) {
|
||
debugPrint('[DeviceApi] sendAppOp op=$op sn=$sn pkg=$packageName failed: ${e.message}');
|
||
return e.message;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
static List<T> _parseList<T>(
|
||
dynamic data,
|
||
T Function(Map<String, dynamic>) fromJson,
|
||
) {
|
||
if (data is! List) return const [];
|
||
return data
|
||
.whereType<Map<String, dynamic>>()
|
||
.map(fromJson)
|
||
.toList(growable: false);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 获取设备最近截图列表(家属端)。
|
||
///
|
||
/// [sn] 为设备序列号(可空,由后台按绑定关系补全)。
|
||
/// 返回 [ScreenshotVO] 列表,并自动补全图片可访问的完整 URL(拼接 baseUrl)。
|
||
Future<List<ScreenshotVO>> getRecentScreenshots({String? sn}) async {
|
||
final query = sn != null && sn.isNotEmpty ? {'sn': sn} : <String, dynamic>{};
|
||
// DioClient.get 已统一解析出响应体中的 data 字段,
|
||
// 后台返回结构为 { code, msg, data: [...] },故此处 payload 即为截图数组。
|
||
final payload = await DioClient.get(
|
||
'v1/client/sn/upload-screenshot',
|
||
queryParameters: query,
|
||
);
|
||
// 防御性提取:兼容后台直接返回 List,或返回 { data: [...] } 的包裹结构。
|
||
final List<dynamic> listData;
|
||
if (payload is List) {
|
||
listData = payload;
|
||
} else if (payload is Map && payload['data'] is List) {
|
||
listData = payload['data'] as List;
|
||
} else {
|
||
debugPrint('[DeviceApi] getRecentScreenshots 返回非预期结构: sn=$sn payload=$payload');
|
||
return const [];
|
||
}
|
||
debugPrint('[DeviceApi] getRecentScreenshots sn=$sn count=${listData.length}');
|
||
final origin = Uri.parse(AppConstants.kBaseUrl).origin;
|
||
return listData.whereType<Map<String, dynamic>>().map((e) {
|
||
final vo = ScreenshotVO.fromJson(e);
|
||
// 后台返回的 url 为相对路径(如 /static/screenshot/xxx),补全为完整地址。
|
||
final raw = vo.url;
|
||
final fullUrl = (raw != null && raw.startsWith('/'))
|
||
? '$origin$raw'
|
||
: (raw ?? '');
|
||
return ScreenshotVO(
|
||
id: vo.id,
|
||
sn: vo.sn,
|
||
fileName: vo.fileName,
|
||
fileSize: vo.fileSize,
|
||
url: fullUrl,
|
||
uploadTime: vo.uploadTime,
|
||
);
|
||
}).toList(growable: false);
|
||
}
|
||
|
||
/// 删除单张设备截图(家属端 client 接口)。
|
||
///
|
||
/// [sn] 设备序列号,[id] 截图ID。成功返回 null;失败返回后台错误提示。
|
||
Future<String?> deleteScreenshot(String sn, int id) async {
|
||
try {
|
||
await DioClient.delete(
|
||
'v1/client/sn/ops/screenshots/$id?sn=${Uri.encodeQueryComponent(sn)}',
|
||
);
|
||
debugPrint('[DeviceApi] deleteScreenshot sn=$sn id=$id success');
|
||
return null;
|
||
} on ApiException catch (e) {
|
||
debugPrint('[DeviceApi] deleteScreenshot sn=$sn id=$id failed: ${e.message}');
|
||
return e.message;
|
||
}
|
||
}
|
||
|
||
/// 清空设备全部截图(家属端 client 接口)。
|
||
///
|
||
/// [sn] 设备序列号。成功返回 null;失败返回后台错误提示。
|
||
Future<String?> clearScreenshots(String sn) async {
|
||
try {
|
||
await DioClient.delete(
|
||
'v1/client/sn/ops/screenshots?sn=${Uri.encodeQueryComponent(sn)}',
|
||
);
|
||
debugPrint('[DeviceApi] clearScreenshots sn=$sn success');
|
||
return null;
|
||
} on ApiException catch (e) {
|
||
debugPrint('[DeviceApi] clearScreenshots sn=$sn failed: ${e.message}');
|
||
return e.message;
|
||
}
|
||
}
|
||
}
|