2025-08-21 16:44:24 +08:00
|
|
|
|
import 'dart:convert';
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:qhd_prevention/customWidget/search_bar_widget.dart';
|
|
|
|
|
|
import 'package:qhd_prevention/http/ApiService.dart';
|
|
|
|
|
|
import 'package:qhd_prevention/tools/tools.dart';
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Models -----
|
|
|
|
|
|
class Category {
|
|
|
|
|
|
final String ELECTRONIC_FENCE_AREA_ID;
|
|
|
|
|
|
final String POSITIONS;
|
|
|
|
|
|
final String name;
|
|
|
|
|
|
final List<Category> children;
|
|
|
|
|
|
|
|
|
|
|
|
Category({
|
|
|
|
|
|
required this.ELECTRONIC_FENCE_AREA_ID,
|
|
|
|
|
|
required this.name,
|
|
|
|
|
|
this.POSITIONS = '',
|
|
|
|
|
|
this.children = const [],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
factory Category.fromJson(Map<String, dynamic> json) {
|
|
|
|
|
|
// 保护式读取,避免 null 转 List、String 时抛异常
|
|
|
|
|
|
final id = (json['ELECTRONIC_FENCE_AREA_ID'] ?? '').toString();
|
|
|
|
|
|
final name = (json['name'] ?? '').toString();
|
|
|
|
|
|
final positions = (json['POSITIONS'] ?? '').toString();
|
|
|
|
|
|
|
2025-09-16 08:28:08 +08:00
|
|
|
|
// children 仍然尝试解析(兼容旧结构),但在扁平模式下不会使用
|
2025-08-21 16:44:24 +08:00
|
|
|
|
final rawChildren = json['children'];
|
|
|
|
|
|
List<Category> childrenList = [];
|
|
|
|
|
|
if (rawChildren is List) {
|
|
|
|
|
|
childrenList = rawChildren
|
|
|
|
|
|
.where((e) => e != null)
|
|
|
|
|
|
.map((e) {
|
|
|
|
|
|
if (e is Map<String, dynamic>) {
|
|
|
|
|
|
return Category.fromJson(e);
|
|
|
|
|
|
} else if (e is Map) {
|
|
|
|
|
|
return Category.fromJson(Map<String, dynamic>.from(e));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.whereType<Category>()
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Category(
|
|
|
|
|
|
ELECTRONIC_FENCE_AREA_ID: id,
|
|
|
|
|
|
name: name,
|
|
|
|
|
|
POSITIONS: positions,
|
|
|
|
|
|
children: childrenList,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-16 08:28:08 +08:00
|
|
|
|
typedef DeptSelectCallback = void Function(String id, String POSITIONS, String name);
|
2025-08-21 16:44:24 +08:00
|
|
|
|
|
|
|
|
|
|
class WorkAreaPicker extends StatefulWidget {
|
|
|
|
|
|
final DeptSelectCallback onSelected;
|
|
|
|
|
|
|
|
|
|
|
|
const WorkAreaPicker({Key? key, required this.onSelected}) : super(key: key);
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
_WorkAreaPickerState createState() => _WorkAreaPickerState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _WorkAreaPickerState extends State<WorkAreaPicker> {
|
|
|
|
|
|
String selectedId = '';
|
|
|
|
|
|
String selectedName = '';
|
|
|
|
|
|
String selected_POSITIONS = '';
|
|
|
|
|
|
|
|
|
|
|
|
List<Category> original = [];
|
|
|
|
|
|
List<Category> filtered = [];
|
|
|
|
|
|
bool loading = true;
|
|
|
|
|
|
|
|
|
|
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
|
|
|
|
|
selectedId = '';
|
|
|
|
|
|
selectedName = '';
|
|
|
|
|
|
selected_POSITIONS = '';
|
|
|
|
|
|
_searchController.addListener(_onSearchChanged);
|
|
|
|
|
|
_loadData();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
|
|
|
|
|
_searchController.removeListener(_onSearchChanged);
|
|
|
|
|
|
_searchController.dispose();
|
|
|
|
|
|
super.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _loadData() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final result = await ApiService.getWorkAreaList();
|
2025-09-16 08:28:08 +08:00
|
|
|
|
List<dynamic> raw = result['varList'] ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
// 解析为 Category 列表(可能包含 children)
|
|
|
|
|
|
final parsed = raw
|
|
|
|
|
|
.map((e) {
|
|
|
|
|
|
if (e is Map<String, dynamic>) return Category.fromJson(e);
|
|
|
|
|
|
if (e is Map) return Category.fromJson(Map<String, dynamic>.from(e));
|
|
|
|
|
|
return null;
|
|
|
|
|
|
})
|
|
|
|
|
|
.whereType<Category>()
|
|
|
|
|
|
.toList();
|
2025-08-21 16:44:24 +08:00
|
|
|
|
|
2025-09-16 08:28:08 +08:00
|
|
|
|
// 扁平化:父与子都放到同一列表中(以兼容后端可能还是两层结构)
|
|
|
|
|
|
final List<Category> flat = [];
|
|
|
|
|
|
for (final c in parsed) {
|
|
|
|
|
|
flat.add(c);
|
|
|
|
|
|
if (c.children.isNotEmpty) {
|
|
|
|
|
|
flat.addAll(c.children);
|
|
|
|
|
|
}
|
2025-08-21 16:44:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-16 08:28:08 +08:00
|
|
|
|
// 规范化 id:如果后端 ID 为空,则为该项赋予一个独一无二的占位 id,
|
|
|
|
|
|
// 避免与默认 selectedId = '' 冲突导致“全部被选中”的问题。
|
|
|
|
|
|
final List<Category> normalized = [];
|
|
|
|
|
|
for (var i = 0; i < flat.length; i++) {
|
|
|
|
|
|
final c = flat[i];
|
|
|
|
|
|
final rawId = (c.ELECTRONIC_FENCE_AREA_ID ?? '').toString().trim();
|
|
|
|
|
|
if (rawId.isEmpty) {
|
|
|
|
|
|
// 生成占位 id(基于索引和 name hash,足够唯一且不会是空字符串)
|
|
|
|
|
|
final generatedId = '__generated_${i}_${c.name.hashCode}';
|
|
|
|
|
|
normalized.add(Category(
|
|
|
|
|
|
ELECTRONIC_FENCE_AREA_ID: generatedId,
|
|
|
|
|
|
name: c.name,
|
|
|
|
|
|
POSITIONS: c.POSITIONS,
|
|
|
|
|
|
children: const [],
|
|
|
|
|
|
));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 保持原 id
|
|
|
|
|
|
normalized.add(Category(
|
|
|
|
|
|
ELECTRONIC_FENCE_AREA_ID: rawId,
|
|
|
|
|
|
name: c.name,
|
|
|
|
|
|
POSITIONS: c.POSITIONS,
|
|
|
|
|
|
children: const [],
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-21 16:44:24 +08:00
|
|
|
|
|
|
|
|
|
|
setState(() {
|
2025-09-16 08:28:08 +08:00
|
|
|
|
original = normalized;
|
2025-08-21 16:44:24 +08:00
|
|
|
|
filtered = original;
|
|
|
|
|
|
loading = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e, st) {
|
2025-09-16 08:28:08 +08:00
|
|
|
|
debugPrint('WorkAreaPicker._loadData error: $e\n$st');
|
2025-08-21 16:44:24 +08:00
|
|
|
|
setState(() => loading = false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _onSearchChanged() {
|
|
|
|
|
|
final query = _searchController.text.toLowerCase().trim();
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
filtered = query.isEmpty ? original : _filterCategories(original, query);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-16 08:28:08 +08:00
|
|
|
|
// 扁平过滤:在 name 或 POSITIONS 中搜索
|
2025-08-21 16:44:24 +08:00
|
|
|
|
List<Category> _filterCategories(List<Category> list, String query) {
|
2025-09-16 08:28:08 +08:00
|
|
|
|
if (query.isEmpty) return list;
|
|
|
|
|
|
final q = query.toLowerCase();
|
|
|
|
|
|
return list.where((c) {
|
|
|
|
|
|
final name = (c.name ?? '').toLowerCase();
|
|
|
|
|
|
final pos = (c.POSITIONS ?? '').toLowerCase();
|
|
|
|
|
|
return name.contains(q) || pos.contains(q);
|
|
|
|
|
|
}).toList();
|
2025-08-21 16:44:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-16 08:28:08 +08:00
|
|
|
|
Widget _buildRow(Category cat) {
|
|
|
|
|
|
final isSelected = selectedId == cat.ELECTRONIC_FENCE_AREA_ID;
|
|
|
|
|
|
return InkWell(
|
|
|
|
|
|
onTap: () {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
selectedId = cat.ELECTRONIC_FENCE_AREA_ID;
|
|
|
|
|
|
selectedName = cat.name;
|
|
|
|
|
|
selected_POSITIONS = cat.POSITIONS;
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Expanded(child: Text(cat.name)),
|
|
|
|
|
|
Icon(
|
|
|
|
|
|
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
|
|
|
|
|
color: isSelected ? Colors.green : Colors.grey,
|
2025-08-21 16:44:24 +08:00
|
|
|
|
),
|
2025-09-16 08:28:08 +08:00
|
|
|
|
],
|
2025-08-21 16:44:24 +08:00
|
|
|
|
),
|
2025-09-16 08:28:08 +08:00
|
|
|
|
),
|
2025-08-21 16:44:24 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
return Container(
|
|
|
|
|
|
width: MediaQuery.of(context).size.width,
|
|
|
|
|
|
height: MediaQuery.of(context).size.height * 0.7,
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Container(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () => Navigator.of(context).pop(),
|
|
|
|
|
|
child: const Text('取消', style: TextStyle(fontSize: 16)),
|
|
|
|
|
|
),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
|
|
|
|
child: SearchBarWidget(
|
|
|
|
|
|
controller: _searchController,
|
|
|
|
|
|
isShowSearchButton: false,
|
|
|
|
|
|
onSearch: (keyboard) {},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () {
|
|
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
|
|
widget.onSelected(
|
|
|
|
|
|
selectedId,
|
|
|
|
|
|
selected_POSITIONS,
|
|
|
|
|
|
selectedName,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
child: const Text(
|
|
|
|
|
|
'确定',
|
|
|
|
|
|
style: TextStyle(fontSize: 16, color: Colors.green),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2025-09-16 08:28:08 +08:00
|
|
|
|
const Divider(height: 1),
|
2025-08-21 16:44:24 +08:00
|
|
|
|
Expanded(
|
2025-09-16 08:28:08 +08:00
|
|
|
|
child: loading
|
|
|
|
|
|
? const Center(child: CircularProgressIndicator())
|
|
|
|
|
|
: (filtered.isEmpty
|
|
|
|
|
|
? const Center(child: Text('没有找到匹配的工作区域'))
|
|
|
|
|
|
: Container(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
child: ListView.builder(
|
|
|
|
|
|
itemCount: filtered.length,
|
|
|
|
|
|
itemBuilder: (ctx, idx) => _buildRow(filtered[idx]),
|
|
|
|
|
|
),
|
|
|
|
|
|
)),
|
2025-08-21 16:44:24 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|