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