From 41d69277b58636fd55ec7bf4bf7f70a71e4f6639 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Mon, 29 Sep 2025 08:34:14 +0800 Subject: [PATCH 1/5] feat(banks): add banks table, API list endpoint, Flutter BankSelector component and embed in AccountAddScreen (optional) --- .../migrations/031_create_banks_table.sql | 36 ++ jive-api/src/handlers/banks.rs | 71 ++++ jive-api/src/handlers/mod.rs | 1 + jive-api/src/main.rs | 10 +- jive-api/src/models/bank.rs | 19 + jive-api/src/models/mod.rs | 4 + jive-flutter/lib/models/bank.dart | 62 +++ .../screens/accounts/account_add_screen.dart | 6 +- jive-flutter/lib/services/bank_service.dart | 207 ++++++++++ .../ui/components/banks/bank_selector.dart | 364 ++++++++++++++++++ 10 files changed, 775 insertions(+), 5 deletions(-) create mode 100644 jive-api/migrations/031_create_banks_table.sql create mode 100644 jive-api/src/handlers/banks.rs create mode 100644 jive-api/src/models/bank.rs create mode 100644 jive-flutter/lib/models/bank.dart create mode 100644 jive-flutter/lib/services/bank_service.dart create mode 100644 jive-flutter/lib/ui/components/banks/bank_selector.dart diff --git a/jive-api/migrations/031_create_banks_table.sql b/jive-api/migrations/031_create_banks_table.sql new file mode 100644 index 00000000..a29bf015 --- /dev/null +++ b/jive-api/migrations/031_create_banks_table.sql @@ -0,0 +1,36 @@ +-- 创建银行表,支持拼音搜索 +CREATE TABLE banks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + name_cn VARCHAR(100), + name_en VARCHAR(200), + name_cn_pinyin VARCHAR(200), + name_cn_abbr VARCHAR(50), + icon_filename VARCHAR(100), + icon_url TEXT, + is_crypto BOOLEAN DEFAULT FALSE, + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_banks_code ON banks(code); +CREATE INDEX idx_banks_name_cn ON banks(name_cn); +CREATE INDEX idx_banks_pinyin ON banks(name_cn_pinyin); +CREATE INDEX idx_banks_abbr ON banks(name_cn_abbr); +CREATE INDEX idx_banks_is_active ON banks(is_active); +CREATE INDEX idx_banks_sort_order ON banks(sort_order DESC, name_cn); + +COMMENT ON TABLE banks IS '银行和金融机构表'; +COMMENT ON COLUMN banks.code IS '唯一标识码(从icon文件名提取)'; +COMMENT ON COLUMN banks.name IS '银行名称(主名称)'; +COMMENT ON COLUMN banks.name_cn IS '中文名称'; +COMMENT ON COLUMN banks.name_en IS '英文名称'; +COMMENT ON COLUMN banks.name_cn_pinyin IS '中文全拼(用于拼音搜索)'; +COMMENT ON COLUMN banks.name_cn_abbr IS '中文简拼(用于首字母搜索)'; +COMMENT ON COLUMN banks.icon_filename IS '图标文件名'; +COMMENT ON COLUMN banks.is_crypto IS '是否为加密货币'; +COMMENT ON COLUMN banks.sort_order IS '排序权重(热门银行可设置更高值)'; +COMMENT ON COLUMN banks.is_active IS '是否启用'; \ No newline at end of file diff --git a/jive-api/src/handlers/banks.rs b/jive-api/src/handlers/banks.rs new file mode 100644 index 00000000..220a109b --- /dev/null +++ b/jive-api/src/handlers/banks.rs @@ -0,0 +1,71 @@ +use axum::{ + extract::{Query, State}, + response::Json, +}; +use serde::Deserialize; +use sqlx::{PgPool, QueryBuilder}; + +use crate::error::{ApiError, ApiResult}; +use crate::models::bank::Bank; + +#[derive(Debug, Deserialize)] +pub struct BankQuery { + pub search: Option, + pub is_crypto: Option, + pub limit: Option, +} + +pub async fn list_banks( + Query(params): Query, + State(pool): State, +) -> ApiResult>> { + let mut query = QueryBuilder::new( + "SELECT id, code, name, name_cn, name_en, icon_filename, is_crypto + FROM banks WHERE is_active = true" + ); + + if let Some(search) = params.search { + query.push(" AND ("); + query.push("name_cn ILIKE "); + query.push_bind(format!("%{}%", search)); + query.push(" OR name ILIKE "); + query.push_bind(format!("%{}%", search)); + query.push(" OR name_en ILIKE "); + query.push_bind(format!("%{}%", search)); + query.push(" OR name_cn_pinyin ILIKE "); + query.push_bind(format!("%{}%", search)); + query.push(" OR name_cn_abbr ILIKE "); + query.push_bind(format!("%{}%", search)); + query.push(")"); + } + + if let Some(is_crypto) = params.is_crypto { + query.push(" AND is_crypto = "); + query.push_bind(is_crypto); + } + + query.push(" ORDER BY sort_order DESC, name_cn, name"); + query.push(" LIMIT "); + query.push_bind(params.limit.unwrap_or(100)); + + let banks = query + .build() + .fetch_all(&pool) + .await + .map_err(|e| ApiError::DatabaseError(e.to_string()))?; + + let mut response = Vec::new(); + for row in banks { + response.push(Bank { + id: row.get("id"), + code: row.get("code"), + name: row.get("name"), + name_cn: row.get("name_cn"), + name_en: row.get("name_en"), + icon_filename: row.get("icon_filename"), + is_crypto: row.get("is_crypto"), + }); + } + + Ok(Json(response)) +} \ No newline at end of file diff --git a/jive-api/src/handlers/mod.rs b/jive-api/src/handlers/mod.rs index 11b87e21..9a4efd06 100644 --- a/jive-api/src/handlers/mod.rs +++ b/jive-api/src/handlers/mod.rs @@ -1,5 +1,6 @@ pub mod template_handler; pub mod accounts; +pub mod banks; pub mod transactions; pub mod payees; pub mod rules; diff --git a/jive-api/src/main.rs b/jive-api/src/main.rs index 96f0ddf2..27b3bcd9 100644 --- a/jive-api/src/main.rs +++ b/jive-api/src/main.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use tokio::net::TcpListener; use tower::ServiceBuilder; use tower_http::{ + services::ServeDir, trace::TraceLayer, }; use tracing::{info, warn, error}; @@ -30,6 +31,7 @@ use jive_money_api::{handlers, services, ws}; // 导入处理器 use handlers::template_handler::*; use handlers::accounts::*; +use handlers::banks; use handlers::transactions::*; use handlers::payees::*; use handlers::rules::*; @@ -247,7 +249,10 @@ async fn main() -> Result<(), Box> { .route("/api/v1/accounts/:id", put(update_account)) .route("/api/v1/accounts/:id", delete(delete_account)) .route("/api/v1/accounts/statistics", get(get_account_statistics)) - + + // 银行管理 API + .route("/api/v1/banks", get(banks::list_banks)) + // 交易管理 API .route("/api/v1/transactions", get(list_transactions)) .route("/api/v1/transactions", post(create_transaction)) @@ -373,7 +378,8 @@ async fn main() -> Result<(), Box> { .route("/api/v1/categories/import", post(category_handler::batch_import_templates)) // 静态文件 - .route("/static/icons/*path", get(serve_icon)); + .route("/static/icons/*path", get(serve_icon)) + .nest_service("/static/bank_icons", ServeDir::new("static/bank_icons")); // 可选 Demo 占位符接口(按特性开关) #[cfg(feature = "demo_endpoints")] diff --git a/jive-api/src/models/bank.rs b/jive-api/src/models/bank.rs new file mode 100644 index 00000000..01f07eab --- /dev/null +++ b/jive-api/src/models/bank.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Bank { + pub id: Uuid, + pub code: String, + pub name: String, + pub name_cn: Option, + pub name_en: Option, + pub icon_filename: Option, + pub is_crypto: bool, +} + +impl Bank { + pub fn display_name(&self) -> &str { + self.name_cn.as_deref().unwrap_or(&self.name) + } +} \ No newline at end of file diff --git a/jive-api/src/models/mod.rs b/jive-api/src/models/mod.rs index f510732b..6bee8f32 100644 --- a/jive-api/src/models/mod.rs +++ b/jive-api/src/models/mod.rs @@ -1,12 +1,16 @@ #![allow(dead_code)] +pub mod account; pub mod audit; +pub mod bank; pub mod family; pub mod invitation; pub mod membership; pub mod permission; pub mod transaction; +#[allow(unused_imports)] +pub use account::{AccountMainType, AccountSubType}; #[allow(unused_imports)] pub use audit::{AuditAction, AuditLog, AuditLogFilter, CreateAuditLogRequest}; #[allow(unused_imports)] diff --git a/jive-flutter/lib/models/bank.dart b/jive-flutter/lib/models/bank.dart new file mode 100644 index 00000000..18129a45 --- /dev/null +++ b/jive-flutter/lib/models/bank.dart @@ -0,0 +1,62 @@ +class Bank { + final String id; + final String code; + final String name; + final String? nameCn; + final String? nameEn; + final String? iconFilename; + final bool isCrypto; + + Bank({ + required this.id, + required this.code, + required this.name, + this.nameCn, + this.nameEn, + this.iconFilename, + this.isCrypto = false, + }); + + String get displayName => nameCn ?? name; + + String? get iconUrl => iconFilename != null + ? '/static/bank_icons/$iconFilename' + : null; + + factory Bank.fromJson(Map json) { + return Bank( + id: json['id'] as String, + code: json['code'] as String, + name: json['name'] as String, + nameCn: json['name_cn'] as String?, + nameEn: json['name_en'] as String?, + iconFilename: json['icon_filename'] as String?, + isCrypto: json['is_crypto'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'code': code, + 'name': name, + 'name_cn': nameCn, + 'name_en': nameEn, + 'icon_filename': iconFilename, + 'is_crypto': isCrypto, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Bank && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() => 'Bank($displayName, $code)'; +} \ No newline at end of file diff --git a/jive-flutter/lib/screens/accounts/account_add_screen.dart b/jive-flutter/lib/screens/accounts/account_add_screen.dart index 81c9e589..46b53f6a 100644 --- a/jive-flutter/lib/screens/accounts/account_add_screen.dart +++ b/jive-flutter/lib/screens/accounts/account_add_screen.dart @@ -47,7 +47,7 @@ class _AccountAddScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final currentLedger = ref.watch(currentLedgerProvider); + // Using read below for ledger id on save; no need to watch here. return Scaffold( appBar: AppBar( @@ -408,7 +408,7 @@ class _AccountAddScreenState extends ConsumerState { try { // TODO: 调用API保存账户 - final account = { + final _account = { 'name': _nameController.text, 'type': _selectedType, 'balance': double.parse(_balanceController.text), @@ -425,7 +425,7 @@ class _AccountAddScreenState extends ConsumerState { 'ledger_id': ref.read(currentLedgerProvider)?.id, }; - // 显示成功消息 + // 显示成功消息(TODO: 实际保存后再提示) ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('账户已创建')), ); diff --git a/jive-flutter/lib/services/bank_service.dart b/jive-flutter/lib/services/bank_service.dart new file mode 100644 index 00000000..4ebf597f --- /dev/null +++ b/jive-flutter/lib/services/bank_service.dart @@ -0,0 +1,207 @@ +import 'package:flutter/foundation.dart'; +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; +import 'package:jive_money/core/network/http_client.dart'; +import 'package:jive_money/core/network/api_readiness.dart'; +import 'package:jive_money/core/storage/token_storage.dart'; +import 'package:jive_money/models/bank.dart'; + +class BankService { + static const String _cacheKey = 'banks_cache'; + static const String _cacheTimestampKey = 'banks_cache_timestamp'; + static const Duration _cacheExpiration = Duration(hours: 24); + + final String? token; + + BankService(this.token); + + Future> _headers() async { + final t = token ?? await TokenStorage.getAccessToken(); + return { + 'Content-Type': 'application/json', + if (t != null) 'Authorization': 'Bearer $t', + }; + } + + Future> getBanks({ + String? search, + bool? isCrypto, + int? limit, + bool forceRefresh = false, + }) async { + if (!forceRefresh) { + final cached = await _getCachedBanks(); + if (cached != null && cached.isNotEmpty) { + return _filterBanksLocally(cached, search: search, isCrypto: isCrypto); + } + } + + try { + final dio = HttpClient.instance.dio; + await ApiReadiness.ensureReady(dio); + + final queryParams = {}; + if (search != null && search.isNotEmpty) { + queryParams['search'] = search; + } + if (isCrypto != null) { + queryParams['is_crypto'] = isCrypto; + } + if (limit != null) { + queryParams['limit'] = limit; + } + + final resp = await dio.get( + '/banks', + queryParameters: queryParams.isNotEmpty ? queryParams : null, + options: Options(headers: await _headers()), + ); + + if (resp.statusCode == 200) { + final List data = resp.data is List + ? resp.data + : (resp.data['data'] ?? resp.data); + + final banks = data.map((json) => Bank.fromJson(json)).toList(); + + if (search == null && isCrypto == null) { + await _cacheBanks(banks); + } + + return banks; + } else { + throw Exception('Failed to load banks: ${resp.statusCode}'); + } + } catch (e) { + debugPrint('Error fetching banks from API: $e'); + final cached = await _getCachedBanks(); + if (cached != null && cached.isNotEmpty) { + return _filterBanksLocally(cached, search: search, isCrypto: isCrypto); + } + rethrow; + } + } + + Future getBankById(String id) async { + final banks = await getBanks(); + try { + return banks.firstWhere((bank) => bank.id == id); + } catch (e) { + return null; + } + } + + Future> searchBanks(String query, {bool? isCrypto}) async { + if (query.isEmpty) { + return getBanks(isCrypto: isCrypto); + } + + return getBanks(search: query, isCrypto: isCrypto); + } + + Future> getCryptoBanks() async { + return getBanks(isCrypto: true); + } + + Future> getRegularBanks() async { + return getBanks(isCrypto: false); + } + + List _filterBanksLocally( + List banks, { + String? search, + bool? isCrypto, + }) { + var filtered = banks; + + if (isCrypto != null) { + filtered = filtered.where((bank) => bank.isCrypto == isCrypto).toList(); + } + + if (search != null && search.isNotEmpty) { + final searchLower = search.toLowerCase(); + filtered = filtered.where((bank) { + return bank.name.toLowerCase().contains(searchLower) || + (bank.nameCn?.toLowerCase().contains(searchLower) ?? false) || + (bank.nameEn?.toLowerCase().contains(searchLower) ?? false) || + bank.code.toLowerCase().contains(searchLower); + }).toList(); + } + + return filtered; + } + + Future _cacheBanks(List banks) async { + try { + final prefs = await SharedPreferences.getInstance(); + final banksJson = banks.map((bank) => bank.toJson()).toList(); + await prefs.setString(_cacheKey, jsonEncode(banksJson)); + await prefs.setInt( + _cacheTimestampKey, + DateTime.now().millisecondsSinceEpoch, + ); + } catch (e) { + debugPrint('Error caching banks: $e'); + } + } + + Future?> _getCachedBanks() async { + try { + final prefs = await SharedPreferences.getInstance(); + final cachedJson = prefs.getString(_cacheKey); + final cachedTimestamp = prefs.getInt(_cacheTimestampKey); + + if (cachedJson == null || cachedTimestamp == null) { + return null; + } + + final cacheTime = DateTime.fromMillisecondsSinceEpoch(cachedTimestamp); + final now = DateTime.now(); + + if (now.difference(cacheTime) > _cacheExpiration) { + await clearCache(); + return null; + } + + final List jsonList = jsonDecode(cachedJson); + return jsonList.map((json) => Bank.fromJson(json)).toList(); + } catch (e) { + debugPrint('Error reading cached banks: $e'); + return null; + } + } + + Future clearCache() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_cacheKey); + await prefs.remove(_cacheTimestampKey); + } catch (e) { + debugPrint('Error clearing bank cache: $e'); + } + } + + Future isCacheValid() async { + try { + final prefs = await SharedPreferences.getInstance(); + final cachedTimestamp = prefs.getInt(_cacheTimestampKey); + + if (cachedTimestamp == null) { + return false; + } + + final cacheTime = DateTime.fromMillisecondsSinceEpoch(cachedTimestamp); + final now = DateTime.now(); + + return now.difference(cacheTime) <= _cacheExpiration; + } catch (e) { + return false; + } + } + + Future refreshBanks() async { + await clearCache(); + await getBanks(forceRefresh: true); + } +} \ No newline at end of file diff --git a/jive-flutter/lib/ui/components/banks/bank_selector.dart b/jive-flutter/lib/ui/components/banks/bank_selector.dart new file mode 100644 index 00000000..5176e8bd --- /dev/null +++ b/jive-flutter/lib/ui/components/banks/bank_selector.dart @@ -0,0 +1,364 @@ +import 'package:flutter/material.dart'; +import 'package:jive_money/models/bank.dart'; +import 'package:jive_money/services/bank_service.dart'; +import 'package:jive_money/core/constants/app_constants.dart'; + +class BankSelector extends StatefulWidget { + final Bank? selectedBank; + final ValueChanged onBankSelected; + final bool isCryptoMode; + + const BankSelector({ + super.key, + this.selectedBank, + required this.onBankSelected, + this.isCryptoMode = false, + }); + + @override + State createState() => _BankSelectorState(); +} + +class _BankSelectorState extends State { + final BankService _bankService = BankService(null); + List _banks = []; + bool _isLoading = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadBanks(); + } + + Future _loadBanks() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final banks = await _bankService.getBanks( + isCrypto: widget.isCryptoMode, + ); + setState(() { + _banks = banks; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = '加载银行列表失败: $e'; + _isLoading = false; + }); + } + } + + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + onTap: () => _showBankPicker(context), + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + child: InputDecorator( + decoration: InputDecoration( + labelText: widget.isCryptoMode ? '加密货币' : '银行/机构', + hintText: widget.isCryptoMode ? '选择加密货币' : '选择银行或机构', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + suffixIcon: widget.selectedBank != null + ? IconButton( + icon: const Icon(Icons.clear, size: 20), + onPressed: () => widget.onBankSelected(null), + ) + : const Icon(Icons.arrow_drop_down), + ), + child: Row( + children: [ + if (widget.selectedBank != null) ...[ + if (widget.selectedBank!.iconFilename != null) + CircleAvatar( + radius: 12, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + child: Text( + widget.selectedBank!.displayName[0].toUpperCase(), + style: theme.textTheme.bodySmall, + ), + ) + else + Icon( + widget.isCryptoMode + ? Icons.currency_bitcoin + : Icons.account_balance, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.selectedBank!.displayName, + overflow: TextOverflow.ellipsis, + ), + ), + ] else + Text( + widget.isCryptoMode ? '点击选择加密货币' : '点击选择银行', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.hintColor, + ), + ), + ], + ), + ), + ); + } + + Future _showBankPicker(BuildContext context) async { + final selectedBank = await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + maxChildSize: 0.95, + minChildSize: 0.5, + expand: false, + builder: (context, scrollController) => _BankPickerSheet( + scrollController: scrollController, + banks: _banks, + isLoading: _isLoading, + error: _error, + isCryptoMode: widget.isCryptoMode, + onRefresh: _loadBanks, + bankService: _bankService, + ), + ), + ); + + if (selectedBank != null) { + widget.onBankSelected(selectedBank); + } + } +} + +class _BankPickerSheet extends StatefulWidget { + final ScrollController scrollController; + final List banks; + final bool isLoading; + final String? error; + final bool isCryptoMode; + final VoidCallback onRefresh; + final BankService bankService; + + const _BankPickerSheet({ + required this.scrollController, + required this.banks, + required this.isLoading, + this.error, + required this.isCryptoMode, + required this.onRefresh, + required this.bankService, + }); + + @override + State<_BankPickerSheet> createState() => _BankPickerSheetState(); +} + +class _BankPickerSheetState extends State<_BankPickerSheet> { + final TextEditingController _searchController = TextEditingController(); + List _filteredBanks = []; + + @override + void initState() { + super.initState(); + _filteredBanks = widget.banks; + _searchController.addListener(_filterBanks); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _filterBanks() { + final query = _searchController.text.toLowerCase(); + setState(() { + if (query.isEmpty) { + _filteredBanks = widget.banks; + } else { + _filteredBanks = widget.banks.where((bank) { + return bank.name.toLowerCase().contains(query) || + (bank.nameCn?.toLowerCase().contains(query) ?? false) || + (bank.nameEn?.toLowerCase().contains(query) ?? false) || + bank.code.toLowerCase().contains(query); + }).toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.dividerColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Text( + widget.isCryptoMode ? '选择加密货币' : '选择银行', + style: theme.textTheme.titleLarge, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: widget.isCryptoMode + ? '搜索加密货币...' + : '搜索银行名称、拼音或代码...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(AppConstants.borderRadius), + ), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + }, + ) + : null, + ), + ), + ], + ), + ), + Expanded( + child: _buildContent(theme), + ), + ], + ); + } + + Widget _buildContent(ThemeData theme) { + if (widget.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (widget.error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error), + const SizedBox(height: 16), + Text(widget.error!, style: theme.textTheme.bodyLarge), + const SizedBox(height: 16), + ElevatedButton( + onPressed: widget.onRefresh, + child: const Text('重试'), + ), + ], + ), + ); + } + + if (_filteredBanks.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search_off, size: 48, color: theme.hintColor), + const SizedBox(height: 16), + Text( + '未找到匹配的${widget.isCryptoMode ? '加密货币' : '银行'}', + style: theme.textTheme.bodyLarge?.copyWith(color: theme.hintColor), + ), + ], + ), + ); + } + + return ListView.builder( + controller: widget.scrollController, + itemCount: _filteredBanks.length + 1, + itemBuilder: (context, index) { + if (index == _filteredBanks.length) { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Divider(), + const SizedBox(height: 8), + Text( + '没有找到银行?', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.hintColor, + ), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () { + // TODO: 实现反馈功能 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('感谢反馈!我们会尽快添加更多银行'), + ), + ); + }, + icon: const Icon(Icons.feedback_outlined), + label: const Text('点击这里反馈'), + ), + ], + ), + ); + } + + final bank = _filteredBanks[index]; + return ListTile( + leading: bank.iconFilename != null + ? CircleAvatar( + backgroundColor: theme.colorScheme.surfaceContainerHighest, + child: Text( + bank.displayName[0].toUpperCase(), + style: theme.textTheme.titleMedium, + ), + ) + : Icon( + widget.isCryptoMode + ? Icons.currency_bitcoin + : Icons.account_balance, + ), + title: Text(bank.displayName), + subtitle: Text(bank.code), + onTap: () => Navigator.of(context).pop(bank), + ); + }, + ); + } +} \ No newline at end of file From d0851ffebf071b41d8b5f5b87ac120925423e217 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Mon, 29 Sep 2025 08:38:11 +0800 Subject: [PATCH 2/5] api(banks): add version endpoint and route; prep for static icons routing --- jive-api/src/handlers/banks.rs | 31 +++++++++++++++++++++++++++++-- jive-api/src/main.rs | 1 + 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/jive-api/src/handlers/banks.rs b/jive-api/src/handlers/banks.rs index 220a109b..da0046a9 100644 --- a/jive-api/src/handlers/banks.rs +++ b/jive-api/src/handlers/banks.rs @@ -3,7 +3,8 @@ use axum::{ response::Json, }; use serde::Deserialize; -use sqlx::{PgPool, QueryBuilder}; +use serde::Serialize; +use sqlx::{PgPool, QueryBuilder, Row}; use crate::error::{ApiError, ApiResult}; use crate::models::bank::Bank; @@ -68,4 +69,30 @@ pub async fn list_banks( } Ok(Json(response)) -} \ No newline at end of file +} + +#[derive(Debug, Serialize)] +pub struct BanksVersionResponse { + pub version: String, + pub count: i64, + pub updated_at: chrono::DateTime, +} + +/// GET /api/v1/banks/version — return latest banks metadata (if present) +pub async fn get_banks_version( + State(pool): State, +) -> ApiResult> { + let row = sqlx::query( + r#"SELECT version, total_count, updated_at + FROM banks_metadata ORDER BY id DESC LIMIT 1"#, + ) + .fetch_one(&pool) + .await + .map_err(|_| ApiError::NotFound("Banks metadata not found".into()))?; + + Ok(Json(BanksVersionResponse { + version: row.get("version"), + count: row.get("total_count"), + updated_at: row.get("updated_at"), + })) +} diff --git a/jive-api/src/main.rs b/jive-api/src/main.rs index 27b3bcd9..e96667c1 100644 --- a/jive-api/src/main.rs +++ b/jive-api/src/main.rs @@ -252,6 +252,7 @@ async fn main() -> Result<(), Box> { // 银行管理 API .route("/api/v1/banks", get(banks::list_banks)) + .route("/api/v1/banks/version", get(banks::get_banks_version)) // 交易管理 API .route("/api/v1/transactions", get(list_transactions)) From 31a938946163e5ea4a609413f14dda6e3a879492 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Mon, 29 Sep 2025 08:48:47 +0800 Subject: [PATCH 3/5] api(static): serve /static/bank_icons via ServeDir --- jive-api/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jive-api/src/main.rs b/jive-api/src/main.rs index e96667c1..0633feb3 100644 --- a/jive-api/src/main.rs +++ b/jive-api/src/main.rs @@ -274,6 +274,9 @@ async fn main() -> Result<(), Box> { .route("/api/v1/payees/suggestions", get(get_payee_suggestions)) .route("/api/v1/payees/statistics", get(get_payee_statistics)) .route("/api/v1/payees/merge", post(merge_payees)) + + // 静态资源:银行图标 + .nest_service("/static/bank_icons", ServeDir::new("jive-api/static/bank_icons")) // 规则引擎 API .route("/api/v1/rules", get(list_rules)) From 2c10359f6b6f8ad139c1c5a085228f31fb5b0cbe Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:55:13 +0800 Subject: [PATCH 4/5] fix: remove payees FK constraint, comment out account module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove REFERENCES payees(id) constraint (payees table doesn't exist yet) - Add TODO comment for future FK constraint - Comment out account module (not in this branch) - Fixes CI compilation error: relation payees does not exist 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- jive-api/migrations/013_add_payee_id_to_transactions.sql | 5 +++-- jive-api/src/models/mod.rs | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/jive-api/migrations/013_add_payee_id_to_transactions.sql b/jive-api/migrations/013_add_payee_id_to_transactions.sql index fc275d63..00117f79 100644 --- a/jive-api/migrations/013_add_payee_id_to_transactions.sql +++ b/jive-api/migrations/013_add_payee_id_to_transactions.sql @@ -4,9 +4,10 @@ -- Ensure extension for uuid generation exists (if needed by payees references elsewhere) -- CREATE EXTENSION IF NOT EXISTS pgcrypto; --- Add column if missing +-- Add column if missing (nullable UUID, no FK constraint for now) +-- TODO: Add REFERENCES payees(id) constraint once payees table is created ALTER TABLE transactions -ADD COLUMN IF NOT EXISTS payee_id UUID REFERENCES payees(id); +ADD COLUMN IF NOT EXISTS payee_id UUID; -- Index for filtering by payee_id CREATE INDEX IF NOT EXISTS idx_transactions_payee_id ON transactions(payee_id); diff --git a/jive-api/src/models/mod.rs b/jive-api/src/models/mod.rs index 6bee8f32..ffd625fd 100644 --- a/jive-api/src/models/mod.rs +++ b/jive-api/src/models/mod.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -pub mod account; +// pub mod account; // Temporarily commented - file not in this branch yet pub mod audit; pub mod bank; pub mod family; @@ -9,8 +9,8 @@ pub mod membership; pub mod permission; pub mod transaction; -#[allow(unused_imports)] -pub use account::{AccountMainType, AccountSubType}; +// #[allow(unused_imports)] +// pub use account::{AccountMainType, AccountSubType}; // Commented with module #[allow(unused_imports)] pub use audit::{AuditAction, AuditLog, AuditLogFilter, CreateAuditLogRequest}; #[allow(unused_imports)] From 1438ac447f3b845b8b5cf8847c756dbd6fd70f55 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:08:37 +0800 Subject: [PATCH 5/5] fix: resolve merge conflicts with main in family_settings_service and transaction_provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolved conflict in family_settings_service.dart by keeping main's parameterized method calls - Resolved conflict in transaction_provider.dart by removing duplicate enum and method definitions - Both files now compile without errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../lib/providers/transaction_provider.dart | 70 +------------------ .../lib/services/family_settings_service.dart | 8 +-- 2 files changed, 2 insertions(+), 76 deletions(-) diff --git a/jive-flutter/lib/providers/transaction_provider.dart b/jive-flutter/lib/providers/transaction_provider.dart index cb8409ab..83503af8 100644 --- a/jive-flutter/lib/providers/transaction_provider.dart +++ b/jive-flutter/lib/providers/transaction_provider.dart @@ -4,14 +4,8 @@ import 'package:jive_money/services/api/transaction_service.dart'; import 'package:jive_money/models/transaction.dart'; import 'package:jive_money/models/transaction_filter.dart'; import 'package:shared_preferences/shared_preferences.dart'; -<<<<<<< HEAD -======= import 'package:jive_money/providers/ledger_provider.dart'; -enum TransactionGrouping { date, category, account } ->>>>>>> origin/main - -/// 交易状态 enum TransactionGrouping { date, category, account } class TransactionState { @@ -23,10 +17,7 @@ class TransactionState { final int totalCount; final double totalIncome; final double totalExpense; -<<<<<<< HEAD - // Phase B scaffolding: grouping + collapsed groups -======= ->>>>>>> origin/main + final TransactionGrouping grouping; final Set groupCollapse; @@ -312,65 +303,6 @@ class TransactionController extends StateNotifier { } /// 设置分组方式(Phase B) - void setGrouping(TransactionGrouping grouping) { - if (state.grouping == grouping) return; - state = state.copyWith(grouping: grouping); - _persistGrouping(); - } - - /// 切换分组折叠状态(Phase B) - void toggleGroupCollapse(String key) { - final collapsed = Set.from(state.groupCollapse); - if (collapsed.contains(key)) { - collapsed.remove(key); - } else { - collapsed.add(key); - } - state = state.copyWith(groupCollapse: collapsed); - _persistGroupCollapse(collapsed); - } - - // ---- View preference persistence (Phase B1) ---- - Future _loadViewPrefs() async { - try { - final prefs = await SharedPreferences.getInstance(); - final groupingStr = prefs.getString("tx_grouping"); - TransactionGrouping grouping = state.grouping; - if (groupingStr != null) { - grouping = TransactionGrouping.values.firstWhere( - (g) => g.name == groupingStr, - orElse: () => TransactionGrouping.date, - ); - } - final collapsedList = - prefs.getStringList("tx_group_collapse") ?? const []; - if (grouping != state.grouping || - collapsedList.length != state.groupCollapse.length) { - state = state.copyWith( - grouping: grouping, - groupCollapse: collapsedList.toSet(), - ); - } - } catch (_) { - // Ignore persistence errors - } - } - - Future _persistGrouping() async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString("tx_grouping", state.grouping.name); - } catch (_) {} - } - - Future _persistGroupCollapse(Set collapsed) async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setStringList("tx_group_collapse", collapsed.toList()); - } catch (_) {} - } - - /// 更新状态并计算统计数据 void _updateState(List transactions) { final filteredTransactions = state.filter != null ? _filterTransactions(transactions, state.filter!) diff --git a/jive-flutter/lib/services/family_settings_service.dart b/jive-flutter/lib/services/family_settings_service.dart index c01e7cfa..956c60bd 100644 --- a/jive-flutter/lib/services/family_settings_service.dart +++ b/jive-flutter/lib/services/family_settings_service.dart @@ -178,12 +178,6 @@ class FamilySettingsService extends ChangeNotifier { switch (change.entityType) { case 'family_settings': if (change.type == ChangeType.update) { -<<<<<<< HEAD - await _familyService.updateFamilySettings(); - success = true; - } else if (change.type == ChangeType.delete) { - await _familyService.deleteFamilySettings(); -======= await _familyService.updateFamilySettings( change.entityId, FamilySettings.fromJson(change.data!).toJson(), @@ -191,7 +185,7 @@ class FamilySettingsService extends ChangeNotifier { success = true; } else if (change.type == ChangeType.delete) { await _familyService.deleteFamilySettings(change.entityId); ->>>>>>> origin/main + success = true; } break;