Files
CloudHome-Ops/lib/screens/admin/sys/user_page.dart

1192 lines
41 KiB
Dart
Raw Permalink Normal View History

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../models/org_node.dart';
import '../../../models/sys_role.dart';
import '../../../models/sys_user.dart';
import '../../../providers/auth_provider.dart';
import '../../../services/api_client.dart';
import '../../../services/org_service.dart';
import '../../../services/sys_service.dart';
import '../../../utils/credential_storage.dart';
import '../../../utils/i10n.dart';
import '../../../utils/session_org_scope.dart';
import '../../../widgets/org_picker_sheet.dart';
/// 用户管理页。
class UserPage extends ConsumerStatefulWidget {
const UserPage({super.key});
@override
ConsumerState<UserPage> createState() => _UserPageState();
}
class _UserPageState extends ConsumerState<UserPage> {
final SystemService _service = SystemService();
final List<SysUser> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final TextEditingController _searchController = TextEditingController();
String _searchKeyword = '';
List<SysRole> _roles = [];
bool _rolesLoaded = false;
List<OrgNode> _orgNodes = [];
final Map<String, String> _orgNameMap = {};
/// orgId -> 组织类型(0=平台 1=渠道 2=代理商 3=门店 4=设备分组),
/// 用于「角色」字段在角色名缺失时回退展示组织类型,而非裸数字 roleId。
final Map<String, int> _orgTypeMap = {};
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
/// 当前账号密码是否明文展示(仅在无下级账号时展示自身密码)。
bool _showOwnPassword = false;
/// 当前选中的组织(门店/渠道商)筛选值,null 表示全部。
String? _selectedOrgId;
/// 当前登录用户组织范围内的全部组织 ID(用于客户端二次收敛用户列表)。
/// 仅在 [SessionOrgScope] 启用时填充;为 null 表示尚未加载。
Set<String>? _scopeOrgIds;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadRoles();
_loadOrgs();
_loadData();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadRoles() async {
final t = AppLocalizations.of(context);
try {
// 优先用 /sys/role/select;不存在时回退到 /sys/role/list。
_roles = await _service.getRoleSelect();
if (_roles.isEmpty) {
_roles = await _service.getRoleList(limit: 1000);
}
} catch (e) {
// 下拉接口失败时尝试分页接口。
try {
_roles = await _service.getRoleList(limit: 1000);
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.rolesLoadFailed)),
);
}
}
} finally {
_rolesLoaded = true;
if (mounted) setState(() {});
}
}
Future<void> _loadOrgs() async {
// 优先取组织树,保留层级关系;失败时回退到扁平列表。
Future<List<OrgNode>> fetch() async {
try {
final tree = await OrgService().getOrgTree();
if (tree.isNotEmpty) return tree;
} catch (_) {}
try {
final flat = await OrgService().getOrgFlatList();
if (flat.isNotEmpty) return flat;
} catch (_) {}
try {
final select = await OrgService().getOrgSelect();
if (select.isNotEmpty) return select;
} catch (_) {}
return const <OrgNode>[];
}
_orgNodes = await fetch();
_buildOrgNameMap(_orgNodes);
if (mounted) setState(() {});
}
/// 递归建立 orgId -> name / type 映射,用于展示用户所属门店/渠道商及其组织类型。
void _buildOrgNameMap(List<OrgNode> nodes) {
for (final node in nodes) {
if (node.id.isNotEmpty) {
if (node.name.isNotEmpty) _orgNameMap[node.id] = node.name;
_orgTypeMap[node.id] = node.type;
}
if (node.children.isNotEmpty) {
_buildOrgNameMap(node.children);
}
}
}
/// 展示用户归属(门店/渠道商/代理商)。
String _displayOrgName(SysUser user) {
final t = AppLocalizations.of(context);
if (user.orgName.isNotEmpty) return user.orgName;
if (user.orgId.isNotEmpty) {
final name = _orgNameMap[user.orgId];
if (name != null && name.isNotEmpty) return name;
}
return t.unassigned;
}
/// 组织类型 → 中文标签(0=平台 1=渠道 2=代理商 3=门店 4=设备分组)。
String _orgTypeLabel(int type) {
switch (type) {
case 1:
return '渠道';
case 2:
return '代理商';
case 3:
return '门店';
case 4:
return '设备分组';
default:
return '平台';
}
}
/// 用户归属展示:优先按所属组织类型展示「门店 / 渠道 / 代理商」,
/// 这是该字段最直观的归属信息(避免显示裸数字 roleId)。
/// 组织类型无法解析(orgId 缺失或不在组织树)时,再回退到角色名 / 角色名数组。
String _displayRoleName(SysUser user) {
final t = AppLocalizations.of(context);
final type = user.orgId.isNotEmpty ? _orgTypeMap[user.orgId] : null;
if (type != null) return _orgTypeLabel(type);
if (user.roleNames.isNotEmpty) return user.roleNames.join('/');
if (user.roleName.isNotEmpty) return user.roleName;
final matched = _roles.where((r) => r.roleId == user.roleId).toList();
if (matched.isNotEmpty) return matched.first.roleName;
return t.unassigned;
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final scope = SessionOrgScope.instance;
// 首次加载时预取「当前用户组织范围内」的全部组织 ID,
// 用于客户端二次收敛,确保受限账号只能看到本组织及下属组织的用户。
if (scope.enabled && _scopeOrgIds == null) {
_scopeOrgIds = await OrgService().scopedOrgIdSet();
}
// 未手动选择组织时:
// - 管理员不附加 orgId,拉取全量用户;
// - 受限账号(渠道商/代理商)也不附加 orgId,由后端按当前会话的数据权限
// 返回范围内用户,避免直接传 agent/channel 的 orgId 导致后端误判为空。
// 手动选择组织后,按所选 orgId 精确筛选。
final effectiveOrgId = _selectedOrgId;
// 用户名搜索不传给后端:后端按 username 大小写敏感匹配,会漏掉大小写
// 不同的记录(如「ADMIN」搜不到「admin」)。改为前端不区分大小写过滤,
// 确保大小写均可命中。
final list = await _service.getUserList(
page: _page,
limit: _limit,
username: null,
orgId: effectiveOrgId,
);
// 客户端收敛:受限账号仅保留归属组织在范围内的用户(后端可能忽略 orgId 参数)。
var filtered = scope.enabled && _scopeOrgIds != null
? list.where((u) => _scopeOrgIds!.contains(u.orgId)).toList()
: list;
// 前端不区分大小写按用户名过滤(核心修复点)。
final kwLower = _searchKeyword.trim().toLowerCase();
if (kwLower.isNotEmpty) {
filtered = filtered
.where((u) => u.username.toLowerCase().contains(kwLower))
.toList();
}
_items.addAll(filtered);
_hasMore = list.length >= _limit;
// 列表接口不返回角色字段(Web 端亦不显示角色列);
// 对「角色未分配」的用户批量拉取详情以补全 roleName/roleId。
_enrichRoles(filtered);
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// 对列表中「角色未分配」的用户批量拉取详情,补全 roleName/roleId。
///
/// /sys/user/list 不返回角色字段(Web 端列表亦无角色列),
/// 角色仅在 /sys/user/info/{id} 详情接口中返回。本方法以并发限制 3
/// 逐批拉取,避免 N+1 压垮接口;失败时静默跳过(不阻断列表展示)。
Future<void> _enrichRoles(List<SysUser> fresh) async {
final needEnrich = fresh
.where((u) =>
u.roleId.isEmpty &&
u.roleName.isEmpty &&
u.roleNames.isEmpty)
.toList();
if (needEnrich.isEmpty || !mounted) return;
const concurrency = 3;
for (var i = 0; i < needEnrich.length; i += concurrency) {
if (!mounted) return;
final batch = needEnrich.skip(i).take(concurrency).toList();
final futures = batch.map((u) async {
try {
final detail = await _service.getUserInfo(u.id);
return MapEntry(u.id, detail);
} catch (_) {
return null;
}
}).toList();
final results = await Future.wait(futures);
var changed = false;
for (final r in results) {
if (r == null || !mounted) continue;
final idx = _items.indexWhere((u) => u.id == r.key);
if (idx < 0) continue;
final detail = r.value;
if (detail.roleId.isNotEmpty ||
detail.roleName.isNotEmpty ||
detail.roleNames.isNotEmpty) {
_items[idx] = _items[idx].copyWith(
roleId: detail.roleId,
roleName: detail.roleName,
roleNames: detail.roleNames,
);
changed = true;
}
}
if (changed && mounted) setState(() {});
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
void _onSearchChanged(String value) {
_searchKeyword = value;
_refresh();
}
Future<void> _showForm({SysUser? user}) async {
final t = AppLocalizations.of(context);
if (!_rolesLoaded) await _loadRoles();
// 编辑时若角色信息缺失,拉取用户详情以正确初始化角色字段。
SysUser? effectiveUser = user;
if (user != null &&
user.roleId.isEmpty &&
user.roleName.isEmpty &&
user.roleNames.isEmpty) {
try {
effectiveUser = await _service.getUserInfo(user.id);
} catch (_) {
effectiveUser = user;
}
if (!mounted) return;
}
final currentUser = ref.read(authProvider).user;
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
builder: (context) => _UserForm(
user: effectiveUser,
roles: _roles,
orgNodes: _orgNodes,
currentUserOrgId: currentUser?.orgId,
currentUserRoleId: currentUser?.roleId,
),
);
if (result == null) return;
try {
await _service.saveUser(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.saved)),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${t.saveFailed}:${formatApiError(e)}')),
);
}
}
}
Future<void> _deleteUser(SysUser user) async {
final t = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.deleteUserConfirmTitle),
content: Text(t.deleteUserConfirmContent(user.username)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(t.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(t.delete),
),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteUser([int.tryParse(user.id) ?? 0]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.deleted)),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${t.deleteFailed}:${formatApiError(e)}')),
);
}
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(t.userManagement),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
// 所属组织(门店)筛选,参考 web 端下拉样式。
Expanded(
flex: 2,
child: InputDecorator(
decoration: InputDecoration(
labelText: t.org,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.account_tree),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String?>(
isExpanded: true,
value: _selectedOrgId,
hint: Text(t.all),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text(t.all),
),
..._orgNameMap.entries
.where((e) => e.key.isNotEmpty && e.value.isNotEmpty)
.map((e) => DropdownMenuItem<String?>(
value: e.key,
child: Text(e.value),
)),
],
onChanged: (v) {
setState(() => _selectedOrgId = v);
_refresh();
},
),
),
),
),
const SizedBox(width: 8),
// 用户名搜索
Expanded(
flex: 3,
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: t.searchUsernameHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchKeyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
),
border: const OutlineInputBorder(),
),
onChanged: _onSearchChanged,
),
),
],
),
),
Expanded(child: _buildBody()),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.person_add),
),
);
}
/// 当前账号是否存在下级账号(即列表中存在 username 与当前登录账号不同的用户)。
///
/// - 当前用户未登录:按 false 处理;
/// - 列表为空 或 仅包含当前账号自身:false(展示自身账号信息);
/// - 包含其他用户:true(展示下级账号列表,不展示自身密码)。
bool get _hasSubordinates {
final currentUser = ref.read(authProvider).user;
if (currentUser == null) return false;
return _items.any((u) => u.username != currentUser.account);
}
Widget _buildBody() {
final t = AppLocalizations.of(context);
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: Text(t.retry),
),
],
),
);
}
final hasSubordinates = _hasSubordinates;
// 数据加载完成且当前账号无下级账号:展示自身账号名与密码。
if (!_loading && !hasSubordinates) {
return RefreshIndicator(
onRefresh: _refresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: _buildCurrentUserCard(),
),
);
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
if (index == _items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore) {
return Padding(
padding: const EdgeInsets.all(16),
child: Center(child: Text(t.noMoreData)),
);
}
return const SizedBox.shrink();
}
final user = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
title: Text(user.username),
subtitle: Text(
'${t.roleLabel}:${_displayRoleName(user)} | '
'${user.status == 0 ? t.statusNormal : t.statusDisabled} | '
'${t.orgLabel}:${_displayOrgName(user)}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(user: user);
} else if (value == 'delete') {
_deleteUser(user);
}
},
itemBuilder: (context) => [
PopupMenuItem(value: 'edit', child: Text(t.edit)),
PopupMenuItem(value: 'delete', child: Text(t.delete)),
],
),
),
);
},
),
);
}
/// 无下级账号时展示的「当前账号信息」卡片。
///
/// 展示当前登录账号的账号名与密码:
/// - 账号:从 [authProvider.user.account] 读取;
/// - 密码:从 [CredentialStorage.savedPassword] 读取,支持点击图标切换显隐;
/// 若用户未勾选「记住密码」导致本地无密码,则显示「未保存」。
Widget _buildCurrentUserCard() {
final t = AppLocalizations.of(context);
final currentUser = ref.read(authProvider).user;
final account = currentUser?.account ?? '';
final savedPassword = CredentialStorage.instance.savedPassword;
final hasPassword = savedPassword != null && savedPassword.isNotEmpty;
final displayPassword = _showOwnPassword
? (savedPassword ?? t.passwordNotSaved)
: (hasPassword ? '••••••' : t.passwordNotSaved);
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.account_circle,
color: Theme.of(context).colorScheme.primary,
size: 28,
),
const SizedBox(width: 12),
Text(
t.currentAccountInfo,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 20),
_buildInfoRow(label: t.account, value: account),
const SizedBox(height: 16),
_buildInfoRow(
label: t.password,
value: displayPassword,
trailing: hasPassword
? IconButton(
icon: Icon(
_showOwnPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
tooltip: _showOwnPassword
? t.hidePassword
: t.showPassword,
onPressed: () => setState(
() => _showOwnPassword = !_showOwnPassword,
),
)
: null,
),
const SizedBox(height: 20),
Text(
t.noSubordinateAccount,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
),
),
);
}
Widget _buildInfoRow({
required String label,
required String value,
Widget? trailing,
}) {
return Row(
children: [
SizedBox(
width: 64,
child: Text(
'$label:',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
if (trailing != null) trailing,
],
);
}
}
/// 组织下拉项:携带层级深度,用于按组织树自上而下排序并缩进展示。
class _OrgOption {
const _OrgOption({
required this.node,
required this.depth,
});
final OrgNode node;
final int depth;
}
/// 用户表单。
class _UserForm extends StatefulWidget {
const _UserForm({
this.user,
this.roles = const [],
this.orgNodes = const [],
this.currentUserOrgId,
this.currentUserRoleId,
});
final SysUser? user;
final List<SysRole> roles;
final List<OrgNode> orgNodes;
/// 当前登录用户所属组织 ID,新增用户时作为默认值。
final String? currentUserOrgId;
/// 当前登录用户角色 ID,新增用户时作为默认值。
final String? currentUserRoleId;
@override
State<_UserForm> createState() => _UserFormState();
}
class _UserFormState extends State<_UserForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _usernameCtrl;
late TextEditingController _emailCtrl;
late TextEditingController _mobileCtrl;
late TextEditingController _passwordCtrl;
late TextEditingController _confirmPasswordCtrl;
late String _roleId;
late int _status;
late String _orgId;
/// 密码是否以密文显示(true 隐藏,false 显示)。
bool _obscurePassword = true;
/// 确认密码是否以密文显示(true 隐藏,false 显示)。
bool _obscureConfirmPassword = true;
List<_OrgOption> _orgOptions = [];
@override
void initState() {
super.initState();
final u = widget.user;
final isAdd = u == null;
_usernameCtrl = TextEditingController(text: u?.username ?? '');
_emailCtrl = TextEditingController(text: u?.email ?? '');
_mobileCtrl = TextEditingController(text: u?.mobile ?? '');
_passwordCtrl = TextEditingController();
_confirmPasswordCtrl = TextEditingController();
// 编辑模式下,密码输入后需要动态显示「确认密码」字段。
// 仅在编辑态监听,新增态确认密码始终显示。
if (u != null) {
_passwordCtrl.addListener(_onPasswordChanged);
}
// 编辑时沿用原值;新增时默认选中当前登录用户的组织与角色。
_roleId = u?.roleId ?? '';
if (_roleId.isEmpty && isAdd) {
_roleId = widget.currentUserRoleId ?? '';
}
// 若后端未返回 roleId,仅返回了 roleName/roleNames,尝试按名称匹配角色 ID。
if (_roleId.isEmpty && u != null) {
final nameToMatch = u.roleName.isNotEmpty
? u.roleName
: (u.roleNames.isNotEmpty ? u.roleNames.first : '');
if (nameToMatch.isNotEmpty) {
final matched = _findRoleByName(widget.roles, nameToMatch);
if (matched != null) {
_roleId = matched.roleId;
}
}
}
_status = u?.status ?? 0;
_orgId = u?.orgId ?? '';
// 若后端未返回 orgId,仅返回了 orgName,尝试在组织树中按名称匹配组织 ID。
if (_orgId.isEmpty && u != null && u.orgName.isNotEmpty) {
final matched = _findOrgByName(widget.orgNodes, u.orgName);
if (matched != null) {
_orgId = matched.id;
}
}
if (_orgId.isEmpty && isAdd) {
_orgId = widget.currentUserOrgId ?? '';
}
_orgOptions = _buildOrgOptions(widget.orgNodes);
}
/// 按角色名称查找角色,支持不区分大小写与前后空格。
SysRole? _findRoleByName(List<SysRole> roles, String name) {
final target = name.trim();
if (target.isEmpty) return null;
final targetLower = target.toLowerCase();
return roles.cast<SysRole?>().firstWhere(
(r) => r!.roleName.trim().toLowerCase() == targetLower,
orElse: () => null,
);
}
/// 在组织树/列表中按名称查找节点(递归),支持不区分大小写与前后空格。
OrgNode? _findOrgByName(List<OrgNode> nodes, String name) {
final target = name.trim();
if (target.isEmpty) return null;
final targetLower = target.toLowerCase();
for (final node in nodes) {
if (node.name.trim().toLowerCase() == targetLower) return node;
final found = _findOrgByName(node.children, name);
if (found != null) return found;
}
return null;
}
/// 编辑模式下密码输入变化时的回调:用于动态显示/隐藏「确认密码」字段。
void _onPasswordChanged() {
if (widget.user != null) setState(() {});
}
@override
void dispose() {
_usernameCtrl.dispose();
_emailCtrl.dispose();
_mobileCtrl.dispose();
_passwordCtrl.removeListener(_onPasswordChanged);
_passwordCtrl.dispose();
_confirmPasswordCtrl.dispose();
super.dispose();
}
/// 编辑模式下,把原角色信息展示为只读文本。
String _displayRoleForEdit(SysUser user) {
final t = AppLocalizations.of(context);
if (user.roleNames.isNotEmpty) return user.roleNames.join('/');
if (user.roleName.isNotEmpty) return user.roleName;
if (user.roleId.isNotEmpty) return user.roleId;
return t.unassigned;
}
/// 把表单内部选项转换为选择器需要的公共数据结构。
List<OrgPickerOption> _toPickerOptions() {
return _orgOptions
.map((o) => OrgPickerOption(
node: o.node,
depth: o.depth,
selectable: true,
))
.toList();
}
/// 根据输入的组织节点(树形或扁平)构建层级有序的下拉选项。
List<_OrgOption> _buildOrgOptions(List<OrgNode> nodes) {
final hasTree = nodes.any((n) => n.children.isNotEmpty);
if (hasTree) {
return _buildHierarchicalOptions(nodes);
}
return _buildHierarchyFromFlatList(nodes);
}
/// 把组织树递归扁平化为带深度的下拉项。
List<_OrgOption> _buildHierarchicalOptions(List<OrgNode> nodes,
{int depth = 0}) {
final result = <_OrgOption>[];
for (final node in nodes) {
result.add(_OrgOption(node: node, depth: depth));
if (node.children.isNotEmpty) {
result.addAll(
_buildHierarchicalOptions(node.children, depth: depth + 1));
}
}
return result;
}
/// 当树接口不可用时,根据 parentId 将扁平列表重建为层级顺序。
List<_OrgOption> _buildHierarchyFromFlatList(List<OrgNode> nodes) {
final map = <String, OrgNode>{};
final roots = <OrgNode>[];
for (final node in nodes) {
map[node.id] = node;
}
for (final node in nodes) {
if (node.parentId.isEmpty || !map.containsKey(node.parentId)) {
roots.add(node);
} else {
final parent = map[node.parentId]!;
map[node.parentId] = OrgNode(
id: parent.id,
name: parent.name,
type: parent.type,
parentId: parent.parentId,
children: [...parent.children, node],
address: parent.address,
contactPhone: parent.contactPhone,
activationCode: parent.activationCode,
province: parent.province,
city: parent.city,
themeId: parent.themeId,
);
}
}
return _buildHierarchicalOptions(roots);
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
// 后端 status:1=启用/正常,0=禁用。App 内部 _status 以 0=正常、1=禁用 表示,
// 这里转换回后端值再提交,避免状态在保存后被反转。
final backendStatus = _status == 0 ? 1 : 0;
final data = <String, dynamic>{
'username': _usernameCtrl.text.trim(),
'email': _emailCtrl.text.trim(),
'mobile': _mobileCtrl.text.trim(),
'status': backendStatus,
};
// 提交角色:后端更新接口以 roleIdList 为准(与 Web 端 saveUser 一致)。
final u = widget.user;
if (_roleId.isNotEmpty) {
data['roleId'] = _roleId;
data['roleIdList'] = [_roleId];
} else if (u?.roleId.isNotEmpty == true) {
data['roleId'] = u!.roleId;
data['roleIdList'] = [u.roleId];
}
if (u != null && u.roleNames.isNotEmpty && data['roleIdList'] == null) {
data['roleName'] = u.roleNames.join(',');
}
// 提交所属组织(门店 / 渠道商)。
if (_orgId.isNotEmpty) {
data['orgId'] = _orgId;
} else if (u?.orgId.isNotEmpty == true) {
data['orgId'] = u!.orgId;
}
if (u != null) {
data['id'] = u.id;
}
if (_passwordCtrl.text.isNotEmpty) {
data['password'] = _passwordCtrl.text;
}
Navigator.pop(context, data);
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final isEdit = widget.user != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isEdit ? t.editUser : t.addUser,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextFormField(
controller: _usernameCtrl,
decoration: InputDecoration(
labelText: t.username,
border: const OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? t.usernameRequired : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _emailCtrl,
decoration: InputDecoration(
labelText: t.email,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _mobileCtrl,
decoration: InputDecoration(
labelText: t.mobile,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordCtrl,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: t.password,
hintText: isEdit ? t.passwordEditHint : t.passwordRequired,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: (v) {
if (!isEdit && (v == null || v.isEmpty)) {
return t.passwordRequired;
}
return null;
},
),
if (!isEdit || _passwordCtrl.text.isNotEmpty) ...[
const SizedBox(height: 12),
TextFormField(
controller: _confirmPasswordCtrl,
obscureText: _obscureConfirmPassword,
decoration: InputDecoration(
labelText: t.confirmPassword,
hintText: t.confirmPasswordHint,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
),
),
validator: (v) {
if (!isEdit || _passwordCtrl.text.isNotEmpty) {
if (v == null || v.isEmpty) {
return t.confirmPasswordRequired;
}
if (v != _passwordCtrl.text) {
return t.passwordsDoNotMatch;
}
}
return null;
},
),
],
const SizedBox(height: 12),
// 所属组织(门店 / 渠道商)选择器:进入编辑页自动展开并高亮已选项。
if (widget.orgNodes.isEmpty && _orgId.isEmpty)
TextFormField(
initialValue: widget.user?.orgName ?? t.none,
enabled: false,
decoration: InputDecoration(
labelText: t.org,
border: const OutlineInputBorder(),
helperText: t.noOrgHint,
),
)
else
OrgPickerFormField(
value: _orgId.isEmpty ? null : _orgId,
labelText: t.org,
options: _toPickerOptions(),
searchHint: t.searchOrgHint,
emptyHint: t.noMatchingOrg,
// 修复:新增/编辑用户时不再自动弹出组织选择器,
// 需先填完表单基本信息、再主动点击「所属组织」字段才展开。
autoOpen: false,
onChanged: (v) => setState(() => _orgId = v ?? ''),
),
const SizedBox(height: 12),
// 角色选择器:可视化高亮勾选,编辑时按 roleId 自动选中;
// 新增默认选中当前登录用户角色(原逻辑),亦可不选中。
if (widget.roles.isEmpty && _roleId.isEmpty && widget.user != null)
TextFormField(
initialValue: _displayRoleForEdit(widget.user!),
enabled: false,
decoration: InputDecoration(
labelText: t.role,
border: const OutlineInputBorder(),
helperText: t.rolesLoadFailed,
),
)
else
FormField<String>(
initialValue: _roleId,
validator: (v) {
if (widget.user != null) return null;
return _roleId.isEmpty ? t.roleRequired : null;
},
builder: (field) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.role,
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.roles.map((role) {
final selected = _roleId == role.roleId;
return ChoiceChip(
showCheckmark: false,
avatar: selected
? Icon(
Icons.check,
size: 18,
color: Theme.of(context)
.colorScheme
.onPrimary,
)
: null,
selectedColor:
Theme.of(context).colorScheme.primary,
labelStyle: TextStyle(
color: selected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
),
selected: selected,
label: Text(role.roleName),
onSelected: (sel) {
setState(() =>
_roleId = sel ? role.roleId : '');
field.didChange(_roleId);
},
);
}).toList(),
),
if (field.hasError)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
field.errorText ?? '',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.error,
),
),
),
],
),
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: _status,
decoration: InputDecoration(
labelText: t.status,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(value: 0, child: Text(t.statusNormal)),
DropdownMenuItem(value: 1, child: Text(t.statusDisabled)),
],
onChanged: (v) => setState(() => _status = v ?? 0),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.cancel),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submit,
child: Text(t.save),
),
],
),
],
),
),
),
);
}
}