1192 lines
41 KiB
Dart
1192 lines
41 KiB
Dart
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),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|