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

1192 lines
41 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
],
),
],
),
),
),
);
}
}