import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/org_node.dart'; import '../../../services/api_client.dart'; import '../../../services/org_service.dart'; import '../../../utils/formatters.dart'; import '../../../utils/i10n.dart'; import 'channel_version_manage_dialog.dart'; /// 渠道商管理页:渠道 → 代理/子代理 树形 CRUD。 /// /// 功能结构、布局与交互逻辑对齐 Web 后台 `org.vue`(screen-front /// `views/modules/org/org.vue`): /// - 列表:渠道 + 代理树形展示,默认展开顶层渠道; /// - 搜索:按名称模糊匹配; /// - 筛选:类型(全部/渠道/代理)+ 状态(全部/启用/禁用),与 Web 一致; /// - 详情:点按行打开底部详情卡,展示全部字段; /// - 新增/编辑/新增子级/删除:底部表单或确认弹窗; /// - 删除守卫:存在子集时禁止删除(Web "存在子集时不允许删除"); /// - 移动端适配:点按区域放大、`Dismissible` 左滑删除手势。 /// /// 数据来自 [OrgService.getChannelTree](组合 /sys/org/channels 与 /// /sys/org/channelAgentTree),增删改复用既有组织接口 /// [OrgService.createOrg] / [updateOrg] / [deleteOrg]。 class ChannelManagementPage extends ConsumerStatefulWidget { const ChannelManagementPage({super.key}); @override ConsumerState createState() => _ChannelManagementPageState(); } class _ChannelManagementPageState extends ConsumerState { List _tree = []; bool _loading = false; String? _error; final _searchController = TextEditingController(); String _keyword = ''; int? _typeFilter; String? _statusFilter; final Set _expanded = {}; @override void initState() { super.initState(); _load(); } @override void dispose() { _searchController.dispose(); super.dispose(); } void _snack(String msg) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } Future _load() async { setState(() { _loading = true; _error = null; }); try { final tree = await OrgService().getChannelTree(); if (!mounted) return; // 默认展开顶层渠道,便于一眼看到其下代理。 final autoExpand = tree.map((n) => n.id).toSet(); setState(() { _tree = tree; _loading = false; _expanded.addAll(autoExpand); }); } catch (e) { if (!mounted) return; setState(() { _loading = false; _error = formatApiError(e); }); } } /// 计算当前应展示的扁平行(含层级 depth 与父级名称)。 /// /// - 无筛选(关键词 / 类型 / 状态都为空):按 [_expanded] 折叠/展开; /// - 有筛选:忽略展开态,递归展开全部并仅显示命中节点(父未命中但子命中时 /// 子仍可见),确保筛选结果不被折叠隐藏。 List<_ChannelRow> _computeRows() { final rows = <_ChannelRow>[]; final kw = _keyword.trim().toLowerCase(); final filterActive = kw.isNotEmpty || _typeFilter != null || _statusFilter != null; bool pass(OrgNode n) { if (kw.isNotEmpty && !n.name.toLowerCase().contains(kw)) return false; if (_typeFilter != null && n.type != _typeFilter) return false; if (_statusFilter != null && n.status.toUpperCase() != _statusFilter) { return false; } return true; } void walk(List nodes, int depth, String? parentName) { for (final n in nodes) { if (filterActive ? pass(n) : true) { rows.add( _ChannelRow(node: n, depth: depth, parentName: parentName), ); } final childrenVisible = n.children.isNotEmpty && (filterActive || _expanded.contains(n.id)); if (childrenVisible) walk(n.children, depth + 1, n.name); } } walk(_tree, 0, null); return rows; } void _toggleExpand(OrgNode node) { if (node.children.isEmpty) return; setState(() { if (_expanded.contains(node.id)) { _expanded.remove(node.id); } else { _expanded.add(node.id); } }); } /// 渠道/代理可新增子级(与 Web [canShowAddChildForRow] 对齐:CHANNEL/SERVICE_PROVIDER)。 bool _canAddChild(OrgNode n) => n.type == 1 || n.type == 2; /// 可下钻版本管理(与 Web [canShowVersionForRow] 对齐:CHANNEL/SERVICE_PROVIDER)。 bool _canShowVersion(OrgNode n) => n.type == 1 || n.type == 2; Color _typeColor(BuildContext context, int type) { if (type == 1) return Colors.green; if (type == 2) return Colors.orange; return Theme.of(context).colorScheme.primary; } /// 删除节点(含确认弹窗)。存在子集时直接拦截并提示,与 Web 一致。 /// /// 返回是否删除成功;[skipConfirm] 用于滑动手势等已显式触发的场景。 Future _delete(OrgNode node, {bool skipConfirm = false}) async { final t = AppLocalizations.of(context); if (node.children.isNotEmpty) { _snack(t.channelCannotDeleteWithChildren); return false; } if (!skipConfirm) { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(t.confirmDelete), content: Text(t.deleteConfirmContent(node.name)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, ), onPressed: () => Navigator.pop(ctx, true), child: Text(t.delete), ), ], ), ); if (confirmed != true) return false; } try { await OrgService().deleteOrg(node.id); if (!mounted) return false; _load(); _snack(t.deleted); return true; } catch (e) { if (!mounted) return false; _snack(e is ApiException ? e.message : t.deleteFailed); return false; } } /// 底部详情卡:展示节点全部字段 + 操作入口(新增子级/编辑/删除)。 void _showDetail(OrgNode node, String? parentName) { final t = AppLocalizations.of(context); final hasChildren = node.children.isNotEmpty; showModalBottomSheet( useSafeArea: true, context: context, isScrollControlled: true, builder: (ctx) => DraggableScrollableSheet( initialChildSize: 0.6, maxChildSize: 0.9, expand: false, builder: (sheetCtx, scrollCtrl) => Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Row( children: [ Expanded( child: Text( node.name, style: Theme.of(ctx).textTheme.titleLarge, ), ), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(ctx), ), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ _DetailChip(label: node.typeLabel, color: _typeColor(ctx, node.type)), const SizedBox(width: 8), _DetailChip( label: node.isDisabled ? t.channelDisabled : t.channelEnabled, color: node.isDisabled ? Colors.red : Colors.green, ), ], ), ), const Divider(height: 20), Expanded( child: ListView( controller: scrollCtrl, padding: const EdgeInsets.symmetric(horizontal: 16), children: [ _DetailRow(t.channelOrgCode, node.orgCodeLabel), _DetailRow(t.channelSort, node.orderNo.toString()), _DetailRow(t.channelParent, parentName ?? '-'), _DetailRow( t.channelContactPhone, node.contactPhone.isEmpty ? '-' : node.contactPhone, ), _DetailRow( t.channelAddress, node.address.isEmpty ? '-' : node.address, ), if (node.createdAt != null) _DetailRow( t.createTime, Formatters.formatDateTime(node.createdAt!), ), if (node.expireTime != null) _DetailRow( t.channelExpireTime, Formatters.formatDate(node.expireTime!), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _canShowVersion(node) ? SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () { Navigator.pop(ctx); showChannelVersionManageDialog(context, node: node); }, icon: const Icon(Icons.system_update_alt), label: Text(t.channelVersionManage), ), ) : const SizedBox.shrink(), ), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Row( children: [ if (_canAddChild(node)) Expanded( child: OutlinedButton.icon( onPressed: () { Navigator.pop(ctx); _showForm(parent: node); }, icon: const Icon(Icons.add), label: Text(t.addSubChannel), ), ), if (_canAddChild(node)) const SizedBox(width: 8), Expanded( child: OutlinedButton.icon( onPressed: () { Navigator.pop(ctx); _showForm(editing: node); }, icon: const Icon(Icons.edit), label: Text(t.edit), ), ), const SizedBox(width: 8), Expanded( child: FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.errorContainer, foregroundColor: Theme.of(ctx).colorScheme.onErrorContainer, ), onPressed: hasChildren ? null : () { Navigator.pop(ctx); _delete(node); }, icon: const Icon(Icons.delete), label: Text(t.delete), ), ), ], ), ), ], ), ), ); } @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); final rows = _computeRows(); return Scaffold( appBar: AppBar( automaticallyImplyLeading: false, title: Text(t.channelManagement), bottom: PreferredSize( preferredSize: const Size.fromHeight(168), child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column( children: [ TextField( controller: _searchController, decoration: InputDecoration( hintText: t.searchChannelHint, prefixIcon: const Icon(Icons.search), isDense: true, suffixIcon: _keyword.isEmpty ? null : IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); setState(() => _keyword = ''); }, ), ), onChanged: (v) => setState(() => _keyword = v), ), const SizedBox(height: 8), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( spacing: 8, children: [ SegmentedButton( segments: [ ButtonSegment(value: null, label: Text(t.all)), ButtonSegment( value: 1, label: Text(t.channelTypeChannel), ), ButtonSegment( value: 2, label: Text(t.channelTypeAgent), ), ], selected: {_typeFilter}, onSelectionChanged: (set) => setState(() => _typeFilter = set.first), ), SegmentedButton( segments: [ ButtonSegment(value: null, label: Text(t.all)), ButtonSegment( value: 'ACTIVE', label: Text(t.channelEnabled), ), ButtonSegment( value: 'DISABLED', label: Text(t.channelDisabled), ), ], selected: {_statusFilter}, onSelectionChanged: (set) => setState(() => _statusFilter = set.first), ), ], ), ), ], ), ), ), ), body: _error != null ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(_error!), const SizedBox(height: 8), FilledButton.tonal(onPressed: _load, child: Text(t.retry)), ], ), ) : _loading && _tree.isEmpty ? const Center(child: CircularProgressIndicator()) : rows.isEmpty ? Center(child: Text(t.noData)) : RefreshIndicator( onRefresh: _load, child: ListView.separated( padding: const EdgeInsets.symmetric(vertical: 8), physics: const AlwaysScrollableScrollPhysics(), itemCount: rows.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (ctx, i) { final row = rows[i]; return Dismissible( key: Key(row.node.id), direction: DismissDirection.endToStart, background: Container( alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 24), color: Theme.of(context).colorScheme.error, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ const Icon(Icons.delete, color: Colors.white), const SizedBox(width: 8), Text( t.channelSwipeToDelete, style: const TextStyle(color: Colors.white), ), ], ), ), confirmDismiss: (_) => _delete( row.node, skipConfirm: true, ), child: _ChannelRowTile( row: row, isExpanded: _expanded.contains(row.node.id), onToggle: _toggleExpand, onDetail: _showDetail, onEdit: (n) => _showForm(editing: n), onAddChild: (n) => _showForm(parent: n), onVersionManage: (n) => showChannelVersionManageDialog( context, node: n, ), onDelete: _delete, ), ); }, ), ), floatingActionButton: FloatingActionButton( onPressed: () => _showForm(), tooltip: t.addChannel, child: const Icon(Icons.add), ), ); } /// 新增/编辑/新增子级 的底部表单。 void _showForm({OrgNode? editing, OrgNode? parent}) { final t = AppLocalizations.of(context); final isEdit = editing != null; final isChild = parent != null && !isEdit; // 类型:编辑沿用原类型;新增子级默认代理(SERVICE_PROVIDER); // 新增顶层渠道默认渠道(CHANNEL)。 int typeValue = editing?.type ?? (isChild ? 2 : 1); final nameCtrl = TextEditingController(text: editing?.name ?? ''); final orderCtrl = TextEditingController( text: editing?.orderNo.toString() ?? '0', ); final phoneCtrl = TextEditingController(text: editing?.contactPhone ?? ''); final addressCtrl = TextEditingController(text: editing?.address ?? ''); int statusValue = editing?.isDisabled == true ? 1 : 0; showModalBottomSheet( useSafeArea: true, context: context, isScrollControlled: true, builder: (ctx) => StatefulBuilder( builder: (ctx, setSheet) => Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(ctx).viewInsets.bottom, ), child: Padding( padding: const EdgeInsets.all(16), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isEdit ? t.editChannel : isChild ? t.addSubChannel : t.addChannel, style: Theme.of(ctx).textTheme.titleMedium, ), const SizedBox(height: 16), if (isChild) Padding( padding: const EdgeInsets.only(bottom: 12), child: Text( '${t.channelParent}:${parent!.name}', style: Theme.of(ctx) .textTheme .bodySmall ?.copyWith(color: Theme.of(ctx).hintColor), ), ), TextField( controller: nameCtrl, decoration: InputDecoration( labelText: t.channelName, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), DropdownButtonFormField( value: typeValue, decoration: InputDecoration( labelText: t.channelType, border: const OutlineInputBorder(), ), items: const [ DropdownMenuItem(value: 1, child: Text('渠道')), DropdownMenuItem(value: 2, child: Text('代理')), ], onChanged: (v) { if (v != null) setSheet(() => typeValue = v); }, ), const SizedBox(height: 12), TextField( controller: orderCtrl, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: t.channelSort, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), DropdownButtonFormField( value: statusValue, decoration: InputDecoration( labelText: t.channelStatus, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem(value: 0, child: Text(t.channelEnabled)), DropdownMenuItem(value: 1, child: Text(t.channelDisabled)), ], onChanged: (v) { if (v != null) setSheet(() => statusValue = v); }, ), const SizedBox(height: 12), TextField( controller: phoneCtrl, keyboardType: TextInputType.phone, decoration: InputDecoration( labelText: t.channelContactPhone, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: addressCtrl, decoration: InputDecoration( labelText: t.channelAddress, border: const OutlineInputBorder(), ), ), const SizedBox(height: 20), Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.pop(ctx), child: Text(t.cancel), ), ), const SizedBox(width: 12), Expanded( child: FilledButton( onPressed: () async { final name = nameCtrl.text.trim(); if (name.isEmpty) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.channelNameRequired)), ); return; } final typeString = typeValue == 1 ? 'CHANNEL' : 'SERVICE_PROVIDER'; final statusString = statusValue == 0 ? 'ACTIVE' : 'DISABLED'; final data = { 'name': name, 'type': typeString, 'orderNo': int.tryParse(orderCtrl.text.trim()) ?? 0, 'status': statusString, 'contactPhone': phoneCtrl.text.trim(), 'address': addressCtrl.text.trim(), if (isEdit) 'id': editing?.id, if (isChild) 'parentId': parent?.id, }; try { if (isEdit) { await OrgService().updateOrg(data); } else { await OrgService().createOrg(data); } if (!ctx.mounted) return; Navigator.pop(ctx); _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.saved)), ); } catch (e) { if (!ctx.mounted) return; ScaffoldMessenger.of(ctx).showSnackBar( SnackBar( content: Text( e is ApiException ? e.message : t.saveFailed, ), ), ); } }, child: Text(t.save), ), ), ], ), ], ), ), ), ), ), ); } } /// 渠道树的一行(节点 + 层级深度 + 父级名称)。 class _ChannelRow { const _ChannelRow({ required this.node, required this.depth, this.parentName, }); final OrgNode node; final int depth; final String? parentName; } /// 渠道行卡片:层级缩进 + 展开按钮 + 类型/状态徽章 + 点按查看详情 + 操作菜单。 class _ChannelRowTile extends StatelessWidget { const _ChannelRowTile({ required this.row, required this.isExpanded, required this.onToggle, required this.onDetail, required this.onEdit, required this.onAddChild, required this.onVersionManage, required this.onDelete, }); final _ChannelRow row; final bool isExpanded; final void Function(OrgNode) onToggle; final void Function(OrgNode, String?) onDetail; final void Function(OrgNode) onEdit; final void Function(OrgNode) onAddChild; final void Function(OrgNode) onVersionManage; final Future Function(OrgNode, {bool skipConfirm}) onDelete; Color _typeColor(BuildContext context, int type) { if (type == 1) return Colors.green; if (type == 2) return Colors.orange; return Theme.of(context).colorScheme.primary; } @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); final node = row.node; final hasChildren = node.children.isNotEmpty; return Row( children: [ if (hasChildren) IconButton( icon: Icon(isExpanded ? Icons.expand_less : Icons.expand_more), visualDensity: VisualDensity.compact, onPressed: () => onToggle(node), ) else const SizedBox(width: 40), Expanded( child: InkWell( // 点按行主体打开详情(清晰的大点按区域,触控友好)。 onTap: () => onDetail(node, row.parentName), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( node.name, style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 15, ), ), ), Chip( label: Text(node.typeLabel), visualDensity: VisualDensity.compact, backgroundColor: _typeColor(context, node.type).withOpacity(0.15), labelStyle: TextStyle( color: _typeColor(context, node.type), fontSize: 12, ), ), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( color: node.isDisabled ? Colors.red.withOpacity(0.15) : Colors.green.withOpacity(0.15), borderRadius: BorderRadius.circular(12), ), child: Text( node.isDisabled ? t.channelDisabled : t.channelEnabled, style: TextStyle( fontSize: 12, color: node.isDisabled ? Colors.red : Colors.green, ), ), ), ], ), const SizedBox(height: 4), Text( '${t.channelOrgCode}:${node.orgCodeLabel} · ' '${t.channelSort}:${node.orderNo}' '${node.createdAt != null ? ' · ${t.createTime}:${Formatters.formatDateTime(node.createdAt!)}' : ''}', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ), ), PopupMenuButton( onSelected: (v) { if (v == 'add') onAddChild(node); if (v == 'edit') onEdit(node); if (v == 'version') onVersionManage(node); if (v == 'delete') onDelete(node); }, itemBuilder: (_) => [ if (node.type == 1 || node.type == 2) PopupMenuItem(value: 'add', child: Text(t.addSubChannel)), PopupMenuItem(value: 'edit', child: Text(t.edit)), if (node.type == 1 || node.type == 2) PopupMenuItem( value: 'version', child: Text(t.channelVersionManage), ), PopupMenuItem( value: 'delete', enabled: !hasChildren, child: Text( hasChildren ? '${t.delete}(有子集)' : t.delete, style: TextStyle( color: hasChildren ? Theme.of(context).disabledColor : Theme.of(context).colorScheme.error, ), ), ), ], ), ], ); } } /// 详情卡:字段行。 class _DetailRow extends StatelessWidget { const _DetailRow(this.label, this.value); final String label; final String value; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 96, child: Text( label, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), Expanded( child: Text( value, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), ), ], ), ); } } /// 详情卡:类型/状态徽章。 class _DetailChip extends StatelessWidget { const _DetailChip({required this.label, required this.color}); final String label; final Color color; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(12), ), child: Text( label, style: TextStyle(fontSize: 12, color: color), ), ); } }