From 8d4ab62f518f1824dbe7c409393994778d45fd50 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:37:10 +0800 Subject: [PATCH 1/3] feat(category): restore management page import flow; use dry-run preview; show details bottom sheet after import --- .../category_management_enhanced.dart | 210 ++++++++++++++---- 1 file changed, 167 insertions(+), 43 deletions(-) diff --git a/jive-flutter/lib/screens/management/category_management_enhanced.dart b/jive-flutter/lib/screens/management/category_management_enhanced.dart index 8dacab1d..d159b716 100644 --- a/jive-flutter/lib/screens/management/category_management_enhanced.dart +++ b/jive-flutter/lib/screens/management/category_management_enhanced.dart @@ -1,60 +1,184 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/category.dart'; +import '../../models/category_template.dart'; +import '../../providers/category_provider.dart'; +import '../../providers/ledger_provider.dart'; +import '../../services/api/category_service.dart'; +import '../../services/api/category_service_integrated.dart'; +import '../../widgets/bottom_sheets/import_details_sheet.dart'; -// 占位版:增强分类管理页面暂时下线以稳定测试。 -// 后续 PR 将恢复原完整交互(模板导入 / 拖拽排序 / 批量操作 / 转标签 / 统计等)。 -class CategoryManagementEnhancedPage extends StatelessWidget { +class CategoryManagementEnhancedPage extends ConsumerStatefulWidget { const CategoryManagementEnhancedPage({super.key}); + @override + ConsumerState createState() => _CategoryManagementEnhancedPageState(); +} + +class _CategoryManagementEnhancedPageState extends ConsumerState { + bool _busy = false; + @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; return Scaffold( - appBar: AppBar(title: const Text('分类管理 (占位)')), + appBar: AppBar( + title: const Text('分类管理'), + actions: [ + IconButton( + tooltip: '从模板库导入', + icon: const Icon(Icons.library_add), + onPressed: _busy ? null : _showTemplateLibrary, + ), + ], + ), body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 440), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.category_outlined, size: 72, color: cs.primary), - const SizedBox(height: 16), - const Text( - '增强版分类管理暂时下线', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 12), - Text( - '为稳定当前 PR 的测试环境,复杂分类增强功能已暂时移除。', - textAlign: TextAlign.center, - style: TextStyle(color: cs.onSurface.withOpacity(.72)), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: () => showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('提示'), - content: const Text('完整功能将于后续 PR 恢复'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('关闭'), - ) + child: _busy + ? const CircularProgressIndicator() + : const Text('分类管理(最小版):点击右上角导入模板') + ), + ); + } + + Future _showTemplateLibrary() async { + final ledgerId = ref.read(currentLedgerProvider)?.id; + if (ledgerId == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('无当前账本,无法导入模板'))); + return; + } + + setState(() { _busy = true; }); + List templates = []; + try { + templates = await CategoryServiceIntegrated().getAllTemplates(forceRefresh: true); + } catch (_) {} + if (!mounted) return; + setState(() { _busy = false; }); + + final selected = {}; + String conflict = 'skip'; // skip|rename|update + ImportResult? preview; + + await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setLocal) => AlertDialog( + title: const Text('从模板库导入'), + content: SizedBox( + width: 480, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Text('冲突策略: '), + const SizedBox(width: 8), + DropdownButton( + value: conflict, + items: const [ + DropdownMenuItem(value: 'skip', child: Text('跳过')), + DropdownMenuItem(value: 'rename', child: Text('重命名')), + DropdownMenuItem(value: 'update', child: Text('覆盖')), + ], + onChanged: (v) { if (v!=null) setLocal((){ conflict = v; }); }, + ), ], ), - ), - child: const Text('占位'), + const SizedBox(height: 8), + SizedBox( + height: 320, + child: ListView.builder( + itemCount: templates.length, + itemBuilder: (_, i) { + final t = templates[i]; + final checked = selected.contains(t); + return CheckboxListTile( + value: checked, + onChanged: (_) => setLocal((){ + if (checked) { selected.remove(t); } else { selected.add(t); } + }), + dense: true, + title: Text(t.name), + subtitle: Text(t.classification.name), + ); + }, + ), + ), + if (preview != null) ...[ + const Divider(), + Align( + alignment: Alignment.centerLeft, + child: Text('预览(服务端 dry-run )', style: Theme.of(context).textTheme.titleSmall), + ), + SizedBox( + height: 160, + child: ListView.builder( + itemCount: preview!.details.length, + itemBuilder: (_, i) { + final d = preview!.details[i]; + final color = (d.action == 'failed' || d.action == 'skipped') ? Colors.orange : Colors.green; + return ListTile( + dense: true, + title: Text(d.finalName ?? d.originalName), + subtitle: Text(d.action + (d.reason!=null ? ' (${d.reason})' : '')), + trailing: Icon( + d.action == 'failed' ? Icons.error : (d.action=='skipped'? Icons.warning_amber : Icons.check_circle), + color: color, + ), + ); + }, + ), + ), + ], + ], ), - const SizedBox(height: 12), - const Text( - 'TODO: 模板导入 / 拖拽排序 / 批量操作 / 统计 重新引入', - style: TextStyle(fontSize: 11, color: Colors.grey), - textAlign: TextAlign.center, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')), + TextButton( + onPressed: selected.isEmpty ? null : () async { + try { + final items = selected.map((t) => { 'template_id': t.id }).toList(); + final res = await CategoryService().importTemplatesAdvanced( + ledgerId: ledgerId, + items: items, + onConflict: conflict, + dryRun: true, + ); + setLocal((){ preview = res; }); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('预览失败: $e'))); + } + } + }, + child: const Text('预览'), + ), + FilledButton( + onPressed: (selected.isEmpty) ? null : () async { + Navigator.pop(ctx); + try { + final items = selected.map((t) => { 'template_id': t.id }).toList(); + final result = await CategoryService().importTemplatesAdvanced( + ledgerId: ledgerId, + items: items, + onConflict: conflict, + ); + if (!mounted) return; + await ref.read(userCategoriesProvider.notifier).refreshFromBackend(ledgerId: ledgerId); + await ImportDetailsSheet.show(context, result); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('导入失败: $e'))); + } + }, + child: const Text('确认导入'), ), ], ), - ), - ), + ); + }, ); } } From 8cef9ac490ef1f83831af6fe72fb0deb791d57f2 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:24:08 +0800 Subject: [PATCH 2/3] feat(api): templates list pagination (page/per_page) + ETag support (etag param, 304) --- jive-api/src/handlers/template_handler.rs | 75 ++++++++++++++++++----- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/jive-api/src/handlers/template_handler.rs b/jive-api/src/handlers/template_handler.rs index 1a555957..48296a00 100644 --- a/jive-api/src/handlers/template_handler.rs +++ b/jive-api/src/handlers/template_handler.rs @@ -20,6 +20,10 @@ pub struct TemplateQuery { pub group: Option, pub featured: Option, pub since: Option, // ISO8601 timestamp for incremental sync + pub page: Option, + pub per_page: Option, + // Lightweight ETag support via query param (clients may also mirror this to If-None-Match if desired) + pub etag: Option, } /// 模板响应 @@ -119,14 +123,14 @@ pub async fn get_templates( _ => "name", }; - let query_str = format!( - "SELECT id, {} as name, name_en, name_zh, description, classification, color, icon, - category_group, is_featured, is_active, global_usage_count, tags, version, + let base_select = format!( + "SELECT id, {} as name, name_en, name_zh, description, classification, color, icon, \ + category_group, is_featured, is_active, global_usage_count, tags, version, \ created_at, updated_at FROM system_category_templates WHERE is_active = true", name_field ); - let mut query = sqlx::QueryBuilder::new(query_str); + let mut query = sqlx::QueryBuilder::new(base_select.clone()); // 添加过滤条件 if let Some(classification) = ¶ms.r#type { @@ -151,9 +155,54 @@ pub async fn get_templates( query.push(" AND updated_at > "); query.push_bind(since); } - + + // Stats for ETag and total (duplicate the same filters) + let mut stats_q = sqlx::QueryBuilder::new( + "SELECT COALESCE(MAX(updated_at), to_timestamp(0)) AS max_updated, COUNT(*) AS total FROM system_category_templates WHERE is_active = true" + ); + if let Some(classification) = ¶ms.r#type { + let classification = classification.to_lowercase(); + if classification != "all" { + stats_q.push(" AND classification = "); + stats_q.push_bind(classification); + } + } + if let Some(group) = ¶ms.group { + stats_q.push(" AND category_group = "); + stats_q.push_bind(group); + } + if let Some(featured) = params.featured { + stats_q.push(" AND is_featured = "); + stats_q.push_bind(featured); + } + if let Some(since) = ¶ms.since { + stats_q.push(" AND updated_at > "); + stats_q.push_bind(since); + } + let stats_row = stats_q + .build() + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let max_updated: chrono::DateTime = stats_row.try_get("max_updated").unwrap_or(chrono::DateTime::::from_timestamp(0, 0).unwrap()); + let total_count: i64 = stats_row.try_get("total").unwrap_or(0); + + // Compute a simple ETag and return 304 if matches + let computed_etag = format!("W/\"{}:{}\"", max_updated.timestamp(), total_count); + if let Some(client_etag) = ¶ms.etag { + if *client_etag == computed_etag { + return Err(StatusCode::NOT_MODIFIED); + } + } + + // Pagination + let per_page = params.per_page.unwrap_or(50).clamp(1, 100) as i64; + let page = params.page.unwrap_or(1).max(1) as i64; + let offset = (page - 1) * per_page; + query.push(" ORDER BY is_featured DESC, global_usage_count DESC, name"); - + query.push(" LIMIT ").push_bind(per_page).push(" OFFSET ").push_bind(offset); + let templates = query .build_query_as::() .fetch_all(&pool) @@ -162,18 +211,12 @@ pub async fn get_templates( eprintln!("Database query error: {:?}", e); StatusCode::INTERNAL_SERVER_ERROR })?; - - // 获取总数 - let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM system_category_templates WHERE is_active = true") - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + // 使用前面统计的 total_count 和 max_updated let response = TemplateResponse { templates, version: "1.0.0".to_string(), - last_updated: chrono::Utc::now().to_rfc3339(), - total: total.0, + last_updated: max_updated.to_rfc3339(), + total: total_count, }; Ok(Json(response)) @@ -432,4 +475,4 @@ pub async fn submit_usage( } StatusCode::OK -} \ No newline at end of file +} From 091c8d30c8755db488d5c323551697d7e0a74c39 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:34:20 +0800 Subject: [PATCH 3/3] feat(templates): add ETag + pagination fetch (client) and result wrapper --- .../lib/services/api/category_service.dart | 129 ++++++++++++------ 1 file changed, 84 insertions(+), 45 deletions(-) diff --git a/jive-flutter/lib/services/api/category_service.dart b/jive-flutter/lib/services/api/category_service.dart index 72137e62..5116fe21 100644 --- a/jive-flutter/lib/services/api/category_service.dart +++ b/jive-flutter/lib/services/api/category_service.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import '../../core/network/http_client.dart'; import '../../core/config/api_config.dart'; import '../../models/category.dart'; +import '../../models/category_template.dart'; /// 分类API服务 class CategoryService { @@ -100,6 +102,13 @@ class CategoryService { } } + /// 获取所有系统分类模板 + Future> getAllTemplates({ + bool forceRefresh = false, + }) async { + return getSystemTemplates(); + } + /// 获取系统分类模板 Future> getSystemTemplates({ String? group, @@ -272,6 +281,81 @@ class CategoryService { throw Exception('Failed to batch recategorize: $e'); } } + + /// 带 ETag 与分页的模板获取(与后端 /api/v1/templates/list 对齐) + Future getTemplatesWithEtag({ + String? etag, + int page = 1, + int perPage = 50, + String? group, + CategoryClassification? classification, + bool? featuredOnly, + }) async { + try { + final qp = { + 'page': page, + 'per_page': perPage, + if (group != null) 'group': group, + if (classification != null) + 'type': classification.toString().split('.').last, + if (featuredOnly != null) 'featured': featuredOnly, + if (etag != null && etag.isNotEmpty) 'etag': etag, + }; + final resp = await _client.get( + '${ApiConfig.apiUrl}/templates/list', + queryParameters: qp, + ); + + if (resp.statusCode == 304) { + return TemplateCatalogResult(const [], etag, true, 0, page, perPage); + } + if (resp.statusCode == 200) { + final data = resp.data is Map ? resp.data as Map : jsonDecode(resp.data as String) as Map; + final List itemsJson = data['templates'] ?? []; + final items = itemsJson.map((e) => SystemCategoryTemplate.fromJson(Map.from(e))).toList(); + final total = (data['total'] as num?)?.toInt() ?? items.length; + final lastUpdated = data['last_updated']?.toString(); + final newEtag = _computeWeakEtag(lastUpdated, total); + return TemplateCatalogResult(items, newEtag, false, total, page, perPage); + } + throw Exception('Failed to load templates: ${resp.statusCode}'); + } catch (e) { + // 网络失败时返回 notModified=false 且 items 为空,交由调用方决定回退策略 + return TemplateCatalogResult(const [], etag, false, 0, page, perPage, error: e.toString()); + } + } + + String? _computeWeakEtag(String? lastUpdatedIso, int total) { + if (lastUpdatedIso == null) return null; + try { + final dt = DateTime.parse(lastUpdatedIso).toUtc(); + final ts = (dt.millisecondsSinceEpoch / 1000).floor(); + return 'W/"$ts:$total"'; + } catch (_) { + return null; + } + } +} + +/// 模板目录结果(含 ETag) +class TemplateCatalogResult { + final List items; + final String? etag; + final bool notModified; + final int total; + final int page; + final int perPage; + final String? error; + + const TemplateCatalogResult( + this.items, + this.etag, + this.notModified, + this.total, + this.page, + this.perPage, { + this.error, + }); } /// 导入结果 @@ -383,48 +467,3 @@ class BatchOperationResult { ); } } - -/// 系统分类模板 -class SystemCategoryTemplate { - final String id; - final String name; - final String? nameEn; - final String? description; - final CategoryClassification classification; - final String color; - final String? icon; - final String? group; - final bool isFeatured; - final List tags; - - SystemCategoryTemplate({ - required this.id, - required this.name, - this.nameEn, - this.description, - required this.classification, - required this.color, - this.icon, - this.group, - this.isFeatured = false, - this.tags = const [], - }); - - factory SystemCategoryTemplate.fromJson(Map json) { - return SystemCategoryTemplate( - id: json['id'], - name: json['name'], - nameEn: json['name_en'], - description: json['description'], - classification: CategoryClassification.values.firstWhere( - (e) => e.toString().split('.').last == json['classification'], - orElse: () => CategoryClassification.expense, - ), - color: json['color'], - icon: json['icon'], - group: json['group'], - isFeatured: json['is_featured'] ?? false, - tags: (json['tags'] as List?)?.cast() ?? [], - ); - } -}