Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 59 additions & 16 deletions jive-api/src/handlers/template_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ pub struct TemplateQuery {
pub group: Option<String>,
pub featured: Option<bool>,
pub since: Option<String>, // ISO8601 timestamp for incremental sync
pub page: Option<i64>,
pub per_page: Option<i64>,
// Lightweight ETag support via query param (clients may also mirror this to If-None-Match if desired)
pub etag: Option<String>,
}

/// 模板响应
Expand Down Expand Up @@ -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) = &params.r#type {
Expand All @@ -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) = &params.r#type {
let classification = classification.to_lowercase();
if classification != "all" {
stats_q.push(" AND classification = ");
stats_q.push_bind(classification);
}
Comment on lines +163 to +168

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

It's correct to lowercase the classification here for a case-insensitive comparison. However, the same logic is missing when applying this filter to the main query builder (around line 139). This inconsistency can cause the total_count to mismatch the number of returned templates, which is a critical bug. Please ensure the filtering logic is identical in both places. This issue also highlights the risk of the duplicated filtering logic.

}
if let Some(group) = &params.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) = &params.since {
stats_q.push(" AND updated_at > ");
stats_q.push_bind(since);
}
Comment on lines +159 to +181

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This block for building the stats_q query duplicates the filtering logic from lines 136-157. This violates the DRY (Don't Repeat Yourself) principle and is error-prone, as demonstrated by the inconsistent classification handling mentioned in another comment. Any future changes to filtering logic will need to be applied in two places, increasing maintenance overhead. Please consider refactoring to a helper function or another pattern to apply filters to both QueryBuilder instances from a single source of truth.

Comment on lines +159 to +181
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filters are duplicated for stats and data queries, causing two scans. Consider a single CTE or subquery to compute rows once, e.g., WITH filtered AS (...) SELECT COUNT(*), MAX(updated_at) FROM filtered; and SELECT ... FROM filtered ORDER BY ... LIMIT/OFFSET. This reduces DB work and avoids filter-drift bugs.

Copilot uses AI. Check for mistakes.
let stats_row = stats_q
.build()
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let max_updated: chrono::DateTime<chrono::Utc> = stats_row.try_get("max_updated").unwrap_or(chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap());
let total_count: i64 = stats_row.try_get("total").unwrap_or(0);
Comment on lines +187 to +188

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Using unwrap_or on a Result from try_get will panic if the operation fails (e.g., a column name typo or type mismatch), crashing the request handler. Please use unwrap_or_else to handle the error gracefully. It would also be beneficial to log the error inside the closure for easier debugging.

Suggested change
let max_updated: chrono::DateTime<chrono::Utc> = stats_row.try_get("max_updated").unwrap_or(chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap());
let total_count: i64 = stats_row.try_get("total").unwrap_or(0);
let max_updated: chrono::DateTime<chrono::Utc> = stats_row.try_get("max_updated").unwrap_or_else(|_| chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap());
let total_count: i64 = stats_row.try_get("total").unwrap_or_else(|_| 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) = &params.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::<SystemTemplate>()
.fetch_all(&pool)
Expand All @@ -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,
Comment on lines 215 to +219
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider surfacing the computed ETag in successful responses (e.g., include an etag field in TemplateResponse or set the ETag header) to help clients avoid re-computing it. Returning the header is preferable for intermediaries and caches.

Copilot uses AI. Check for mistakes.
};

Ok(Json(response))
Expand Down Expand Up @@ -432,4 +475,4 @@ pub async fn submit_usage(
}

StatusCode::OK
}
}
210 changes: 167 additions & 43 deletions jive-flutter/lib/screens/management/category_management_enhanced.dart
Original file line number Diff line number Diff line change
@@ -1,60 +1,184 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/category.dart';
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import appears unused in this file. Remove it to keep imports clean.

Suggested change
import '../../models/category.dart';

Copilot uses AI. Check for mistakes.
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<CategoryManagementEnhancedPage> createState() => _CategoryManagementEnhancedPageState();
}

class _CategoryManagementEnhancedPageState extends ConsumerState<CategoryManagementEnhancedPage> {
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<void> _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<SystemCategoryTemplate> templates = [];
try {
templates = await CategoryServiceIntegrated().getAllTemplates(forceRefresh: true);
} catch (_) {}
Comment on lines +53 to +54

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This empty catch block silently swallows any errors that occur while fetching templates. This leads to a poor user experience, as the user will be shown an empty import dialog without any indication of what went wrong. The error should be caught and communicated to the user, for example, by showing a SnackBar, and the loading indicator should be correctly handled.

Suggested change
templates = await CategoryServiceIntegrated().getAllTemplates(forceRefresh: true);
} catch (_) {}
templates = await CategoryServiceIntegrated().getAllTemplates(forceRefresh: true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading templates: $e')));
}
setState(() { _busy = false; });
return;
}

if (!mounted) return;
setState(() { _busy = false; });

final selected = <SystemCategoryTemplate>{};
String conflict = 'skip'; // skip|rename|update
ImportResult? preview;

await showDialog<void>(
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<String>(
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('确认导入'),
),
],
),
),
),
);
},
);
}
}
Loading
Loading