From 8dfc22691a0905786acc1fb10193ff1ceb59f744 Mon Sep 17 00:00:00 2001 From: gongyun Date: Sun, 31 May 2026 08:19:13 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=A2=84=E7=95=99?= =?UTF-8?q?=E4=BB=98=E8=B4=B9=E7=94=A8=E6=88=B7=E7=BA=BF=E8=B7=AF=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E4=B8=8E=E7=BA=BF=E8=B7=AF=E8=87=AA=E9=80=89?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/settings/network_test_page.dart | 758 ++++++++++++++++++ .../pages/settings/settings_page.dart | 506 ++++++++---- pubspec.yaml | 2 +- 3 files changed, 1103 insertions(+), 163 deletions(-) create mode 100644 lib/presentation/pages/settings/network_test_page.dart diff --git a/lib/presentation/pages/settings/network_test_page.dart b/lib/presentation/pages/settings/network_test_page.dart new file mode 100644 index 0000000..905e31f --- /dev/null +++ b/lib/presentation/pages/settings/network_test_page.dart @@ -0,0 +1,758 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +import '../../widgets/desktop_constrained.dart'; + +enum _NetworkTestTab { network, service, custom } + +class NetworkTestPage extends StatefulWidget { + const NetworkTestPage({super.key}); + + @override + State createState() => _NetworkTestPageState(); +} + +class _NetworkTestPageState extends State { + static final _panUri = Uri.parse('https://pan.gongyun.org'); + static final _infoUri = Uri.parse('https://www.gongyun.org/appnetinfo.html'); + + _NetworkTestTab _tab = _NetworkTestTab.network; + bool _networkLoading = false; + bool _serviceLoading = false; + _NetworkDiagnostics? _networkDiagnostics; + _ServiceDiagnostics? _serviceDiagnostics; + String? _networkError; + String? _serviceError; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest()); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('网络测试'), + actions: [ + IconButton( + tooltip: '说明', + icon: const Icon(Icons.info_outline), + onPressed: () => + launchUrl(_infoUri, mode: LaunchMode.externalApplication), + ), + ], + ), + body: DesktopConstrained( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: SizedBox( + width: double.infinity, + child: SegmentedButton<_NetworkTestTab>( + segments: const [ + ButtonSegment( + value: _NetworkTestTab.network, + icon: Icon(Icons.public), + label: Text('网络'), + ), + ButtonSegment( + value: _NetworkTestTab.service, + icon: Icon(Icons.dns_outlined), + label: Text('服务'), + ), + ButtonSegment( + value: _NetworkTestTab.custom, + icon: Icon(Icons.route_outlined), + label: Text('自定义'), + ), + ], + selected: {_tab}, + onSelectionChanged: (selection) { + final selected = selection.first; + if (selected == _NetworkTestTab.custom) { + _showCustomUnavailableDialog(); + return; + } + setState(() => _tab = selected); + if (selected == _NetworkTestTab.network && + _networkDiagnostics == null && + !_networkLoading) { + _runNetworkTest(); + } + if (selected == _NetworkTestTab.service && + _serviceDiagnostics == null && + !_serviceLoading) { + _runServiceTest(); + } + }, + ), + ), + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: switch (_tab) { + _NetworkTestTab.network => _NetworkPanel( + key: const ValueKey('network'), + loading: _networkLoading, + error: _networkError, + diagnostics: _networkDiagnostics, + onRefresh: _runNetworkTest, + ), + _NetworkTestTab.service => _ServicePanel( + key: const ValueKey('service'), + loading: _serviceLoading, + error: _serviceError, + diagnostics: _serviceDiagnostics, + onRefresh: _runServiceTest, + ), + _NetworkTestTab.custom => const SizedBox.shrink(), + }, + ), + ), + ], + ), + ), + ); + } + + Future _showCustomUnavailableDialog() async { + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('自定义线路'), + content: const Text('此功能暂不可用'), + actions: [ + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('好'), + ), + ], + ), + ); + } + + Future _runNetworkTest() async { + setState(() { + _networkLoading = true; + _networkError = null; + }); + + try { + final ipFuture = _fetchIpInfo(); + final panProbe = await _probeUri(_panUri); + final stability = await _testStability(_panUri); + final ipInfo = await ipFuture; + + if (!mounted) return; + setState(() { + _networkDiagnostics = _NetworkDiagnostics( + panReachable: panProbe.reachable, + latencyMs: panProbe.latencyMs, + ipInfo: ipInfo, + stable: stability.stable, + sampleCount: stability.sampleCount, + successCount: stability.successCount, + ); + }); + } catch (e) { + if (mounted) setState(() => _networkError = e.toString()); + } finally { + if (mounted) setState(() => _networkLoading = false); + } + } + + Future _runServiceTest() async { + setState(() { + _serviceLoading = true; + _serviceError = null; + }); + + try { + final monitorHeartbeats = await _fetchMonitorHeartbeats('gongyun'); + + _ServiceItem monitor(String label, int monitorId) { + return _monitorItem( + label: label, + monitorId: monitorId, + heartbeats: monitorHeartbeats, + ); + } + + final telegramBot = await _probeItem( + label: 'Telegram bot', + uri: Uri.parse('https://bot.mygongyun.com'), + healthyStatusCodes: const {200, 401}, + ); + + final business = <_ServiceItem>[ + monitor('官网', 3), + monitor('网盘', 2), + monitor('短链', 37), + monitor('SSO', 39), + telegramBot, + ]; + + final dc = <_ServiceItem>[ + monitor('DC3-CN-SZ', 13), + monitor('DC3-CN-MIAMI', 11), + monitor('DC3-CN-NY', 12), + monitor('DC3-CN-LV', 10), + ]; + + final backend = <_ServiceItem>[monitor('数据库', 32), monitor('键值对数据库', 36)]; + + final storage = <_ServiceItem>[ + monitor('DC-S-NA-1', 5), + monitor('DC-S-NA-2', 6), + monitor('DC-S-EU', 7), + monitor('DC-S-EP', 8), + ]; + + final acceleration = <_ServiceItem>[ + monitor('香港', 18), + monitor('香港', 19), + monitor('新加坡', 22), + monitor('洛杉矶', 24), + monitor('蒙特利尔', 21), + ]; + + if (!mounted) return; + setState(() { + _serviceDiagnostics = _ServiceDiagnostics( + fetchedAt: DateTime.now(), + business: business, + dc: dc, + backend: backend, + storage: storage, + acceleration: acceleration, + ); + }); + } catch (e) { + if (mounted) setState(() => _serviceError = e.toString()); + } finally { + if (mounted) setState(() => _serviceLoading = false); + } + } + + Future<_IpInfo> _fetchIpInfo() async { + try { + final response = await http + .get(Uri.parse('https://ipwho.is/')) + .timeout(const Duration(seconds: 8)); + if (response.statusCode >= 200 && response.statusCode < 300) { + final data = jsonDecode(response.body) as Map; + if (data['success'] != false) { + final connection = data['connection']; + final connectionMap = connection is Map + ? connection + : {}; + return _IpInfo( + ip: data['ip']?.toString() ?? '未知', + asn: connectionMap['asn']?.toString() ?? '未知', + operatorName: connectionMap['isp']?.toString().isNotEmpty == true + ? connectionMap['isp'].toString() + : (connectionMap['org']?.toString() ?? '未知'), + ); + } + } + } catch (_) { + // Fall back to the next public endpoint. + } + + try { + final response = await http + .get(Uri.parse('https://ipapi.co/json/')) + .timeout(const Duration(seconds: 8)); + if (response.statusCode >= 200 && response.statusCode < 300) { + final data = jsonDecode(response.body) as Map; + return _IpInfo( + ip: data['ip']?.toString() ?? '未知', + asn: data['asn']?.toString() ?? '未知', + operatorName: data['org']?.toString() ?? '未知', + ); + } + } catch (_) { + // Handled by the default value below. + } + + return const _IpInfo(ip: '未知', asn: '未知', operatorName: '未知'); + } + + _ServiceItem _monitorItem({ + required String label, + required int monitorId, + required Map heartbeats, + }) { + final heartbeat = heartbeats[monitorId]; + return _ServiceItem( + label: label, + online: heartbeat?.online ?? false, + latencyMs: heartbeat?.pingMs, + ); + } + + Future> _fetchMonitorHeartbeats( + String pageId, + ) async { + try { + final response = await http + .get( + Uri.parse('https://status.gongyun.org/api/monitor?pageId=$pageId'), + headers: const {'Cache-Control': 'no-cache'}, + ) + .timeout(const Duration(seconds: 10)); + if (response.statusCode < 200 || response.statusCode >= 300) { + return const {}; + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) return const {}; + final data = decoded['data']; + if (data is! Map) return const {}; + final heartbeatList = data['heartbeatList']; + if (heartbeatList is! Map) return const {}; + + final result = {}; + for (final entry in heartbeatList.entries) { + final monitorId = int.tryParse(entry.key); + final records = entry.value; + if (monitorId == null || records is! List || records.isEmpty) continue; + final latest = records.last; + if (latest is! Map) continue; + final status = _asInt(latest['status']); + final ping = _asInt(latest['ping']); + result[monitorId] = _MonitorHeartbeat( + online: status == 1, + pingMs: ping, + ); + } + + return result; + } catch (_) { + return const {}; + } + } + + int? _asInt(Object? value) { + if (value is int) return value; + if (value is num) return value.round(); + return int.tryParse(value?.toString() ?? ''); + } + + Future<_ServiceItem> _probeItem({ + required String label, + required Uri uri, + Set healthyStatusCodes = const {}, + }) async { + final probe = await _probeUri(uri, healthyStatusCodes: healthyStatusCodes); + return _ServiceItem( + label: label, + online: probe.reachable, + latencyMs: probe.latencyMs, + ); + } + + Future<_ProbeResult> _probeUri( + Uri uri, { + Set healthyStatusCodes = const {}, + }) async { + final stopwatch = Stopwatch()..start(); + try { + final response = await http + .get(uri, headers: const {'Cache-Control': 'no-cache'}) + .timeout(const Duration(seconds: 8)); + stopwatch.stop(); + final isHealthy = healthyStatusCodes.isEmpty + ? response.statusCode >= 200 && response.statusCode < 500 + : healthyStatusCodes.contains(response.statusCode); + return _ProbeResult( + reachable: isHealthy, + latencyMs: stopwatch.elapsedMilliseconds, + ); + } catch (_) { + stopwatch.stop(); + return const _ProbeResult(reachable: false); + } + } + + Future<_StabilityResult> _testStability(Uri uri) async { + final latencies = []; + var successCount = 0; + const sampleCount = 3; + + for (var i = 0; i < sampleCount; i++) { + final probe = await _probeUri(uri); + if (probe.reachable) { + successCount += 1; + if (probe.latencyMs != null) latencies.add(probe.latencyMs!); + } + if (i < sampleCount - 1) { + await Future.delayed(const Duration(milliseconds: 350)); + } + } + + final jitter = latencies.isEmpty + ? 999999 + : latencies.reduce(max) - latencies.reduce(min); + return _StabilityResult( + stable: successCount == sampleCount && jitter < 1500, + sampleCount: sampleCount, + successCount: successCount, + ); + } +} + +class _NetworkPanel extends StatelessWidget { + final bool loading; + final String? error; + final _NetworkDiagnostics? diagnostics; + final VoidCallback onRefresh; + + const _NetworkPanel({ + super.key, + required this.loading, + required this.error, + required this.diagnostics, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + final data = diagnostics; + final errorColor = Theme.of(context).colorScheme.error; + return ListView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 18), + children: [ + _SectionCard( + title: '网络', + children: [ + if (loading) const LinearProgressIndicator(), + if (error != null) _ErrorText(error!), + _InfoRow( + label: '公云存储官网访问测试', + value: data == null ? '检测中' : (data.panReachable ? '正常' : '离线'), + valueColor: _statusColor(context, data?.panReachable), + ), + _InfoRow(label: '当前网络 IP 地址', value: data?.ipInfo.ip ?? '检测中'), + _InfoRow(label: 'IP 归属 AS', value: data?.ipInfo.asn ?? '检测中'), + _InfoRow(label: '运营商', value: data?.ipInfo.operatorName ?? '检测中'), + _InfoRow( + label: '连接时延', + value: data?.latencyMs == null ? '检测中' : '${data!.latencyMs} ms', + ), + _InfoRow( + label: '长期连接稳定性', + value: data == null + ? '检测中' + : '${data.stable ? '稳定' : '不稳定'} ' + '(${data.successCount}/${data.sampleCount})', + valueColor: _statusColor(context, data?.stable), + ), + ], + ), + _SectionCard( + title: '说明', + children: const [ + _DescriptionLine('公云存储作为国际化云存储产品,已接入Cloudflare CDN作为全球加速引擎。'), + _DescriptionLine( + '由于部分国家/地区有法律法规限制与审查,可能会出现包括但不限于网络波动、网络丢包以及网络连接不稳定现象', + ), + _DescriptionLine( + '如诺出现网络连接问题您可将本页面内容向公云存储团队进行报告,团队会根据相关诊断线索进行排查与优化。', + ), + ], + ), + _SectionCard( + title: '警告', + titleColor: errorColor, + children: [ + Text( + '本页面内容涉及你本人敏感信息请勿提供给非公云存储团队人员,以避免你本人相关信息泄露!', + style: TextStyle(color: errorColor, fontWeight: FontWeight.w700), + ), + ], + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: loading ? null : onRefresh, + icon: const Icon(Icons.refresh), + label: const Text('重新检测'), + ), + ], + ); + } +} + +class _ServicePanel extends StatelessWidget { + final bool loading; + final String? error; + final _ServiceDiagnostics? diagnostics; + final VoidCallback onRefresh; + + const _ServicePanel({ + super.key, + required this.loading, + required this.error, + required this.diagnostics, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + final data = diagnostics; + return ListView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 18), + children: [ + _SectionCard( + title: '业务状态', + children: [ + if (loading) const LinearProgressIndicator(), + if (error != null) _ErrorText(error!), + ..._itemsOrLoading(data?.business), + ], + ), + _SectionCard(title: '离线下载与计算节点', children: _itemsOrLoading(data?.dc)), + _SectionCard(title: '后端', children: _itemsOrLoading(data?.backend)), + _SectionCard(title: '存储节点', children: _itemsOrLoading(data?.storage)), + _SectionCard( + title: '加速线路', + children: _itemsOrLoading(data?.acceleration, showLatency: true), + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: loading ? null : onRefresh, + icon: const Icon(Icons.refresh), + label: const Text('重新检测'), + ), + ], + ); + } + + List _itemsOrLoading( + List<_ServiceItem>? items, { + bool showLatency = false, + }) { + if (items == null) { + return const [_InfoRow(label: '状态', value: '检测中')]; + } + return items + .map( + (item) => _InfoRow( + label: item.label, + value: showLatency + ? item.latencyLabel + : (item.online ? '正常' : '离线'), + valueColor: item.online ? Colors.green : Colors.red, + ), + ) + .toList(); + } +} + +class _SectionCard extends StatelessWidget { + final String title; + final List children; + final Color? titleColor; + + const _SectionCard({ + required this.title, + required this.children, + this.titleColor, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: titleColor, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + ...children, + ], + ), + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + final Color? valueColor; + + const _InfoRow({required this.label, required this.value, this.valueColor}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Text(label, style: theme.textTheme.bodyMedium)), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.right, + style: theme.textTheme.bodyMedium?.copyWith( + color: valueColor, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} + +class _DescriptionLine extends StatelessWidget { + final String text; + + const _DescriptionLine(this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Text(text), + ); + } +} + +class _ErrorText extends StatelessWidget { + final String error; + + const _ErrorText(this.error); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + '检测失败:$error', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ); + } +} + +Color? _statusColor(BuildContext context, bool? value) { + if (value == null) return null; + return value ? Colors.green : Theme.of(context).colorScheme.error; +} + +class _NetworkDiagnostics { + final bool panReachable; + final int? latencyMs; + final _IpInfo ipInfo; + final bool stable; + final int sampleCount; + final int successCount; + + const _NetworkDiagnostics({ + required this.panReachable, + required this.latencyMs, + required this.ipInfo, + required this.stable, + required this.sampleCount, + required this.successCount, + }); +} + +class _IpInfo { + final String ip; + final String asn; + final String operatorName; + + const _IpInfo({ + required this.ip, + required this.asn, + required this.operatorName, + }); +} + +class _ServiceDiagnostics { + final DateTime fetchedAt; + final List<_ServiceItem> business; + final List<_ServiceItem> dc; + final List<_ServiceItem> backend; + final List<_ServiceItem> storage; + final List<_ServiceItem> acceleration; + + const _ServiceDiagnostics({ + required this.fetchedAt, + required this.business, + required this.dc, + required this.backend, + required this.storage, + required this.acceleration, + }); +} + +class _ServiceItem { + final String label; + final bool online; + final int? latencyMs; + + const _ServiceItem({ + required this.label, + required this.online, + this.latencyMs, + }); + + String get latencyLabel { + if (!online) return '离线'; + if (latencyMs == null) return '正常 / -- ms'; + return '正常 / $latencyMs ms'; + } +} + +class _MonitorHeartbeat { + final bool online; + final int? pingMs; + + const _MonitorHeartbeat({required this.online, this.pingMs}); +} + +class _ProbeResult { + final bool reachable; + final int? latencyMs; + + const _ProbeResult({required this.reachable, this.latencyMs}); +} + +class _StabilityResult { + final bool stable; + final int sampleCount; + final int successCount; + + const _StabilityResult({ + required this.stable, + required this.sampleCount, + required this.successCount, + }); +} diff --git a/lib/presentation/pages/settings/settings_page.dart b/lib/presentation/pages/settings/settings_page.dart index 8fa22cc..2e370ff 100644 --- a/lib/presentation/pages/settings/settings_page.dart +++ b/lib/presentation/pages/settings/settings_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -16,6 +17,7 @@ import 'file_preferences_page.dart'; import 'app_settings_page.dart'; import 'credit_history_page.dart'; import 'quick_access_settings_page.dart'; +import 'network_test_page.dart'; import '../../../router/app_router.dart'; /// 设置主页 @@ -83,112 +85,127 @@ class _SettingsPageState extends State { ), body: DesktopConstrained( child: ListView( - children: [ - _buildProfileCard(context, user, settings, capacity), - const SizedBox(height: 8), - _buildSection( - title: '账户与安全', - children: [ - _SettingsTile( - icon: Icons.person_outline, - title: '个人资料', - subtitle: '修改昵称、头像', - onTap: () => _navigateTo(context, const ProfileEditPage()), - ), - _SettingsTile( - icon: Icons.security_outlined, - title: '安全设置', - subtitle: _securitySubtitle(settings), - onTap: () => _navigateTo(context, const SecuritySettingsPage()), - ), - ], - ), - _buildSection( - title: '偏好', - children: [ - _SettingsTile( - icon: Icons.sync_outlined, - title: '文件同步', - subtitle: '本地与云端文件自动同步', - onTap: () => Navigator.of(context).pushNamed(RouteNames.syncSettings), - ), - _SettingsTile( - icon: Icons.apps_outlined, - title: '快捷入口', - subtitle: '自定义概览页快捷目录', - onTap: () => _navigateTo(context, const QuickAccessSettingsPage()), - ), - _SettingsTile( - icon: Icons.folder_outlined, - title: '文件偏好', - subtitle: '版本保留、视图同步、分享可见性', - onTap: () => _navigateTo(context, const FilePreferencesPage()), - ), - _SettingsTile( - icon: Icons.tune, - title: '应用设置', - subtitle: '缓存、主题、语言', - onTap: () => _navigateTo(context, const AppSettingsPage()), - ), - ], - ), - // 财务区域(有数据时才显示) - if (settings != null) ..._buildProSections(context, settings), - _buildSection( - title: '关于', - children: [ - ListTile( - leading: const Icon(Icons.info_outline), - title: const Text('应用名称'), - subtitle: const Text('公云存储'), - ), - ListTile( - leading: const Icon(Icons.tag), - title: const Text('版本号'), - subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion), - ), - ListTile( - leading: const Icon(Icons.copyright), - title: const Text('License'), - subtitle: const Text('AGPL-3.0'), - ), - ListTile( - leading: const Icon(Icons.code), - title: const Text('二次开发'), - subtitle: const Text('gongyun_app'), - trailing: const Icon(Icons.open_in_new, size: 16), - onTap: () { - launchUrl( - Uri.parse('https://git.saont.net/gongyun/app'), - mode: LaunchMode.externalApplication, - ); - }, - ), - ListTile( - leading: const Icon(Icons.code), - title: const Text('基于'), - subtitle: const Text('cloudreve4_flutter'), - trailing: const Icon(Icons.open_in_new, size: 16), - onTap: () { - launchUrl( - Uri.parse('https://github.com/LimoYuan/cloudreve4_flutter'), - mode: LaunchMode.externalApplication, - ); - }, - ), - ], - ), - const SizedBox(height: 24), - _buildLogoutButton(context, auth), - const SizedBox(height: 32), - ], - ), + children: [ + _buildProfileCard(context, user, settings, capacity), + const SizedBox(height: 8), + _buildSection( + title: '账户与安全', + children: [ + _SettingsTile( + icon: Icons.person_outline, + title: '个人资料', + subtitle: '修改昵称、头像', + onTap: () => _navigateTo(context, const ProfileEditPage()), + ), + _SettingsTile( + icon: Icons.security_outlined, + title: '安全设置', + subtitle: _securitySubtitle(settings), + onTap: () => + _navigateTo(context, const SecuritySettingsPage()), + ), + ], + ), + _buildSection( + title: '偏好', + children: [ + _SettingsTile( + icon: Icons.sync_outlined, + title: '文件同步', + subtitle: '本地与云端文件自动同步', + onTap: () => + Navigator.of(context).pushNamed(RouteNames.syncSettings), + ), + _SettingsTile( + icon: Icons.apps_outlined, + title: '快捷入口', + subtitle: '自定义概览页快捷目录', + onTap: () => + _navigateTo(context, const QuickAccessSettingsPage()), + ), + _SettingsTile( + icon: Icons.folder_outlined, + title: '文件偏好', + subtitle: '版本保留、视图同步、分享可见性', + onTap: () => + _navigateTo(context, const FilePreferencesPage()), + ), + _SettingsTile( + icon: Icons.tune, + title: '应用设置', + subtitle: '缓存、主题、语言', + onTap: () => _navigateTo(context, const AppSettingsPage()), + ), + _SettingsTile( + icon: Icons.network_check, + title: '网络测试', + subtitle: '服务连通性测试与自定义线路(会员)', + onTap: () => _openNetworkTest(context), + ), + ], + ), + // 财务区域(有数据时才显示) + if (settings != null) ..._buildProSections(context, settings), + _buildSection( + title: '关于', + children: [ + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('应用名称'), + subtitle: const Text('公云存储'), + ), + ListTile( + leading: const Icon(Icons.tag), + title: const Text('版本号'), + subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion), + ), + ListTile( + leading: const Icon(Icons.copyright), + title: const Text('License'), + subtitle: const Text('AGPL-3.0'), + ), + ListTile( + leading: const Icon(Icons.code), + title: const Text('二次开发'), + subtitle: const Text('gongyun_app'), + trailing: const Icon(Icons.open_in_new, size: 16), + onTap: () { + launchUrl( + Uri.parse('https://git.saont.net/gongyun/app'), + mode: LaunchMode.externalApplication, + ); + }, + ), + ListTile( + leading: const Icon(Icons.code), + title: const Text('基于'), + subtitle: const Text('cloudreve4_flutter'), + trailing: const Icon(Icons.open_in_new, size: 16), + onTap: () { + launchUrl( + Uri.parse( + 'https://github.com/LimoYuan/cloudreve4_flutter', + ), + mode: LaunchMode.externalApplication, + ); + }, + ), + ], + ), + const SizedBox(height: 24), + _buildLogoutButton(context, auth), + const SizedBox(height: 32), + ], + ), ), ); } /// 财务区域(存储包、积分、会员) - List _buildProSections(BuildContext context, UserSettingModel settings) { + List _buildProSections( + BuildContext context, + UserSettingModel settings, + ) { final sections = []; final hasStoragePacks = settings.storagePacks.isNotEmpty; final hasCredit = settings.credit > 0; @@ -197,40 +214,54 @@ class _SettingsPageState extends State { if (hasStoragePacks || hasCredit || hasMembership) { final children = []; if (hasMembership) { - children.add(ListTile( - leading: const Icon(Icons.workspace_premium_outlined), - title: const Text('会员'), - subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'), - trailing: TextButton( - onPressed: () => _cancelMembership(context), - child: const Text('取消会员', style: TextStyle(color: Colors.red)), + children.add( + ListTile( + leading: const Icon(Icons.workspace_premium_outlined), + title: const Text('会员'), + subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'), + trailing: TextButton( + onPressed: () => _cancelMembership(context), + child: const Text('取消会员', style: TextStyle(color: Colors.red)), + ), ), - )); + ); } if (hasStoragePacks) { - children.add(ListTile( - leading: const Icon(Icons.inventory_2_outlined), - title: Text('存储包 (${settings.storagePacks.length})'), - subtitle: Text(_storagePackSummary(settings.storagePacks)), - trailing: const Icon(Icons.chevron_right), - onTap: () => _showStoragePacks(context, settings.storagePacks), - )); + children.add( + ListTile( + leading: const Icon(Icons.inventory_2_outlined), + title: Text('存储包 (${settings.storagePacks.length})'), + subtitle: Text(_storagePackSummary(settings.storagePacks)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showStoragePacks(context, settings.storagePacks), + ), + ); } if (hasCredit) { - children.add(ListTile( - leading: const Icon(Icons.account_balance_wallet_outlined), - title: const Text('积分'), - subtitle: Text('${settings.credit} 积分'), - trailing: const Icon(Icons.chevron_right), - onTap: () => _navigateTo(context, CreditHistoryPage(currentCredit: settings.credit)), - )); + children.add( + ListTile( + leading: const Icon(Icons.account_balance_wallet_outlined), + title: const Text('积分'), + subtitle: Text('${settings.credit} 积分'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _navigateTo( + context, + CreditHistoryPage(currentCredit: settings.credit), + ), + ), + ); } sections.add(_buildSection(title: '财务', children: children)); } return sections; } - Widget _buildProfileCard(BuildContext context, UserModel? user, UserSettingModel? settings, UserCapacityModel? capacity) { + Widget _buildProfileCard( + BuildContext context, + UserModel? user, + UserSettingModel? settings, + UserCapacityModel? capacity, + ) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; @@ -253,17 +284,24 @@ class _SettingsPageState extends State { children: [ Text( user?.nickname ?? '未登录', - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 2), Text( user?.email ?? '', - style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), if (user?.group != null) ...[ const SizedBox(height: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), @@ -316,9 +354,12 @@ class _SettingsPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('存储空间', style: theme.textTheme.bodySmall), - Text('$usedText / $totalText', style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - )), + Text( + '$usedText / $totalText', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ], ), const SizedBox(height: 6), @@ -334,7 +375,10 @@ class _SettingsPageState extends State { ); } - Widget _buildSection({required String title, required List children}) { + Widget _buildSection({ + required String title, + required List children, + }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( @@ -366,11 +410,18 @@ class _SettingsPageState extends State { child: OutlinedButton.icon( onPressed: () => _confirmLogout(context, auth), icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error), - label: Text('退出登录', style: TextStyle(color: Theme.of(context).colorScheme.error)), + label: Text( + '退出登录', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), style: OutlinedButton.styleFrom( minimumSize: const Size(double.infinity, 48), - side: BorderSide(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + side: BorderSide( + color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), ), ); @@ -407,33 +458,47 @@ class _SettingsPageState extends State { padding: const EdgeInsets.symmetric(vertical: 16), child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium), ), - ...packs.map((pack) => Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(pack.name, style: Theme.of(ctx).textTheme.titleSmall), - Text(_formatBytes(pack.size), style: Theme.of(ctx).textTheme.labelLarge), - ], - ), - const SizedBox(height: 4), - Text( - '激活: ${_formatDate(pack.activeSince)}' - '${pack.expireAt != null ? " · 到期: ${_formatDate(pack.expireAt!)}" : " · 永久"}', - style: Theme.of(ctx).textTheme.bodySmall, - ), - if (pack.isExpired) ...[ + ...packs.map( + (pack) => Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + pack.name, + style: Theme.of(ctx).textTheme.titleSmall, + ), + Text( + _formatBytes(pack.size), + style: Theme.of(ctx).textTheme.labelLarge, + ), + ], + ), const SizedBox(height: 4), - Text('已过期', style: TextStyle(color: Theme.of(ctx).colorScheme.error, fontSize: 12)), + Text( + '激活: ${_formatDate(pack.activeSince)}' + '${pack.expireAt != null ? " · 到期: ${_formatDate(pack.expireAt!)}" : " · 永久"}', + style: Theme.of(ctx).textTheme.bodySmall, + ), + if (pack.isExpired) ...[ + const SizedBox(height: 4), + Text( + '已过期', + style: TextStyle( + color: Theme.of(ctx).colorScheme.error, + fontSize: 12, + ), + ), + ], ], - ], + ), ), ), - )), + ), const SizedBox(height: 16), ], ), @@ -449,10 +514,15 @@ class _SettingsPageState extends State { title: const Text('取消会员'), content: const Text('确定要取消当前会员吗?取消后将失去会员权益。'), actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消'), + ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), - style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + ), child: const Text('确认取消'), ), ], @@ -479,6 +549,42 @@ class _SettingsPageState extends State { } } + Future _openNetworkTest(BuildContext context) async { + final agreed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: const Text('会话信息授权'), + content: _NetworkTestConsentText( + onOpenPrivacy: () => launchUrl( + Uri.parse('https://www.gongyun.org/policy/privacy.html'), + mode: LaunchMode.externalApplication, + ), + onOpenTerms: () => launchUrl( + Uri.parse('https://www.gongyun.org/policy/terms.html'), + mode: LaunchMode.externalApplication, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('不同意'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('同意'), + ), + ], + ), + ); + + if (agreed == true && context.mounted) { + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const NetworkTestPage())); + } + } + Future _confirmLogout(BuildContext context, AuthProvider auth) async { final confirmed = await showDialog( context: context, @@ -486,10 +592,15 @@ class _SettingsPageState extends State { title: const Text('退出登录'), content: const Text('确定要退出当前账号吗?'), actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消'), + ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), - style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), child: const Text('退出'), ), ], @@ -506,7 +617,9 @@ class _SettingsPageState extends State { String _formatBytes(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; } @@ -515,6 +628,75 @@ class _SettingsPageState extends State { } } +class _NetworkTestConsentText extends StatefulWidget { + final VoidCallback onOpenPrivacy; + final VoidCallback onOpenTerms; + + const _NetworkTestConsentText({ + required this.onOpenPrivacy, + required this.onOpenTerms, + }); + + @override + State<_NetworkTestConsentText> createState() => + _NetworkTestConsentTextState(); +} + +class _NetworkTestConsentTextState extends State<_NetworkTestConsentText> { + late final TapGestureRecognizer _privacyRecognizer; + late final TapGestureRecognizer _termsRecognizer; + + @override + void initState() { + super.initState(); + _privacyRecognizer = TapGestureRecognizer()..onTap = widget.onOpenPrivacy; + _termsRecognizer = TapGestureRecognizer()..onTap = widget.onOpenTerms; + } + + @override + void didUpdateWidget(covariant _NetworkTestConsentText oldWidget) { + super.didUpdateWidget(oldWidget); + _privacyRecognizer.onTap = widget.onOpenPrivacy; + _termsRecognizer.onTap = widget.onOpenTerms; + } + + @override + void dispose() { + _privacyRecognizer.dispose(); + _termsRecognizer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final linkStyle = TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ); + + return RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyMedium, + children: [ + const TextSpan(text: '您需同意'), + TextSpan( + text: '隐私政策', + style: linkStyle, + recognizer: _privacyRecognizer, + ), + const TextSpan(text: '与'), + TextSpan( + text: '用户协议', + style: linkStyle, + recognizer: _termsRecognizer, + ), + const TextSpan(text: '方可进入网络测试页。'), + ], + ), + ); + } +} + class _SettingsTile extends StatelessWidget { final IconData icon; final String title; diff --git a/pubspec.yaml b/pubspec.yaml index 30eb572..c05611a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.3.4+4 +version: 1.3.5+5 environment: sdk: ^3.11.4 From 61ad85f6fc545a376a327ebde467f6804d897d86 Mon Sep 17 00:00:00 2001 From: gongyun Date: Sun, 31 May 2026 09:32:32 +0800 Subject: [PATCH 2/3] Optimize automatic updates --- lib/core/constants/storage_keys.dart | 4 + .../pages/settings/settings_page.dart | 70 +++++++++- lib/presentation/widgets/update_prompt.dart | 129 ++++++------------ lib/services/update_service.dart | 70 +++++++++- 4 files changed, 175 insertions(+), 98 deletions(-) diff --git a/lib/core/constants/storage_keys.dart b/lib/core/constants/storage_keys.dart index e76a5ed..08873be 100644 --- a/lib/core/constants/storage_keys.dart +++ b/lib/core/constants/storage_keys.dart @@ -39,4 +39,8 @@ class StorageKeys { // 公告 static const String siteAnnouncementDismissedFingerprint = 'site_announcement_dismissed_fingerprint'; + + // 版本更新 + static const String updatePromptSkipUntil = 'update_prompt_skip_until'; + static const String updatePromptSkipVersion = 'update_prompt_skip_version'; } diff --git a/lib/presentation/pages/settings/settings_page.dart b/lib/presentation/pages/settings/settings_page.dart index 2e370ff..c5323a6 100644 --- a/lib/presentation/pages/settings/settings_page.dart +++ b/lib/presentation/pages/settings/settings_page.dart @@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../data/models/user_model.dart'; import '../../../data/models/user_setting_model.dart'; +import '../../../services/update_service.dart'; import '../../../services/user_setting_service.dart'; import '../../providers/auth_provider.dart'; import '../../providers/user_setting_provider.dart'; @@ -154,11 +155,7 @@ class _SettingsPageState extends State { title: const Text('应用名称'), subtitle: const Text('公云存储'), ), - ListTile( - leading: const Icon(Icons.tag), - title: const Text('版本号'), - subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion), - ), + _buildVersionTile(), ListTile( leading: const Icon(Icons.copyright), title: const Text('License'), @@ -404,6 +401,22 @@ class _SettingsPageState extends State { ); } + Widget _buildVersionTile() { + return AnimatedBuilder( + animation: UpdateService.instance, + builder: (context, _) { + return ListTile( + leading: const Icon(Icons.tag), + title: const Text('版本号'), + subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion), + trailing: UpdateService.instance.hasUpdate + ? const _BlinkingUpdateDot() + : null, + ); + }, + ); + } + Widget _buildLogoutButton(BuildContext context, AuthProvider auth) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -628,6 +641,53 @@ class _SettingsPageState extends State { } } +class _BlinkingUpdateDot extends StatefulWidget { + const _BlinkingUpdateDot(); + + @override + State<_BlinkingUpdateDot> createState() => _BlinkingUpdateDotState(); +} + +class _BlinkingUpdateDotState extends State<_BlinkingUpdateDot> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + _opacity = Tween( + begin: 0.3, + end: 1, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _opacity, + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ); + } +} + class _NetworkTestConsentText extends StatefulWidget { final VoidCallback onOpenPrivacy; final VoidCallback onOpenTerms; diff --git a/lib/presentation/widgets/update_prompt.dart b/lib/presentation/widgets/update_prompt.dart index cad14ac..90dcbd1 100644 --- a/lib/presentation/widgets/update_prompt.dart +++ b/lib/presentation/widgets/update_prompt.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import '../../data/models/app_update_model.dart'; @@ -31,6 +29,8 @@ class _UpdatePromptState extends State { final update = await UpdateService.instance.checkForUpdate(); if (update == null || !mounted) return; + final shouldShow = await UpdateService.instance.shouldShowPrompt(update); + if (!shouldShow || !mounted) return; _dialogVisible = true; try { @@ -41,98 +41,55 @@ class _UpdatePromptState extends State { } Future _showUpdateDialog(AppUpdateInfo update) async { - var downloading = false; - var progress = 0.0; - String? status; - await showDialog( context: context, barrierDismissible: false, builder: (dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - final canDownload = - update.downloadUrl != null && - (Platform.isAndroid || Platform.isWindows); + Future openDownloadsPage() async { + try { + await UpdateService.instance.openDownloadsPage(); + if (dialogContext.mounted) Navigator.of(dialogContext).pop(); + } catch (e) { + ToastHelper.failure('打开下载页面失败: $e'); + } + } - Future startUpdate() async { - if (downloading || !canDownload) return; + Future skipPrompt() async { + await UpdateService.instance.skipPromptForFiveDays(update); + if (dialogContext.mounted) Navigator.of(dialogContext).pop(); + } - setDialogState(() { - downloading = true; - progress = 0; - status = '正在下载更新...'; - }); + final releaseNotes = (update.description ?? '').trim(); - try { - final file = await UpdateService.instance.downloadUpdate( - update, - onProgress: (received, total) { - if (!mounted || total <= 0) return; - setDialogState(() { - progress = received / total; - }); - }, - ); - if (file == null) return; - - setDialogState(() { - progress = 1; - status = '下载完成,正在打开安装包...'; - }); - await UpdateService.instance.openInstaller(file); - if (dialogContext.mounted) Navigator.of(dialogContext).pop(); - } catch (e) { - setDialogState(() { - downloading = false; - status = '下载或打开安装包失败'; - }); - ToastHelper.failure('更新失败: $e'); - } - } - - return AlertDialog( - title: Text('发现新版本 ${update.version}'), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(update.title), - if ((update.description ?? '').isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - update.description!, - maxLines: 6, - overflow: TextOverflow.ellipsis, - ), - ], - if (downloading) ...[ - const SizedBox(height: 16), - LinearProgressIndicator( - value: progress == 0 ? null : progress, - ), - const SizedBox(height: 8), - Text(status ?? ''), - ], - ], - ), + return AlertDialog( + title: Text('发现新版本 ${update.version}'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420, maxHeight: 360), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('新版本号:${update.version}'), + const SizedBox(height: 12), + Text( + '版本日志', + style: Theme.of(dialogContext).textTheme.titleSmall, + ), + const SizedBox(height: 6), + Text(releaseNotes.isEmpty ? update.title : releaseNotes), + ], ), - actions: [ - TextButton( - onPressed: downloading - ? null - : () => Navigator.of(dialogContext).pop(), - child: const Text('稍后'), - ), - FilledButton( - onPressed: downloading ? null : startUpdate, - child: const Text('立即更新'), - ), - ], - ); - }, + ), + ), + actions: [ + TextButton(onPressed: skipPrompt, child: const Text('跳过')), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('取消'), + ), + FilledButton(onPressed: openDownloadsPage, child: const Text('更新')), + ], ); }, ); diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index ab0d03b..4f3bbde 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -9,10 +9,12 @@ import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:xml/xml.dart'; +import '../core/constants/storage_keys.dart'; import '../core/utils/app_logger.dart'; import '../data/models/app_update_model.dart'; +import 'storage_service.dart'; -class UpdateService { +class UpdateService extends ChangeNotifier { UpdateService._(); static final UpdateService instance = UpdateService._(); @@ -23,6 +25,9 @@ class UpdateService { static final Uri releasesPageUrl = Uri.parse( 'https://git.saont.net/gongyun/app/releases', ); + static final Uri downloadsPageUrl = Uri.parse( + 'https://www.gongyun.org/downloads.html', + ); final Dio _dio = Dio( BaseOptions( @@ -34,17 +39,24 @@ class UpdateService { ); bool _checking = false; + AppUpdateInfo? _availableUpdate; + + AppUpdateInfo? get availableUpdate => _availableUpdate; + bool get hasUpdate => _availableUpdate != null; Future checkForUpdate() async { if (_checking) return null; - if (!_supportsAutoDownload) return null; _checking = true; try { final current = await PackageInfo.fromPlatform(); final latest = await _fetchLatestRelease(currentVersion: current.version); - if (latest == null) return null; - if (_compareVersions(latest.version, current.version) <= 0) return null; + if (latest == null || + _compareVersions(latest.version, current.version) <= 0) { + _setAvailableUpdate(null); + return null; + } + _setAvailableUpdate(latest); return latest; } catch (e, stackTrace) { AppLogger.e('检查更新失败: $e\n$stackTrace'); @@ -54,6 +66,34 @@ class UpdateService { } } + Future shouldShowPrompt(AppUpdateInfo update) async { + final storage = StorageService.instance; + final skipVersion = await storage.getString( + StorageKeys.updatePromptSkipVersion, + ); + final skipUntil = await storage.getInt(StorageKeys.updatePromptSkipUntil); + if (skipVersion != update.version || skipUntil == null) return true; + + final now = DateTime.now().millisecondsSinceEpoch; + if (skipUntil > now) return false; + + await storage.remove(StorageKeys.updatePromptSkipUntil); + await storage.remove(StorageKeys.updatePromptSkipVersion); + return true; + } + + Future skipPromptForFiveDays(AppUpdateInfo update) async { + final skipUntil = DateTime.now() + .add(const Duration(days: 5)) + .millisecondsSinceEpoch; + final storage = StorageService.instance; + await storage.setString( + StorageKeys.updatePromptSkipVersion, + update.version, + ); + await storage.setInt(StorageKeys.updatePromptSkipUntil, skipUntil); + } + Future downloadUpdate( AppUpdateInfo update, { void Function(int received, int total)? onProgress, @@ -96,6 +136,15 @@ class UpdateService { } } + Future openDownloadsPage() async { + if (!await launchUrl( + downloadsPageUrl, + mode: LaunchMode.externalApplication, + )) { + throw Exception('无法打开下载页面: $downloadsPageUrl'); + } + } + Future _fetchLatestRelease({String? currentVersion}) async { final response = await _dio.getUri(releasesRssUrl); final body = response.data; @@ -119,7 +168,6 @@ class UpdateService { final update = await _buildUpdateInfo(release); if (update == null) continue; - if (update.downloadUrl == null) continue; return update; } @@ -206,8 +254,6 @@ class UpdateService { return null; } - bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows; - bool _isReleaseAttachment(Uri uri) { return uri.path.contains('/releases/download/'); } @@ -345,6 +391,16 @@ class UpdateService { return Uri.decodeComponent(segment); } + void _setAvailableUpdate(AppUpdateInfo? update) { + final previous = _availableUpdate; + if (previous?.version == update?.version && + previous?.pageUrl == update?.pageUrl) { + return; + } + _availableUpdate = update; + notifyListeners(); + } + @visibleForTesting Future debugFetchLatestRelease() => _fetchLatestRelease(); } From 5ee6ba1e289b6b69d5bc2cca43048edb93a6f2c5 Mon Sep 17 00:00:00 2001 From: gongyun Date: Sun, 31 May 2026 10:13:36 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=AE=8C=E5=96=84PR=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E5=90=8E=E5=87=BA=E7=8E=B0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/settings/network_test_page.dart | 70 ++++++++++++--- .../pages/settings/settings_page.dart | 90 +++++++++++++------ lib/services/update_service.dart | 28 ++++-- test/services/update_service_test.dart | 23 +++++ 4 files changed, 161 insertions(+), 50 deletions(-) create mode 100644 test/services/update_service_test.dart diff --git a/lib/presentation/pages/settings/network_test_page.dart b/lib/presentation/pages/settings/network_test_page.dart index 905e31f..a8f3141 100644 --- a/lib/presentation/pages/settings/network_test_page.dart +++ b/lib/presentation/pages/settings/network_test_page.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; import '../../widgets/desktop_constrained.dart'; +import '../../widgets/toast_helper.dart'; enum _NetworkTestTab { network, service, custom } @@ -20,6 +21,13 @@ class NetworkTestPage extends StatefulWidget { class _NetworkTestPageState extends State { static final _panUri = Uri.parse('https://pan.gongyun.org'); static final _infoUri = Uri.parse('https://www.gongyun.org/appnetinfo.html'); + static final _primaryIpLookupUri = Uri.parse('https://ipwho.is/'); + static final _secondaryIpLookupUri = Uri.parse('https://ipapi.co/json/'); + static const _probeHeaders = {'Cache-Control': 'no-cache'}; + static const _probeGetFallbackHeaders = { + 'Cache-Control': 'no-cache', + 'Range': 'bytes=0-0', + }; _NetworkTestTab _tab = _NetworkTestTab.network; bool _networkLoading = false; @@ -44,8 +52,7 @@ class _NetworkTestPageState extends State { IconButton( tooltip: '说明', icon: const Icon(Icons.info_outline), - onPressed: () => - launchUrl(_infoUri, mode: LaunchMode.externalApplication), + onPressed: () => _openExternalUrl(_infoUri), ), ], ), @@ -71,7 +78,7 @@ class _NetworkTestPageState extends State { ButtonSegment( value: _NetworkTestTab.custom, icon: Icon(Icons.route_outlined), - label: Text('自定义'), + label: Text('自定义线路'), ), ], selected: {_tab}, @@ -129,7 +136,7 @@ class _NetworkTestPageState extends State { context: context, builder: (dialogContext) => AlertDialog( title: const Text('自定义线路'), - content: const Text('此功能暂不可用'), + content: const Text('自定义线路为会员功能,当前版本暂未开放。'), actions: [ FilledButton( onPressed: () => Navigator.of(dialogContext).pop(), @@ -140,7 +147,20 @@ class _NetworkTestPageState extends State { ); } + Future _openExternalUrl(Uri uri) async { + try { + final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!opened) { + ToastHelper.failure('无法打开链接: $uri'); + } + } catch (e) { + ToastHelper.failure('无法打开链接: $e'); + } + } + Future _runNetworkTest() async { + if (_networkLoading) return; + setState(() { _networkLoading = true; _networkError = null; @@ -171,6 +191,8 @@ class _NetworkTestPageState extends State { } Future _runServiceTest() async { + if (_serviceLoading) return; + setState(() { _serviceLoading = true; _serviceError = null; @@ -246,7 +268,7 @@ class _NetworkTestPageState extends State { Future<_IpInfo> _fetchIpInfo() async { try { final response = await http - .get(Uri.parse('https://ipwho.is/')) + .get(_primaryIpLookupUri) .timeout(const Duration(seconds: 8)); if (response.statusCode >= 200 && response.statusCode < 300) { final data = jsonDecode(response.body) as Map; @@ -270,7 +292,7 @@ class _NetworkTestPageState extends State { try { final response = await http - .get(Uri.parse('https://ipapi.co/json/')) + .get(_secondaryIpLookupUri) .timeout(const Duration(seconds: 8)); if (response.statusCode >= 200 && response.statusCode < 300) { final data = jsonDecode(response.body) as Map; @@ -307,7 +329,7 @@ class _NetworkTestPageState extends State { final response = await http .get( Uri.parse('https://status.gongyun.org/api/monitor?pageId=$pageId'), - headers: const {'Cache-Control': 'no-cache'}, + headers: _probeHeaders, ) .timeout(const Duration(seconds: 10)); if (response.statusCode < 200 || response.statusCode >= 300) { @@ -367,13 +389,19 @@ class _NetworkTestPageState extends State { }) async { final stopwatch = Stopwatch()..start(); try { - final response = await http - .get(uri, headers: const {'Cache-Control': 'no-cache'}) + var response = await http + .head(uri, headers: _probeHeaders) .timeout(const Duration(seconds: 8)); + if (_shouldRetryProbeWithGet(response.statusCode)) { + response = await http + .get(uri, headers: _probeGetFallbackHeaders) + .timeout(const Duration(seconds: 8)); + } stopwatch.stop(); - final isHealthy = healthyStatusCodes.isEmpty - ? response.statusCode >= 200 && response.statusCode < 500 - : healthyStatusCodes.contains(response.statusCode); + final isHealthy = _isHealthyStatus( + response.statusCode, + healthyStatusCodes, + ); return _ProbeResult( reachable: isHealthy, latencyMs: stopwatch.elapsedMilliseconds, @@ -384,6 +412,17 @@ class _NetworkTestPageState extends State { } } + bool _shouldRetryProbeWithGet(int statusCode) { + return statusCode == 403 || statusCode == 405 || statusCode == 501; + } + + bool _isHealthyStatus(int statusCode, Set healthyStatusCodes) { + if (healthyStatusCodes.isNotEmpty) { + return healthyStatusCodes.contains(statusCode); + } + return statusCode >= 200 && statusCode < 500; + } + Future<_StabilityResult> _testStability(Uri uri) async { final latencies = []; var successCount = 0; @@ -438,7 +477,7 @@ class _NetworkPanel extends StatelessWidget { if (loading) const LinearProgressIndicator(), if (error != null) _ErrorText(error!), _InfoRow( - label: '公云存储官网访问测试', + label: '公云存储网盘访问测试', value: data == null ? '检测中' : (data.panReachable ? '正常' : '离线'), valueColor: _statusColor(context, data?.panReachable), ), @@ -463,11 +502,14 @@ class _NetworkPanel extends StatelessWidget { title: '说明', children: const [ _DescriptionLine('公云存储作为国际化云存储产品,已接入Cloudflare CDN作为全球加速引擎。'), + _DescriptionLine( + '当前网络 IP 地址、AS 与运营商信息通过第三方服务 ipwho.is 查询;如该服务不可用,将使用 ipapi.co 作为备用查询。', + ), _DescriptionLine( '由于部分国家/地区有法律法规限制与审查,可能会出现包括但不限于网络波动、网络丢包以及网络连接不稳定现象', ), _DescriptionLine( - '如诺出现网络连接问题您可将本页面内容向公云存储团队进行报告,团队会根据相关诊断线索进行排查与优化。', + '如若出现网络连接问题,您可将本页面内容向公云存储团队报告,团队会根据相关诊断线索进行排查与优化。', ), ], ), diff --git a/lib/presentation/pages/settings/settings_page.dart b/lib/presentation/pages/settings/settings_page.dart index c5323a6..af6a304 100644 --- a/lib/presentation/pages/settings/settings_page.dart +++ b/lib/presentation/pages/settings/settings_page.dart @@ -167,9 +167,8 @@ class _SettingsPageState extends State { subtitle: const Text('gongyun_app'), trailing: const Icon(Icons.open_in_new, size: 16), onTap: () { - launchUrl( + _openExternalUrl( Uri.parse('https://git.saont.net/gongyun/app'), - mode: LaunchMode.externalApplication, ); }, ), @@ -179,11 +178,10 @@ class _SettingsPageState extends State { subtitle: const Text('cloudreve4_flutter'), trailing: const Icon(Icons.open_in_new, size: 16), onTap: () { - launchUrl( + _openExternalUrl( Uri.parse( 'https://github.com/LimoYuan/cloudreve4_flutter', ), - mode: LaunchMode.externalApplication, ); }, ), @@ -405,13 +403,16 @@ class _SettingsPageState extends State { return AnimatedBuilder( animation: UpdateService.instance, builder: (context, _) { + final hasUpdate = UpdateService.instance.hasUpdate; return ListTile( - leading: const Icon(Icons.tag), + leading: Icon( + hasUpdate ? Icons.new_releases_outlined : Icons.tag, + color: hasUpdate ? Theme.of(context).colorScheme.primary : null, + ), title: const Text('版本号'), subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion), - trailing: UpdateService.instance.hasUpdate - ? const _BlinkingUpdateDot() - : null, + onTap: hasUpdate ? () => _openDownloadsPage() : null, + trailing: hasUpdate ? const _UpdateAvailableBadge() : null, ); }, ); @@ -562,6 +563,25 @@ class _SettingsPageState extends State { } } + Future _openExternalUrl(Uri uri) async { + try { + final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!opened) { + ToastHelper.failure('无法打开链接: $uri'); + } + } catch (e) { + ToastHelper.failure('无法打开链接: $e'); + } + } + + Future _openDownloadsPage() async { + try { + await UpdateService.instance.openDownloadsPage(); + } catch (e) { + ToastHelper.failure('打开下载页面失败: $e'); + } + } + Future _openNetworkTest(BuildContext context) async { final agreed = await showDialog( context: context, @@ -569,13 +589,11 @@ class _SettingsPageState extends State { builder: (dialogContext) => AlertDialog( title: const Text('会话信息授权'), content: _NetworkTestConsentText( - onOpenPrivacy: () => launchUrl( + onOpenPrivacy: () => _openExternalUrl( Uri.parse('https://www.gongyun.org/policy/privacy.html'), - mode: LaunchMode.externalApplication, ), - onOpenTerms: () => launchUrl( + onOpenTerms: () => _openExternalUrl( Uri.parse('https://www.gongyun.org/policy/terms.html'), - mode: LaunchMode.externalApplication, ), ), actions: [ @@ -641,14 +659,14 @@ class _SettingsPageState extends State { } } -class _BlinkingUpdateDot extends StatefulWidget { - const _BlinkingUpdateDot(); +class _UpdateAvailableBadge extends StatefulWidget { + const _UpdateAvailableBadge(); @override - State<_BlinkingUpdateDot> createState() => _BlinkingUpdateDotState(); + State<_UpdateAvailableBadge> createState() => _UpdateAvailableBadgeState(); } -class _BlinkingUpdateDotState extends State<_BlinkingUpdateDot> +class _UpdateAvailableBadgeState extends State<_UpdateAvailableBadge> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _opacity; @@ -674,15 +692,31 @@ class _BlinkingUpdateDotState extends State<_BlinkingUpdateDot> @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return FadeTransition( opacity: _opacity, - child: Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: colorScheme.error, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '发现新版本', + style: TextStyle( + color: colorScheme.error, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 4), + Icon(Icons.chevron_right, size: 18, color: colorScheme.error), + ], ), ); } @@ -734,9 +768,8 @@ class _NetworkTestConsentTextState extends State<_NetworkTestConsentText> { fontWeight: FontWeight.w700, ); - return RichText( - text: TextSpan( - style: Theme.of(context).textTheme.bodyMedium, + return Text.rich( + TextSpan( children: [ const TextSpan(text: '您需同意'), TextSpan( @@ -750,7 +783,10 @@ class _NetworkTestConsentTextState extends State<_NetworkTestConsentText> { style: linkStyle, recognizer: _termsRecognizer, ), - const TextSpan(text: '方可进入网络测试页。'), + const TextSpan( + text: + '方可进入网络测试页。测试会访问公云存储服务,并通过第三方服务 ipwho.is 查询当前网络 IP、AS 与运营商信息;如 ipwho.is 不可用,将使用 ipapi.co 作为备用查询。', + ), ], ), ); diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 4f3bbde..ce012b7 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -15,7 +15,16 @@ import '../data/models/app_update_model.dart'; import 'storage_service.dart'; class UpdateService extends ChangeNotifier { - UpdateService._(); + UpdateService._() + : _dio = Dio(_dioOptions), + _packageInfoProvider = PackageInfo.fromPlatform; + + @visibleForTesting + UpdateService.test({ + Dio? dio, + Future Function()? packageInfoProvider, + }) : _dio = dio ?? Dio(_dioOptions), + _packageInfoProvider = packageInfoProvider ?? PackageInfo.fromPlatform; static final UpdateService instance = UpdateService._(); @@ -29,15 +38,16 @@ class UpdateService extends ChangeNotifier { 'https://www.gongyun.org/downloads.html', ); - final Dio _dio = Dio( - BaseOptions( - connectTimeout: const Duration(seconds: 12), - receiveTimeout: const Duration(seconds: 20), - followRedirects: true, - responseType: ResponseType.plain, - ), + static final BaseOptions _dioOptions = BaseOptions( + connectTimeout: const Duration(seconds: 12), + receiveTimeout: const Duration(seconds: 20), + followRedirects: true, + responseType: ResponseType.plain, ); + final Dio _dio; + final Future Function() _packageInfoProvider; + bool _checking = false; AppUpdateInfo? _availableUpdate; @@ -49,7 +59,7 @@ class UpdateService extends ChangeNotifier { _checking = true; try { - final current = await PackageInfo.fromPlatform(); + final current = await _packageInfoProvider(); final latest = await _fetchLatestRelease(currentVersion: current.version); if (latest == null || _compareVersions(latest.version, current.version) <= 0) { diff --git a/test/services/update_service_test.dart b/test/services/update_service_test.dart new file mode 100644 index 0000000..b0631f5 --- /dev/null +++ b/test/services/update_service_test.dart @@ -0,0 +1,23 @@ +import 'package:cloudreve4_flutter/services/update_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('UpdateService', () { + test( + 'checkForUpdate releases checking flag when package info fails', + () async { + var calls = 0; + final service = UpdateService.test( + packageInfoProvider: () async { + calls += 1; + throw Exception('package info unavailable'); + }, + ); + + expect(await service.checkForUpdate(), isNull); + expect(await service.checkForUpdate(), isNull); + expect(calls, 2); + }, + ); + }); +}