748 lines
28 KiB
Dart
748 lines
28 KiB
Dart
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<List<OrgNode>> getOrgTree() async {
|
||
final response = await _dio.get<dynamic>('/sys/org/tree');
|
||
final list = _extractList(response.data);
|
||
final tree = list
|
||
.whereType<Map<String, dynamic>>()
|
||
.map(OrgNode.fromJson)
|
||
.toList();
|
||
return _scopeTree(tree);
|
||
}
|
||
|
||
/// 获取组织列表(分页)。
|
||
///
|
||
/// GET /sys/org/list?page=&limit=
|
||
Future<List<OrgNode>> getOrgList({
|
||
int page = 1,
|
||
int limit = 20,
|
||
}) async {
|
||
final response = await _dio.get<dynamic>(
|
||
'/sys/org/list',
|
||
queryParameters: {'page': page, 'limit': limit},
|
||
);
|
||
final list = _extractList(response.data);
|
||
return list
|
||
.whereType<Map<String, dynamic>>()
|
||
.map(OrgNode.fromJson)
|
||
.toList();
|
||
}
|
||
|
||
/// 获取组织扁平列表(不分页,用于下拉选择)。
|
||
///
|
||
/// GET /sys/org/flatList
|
||
///
|
||
/// 命中 [SessionOrgScope] 时,仅保留当前用户组织子树内的节点,
|
||
/// 避免下拉选项出现其他组织的越权数据。
|
||
Future<List<OrgNode>> getOrgFlatList() async {
|
||
final response = await _dio.get<dynamic>('/sys/org/flatList');
|
||
final list = _extractList(response.data);
|
||
final all = list
|
||
.whereType<Map<String, dynamic>>()
|
||
.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<List<OrgNode>> getOrgSelect() async {
|
||
final response = await _dio.get<dynamic>('/sys/org/select');
|
||
final list = _extractList(response.data);
|
||
final all = list
|
||
.whereType<Map<String, dynamic>>()
|
||
.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<OrgNode> getOrgInfo(String id) async {
|
||
final response = await _dio.get<dynamic>('/sys/org/info/$id');
|
||
final data = response.data;
|
||
if (data is! Map<String, dynamic>) {
|
||
throw ApiException(500, '组织信息加载失败:$id');
|
||
}
|
||
return OrgNode.fromJson(data);
|
||
}
|
||
|
||
/// 获取渠道列表(type=1 的组织节点)。
|
||
///
|
||
/// GET /sys/org/channels
|
||
Future<List<OrgNode>> getOrgChannels() async {
|
||
final response = await _dio.get<dynamic>('/sys/org/channels');
|
||
final list = _extractList(response.data);
|
||
return list
|
||
.whereType<Map<String, dynamic>>()
|
||
.map(OrgNode.fromJson)
|
||
.toList();
|
||
}
|
||
|
||
/// 获取渠道下的代理商树(渠道 → 代理商 → 门店)。
|
||
///
|
||
/// GET /sys/org/channelAgentTree?channelId={channelId}
|
||
///
|
||
/// 注:与 Web 后台 [org.ts] 的 [getChannelAgentTree] 严格一致——channelId 走
|
||
/// **查询参数**而非路径段(早期误写成 /channelAgentTree/{id} 会导致后端取不到
|
||
/// 渠道、代理树加载为空)。
|
||
Future<List<OrgNode>> getChannelAgentTree(String channelId) async {
|
||
final response = await _dio.get<dynamic>(
|
||
'/sys/org/channelAgentTree',
|
||
queryParameters: {'channelId': channelId},
|
||
);
|
||
final list = _extractList(response.data);
|
||
return list
|
||
.whereType<Map<String, dynamic>>()
|
||
.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<List<OrgNode>> getChannelTree() async {
|
||
// 主路径:/sys/org/channels + /sys/org/channelAgentTree
|
||
try {
|
||
final channels = await getOrgChannels();
|
||
final tree = <OrgNode>[];
|
||
for (final channel in channels) {
|
||
List<OrgNode> agents = const <OrgNode>[];
|
||
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<OrgNode> _collectChannelNodes(List<OrgNode> nodes) {
|
||
final result = <OrgNode>[];
|
||
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<OrgNode> _sortTree(List<OrgNode> 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<String?> createOrg(Map<String, dynamic> data) async {
|
||
final payload = Map<String, dynamic>.from(data);
|
||
// 新增必须剥离主键,防止后端误判为更新或重复插入。
|
||
payload.remove('id');
|
||
payload.remove('orgId');
|
||
final response = await _dio.post<dynamic>('/sys/org/save', data: payload);
|
||
final result = response.data;
|
||
if (result is Map<String, dynamic>) {
|
||
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<String?> updateOrg(Map<String, dynamic> data) async {
|
||
final payload = Map<String, dynamic>.from(data);
|
||
// 后端 update 仅使用 id;orgId 会被忽略甚至触发「组织不存在」,必须剥离。
|
||
final id = (data['id'] ?? data['orgId'])?.toString() ?? '';
|
||
payload.remove('orgId');
|
||
payload['id'] = id;
|
||
final response = await _dio.post<dynamic>('/sys/org/update', data: payload);
|
||
final result = response.data;
|
||
if (result is Map<String, dynamic>) {
|
||
return result['orgId']?.toString() ?? result['id']?.toString();
|
||
}
|
||
if (result is String) return result;
|
||
return null;
|
||
}
|
||
|
||
/// 新增或修改组织(兼容旧调用方,如组织树页面)。
|
||
///
|
||
/// 注意:后端**未注册** `/sys/org/saveOrUpdate` 路由,调用必返回
|
||
/// 「未知异常,请联系管理员」。故按 body 是否携带 id/orgId 自动分流到
|
||
/// [createOrg](新增)或 [updateOrg](编辑)。
|
||
Future<String?> saveOrUpdateOrg(Map<String, dynamic> 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<void> deleteOrg(String id) async {
|
||
await _dio.post<dynamic>('/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<List<StoreInfo>> 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<dynamic>('/sys/store/myStores');
|
||
final list = _extractList(response.data);
|
||
final stores = list
|
||
.whereType<Map<String, dynamic>>()
|
||
.map(StoreInfo.fromJson)
|
||
.toList();
|
||
if (stores.isNotEmpty) return stores;
|
||
} catch (_) {
|
||
// 继续回退。
|
||
}
|
||
// 第 3 步:按登录组织查门店。
|
||
try {
|
||
final stores = await getStoreListByLoginOrg();
|
||
if (stores.isNotEmpty) return stores;
|
||
} catch (_) {
|
||
// 全部失败,返回空。
|
||
}
|
||
return const <StoreInfo>[];
|
||
}
|
||
|
||
/// 统计门店数量(type=3)。
|
||
///
|
||
/// 复用 [getMyStores] 的回退链,确保门店总数与门店列表一致,
|
||
/// 避免组织树异常时直接返回 0 导致「门店总数=0」的问题。
|
||
Future<int> 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<StoreInfo> _collectStores(List<OrgNode> nodes, {String parentId = ''}) {
|
||
final stores = <StoreInfo>[];
|
||
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 = <String>[];
|
||
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<List<StoreInfo>> getStoresByOrgId(String orgId) async {
|
||
final response =
|
||
await _dio.get<dynamic>('/sys/store/byOrgId/$orgId');
|
||
final list = _extractList(response.data);
|
||
return list
|
||
.whereType<Map<String, dynamic>>()
|
||
.map(StoreInfo.fromJson)
|
||
.toList();
|
||
}
|
||
|
||
/// 按门店 ID 获取设备分组列表。
|
||
///
|
||
/// GET /sys/store/deviceGroups/{storeId}
|
||
Future<List<OrgNode>> getDeviceGroupsByStoreId(String storeId) async {
|
||
final response =
|
||
await _dio.get<dynamic>('/sys/store/deviceGroups/$storeId');
|
||
final list = _extractList(response.data);
|
||
return list
|
||
.whereType<Map<String, dynamic>>()
|
||
.map(OrgNode.fromJson)
|
||
.toList();
|
||
}
|
||
|
||
/// 门店搜索(分页,支持多条件筛选)。
|
||
///
|
||
/// GET /sys/store/search?page=&limit=&orgId=&name=&status=
|
||
///
|
||
/// 未显式传入 [orgId] 时,自动带上 [SessionOrgScope] 的当前用户组织,
|
||
/// 防止未指定组织时越权查询到其他组织的门店([SessionOrgScope] 未启用时
|
||
/// 不附加,保留管理员全量查询能力)。
|
||
Future<List<StoreInfo>> 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<dynamic>(
|
||
'/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<String, dynamic>>()
|
||
.map(StoreInfo.fromJson)
|
||
.toList();
|
||
}
|
||
|
||
/// 获取当前登录组织下的门店列表(可按名称模糊查询)。
|
||
///
|
||
/// GET /sys/store/listByLoginOrg?name=
|
||
Future<List<StoreInfo>> getStoreListByLoginOrg({String? name}) async {
|
||
final response = await _dio.get<dynamic>(
|
||
'/sys/store/listByLoginOrg',
|
||
queryParameters: {
|
||
if (name != null) 'name': name,
|
||
},
|
||
);
|
||
final list = _extractList(response.data);
|
||
return list
|
||
.whereType<Map<String, dynamic>>()
|
||
.map(StoreInfo.fromJson)
|
||
.toList();
|
||
}
|
||
|
||
/// 查询门店配置(到期时间 / 禁用状态等)。
|
||
///
|
||
/// GET /device/storeConfig/info/{orgId}
|
||
///
|
||
/// 与 Web 后台「门店配置」弹窗同源(screen-front `getStoreConfig`)。
|
||
/// 组织树与通用门店列表接口通常不返回 `expireTime`,门店的到期时间必须由此
|
||
/// 端点补全——列表/详情均用它做权威兜底。接口异常或无数据返回 [StoreConfig.empty]。
|
||
Future<StoreConfig> getStoreConfig(String orgId) async {
|
||
try {
|
||
final response =
|
||
await _dio.get<dynamic>('/device/storeConfig/info/$orgId');
|
||
final data = response.data;
|
||
if (data is! Map<String, dynamic>) 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<String?> saveStore(Map<String, dynamic> 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<String, dynamic>.from(data)..['orgId'] = realOrgId;
|
||
await _persistStoreExtension(ext);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// 更新门店(含扩展字段)。
|
||
///
|
||
/// 固定走 [updateOrg](POST /sys/org/update,只认 id)。
|
||
Future<String?> updateStore(Map<String, dynamic> data) async {
|
||
final result = updateOrg(data);
|
||
final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? '';
|
||
if (realOrgId.isNotEmpty) {
|
||
final ext = Map<String, dynamic>.from(data)..['orgId'] = realOrgId;
|
||
await _persistStoreExtension(ext);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// 设置门店主题皮肤。
|
||
///
|
||
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setTheme`。
|
||
/// 上报失败时异常向外传播,由调用方决定提示策略(门店列表页会静默忽略,
|
||
/// 不影响门店本身已保存的结果)。
|
||
Future<void> setStoreTheme(String orgId, String themeId) async {
|
||
// 本地缓存兜底:即使上报失败,本机回显仍可用。
|
||
await StoreLocalCache.instance.saveTheme(orgId, themeId);
|
||
await _dio.post<dynamic>(
|
||
'/sys/store/setTheme',
|
||
data: {'orgId': orgId, 'themeId': themeId},
|
||
);
|
||
}
|
||
|
||
/// 设置门店启动图/Logo。
|
||
///
|
||
/// [splashUrl]/[logoUrl] 可以是:
|
||
/// - 远程 URL(http/https 开头)→ 直接上报;
|
||
/// - 本地文件路径 → 先经 [UploadService.uploadImage] 上传拿 CDN URL 再上报。
|
||
///
|
||
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setBranding`。
|
||
Future<void> setStoreBranding(
|
||
String orgId,
|
||
String splashUrl,
|
||
String logoUrl,
|
||
) async {
|
||
// 本地缓存兜底。
|
||
await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl);
|
||
|
||
final upload = UploadService();
|
||
Future<String> 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<dynamic>(
|
||
'/sys/store/setBranding',
|
||
data: {'orgId': orgId, 'splashUrl': splash, 'logoUrl': logo},
|
||
);
|
||
}
|
||
|
||
/// 设置门店省市区归属与详细地址。
|
||
///
|
||
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setRegion`。
|
||
Future<void> setRegion(
|
||
String orgId,
|
||
String provinceCode,
|
||
String cityCode,
|
||
String districtCode,
|
||
String addressDetail,
|
||
) async {
|
||
await StoreLocalCache.instance.saveRegion(
|
||
orgId,
|
||
provinceCode,
|
||
cityCode,
|
||
districtCode,
|
||
addressDetail,
|
||
);
|
||
await _dio.post<dynamic>(
|
||
'/sys/store/setRegion',
|
||
data: {
|
||
'orgId': orgId,
|
||
'provinceCode': provinceCode,
|
||
'cityCode': cityCode,
|
||
'districtCode': districtCode,
|
||
'addressDetail': addressDetail,
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 把门店扩展字段写入本地缓存([data] 缺省字段忽略)。
|
||
Future<void> _persistStoreExtension(Map<String, dynamic> 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<OrgNode> 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<String> collectDescendantIds(List<OrgNode> nodes, String rootId) {
|
||
final root = _findNode(nodes, rootId);
|
||
final ids = <String>{};
|
||
void walk(List<OrgNode> 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<String> collectDescendantStoreIds(
|
||
List<OrgNode> nodes,
|
||
String rootId,
|
||
) {
|
||
final root = _findNode(nodes, rootId);
|
||
final ids = <String>{};
|
||
void walk(List<OrgNode> 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<OrgNode> _scopeTree(List<OrgNode> 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<Set<String>> scopedOrgIdSet() async {
|
||
final scope = SessionOrgScope.instance;
|
||
if (!scope.enabled) return const {};
|
||
final tree = await getOrgTree();
|
||
final ids = <String>{};
|
||
void collect(List<OrgNode> nodes) {
|
||
for (final n in nodes) {
|
||
ids.add(n.id);
|
||
collect(n.children);
|
||
}
|
||
}
|
||
|
||
collect(tree);
|
||
return ids;
|
||
}
|
||
|
||
/// 兼容列表响应的三种形态:
|
||
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
|
||
static List<dynamic> _extractList(dynamic data) {
|
||
if (data is List) {
|
||
return data;
|
||
}
|
||
if (data is Map<String, dynamic>) {
|
||
final list = data['list'];
|
||
if (list is List) {
|
||
return list;
|
||
}
|
||
final page = data['page'];
|
||
if (page is Map<String, dynamic> && page['list'] is List) {
|
||
return page['list'] as List;
|
||
}
|
||
}
|
||
return const <dynamic>[];
|
||
}
|
||
}
|