import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart'; import '../models/org_node.dart'; import '../models/store_config.dart'; import '../models/store_info.dart'; import '../utils/session_org_scope.dart'; import '../utils/store_local_cache.dart'; import 'api_client.dart'; import 'upload_service.dart'; /// 组织与门店管理服务:组织树、门店、设备分组查询。 /// /// 对接真实「爱视后台」/api/sys/org/* 与 /api/sys/store/* 接口。 /// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。 class OrgService { OrgService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio; final Dio _dio; // ================ 组织管理 ================ /// 获取组织树(全量树形结构,当前用户可见范围)。 /// /// GET /sys/org/tree /// /// 注:后端该接口在部分部署下会返回**全量**组织树,不做数据权限收敛, /// 因此这里在客户端按 [SessionOrgScope] 把树裁剪到「当前登录用户所属组织 /// 子树」(含自身与全部后代)。[SessionOrgScope] 未启用(管理员 / orgId 为空) /// 时原样返回,行为与后端保持一致。 Future> getOrgTree() async { final response = await _dio.get('/sys/org/tree'); final list = _extractList(response.data); final tree = list .whereType>() .map(OrgNode.fromJson) .toList(); return _scopeTree(tree); } /// 获取组织列表(分页)。 /// /// GET /sys/org/list?page=&limit= Future> getOrgList({ int page = 1, int limit = 20, }) async { final response = await _dio.get( '/sys/org/list', queryParameters: {'page': page, 'limit': limit}, ); final list = _extractList(response.data); return list .whereType>() .map(OrgNode.fromJson) .toList(); } /// 获取组织扁平列表(不分页,用于下拉选择)。 /// /// GET /sys/org/flatList /// /// 命中 [SessionOrgScope] 时,仅保留当前用户组织子树内的节点, /// 避免下拉选项出现其他组织的越权数据。 Future> getOrgFlatList() async { final response = await _dio.get('/sys/org/flatList'); final list = _extractList(response.data); final all = list .whereType>() .map(OrgNode.fromJson) .toList(); final scope = SessionOrgScope.instance; if (!scope.enabled) return all; final ids = await scopedOrgIdSet(); if (ids.isEmpty) return all; return all.where((n) => ids.contains(n.id)).toList(); } /// 获取组织下拉选项(同扁平列表,语义区分)。 /// /// GET /sys/org/select /// /// 同样受 [SessionOrgScope] 收敛,仅返回当前用户组织子树内的节点。 Future> getOrgSelect() async { final response = await _dio.get('/sys/org/select'); final list = _extractList(response.data); final all = list .whereType>() .map(OrgNode.fromJson) .toList(); final scope = SessionOrgScope.instance; if (!scope.enabled) return all; final ids = await scopedOrgIdSet(); if (ids.isEmpty) return all; return all.where((n) => ids.contains(n.id)).toList(); } /// 获取组织详情。 /// /// GET /sys/org/info/{id} Future getOrgInfo(String id) async { final response = await _dio.get('/sys/org/info/$id'); final data = response.data; if (data is! Map) { throw ApiException(500, '组织信息加载失败:$id'); } return OrgNode.fromJson(data); } /// 获取渠道列表(type=1 的组织节点)。 /// /// GET /sys/org/channels Future> getOrgChannels() async { final response = await _dio.get('/sys/org/channels'); final list = _extractList(response.data); return list .whereType>() .map(OrgNode.fromJson) .toList(); } /// 获取渠道下的代理商树(渠道 → 代理商 → 门店)。 /// /// GET /sys/org/channelAgentTree?channelId={channelId} /// /// 注:与 Web 后台 [org.ts] 的 [getChannelAgentTree] 严格一致——channelId 走 /// **查询参数**而非路径段(早期误写成 /channelAgentTree/{id} 会导致后端取不到 /// 渠道、代理树加载为空)。 Future> getChannelAgentTree(String channelId) async { final response = await _dio.get( '/sys/org/channelAgentTree', queryParameters: {'channelId': channelId}, ); final list = _extractList(response.data); return list .whereType>() .map(OrgNode.fromJson) .toList(); } /// 获取「渠道商管理」统一树(渠道 → 代理/子代理)。 /// /// 后端无单一渠道商全量树接口,此处组合两个既有接口: /// 1. GET /sys/org/channels 拉顶层渠道列表; /// 2. 对每个渠道 GET /sys/org/channelAgentTree?channelId=xxx 拉其下代理/子代理树。 /// /// **降级策略**:若 /sys/org/channels 失败(部分部署下该端点可能返回服务端异常 /// 或权限错误),自动回退到 GET /sys/org/tree(全量组织树,已在门店列表中验证可用), /// 从中提取 type=1(渠道)与 type=2(代理)节点及其子树,保证页面不白屏。 /// /// 单渠道 agentTree 请求失败不影响整体(该渠道降级为无子节点),其余渠道正常展示。 /// 结果按渠道 orderNo 升序、渠道内子节点递归按 orderNo 升序,便于稳定展示。 Future> getChannelTree() async { // 主路径:/sys/org/channels + /sys/org/channelAgentTree try { final channels = await getOrgChannels(); final tree = []; for (final channel in channels) { List agents = const []; try { agents = await getChannelAgentTree(channel.id); } catch (_) { // 单渠道拉取失败:降级为无子节点,继续后续渠道。 } tree.add(channel.copyWith(children: _sortTree(agents))); } tree.sort(_byOrderNo); if (tree.isNotEmpty) return tree; } catch (e) { debugPrint('[OrgService.getChannelTree] /sys/org/channels 主路径失败: $e'); } // 降级路径:从全量组织树递归提取渠道/代理节点。 try { final orgTree = await getOrgTree(); final fallback = _collectChannelNodes(orgTree); if (fallback.isNotEmpty) { debugPrint( '[OrgService.getChannelTree] 已从 /sys/org/tree 降级加载 ' '${fallback.length} 个渠道/代理节点', ); return _sortTree(fallback); } } catch (e) { debugPrint('[OrgService.getChannelTree] 降级路径也失败: $e'); } // 双路径均失败:抛出原始异常让 UI 展示错误。 throw ApiException(500, '无法加载渠道数据,请检查网络或联系管理员'); } /// 从组织树中**递归**收集渠道(type=1)/代理(type=2) 节点,保留它们之间的层级关系。 /// /// 平台(type=0)/门店(type=3)/分组(type=4) 等非渠道节点作为「过路节点」被跳过 /// (不单独展示),但其下方若含有渠道/代理则继续下钻——确保即使组织树的 /// 顶层是平台节点(type=0),其下的渠道与代理仍能被正确提取。 /// /// 例:平台 → 渠道A → 代理B → 门店C → 代理D /// 提取结果:[渠道A(子=[代理B(子=[代理D])])] static List _collectChannelNodes(List nodes) { final result = []; for (final node in nodes) { if (node.type == 1 || node.type == 2) { // 渠道/代理:保留自身,递归过滤子孙仅保留渠道/代理支系。 result.add(node.copyWith(children: _collectChannelNodes(node.children))); } else { // 非渠道节点:跳过自身,但透传其下方可能含渠道/代理的分支。 if (node.children.isNotEmpty) { result.addAll(_collectChannelNodes(node.children)); } } } return result; } /// 递归按 orderNo 升序排序树(节点及各级子节点)。 static List _sortTree(List nodes) { final sorted = [...nodes]..sort(_byOrderNo); return sorted .map((n) => n.copyWith(children: _sortTree(n.children))) .toList(); } /// 按 orderNo 升序的比较器(orderNo 相同再按 id 兜底,保证稳定)。 static int _byOrderNo(OrgNode a, OrgNode b) { if (a.orderNo != b.orderNo) return a.orderNo.compareTo(b.orderNo); return a.id.compareTo(b.id); } /// 新增门店 / 组织。 /// /// POST /sys/org/save,body **不带 id/orgId**(带 id 会走 insert 分支, /// 报「数据库中已存在该记录」;只带 orgId 会被忽略而误建新记录)。 /// 门店为 type='STORE',且**必填 province**,否则报「门店省份不能为空」。 /// 返回后端 data 中的 orgId(新增门店时可用于后续创建激活码等)。 Future createOrg(Map data) async { final payload = Map.from(data); // 新增必须剥离主键,防止后端误判为更新或重复插入。 payload.remove('id'); payload.remove('orgId'); final response = await _dio.post('/sys/org/save', data: payload); final result = response.data; if (result is Map) { return result['orgId']?.toString() ?? result['id']?.toString(); } if (result is String) return result; return null; } /// 修改门店 / 组织。 /// /// POST /sys/org/update,后端**只认 id**(传 orgId 会报「组织不存在」), /// 因此强制以 id 为主键并剥离 orgId,避免编辑被误判。 /// 返回后端 data 中的 orgId。 Future updateOrg(Map data) async { final payload = Map.from(data); // 后端 update 仅使用 id;orgId 会被忽略甚至触发「组织不存在」,必须剥离。 final id = (data['id'] ?? data['orgId'])?.toString() ?? ''; payload.remove('orgId'); payload['id'] = id; final response = await _dio.post('/sys/org/update', data: payload); final result = response.data; if (result is Map) { return result['orgId']?.toString() ?? result['id']?.toString(); } if (result is String) return result; return null; } /// 新增或修改组织(兼容旧调用方,如组织树页面)。 /// /// 注意:后端**未注册** `/sys/org/saveOrUpdate` 路由,调用必返回 /// 「未知异常,请联系管理员」。故按 body 是否携带 id/orgId 自动分流到 /// [createOrg](新增)或 [updateOrg](编辑)。 Future saveOrUpdateOrg(Map data) async { final isEdit = (data['id'] != null && data['id'].toString().isNotEmpty) || (data['orgId'] != null && data['orgId'].toString().isNotEmpty); return isEdit ? updateOrg(data) : createOrg(data); } /// 删除组织(含子节点校验)。 /// /// POST /sys/org/delete/{id} Future deleteOrg(String id) async { await _dio.post('/sys/org/delete/$id'); } // ================ 门店管理 ================ /// 获取当前登录用户可见的门店列表。 /// /// 排序 / 展示需与 Web 后台「门店管理」保持一致:Web 后台门店管理读取的是 /// 组织树(门店专用接口 /sys/store/* 在该账号下多返回 500,不可用),故 /// 第 1 步直接读组织树并递归收集 type=3 门店,同时透传节点的 [OrgNode.createdAt] /// 作为 [StoreInfo.createTime] 供「建店时间倒序」使用;门店专用接口仅作兜底。 /// /// 回退链(任一成功且非空即用): /// 1. 组织树 GET /sys/org/tree,递归收集 type=3 门店(透传 createdAt); /// 2. 门店专用 GET /sys/store/myStores; /// 3. 登录组织门店 GET /sys/store/listByLoginOrg。 Future> getMyStores() async { // 第 1 步:组织树(与 Web 后台门店管理同源,且透传 createdAt)。 try { final tree = await getOrgTree(); final stores = _collectStores(tree); if (stores.isNotEmpty) return stores; } catch (_) { // 继续回退。 } // 第 2 步:myStores。 try { final response = await _dio.get('/sys/store/myStores'); final list = _extractList(response.data); final stores = list .whereType>() .map(StoreInfo.fromJson) .toList(); if (stores.isNotEmpty) return stores; } catch (_) { // 继续回退。 } // 第 3 步:按登录组织查门店。 try { final stores = await getStoreListByLoginOrg(); if (stores.isNotEmpty) return stores; } catch (_) { // 全部失败,返回空。 } return const []; } /// 统计门店数量(type=3)。 /// /// 复用 [getMyStores] 的回退链,确保门店总数与门店列表一致, /// 避免组织树异常时直接返回 0 导致「门店总数=0」的问题。 Future fetchStoreCount() async { try { return (await getMyStores()).length; } catch (_) { return 0; } } /// 递归把组织树扁平化,并过滤出门店(type=3 或 type='STORE')。 /// /// 新接口 /sys/org/tree 返回的 type 为字符串(CHANNEL/SERVICE_PROVIDER/STORE/GROUP), /// 经 [OrgNode.fromJson] 已映射为 int,但此处兼容判断字符串更保险。 /// /// 组织树节点通常不携带 [parentId],而是通过 [children] 嵌套表达层级; /// 因此递归时把当前节点 [id] 作为子节点的 [parentId] 传入,确保门店的 /// [StoreInfo.parentId] 能正确指向其在树中的直接父组织(渠道/代理商)。 static List _collectStores(List nodes, {String parentId = ''}) { final stores = []; for (final node in nodes) { final isStore = node.type == 3 || node.type.toString().toUpperCase() == 'STORE'; if (isStore) { final province = node.province.trim(); final city = node.city.trim(); final address = node.address.trim(); final fullAddress = []; if (province.isNotEmpty) fullAddress.add(province); if (city.isNotEmpty) fullAddress.add(city); if (address.isNotEmpty) fullAddress.add(address); // 优先使用节点自身携带的 parentId;树接口未返回时,使用递归上下文中的父节点 ID。 final effectiveParentId = node.parentId.isNotEmpty ? node.parentId : parentId; stores.add( StoreInfo( orgId: node.id, parentId: effectiveParentId.isEmpty ? null : effectiveParentId, name: node.name, address: fullAddress.join(''), contactPhone: node.contactPhone, activationCode: node.activationCode, status: 0, // 透传组织树节点的到期时间(部分部署的 /sys/org/tree 会返回 // expireTime/expire_time;缺失则为 null,列表项将据此显示「无到期时间」)。 expireTime: node.expireTime, createTime: node.createdAt, deviceLimit: node.groupLimit, // 新接口 /sys/org/tree 返回的 themeId(如 35/36/37), // 转为字符串供前端 StoreThemePresets 匹配或回显。 themeId: node.themeId?.toString(), ), ); } if (node.children.isNotEmpty) { stores.addAll(_collectStores(node.children, parentId: node.id)); } } return stores; } /// 按组织 ID 获取门店列表。 /// /// GET /sys/store/byOrgId/{orgId} Future> getStoresByOrgId(String orgId) async { final response = await _dio.get('/sys/store/byOrgId/$orgId'); final list = _extractList(response.data); return list .whereType>() .map(StoreInfo.fromJson) .toList(); } /// 按门店 ID 获取设备分组列表。 /// /// GET /sys/store/deviceGroups/{storeId} Future> getDeviceGroupsByStoreId(String storeId) async { final response = await _dio.get('/sys/store/deviceGroups/$storeId'); final list = _extractList(response.data); return list .whereType>() .map(OrgNode.fromJson) .toList(); } /// 门店搜索(分页,支持多条件筛选)。 /// /// GET /sys/store/search?page=&limit=&orgId=&name=&status= /// /// 未显式传入 [orgId] 时,自动带上 [SessionOrgScope] 的当前用户组织, /// 防止未指定组织时越权查询到其他组织的门店([SessionOrgScope] 未启用时 /// 不附加,保留管理员全量查询能力)。 Future> searchStore({ int page = 1, int limit = 20, String? orgId, String? name, int? status, }) async { final effectiveOrgId = orgId ?? (SessionOrgScope.instance.enabled ? SessionOrgScope.instance.currentOrgId : null); final response = await _dio.get( '/sys/store/search', queryParameters: { 'page': page, 'limit': limit, if (effectiveOrgId != null) 'orgId': effectiveOrgId, if (name != null) 'name': name, if (status != null) 'status': status, }, ); final list = _extractList(response.data); return list .whereType>() .map(StoreInfo.fromJson) .toList(); } /// 获取当前登录组织下的门店列表(可按名称模糊查询)。 /// /// GET /sys/store/listByLoginOrg?name= Future> getStoreListByLoginOrg({String? name}) async { final response = await _dio.get( '/sys/store/listByLoginOrg', queryParameters: { if (name != null) 'name': name, }, ); final list = _extractList(response.data); return list .whereType>() .map(StoreInfo.fromJson) .toList(); } /// 查询门店配置(到期时间 / 禁用状态等)。 /// /// GET /device/storeConfig/info/{orgId} /// /// 与 Web 后台「门店配置」弹窗同源(screen-front `getStoreConfig`)。 /// 组织树与通用门店列表接口通常不返回 `expireTime`,门店的到期时间必须由此 /// 端点补全——列表/详情均用它做权威兜底。接口异常或无数据返回 [StoreConfig.empty]。 Future getStoreConfig(String orgId) async { try { final response = await _dio.get('/device/storeConfig/info/$orgId'); final data = response.data; if (data is! Map) return StoreConfig.empty(); return StoreConfig.fromJson(data); } catch (_) { return StoreConfig.empty(); } } // ================ 门店扩展字段(前端占位) ================ /// 新增或更新门店(含主题/品牌/省市区等扩展字段)。 /// /// 门店本质上是 type='STORE' 的组织节点。按是否携带 id/orgId 分流: /// - 新增 → [createOrg](POST /sys/org/save,不带主键,必填 province); /// - 编辑 → [updateOrg](POST /sys/org/update,只认 id)。 /// 返回后端生成的 orgId(新增门店时可用于创建激活码)。 Future saveStore(Map data) async { // 真实上报后端(新增/编辑分流到正确路由)。 final isEdit = (data['id'] != null && data['id'].toString().isNotEmpty) || (data['orgId'] != null && data['orgId'].toString().isNotEmpty); final result = isEdit ? updateOrg(data) : createOrg(data); // 扩展字段写本地缓存:用真实 orgId 回填,避免新建时因 orgId 为空而丢失 // 主题/品牌/省市区等建店配置(Web 端同源,移动端本地兜底回显)。 final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? ''; if (realOrgId.isNotEmpty) { final ext = Map.from(data)..['orgId'] = realOrgId; await _persistStoreExtension(ext); } return result; } /// 更新门店(含扩展字段)。 /// /// 固定走 [updateOrg](POST /sys/org/update,只认 id)。 Future updateStore(Map data) async { final result = updateOrg(data); final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? ''; if (realOrgId.isNotEmpty) { final ext = Map.from(data)..['orgId'] = realOrgId; await _persistStoreExtension(ext); } return result; } /// 设置门店主题皮肤。 /// /// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setTheme`。 /// 上报失败时异常向外传播,由调用方决定提示策略(门店列表页会静默忽略, /// 不影响门店本身已保存的结果)。 Future setStoreTheme(String orgId, String themeId) async { // 本地缓存兜底:即使上报失败,本机回显仍可用。 await StoreLocalCache.instance.saveTheme(orgId, themeId); await _dio.post( '/sys/store/setTheme', data: {'orgId': orgId, 'themeId': themeId}, ); } /// 设置门店启动图/Logo。 /// /// [splashUrl]/[logoUrl] 可以是: /// - 远程 URL(http/https 开头)→ 直接上报; /// - 本地文件路径 → 先经 [UploadService.uploadImage] 上传拿 CDN URL 再上报。 /// /// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setBranding`。 Future setStoreBranding( String orgId, String splashUrl, String logoUrl, ) async { // 本地缓存兜底。 await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl); final upload = UploadService(); Future resolve(String raw) async { if (raw.isEmpty) return raw; // 已是远程 URL 直接复用;否则当作本地路径上传拿 CDN URL。 if (raw.startsWith('http://') || raw.startsWith('https://')) return raw; return upload.uploadImage(XFile(raw), mediaType: 'image'); } final splash = await resolve(splashUrl); final logo = await resolve(logoUrl); await _dio.post( '/sys/store/setBranding', data: {'orgId': orgId, 'splashUrl': splash, 'logoUrl': logo}, ); } /// 设置门店省市区归属与详细地址。 /// /// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setRegion`。 Future setRegion( String orgId, String provinceCode, String cityCode, String districtCode, String addressDetail, ) async { await StoreLocalCache.instance.saveRegion( orgId, provinceCode, cityCode, districtCode, addressDetail, ); await _dio.post( '/sys/store/setRegion', data: { 'orgId': orgId, 'provinceCode': provinceCode, 'cityCode': cityCode, 'districtCode': districtCode, 'addressDetail': addressDetail, }, ); } /// 把门店扩展字段写入本地缓存([data] 缺省字段忽略)。 Future _persistStoreExtension(Map data) async { final orgId = (data['orgId'] ?? data['id'])?.toString() ?? ''; if (orgId.isEmpty) return; final themeId = data['themeId']?.toString(); if (themeId != null && themeId.isNotEmpty) { await StoreLocalCache.instance.saveTheme(orgId, themeId); } final splashUrl = data['splashUrl']?.toString() ?? ''; final logoUrl = data['logoUrl']?.toString() ?? ''; if (splashUrl.isNotEmpty || logoUrl.isNotEmpty) { await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl); } final provinceCode = data['provinceCode']?.toString() ?? ''; final cityCode = data['cityCode']?.toString() ?? ''; final districtCode = data['districtCode']?.toString() ?? ''; final addressDetail = data['addressDetail']?.toString() ?? ''; if (provinceCode.isNotEmpty || cityCode.isNotEmpty || districtCode.isNotEmpty || addressDetail.isNotEmpty) { await StoreLocalCache.instance.saveRegion( orgId, provinceCode, cityCode, districtCode, addressDetail, ); } } // ================ 工具方法 ================ /// 在组织树中按节点 ID 深度优先查找节点。 static OrgNode? _findNode(List nodes, String target) { for (final n in nodes) { if (n.id == target) return n; final found = _findNode(n.children, target); if (found != null) return found; } return null; } /// 收集某节点(含自身)的全部后代 ID(深度优先,含 root 本身)。 /// /// 用于组织树筛选:选中某个渠道 / 代理商 / 分组后,命中其下所有层级的门店。 /// 找不到 [rootId] 对应节点时返回空集合。 /// /// [nodes] 为已按 [SessionOrgScope] 收敛的组织树([getOrgTree] 返回值)。 static Set collectDescendantIds(List nodes, String rootId) { final root = _findNode(nodes, rootId); final ids = {}; void walk(List ns) { for (final n in ns) { ids.add(n.id); walk(n.children); } } if (root != null) walk([root]); return ids; } /// 收集某节点(含自身)的全部后代中「门店(type=3)」的 ID。 /// /// 与 [collectDescendantIds] 的区别:仅收集门店节点,过滤掉渠道/代理/分组等 /// 非门店节点,便于直接作为 `/device/info/list` 的 `storeOrgId` 入参集合使用。 /// 找不到 [rootId] 对应节点时返回空集合。 static Set collectDescendantStoreIds( List nodes, String rootId, ) { final root = _findNode(nodes, rootId); final ids = {}; void walk(List ns) { for (final n in ns) { if (n.type == 3) ids.add(n.id); walk(n.children); } } if (root != null) walk([root]); return ids; } /// 将组织树收敛到「当前登录用户所属组织子树」(含自身与全部后代)。 /// /// - 未启用 [SessionOrgScope](管理员 / orgId 为空):原样返回。 /// - 已启用但树中找不到对应节点:兜底原样返回,避免误清空。 /// /// 这样即便后端 `/sys/org/tree` 返回全量树,非管理员用户也只能看到 /// 自己组织及下属门店的数据。 static List _scopeTree(List tree) { final scope = SessionOrgScope.instance; if (!scope.enabled) return tree; final root = _findNode(tree, scope.currentOrgId); // 兜底:找不到当前组织节点时不过滤(避免误清空导致白屏), // 但打印告警以便排查「用户 orgId 与组织树节点不匹配」的数据异常。 if (root == null) { debugPrint( '[OrgService._scopeTree] 未在组织树中找到当前组织节点 ' '(orgId=${scope.currentOrgId}),放弃客户端收敛', ); return tree; } return [root]; } /// 收集「当前已收敛组织树」包含的全部节点 ID(用于裁剪扁平/下拉列表与用户列表)。 /// /// 仅在 [SessionOrgScope] 启用时返回非空集合([getOrgTree] 已按范围收敛); /// 未启用时返回空集合,调用方据此跳过过滤、保持原样。 Future> scopedOrgIdSet() async { final scope = SessionOrgScope.instance; if (!scope.enabled) return const {}; final tree = await getOrgTree(); final ids = {}; void collect(List nodes) { for (final n in nodes) { ids.add(n.id); collect(n.children); } } collect(tree); return ids; } /// 兼容列表响应的三种形态: /// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。 static List _extractList(dynamic data) { if (data is List) { return data; } if (data is Map) { final list = data['list']; if (list is List) { return list; } final page = data['page']; if (page is Map && page['list'] is List) { return page['list'] as List; } } return const []; } }