标签支持分类

This commit is contained in:
DelLevin-Home
2026-08-12 20:19:09 +08:00
parent 43d8c01bb2
commit 98d8350693
4 changed files with 350 additions and 69 deletions

View File

@@ -81,7 +81,7 @@ class DatabaseHelper {
return await openDatabase(
path,
version: 41,
version: 42,
onCreate: _createDB,
onUpgrade: _onUpgrade,
);
@@ -420,6 +420,17 @@ class DatabaseHelper {
await db.execute('ALTER TABLE movies ADD COLUMN duration INTEGER DEFAULT 0');
}
}
if (oldVersion < 42) {
// 标签表添加父级分类字段(分级分类)
try {
final tagCols = await db.rawQuery('PRAGMA table_info(tags)');
if (!tagCols.any((col) => col['name'] == 'parent_id')) {
await db.execute("ALTER TABLE tags ADD COLUMN parent_id TEXT DEFAULT ''");
}
} catch (e) {
debugPrint('Migration v42 failed: $e');
}
}
}
Future<void> _upgradeBooksTableV26(Database db) async {
final columns = await db.rawQuery('PRAGMA table_info(books)');
@@ -924,6 +935,7 @@ class DatabaseHelper {
type TEXT NOT NULL,
created_at TEXT NOT NULL,
is_hidden INTEGER NOT NULL DEFAULT 0,
parent_id TEXT DEFAULT '',
UNIQUE(name, type)
)
''');

View File

@@ -33,19 +33,56 @@ class TagDao {
return results.isNotEmpty ? results.first : null;
});
/// 添加标签返回新标签ID
Future<String> addTag(String name, String type) => _wrap('addTag', () async {
/// 添加标签返回新标签ID。可选指定父级分类ID空串为顶级
Future<String> addTag(String name, String type, {String parentId = ''}) => _wrap('addTag', () async {
final db = await _dbHelper.database;
final id = 'tag_${DateTime.now().millisecondsSinceEpoch}';
await db.insert('tags', {
'id': id,
'name': name,
'type': type,
'parent_id': parentId,
'created_at': DateTime.now().toIso8601String(),
});
return id;
});
/// 设置标签的父级分类ID空串表示顶级。校验父级不能是自身或自己的后代防止成环
Future<bool> setTagParent(String tagId, String parentId) => _wrap('setTagParent', () async {
final db = await _dbHelper.database;
final tag = await getTagById(tagId);
if (tag == null) return false;
if (parentId.isNotEmpty) {
if (parentId == tagId) return false;
final type = tag['type'] as String;
final descendants = await _collectDescendantIds(db, tagId, type);
if (descendants.contains(parentId)) return false;
}
await db.update('tags', {'parent_id': parentId},
where: 'id = ?', whereArgs: [tagId]);
return true;
});
/// 收集某标签的所有后代ID集合BFS用于环校验
Future<Set<String>> _collectDescendantIds(Database db, String tagId, String type) async {
final result = <String>{};
final queue = <String>[tagId];
while (queue.isNotEmpty) {
final parent = queue.removeAt(0);
final children = await db.query('tags',
where: 'type = ? AND parent_id = ?', whereArgs: [type, parent]);
for (final child in children) {
final id = child['id'] as String;
if (result.add(id)) {
queue.add(id);
}
}
}
return result;
}
/// 重命名标签,同时级联更新所有关联条目(事务保护)
Future<bool> renameTag(String tagId, String newName) => _wrap('renameTag', () async {
final db = await _dbHelper.database;
@@ -114,6 +151,8 @@ class TagDao {
}
}
await txn.update('tags', {'parent_id': ''},
where: 'parent_id = ?', whereArgs: [tagId]);
await txn.delete('tags', where: 'id = ?', whereArgs: [tagId]);
});
});
@@ -121,7 +160,11 @@ class TagDao {
/// 仅删除标签本身,不级联影响已有条目(标签名保留在条目上)
Future<void> deleteTagOnly(String tagId) => _wrap('deleteTagOnly', () async {
final db = await _dbHelper.database;
await db.delete('tags', where: 'id = ?', whereArgs: [tagId]);
await db.transaction((txn) async {
await txn.update('tags', {'parent_id': ''},
where: 'parent_id = ?', whereArgs: [tagId]);
await txn.delete('tags', where: 'id = ?', whereArgs: [tagId]);
});
});
/// 切换标签的隐藏状态

View File

@@ -25,7 +25,7 @@ class _TagManagementPageState extends State<TagManagementPage> {
String _searchQuery = '';
final _searchController = TextEditingController();
bool _showTypePicker = false;
int _selectedGroup = 0; // 0=已使用, 1=未使用, 2=隐藏
final Set<String> _collapsedIds = {}; // 已折叠的标签ID默认展开
@override
void initState() {
@@ -236,20 +236,17 @@ class _TagManagementPageState extends State<TagManagementPage> {
);
}
// 分组模式
final used = <Map<String, dynamic>>[];
final unused = <Map<String, dynamic>>[];
final hidden = <Map<String, dynamic>>[];
// 树形模式:按 parent_id 构建层级
final childrenByParent = _groupByParent(tags);
final roots = childrenByParent[''] ?? [];
// 兜底:父级不在标签集合中的节点也作为根显示,避免标签丢失
final allIds = tags.map((t) => t['id'] as String).toSet();
for (final t in tags) {
if ((t['is_hidden'] as int?) == 1) { hidden.add(t); continue; }
if ((_usageCounts[t['name']] ?? 0) > 0) { used.add(t); continue; }
unused.add(t);
final pid = (t['parent_id'] as String?) ?? '';
if (pid.isNotEmpty && !allIds.contains(pid) && !roots.any((r) => r['id'] == t['id'])) {
roots.add(t);
}
}
used.sort((a, b) => (_usageCounts[b['name']] ?? 0).compareTo(_usageCounts[a['name']] ?? 0));
final groups = [used, unused, hidden];
final groupLabels = ['已使用', '未使用', '隐藏'];
final currentGroup = groups[_selectedGroup];
return SingleChildScrollView(
key: ValueKey(type),
@@ -258,68 +255,176 @@ class _TagManagementPageState extends State<TagManagementPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchBar(colors),
// 分组切换Tab
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 12),
child: Row(
children: [
_buildGroupTab(Icons.check_circle_outline, '已使用', used.length, 0, colors),
const SizedBox(width: 8),
_buildGroupTab(Icons.radio_button_unchecked, '未使用', unused.length, 1, colors),
const SizedBox(width: 8),
_buildGroupTab(Icons.visibility_off_outlined, '隐藏', hidden.length, 2, colors),
],
),
),
if (currentGroup.isEmpty)
_buildEmptyGroup('暂无${groupLabels[_selectedGroup]}标签', colors)
const SizedBox(height: 8),
if (roots.isEmpty)
_buildEmptyGroup('暂无标签', colors)
else
Wrap(spacing: 8, runSpacing: 6, children: currentGroup.map(_buildTagChip).toList()),
..._buildTreeNodes(roots, childrenByParent, 0),
],
),
);
}
Widget _buildGroupTab(IconData icon, String label, int count, int index, ColorScheme colors) {
final selected = _selectedGroup == index;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _selectedGroup = index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
decoration: BoxDecoration(
color: selected ? colors.primary : colors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: selected ? colors.primary : colors.outlineVariant,
width: selected ? 1 : 0.5,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 4),
Text(label, style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
)),
const SizedBox(width: 4),
Text('$count', style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.35),
)),
],
/// 按 parent_id 将标签分组
Map<String, List<Map<String, dynamic>>> _groupByParent(List<Map<String, dynamic>> tags) {
final map = <String, List<Map<String, dynamic>>>{};
for (final t in tags) {
final parent = (t['parent_id'] as String?) ?? '';
map.putIfAbsent(parent, () => []).add(t);
}
return map;
}
/// 递归构建树节点列表
List<Widget> _buildTreeNodes(
List<Map<String, dynamic>> nodes,
Map<String, List<Map<String, dynamic>>> childrenByParent,
int depth,
) {
final result = <Widget>[];
for (final tag in nodes) {
final id = tag['id'] as String;
final sub = childrenByParent[id] ?? [];
result.add(_buildTagRow(tag, sub, depth));
if (sub.isNotEmpty && !_collapsedIds.contains(id)) {
result.addAll(_buildTreeNodes(sub, childrenByParent, depth + 1));
}
}
return result;
}
Widget _buildTagRow(Map<String, dynamic> tag, List<Map<String, dynamic>> children, int depth) {
final colors = Theme.of(context).colorScheme;
final id = tag['id'] as String;
final name = tag['name'] as String;
final count = _usageCounts[name] ?? 0;
final isHidden = (tag['is_hidden'] as int?) == 1;
final isNew = id == _newlyAddedTagId;
final hasChildren = children.isNotEmpty;
final collapsed = _collapsedIds.contains(id);
final rowContent = Row(
children: [
SizedBox(width: depth * 20.0),
// 展开/收起箭头
SizedBox(
width: 28,
height: 44,
child: Center(
child: hasChildren
? GestureDetector(
onTap: () => setState(() {
if (collapsed) {
_collapsedIds.remove(id);
} else {
_collapsedIds.add(id);
}
}),
child: Icon(
collapsed ? Icons.chevron_right : Icons.expand_more,
size: 20,
color: colors.onSurface.withValues(alpha: 0.4),
),
)
: Icon(Icons.circle, size: 4, color: colors.onSurface.withValues(alpha: 0.15)),
),
),
// 标签主体(点击打开菜单,长按拖拽移动分类)
Expanded(
child: GestureDetector(
key: ValueKey(id),
onTap: () => _showTagMenu(tag),
child: Opacity(
opacity: isHidden ? 0.4 : 1.0,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: isNew
? _NewTagHighlight(child: _rowContent(name, count, colors, isHidden: isHidden))
: _rowContent(name, count, colors, isHidden: isHidden),
),
),
),
),
],
);
// 拖动目标:把拖来的标签设为当前标签的子级
return DragTarget<Map<String, dynamic>>(
onWillAcceptWithDetails: (details) => _canDropOn(details.data, tag),
onAcceptWithDetails: (details) => _doDragMove(details.data, tag),
builder: (context, candidates, rejected) {
final isTarget = candidates.isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: isTarget ? colors.primary.withValues(alpha: 0.08) : Colors.transparent,
border: depth == 0
? Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))
: null,
),
child: LongPressDraggable<Map<String, dynamic>>(
data: tag,
feedback: _dragFeedback(name, colors),
childWhenDragging: Opacity(opacity: 0.3, child: rowContent),
child: rowContent,
),
);
},
);
}
/// 是否允许把 [dragged] 拖到 [target] 下(排除自身与成环)
bool _canDropOn(Map<String, dynamic> dragged, Map<String, dynamic> target) {
final draggedId = dragged['id'] as String;
final targetId = target['id'] as String;
if (draggedId == targetId) return false;
final allTags = _tagCache[target['type'] as String] ?? [];
final descendants = _collectDescendants(allTags, draggedId);
return !descendants.contains(targetId);
}
Future<void> _doDragMove(Map<String, dynamic> dragged, Map<String, dynamic> target) async {
final type = dragged['type'] as String;
final success = await context.read<AppProvider>().setTagParent(
dragged['id'] as String, type, target['id'] as String);
if (mounted) {
if (success) ToastUtil.show(context, '已移动到「${target['name']}');
await _loadTags(type);
}
}
Widget _dragFeedback(String name, ColorScheme colors) {
return Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.2), blurRadius: 12, offset: const Offset(0, 4))],
),
child: Text(name, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onPrimary)),
),
);
}
Widget _rowContent(String name, int count, ColorScheme colors, {bool isHidden = false}) {
return Row(
children: [
Flexible(
child: Text(name, style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface,
decoration: isHidden ? TextDecoration.lineThrough : null,
)),
),
if (count > 0) ...[
const SizedBox(width: 6),
Text('$count', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.35))),
],
],
);
}
Widget _buildSearchBar(ColorScheme colors) {
return Container(
height: 40,
@@ -497,6 +602,10 @@ class _TagManagementPageState extends State<TagManagementPage> {
await _loadTags(_currentType);
},
),
_menuAction(Icons.drive_file_move_outlined, '移动到分类', colors, () {
Navigator.pop(ctx);
_showMoveDialog(tag);
}),
_menuAction(Icons.open_in_new_outlined, '查看相关${_typeLabels[_currentIndex].replaceAll('类型', '').replaceAll('标签', '')}', colors, () {
Navigator.pop(ctx);
_showTagItems(name);
@@ -613,6 +722,116 @@ class _TagManagementPageState extends State<TagManagementPage> {
);
}
// ─── 移动到分类 ─────────────────────────────────────────────────────────
Set<String> _collectDescendants(List<Map<String, dynamic>> allTags, String tagId) {
final result = <String>{};
final queue = <String>[tagId];
while (queue.isNotEmpty) {
final parent = queue.removeAt(0);
for (final t in allTags) {
if ((t['parent_id'] as String?) == parent) {
final id = t['id'] as String;
if (result.add(id)) queue.add(id);
}
}
}
return result;
}
void _showMoveDialog(Map<String, dynamic> tag) {
final colors = Theme.of(context).colorScheme;
final tagId = tag['id'] as String;
final type = tag['type'] as String;
final name = tag['name'] as String;
final currentParent = (tag['parent_id'] as String?) ?? '';
final allTags = _tagCache[type] ?? [];
final descendants = _collectDescendants(allTags, tagId);
final candidates = allTags
.where((t) => (t['id'] as String) != tagId && !descendants.contains(t['id'] as String))
.toList();
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(child: Container(
width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16),
decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)),
)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text('将「$name」移动到', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
),
const SizedBox(height: 8),
_moveOption(
colors,
icon: Icons.home_outlined,
title: '顶级(无父级)',
selected: currentParent.isEmpty,
onTap: () => _doMoveParent(ctx, tagId, type, ''),
),
const SizedBox(height: 4),
Flexible(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 16),
children: candidates.map((c) => _moveOption(
colors,
icon: Icons.folder_outlined,
title: c['name'] as String,
selected: c['id'] == currentParent,
onTap: () => _doMoveParent(ctx, tagId, type, c['id'] as String),
)).toList(),
),
),
],
),
),
);
}
Future<void> _doMoveParent(BuildContext ctx, String tagId, String type, String parentId) async {
final success = await context.read<AppProvider>().setTagParent(tagId, type, parentId);
if (ctx.mounted) {
Navigator.pop(ctx);
ToastUtil.show(context, success ? '移动成功' : '无法移动到该分类');
}
await _loadTags(type);
}
Widget _moveOption(
ColorScheme colors, {
required IconData icon,
required String title,
required bool selected,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20),
child: Row(
children: [
Icon(icon, size: 20, color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 14),
Expanded(child: Text(title, style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500,
color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.7),
))),
if (selected) Icon(Icons.check, size: 18, color: colors.primary),
],
),
),
);
}
// ─── 添加标签 ──────────────────────────────────────────────────────────
void _showAddDialog() {

View File

@@ -1170,12 +1170,19 @@ class AppProvider extends ChangeNotifier {
notifyListeners();
}
Future<String> addTag(String name, String type) async {
final id = await _tagDao.addTag(name, type);
Future<String> addTag(String name, String type, {String parentId = ''}) async {
final id = await _tagDao.addTag(name, type, parentId: parentId);
await _reloadByTagType(type);
return id;
}
/// 设置标签的父级分类ID空串表示顶级
Future<bool> setTagParent(String tagId, String type, String parentId) async {
final result = await _tagDao.setTagParent(tagId, parentId);
await _reloadByTagType(type);
return result;
}
Future<bool> renameTag(String tagId, String newName, String type) async {
final result = await _tagDao.renameTag(tagId, newName);
if (result) {