diff --git a/frontend/asset_assistant/lib/main.dart b/frontend/asset_assistant/lib/main.dart index 5b7c121..71c5237 100644 --- a/frontend/asset_assistant/lib/main.dart +++ b/frontend/asset_assistant/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:asset_assistant/pages/country_page.dart'; import 'package:asset_assistant/pages/exchange_add_page.dart'; import 'package:asset_assistant/pages/exchange_page.dart'; import 'package:asset_assistant/pages/home_page.dart'; @@ -27,6 +28,7 @@ class MyApp extends StatelessWidget { routes: { '/login': (context) => const LoginPage(), '/home': (context) => HomePage(), + '/country': (context) => CountryPage(), '/exchange': (context) => ExchangePage(), '/exchange/add': (context) => AddExchangePage(), }, diff --git a/frontend/asset_assistant/lib/pages/country_add_page.dart b/frontend/asset_assistant/lib/pages/country_add_page.dart new file mode 100644 index 0000000..dae3c75 --- /dev/null +++ b/frontend/asset_assistant/lib/pages/country_add_page.dart @@ -0,0 +1,209 @@ +import 'package:asset_assistant/utils/host_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AddCountryPage extends StatefulWidget { + const AddCountryPage({super.key}); + + @override + State createState() => _AddCountryPageState(); +} + +class _AddCountryPageState extends State { + // 输入控制器 + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _codeController = TextEditingController(); + + // 加载状态 + bool _isLoading = false; + + // 表单验证键 + final _formKey = GlobalKey(); + + // 创建国家 + Future _createCountry() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isLoading = true; + }); + + try { + // 获取用户ID + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id'); + if (userId == null) { + if (mounted) { + _showDialog('错误', '请先登录'); + Navigator.pushReplacementNamed(context, '/login'); + } + return; + } + + // 准备请求数据 + final baseUrl = HostUtils().currentHost; + const path = '/country/create'; + final url = '$baseUrl$path'; + + final requestData = { + 'name': _nameController.text.trim(), + 'code': _codeController.text.trim(), + }; + + // 发送请求 + final dio = Dio(); + final response = await dio.post( + url, + data: requestData, + options: Options(headers: {'Content-Type': 'application/json'}), + ); + + // 处理响应 + if (response.statusCode == 200) { + final result = response.data; + if (result['success'] == true) { + if (mounted) { + _showDialog('成功', '国家创建成功', () { + Navigator.pop(context, true); // 返回并通知上一页刷新 + }); + } + } else { + if (mounted) { + _showDialog('失败', result['message'] ?? '创建失败,请重试'); + } + } + } else { + if (mounted) { + _showDialog('错误', '服务器响应异常: ${response.statusCode}'); + } + } + } on DioException catch (e) { + String errorMessage = '网络请求失败'; + if (e.response != null) { + errorMessage = '请求失败: ${e.response?.statusCode}'; + } else if (e.type == DioExceptionType.connectionTimeout) { + errorMessage = '连接超时,请检查网络'; + } else if (e.type == DioExceptionType.connectionError) { + errorMessage = '网络连接错误'; + } + + if (mounted) { + _showDialog('错误', errorMessage); + } + } catch (e) { + if (mounted) { + _showDialog('错误', '发生未知错误: $e'); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + // 显示对话框 + void _showDialog(String title, String content, [VoidCallback? onConfirm]) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + onConfirm?.call(); + }, + child: const Text('确定'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('新增国家'), + centerTitle: true, + elevation: 4, + shadowColor: Colors.black12, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + actions: [ + IconButton( + icon: const Icon(Icons.save), + onPressed: _isLoading ? null : _createCountry, + ), + ], + ), + body: SafeArea( + child: Container( + color: theme.colorScheme.surface, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Form( + key: _formKey, + child: Column( + children: [ + // 国家名称输入框 + TextFormField( + controller: _nameController, + style: TextStyle(color: theme.colorScheme.onSurface), + decoration: InputDecoration( + labelText: '国家名称', + hintText: '请输入国家名称', + prefixIcon: Icon( + Icons.account_balance, + color: theme.colorScheme.secondary, + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入国家名称'; + } + return null; + }, + ), + const SizedBox(height: 24), + + // 国家代码输入框 + TextFormField( + controller: _codeController, + style: TextStyle(color: theme.colorScheme.onSurface), + decoration: InputDecoration( + labelText: '国家代码', + hintText: '请输入国家代码', + prefixIcon: Icon( + Icons.code, + color: theme.colorScheme.secondary, + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入国家代码'; + } + return null; + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/asset_assistant/lib/pages/country_page.dart b/frontend/asset_assistant/lib/pages/country_page.dart new file mode 100644 index 0000000..12c7727 --- /dev/null +++ b/frontend/asset_assistant/lib/pages/country_page.dart @@ -0,0 +1,361 @@ +import 'package:asset_assistant/pages/country_add_page.dart'; +import 'package:asset_assistant/utils/host_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; + +// 国家数据模型 +class Country { + final String countryId; + final String name; + final String code; + + Country({required this.countryId, required this.name, required this.code}); + + // 从JSON构建对象 + factory Country.fromJson(Map json) { + return Country( + countryId: json['country_id'], + name: json['name'], + code: json['code'], + ); + } +} + +// 接口响应模型 +class CountryResponse { + final bool success; + final String message; + final CountryData data; + + CountryResponse({ + required this.success, + required this.message, + required this.data, + }); + + factory CountryResponse.fromJson(Map json) { + return CountryResponse( + success: json['success'], + message: json['message'], + data: CountryData.fromJson(json['data']), + ); + } +} + +// 响应数据模型 +class CountryData { + final int total; + final int page; + final int pageSize; + final List items; + + CountryData({ + required this.total, + required this.page, + required this.pageSize, + required this.items, + }); + + factory CountryData.fromJson(Map json) { + var itemsList = json['items'] as List; + List items = itemsList.map((i) => Country.fromJson(i)).toList(); + + return CountryData( + total: json['total'], + page: json['page'], + pageSize: json['page_size'], + items: items, + ); + } +} + +class CountryPage extends StatefulWidget { + const CountryPage({super.key}); + + @override + State createState() => _CountryPageState(); +} + +class _CountryPageState extends State { + List _countries = []; + bool _isLoading = true; + String? _errorMessage; + int _currentPage = 1; + final int _pageSize = 20; + bool _hasMoreData = true; + + @override + void initState() { + super.initState(); + _fetchCountries(); + } + + // 加载国家列表数据 + Future _fetchCountries({bool isRefresh = false}) async { + if (isRefresh) { + setState(() { + _currentPage = 1; + _hasMoreData = true; + }); + } + + if (!_hasMoreData && !isRefresh) return; + + setState(() { + _isLoading = true; + }); + + try { + final baseUrl = HostUtils().currentHost; + final path = '/country/read'; + final url = '$baseUrl$path'; + + final dio = Dio(); + final response = await dio.get( + url, + queryParameters: { + 'page': _currentPage, + 'page_size': _pageSize, + // 为空时查询所有国家 + 'name': '', + 'code': '', + 'country_id': '', + }, + options: Options(headers: {'Content-Type': 'application/json'}), + ); + + if (response.statusCode == 200) { + final CountryResponse countryResponse = CountryResponse.fromJson( + response.data, + ); + + if (countryResponse.success) { + setState(() { + if (isRefresh) { + _countries = countryResponse.data.items; + } else { + _countries.addAll(countryResponse.data.items); + } + + // 检查是否还有更多数据 + _hasMoreData = _countries.length < countryResponse.data.total; + _currentPage++; + _errorMessage = null; + }); + } else { + setState(() { + _errorMessage = countryResponse.message; + }); + } + } else { + setState(() { + _errorMessage = '服务器响应异常: ${response.statusCode}'; + }); + } + } on DioException catch (e) { + String errorMsg = '网络请求失败'; + if (e.response != null) { + errorMsg = '请求失败: ${e.response?.statusCode}'; + } else if (e.type == DioExceptionType.connectionTimeout) { + errorMsg = '连接超时,请检查网络'; + } else if (e.type == DioExceptionType.connectionError) { + errorMsg = '网络连接错误'; + } + setState(() { + _errorMessage = errorMsg; + }); + } catch (e) { + setState(() { + _errorMessage = '发生未知错误: $e'; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + // 下拉刷新 + Future _refresh() async { + await _fetchCountries(isRefresh: true); + } + + // 加载更多 + void _loadMore() { + if (!_isLoading && _hasMoreData) { + _fetchCountries(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('国家列表'), + centerTitle: true, + elevation: 4, + shadowColor: Colors.black12, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () async { + // 跳转到新增页面,返回时刷新列表 + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const AddCountryPage()), + ); + if (result == true) { + _fetchCountries(isRefresh: true); + } + }, + ), + ], + ), + body: SafeArea(child: _buildBody(theme)), + ); + } + + Widget _buildBody(ThemeData theme) { + if (_isLoading && _countries.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null && _countries.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => _fetchCountries(isRefresh: true), + child: const Text('重试'), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _countries.length + (_hasMoreData ? 1 : 0), + itemBuilder: (context, index) { + if (index < _countries.length) { + final country = _countries[index]; + return _buildCountryItem(theme, country); + } else { + // 加载更多指示器 + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: _isLoading + ? const CircularProgressIndicator() + : const Text('没有更多数据了'), + ), + ); + } + }, + // 监听滚动加载更多 + controller: ScrollController() + ..addListener(() { + if (_isLoading) return; + if (_hasMoreData && + ScrollController().position.pixels >= + ScrollController().position.maxScrollExtent - 200) { + _loadMore(); + } + }), + ), + ); + } + + // 构建国家列表项 + Widget _buildCountryItem(ThemeData theme, Country country) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // 可以添加点击事件 + }, + borderRadius: BorderRadius.circular(8), + splashColor: theme.colorScheme.primary.withAlpha(26), + highlightColor: theme.colorScheme.primary.withAlpha(13), + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.flag, + size: 24, + color: theme.colorScheme.secondary, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + country.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + '代码: ${country.code}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios, + size: 18, + color: theme.hintColor, + ), + ], + ), + ), + ), + ), + Divider( + height: 1, + thickness: 1, + indent: 72, + endIndent: 16, + color: theme.dividerColor, + ), + ], + ); + } +} diff --git a/frontend/asset_assistant/lib/pages/home_page.dart b/frontend/asset_assistant/lib/pages/home_page.dart index 0be008d..f898008 100644 --- a/frontend/asset_assistant/lib/pages/home_page.dart +++ b/frontend/asset_assistant/lib/pages/home_page.dart @@ -6,6 +6,7 @@ class HomePage extends StatelessWidget { final List> features = [ {'icon': Icons.bar_chart, 'title': '数据分析', 'route': null}, {'icon': Icons.balance, 'title': '交易', 'route': null}, + {'icon': Icons.flag_circle, 'title': '国家', 'route': '/country'}, {'icon': Icons.account_balance, 'title': '交易所', 'route': '/exchange'}, {'icon': Icons.branding_watermark, 'title': '品种', 'route': null}, ]; @@ -115,14 +116,12 @@ class HomePage extends StatelessWidget { onTap: () { // 点击事件处理 - 如果有路由信息则导航 if (route != null && context.mounted) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => ExchangePage()), - ); + // 使用路由路径进行导航 + Navigator.pushNamed(context, route!); } }, borderRadius: BorderRadius.circular(8), - splashColor: theme.colorScheme.primary.withAlpha(26), // 修改为withAlpha更兼容 + splashColor: theme.colorScheme.primary.withAlpha(26), highlightColor: theme.colorScheme.primary.withAlpha(13), child: Container( height: 64, diff --git a/frontend/asset_assistant/lib/pages/read.go b/frontend/asset_assistant/lib/pages/read.go new file mode 100644 index 0000000..bb93583 --- /dev/null +++ b/frontend/asset_assistant/lib/pages/read.go @@ -0,0 +1,231 @@ +package logic4country + +import ( + "asset_assistant/db" + "net/http" + "strconv" + "strings" + "time" + + "fmt" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// ReadRequest 读取请求参数结构 +type ReadRequest struct { + CountryID string `form:"country_id"` // 国家ID,可选 + Name string `form:"name"` // 国家名称,可选 + Code string `form:"code"` // 国家代码,可选 + Page string `form:"page"` // 页码,可选 + PageSize string `form:"page_size"` // 每页条数,可选 +} + +// ReadData 读取响应数据结构 +type ReadData struct { + Total int64 `json:"total"` // 总条数 + Page int `json:"page"` // 当前页码 + PageSize int `json:"page_size"` // 每页条数 + Items []CountryInfoViewItem `json:"items"` // 数据列表 +} + +// CountryInfoViewItem 视图数据项结构 +type CountryInfoViewItem struct { + CountryID string `json:"country_id"` // 国家ID + Name string `json:"name"` // 国家名称 + Code string `json:"code"` // 国家代码 +} + +// ReadResponse 读取响应结构 +type ReadResponse struct { + Success bool `json:"success"` // 操作是否成功 + Message string `json:"message"` // 提示信息 + Data ReadData `json:"data"` // 响应数据 +} + +// ReadHandler 处理国家信息查询逻辑 +func ReadHandler(c *gin.Context) { + startTime := time.Now() + // 获取或生成请求ID + reqID := c.Request.Header.Get("X-ReadRequest-ID") + if reqID == "" { + reqID = uuid.New().String() + zap.L().Debug("✨ 生成新的请求ID", zap.String("req_id", reqID)) + } + + // 记录请求接收日志 + zap.L().Info("📥 收到国家查询请求", + zap.String("req_id", reqID), + zap.String("path", c.Request.URL.Path), + zap.String("method", c.Request.Method), + ) + + // 绑定请求参数 + var req ReadRequest + if err := c.ShouldBindQuery(&req); err != nil { + zap.L().Warn("⚠️ 请求参数解析失败", + zap.String("req_id", reqID), + zap.Error(err), + ) + c.JSON(http.StatusBadRequest, ReadResponse{ + Success: false, + Message: "请求参数格式错误", + }) + return + } + + // 验证查询条件至少有一个不为空 + if req.CountryID == "" && req.Name == "" && req.Code == "" { + zap.L().Warn("⚠️ 请求参数验证失败", + zap.String("req_id", reqID), + zap.String("reason", "country_id、name、code不能同时为空"), + ) + c.JSON(http.StatusBadRequest, ReadResponse{ + Success: false, + Message: "请求参数错误:country_id、name、code不能同时为空", + }) + return + } + + // 处理分页参数默认值 + page, err := strconv.Atoi(req.Page) + if err != nil || page < 1 { + page = 1 + } + pageSize, err := strconv.Atoi(req.PageSize) + if err != nil || pageSize < 1 { + pageSize = 20 + } + + zap.L().Debug("✅ 请求参数验证通过", + zap.String("req_id", reqID), + zap.String("country_id", req.CountryID), + zap.String("name", req.Name), + zap.String("code", req.Code), + zap.Int("page", page), + zap.Int("page_size", pageSize), + ) + + // 构建查询条件和参数 + whereClauses := []string{} + args := []interface{}{} + paramIndex := 1 + + if req.CountryID != "" { + whereClauses = append(whereClauses, "country_id = $"+strconv.Itoa(paramIndex)) + args = append(args, req.CountryID) + paramIndex++ + } + if req.Name != "" { + whereClauses = append(whereClauses, "name LIKE $"+strconv.Itoa(paramIndex)) + args = append(args, "%"+req.Name+"%") + paramIndex++ + } + if req.Code != "" { + whereClauses = append(whereClauses, "code LIKE $"+strconv.Itoa(paramIndex)) + args = append(args, "%"+req.Code+"%") + paramIndex++ + } + + // 构建基础SQL + baseSQL := "SELECT country_id, name, code FROM country_info_view" + countSQL := "SELECT COUNT(*) FROM country_info_view" + if len(whereClauses) > 0 { + whereStr := " WHERE " + strings.Join(whereClauses, " AND ") + baseSQL += whereStr + countSQL += whereStr + } + + // 计算分页偏移量 + offset := (page - 1) * pageSize + + // 拼接分页SQL(使用fmt.Sprintf更清晰) + querySQL := fmt.Sprintf("%s ORDER BY country_id LIMIT $%d OFFSET $%d", baseSQL, paramIndex, paramIndex+1) + args = append(args, pageSize, offset) + + // 查询总条数(修正参数传递方式) + var total int64 + countArgs := args[:len(args)-2] // 排除分页参数 + err = db.DB.QueryRow(countSQL, countArgs...).Scan(&total) + if err != nil { + zap.L().Error("❌ 查询总条数失败", + zap.String("req_id", reqID), + zap.Error(err), + ) + c.JSON(http.StatusInternalServerError, ReadResponse{ + Success: false, + Message: "查询数据失败,请稍后重试", + }) + return + } + + // 执行分页查询 + rows, err := db.DB.Query(querySQL, args...) + if err != nil { + zap.L().Error("❌ 分页查询失败", + zap.String("req_id", reqID), + zap.Error(err), + ) + c.JSON(http.StatusInternalServerError, ReadResponse{ + Success: false, + Message: "查询数据失败,请稍后重试", + }) + return + } + defer rows.Close() + + // 处理查询结果 + var items []CountryInfoViewItem + for rows.Next() { + var item CountryInfoViewItem + if err := rows.Scan(&item.CountryID, &item.Name, &item.Code); err != nil { + zap.L().Error("❌ 解析查询结果失败", + zap.String("req_id", reqID), + zap.Error(err), + ) + c.JSON(http.StatusInternalServerError, ReadResponse{ + Success: false, + Message: "数据处理失败,请稍后重试", + }) + return + } + items = append(items, item) + } + + // 检查行迭代过程中是否发生错误 + if err := rows.Err(); err != nil { + zap.L().Error("❌ 行迭代错误", + zap.String("req_id", reqID), + zap.Error(err), + ) + c.JSON(http.StatusInternalServerError, ReadResponse{ + Success: false, + Message: "查询数据失败,请稍后重试", + }) + return + } + + // 记录请求处理耗时 + duration := time.Since(startTime) + zap.L().Info("✅ 国家查询请求处理完成", + zap.String("req_id", reqID), + zap.Int64("total", total), + zap.Int("page", page), + zap.Int("page_size", pageSize), + zap.Duration("duration", duration), + ) + + // 返回成功响应 + c.JSON(http.StatusOK, ReadResponse{ + Success: true, + Message: "查询成功", + Data: ReadData{ + Total: total, + Page: page, + PageSize: pageSize, + Items: items, + }, + }) +}