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 createState() => _UserPageState(); } class _UserPageState extends ConsumerState { final SystemService _service = SystemService(); final List _items = []; bool _loading = false; int _page = 1; bool _hasMore = true; String? _error; final TextEditingController _searchController = TextEditingController(); String _searchKeyword = ''; List _roles = []; bool _rolesLoaded = false; List _orgNodes = []; final Map _orgNameMap = {}; /// orgId -> 组织类型(0=平台 1=渠道 2=代理商 3=门店 4=设备分组), /// 用于「角色」字段在角色名缺失时回退展示组织类型,而非裸数字 roleId。 final Map _orgTypeMap = {}; final ScrollController _scrollController = ScrollController(); final int _limit = 20; /// 当前账号密码是否明文展示(仅在无下级账号时展示自身密码)。 bool _showOwnPassword = false; /// 当前选中的组织(门店/渠道商)筛选值,null 表示全部。 String? _selectedOrgId; /// 当前登录用户组织范围内的全部组织 ID(用于客户端二次收敛用户列表)。 /// 仅在 [SessionOrgScope] 启用时填充;为 null 表示尚未加载。 Set? _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 _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 _loadOrgs() async { // 优先取组织树,保留层级关系;失败时回退到扁平列表。 Future> 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 []; } _orgNodes = await fetch(); _buildOrgNameMap(_orgNodes); if (mounted) setState(() {}); } /// 递归建立 orgId -> name / type 映射,用于展示用户所属门店/渠道商及其组织类型。 void _buildOrgNameMap(List 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 _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 _enrichRoles(List 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 _loadMore() async { if (_loading || !_hasMore) return; _page++; await _loadData(); } Future _refresh() async { await _loadData(reset: true); } void _onSearchChanged(String value) { _searchKeyword = value; _refresh(); } Future _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>( 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 _deleteUser(SysUser user) async { final t = AppLocalizations.of(context); final confirmed = await showDialog( 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( isExpanded: true, value: _selectedOrgId, hint: Text(t.all), items: [ DropdownMenuItem( value: null, child: Text(t.all), ), ..._orgNameMap.entries .where((e) => e.key.isNotEmpty && e.value.isNotEmpty) .map((e) => DropdownMenuItem( 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( 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 roles; final List orgNodes; /// 当前登录用户所属组织 ID,新增用户时作为默认值。 final String? currentUserOrgId; /// 当前登录用户角色 ID,新增用户时作为默认值。 final String? currentUserRoleId; @override State<_UserForm> createState() => _UserFormState(); } class _UserFormState extends State<_UserForm> { final _formKey = GlobalKey(); 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 roles, String name) { final target = name.trim(); if (target.isEmpty) return null; final targetLower = target.toLowerCase(); return roles.cast().firstWhere( (r) => r!.roleName.trim().toLowerCase() == targetLower, orElse: () => null, ); } /// 在组织树/列表中按名称查找节点(递归),支持不区分大小写与前后空格。 OrgNode? _findOrgByName(List 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 _toPickerOptions() { return _orgOptions .map((o) => OrgPickerOption( node: o.node, depth: o.depth, selectable: true, )) .toList(); } /// 根据输入的组织节点(树形或扁平)构建层级有序的下拉选项。 List<_OrgOption> _buildOrgOptions(List nodes) { final hasTree = nodes.any((n) => n.children.isNotEmpty); if (hasTree) { return _buildHierarchicalOptions(nodes); } return _buildHierarchyFromFlatList(nodes); } /// 把组织树递归扁平化为带深度的下拉项。 List<_OrgOption> _buildHierarchicalOptions(List 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 nodes) { final map = {}; final roots = []; 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 = { '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( 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( 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), ), ], ), ], ), ), ), ); } }