895 lines
33 KiB
Dart
895 lines
33 KiB
Dart
|
|
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<ChannelManagementPage> createState() =>
|
|||
|
|
_ChannelManagementPageState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class _ChannelManagementPageState
|
|||
|
|
extends ConsumerState<ChannelManagementPage> {
|
|||
|
|
List<OrgNode> _tree = [];
|
|||
|
|
bool _loading = false;
|
|||
|
|
String? _error;
|
|||
|
|
final _searchController = TextEditingController();
|
|||
|
|
String _keyword = '';
|
|||
|
|
int? _typeFilter;
|
|||
|
|
String? _statusFilter;
|
|||
|
|
final Set<String> _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<void> _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<OrgNode> 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<bool> _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<bool>(
|
|||
|
|
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<int?>(
|
|||
|
|
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<String?>(
|
|||
|
|
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<int>(
|
|||
|
|
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<int>(
|
|||
|
|
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 = <String, dynamic>{
|
|||
|
|
'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<bool> 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<String>(
|
|||
|
|
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),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|