Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c208a06af5
|
@@ -191,7 +191,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
if [[ "$GITHUB_REF" == refs/tags/android-app-v* ]]; then
|
||||||
tag_name="${GITHUB_REF#refs/tags/}"
|
tag_name="${GITHUB_REF#refs/tags/}"
|
||||||
else
|
else
|
||||||
app_version="$(awk -F '[:+]' '/^version:/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' pubspec.yaml)"
|
app_version="$(awk -F '[:+]' '/^version:/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' pubspec.yaml)"
|
||||||
@@ -199,7 +199,7 @@ jobs:
|
|||||||
echo "Unable to resolve app version from pubspec.yaml."
|
echo "Unable to resolve app version from pubspec.yaml."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tag_name="v${app_version}"
|
tag_name="android-app-v${app_version}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
|
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
|
||||||
@@ -249,7 +249,7 @@ jobs:
|
|||||||
|
|
||||||
releases_url="$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases"
|
releases_url="$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases"
|
||||||
release_by_tag_url="$releases_url/tags/$TAG_NAME"
|
release_by_tag_url="$releases_url/tags/$TAG_NAME"
|
||||||
export RELEASE_TITLE="${TAG_NAME}"
|
export RELEASE_TITLE="Android App APK ${TAG_NAME}"
|
||||||
export RELEASE_BODY="Android app installation package (APK) for ${TAG_NAME}."
|
export RELEASE_BODY="Android app installation package (APK) for ${TAG_NAME}."
|
||||||
|
|
||||||
status="$(curl -sS -o release.json -w "%{http_code}" \
|
status="$(curl -sS -o release.json -w "%{http_code}" \
|
||||||
@@ -265,7 +265,7 @@ jobs:
|
|||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"tag_name": tag,
|
"tag_name": tag,
|
||||||
"target_commitish": os.environ.get("TARGET_COMMITISH", ""),
|
"target_commitish": os.environ.get("TARGET_COMMITISH", ""),
|
||||||
"name": os.environ["RELEASE_TITLE"],
|
"name": f"Android App APK {tag}",
|
||||||
"body": f"Android app installation package (APK) for {tag}.",
|
"body": f"Android app installation package (APK) for {tag}.",
|
||||||
"draft": False,
|
"draft": False,
|
||||||
"prerelease": False,
|
"prerelease": False,
|
||||||
@@ -353,7 +353,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done < asset-ids-to-delete.txt
|
done < asset-ids-to-delete.txt
|
||||||
|
|
||||||
release_asset_name="app-release-${TAG_NAME}.apk"
|
release_asset_name="gongyun-${TAG_NAME}.apk"
|
||||||
curl -sS -f \
|
curl -sS -f \
|
||||||
-X POST \
|
-X POST \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
|||||||
@@ -39,8 +39,4 @@ class StorageKeys {
|
|||||||
// 公告
|
// 公告
|
||||||
static const String siteAnnouncementDismissedFingerprint =
|
static const String siteAnnouncementDismissedFingerprint =
|
||||||
'site_announcement_dismissed_fingerprint';
|
'site_announcement_dismissed_fingerprint';
|
||||||
|
|
||||||
// 版本更新
|
|
||||||
static const String updatePromptSkipUntil = 'update_prompt_skip_until';
|
|
||||||
static const String updatePromptSkipVersion = 'update_prompt_skip_version';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,12 +325,8 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
|
|||||||
initialSettings: desktop.InAppWebViewSettings(
|
initialSettings: desktop.InAppWebViewSettings(
|
||||||
javaScriptEnabled: true,
|
javaScriptEnabled: true,
|
||||||
domStorageEnabled: true,
|
domStorageEnabled: true,
|
||||||
isInspectable: true,
|
|
||||||
cacheMode: desktop.CacheMode.LOAD_DEFAULT,
|
|
||||||
supportMultipleWindows: true,
|
|
||||||
transparentBackground: true,
|
transparentBackground: true,
|
||||||
supportZoom: true,
|
supportZoom: false,
|
||||||
useHybridComposition: true,
|
|
||||||
),
|
),
|
||||||
onWebViewCreated: (controller) async {
|
onWebViewCreated: (controller) async {
|
||||||
_desktopController = controller;
|
_desktopController = controller;
|
||||||
|
|||||||
@@ -1,800 +0,0 @@
|
|||||||
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';
|
|
||||||
import '../../widgets/toast_helper.dart';
|
|
||||||
|
|
||||||
enum _NetworkTestTab { network, service, custom }
|
|
||||||
|
|
||||||
class NetworkTestPage extends StatefulWidget {
|
|
||||||
const NetworkTestPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<NetworkTestPage> createState() => _NetworkTestPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NetworkTestPageState extends State<NetworkTestPage> {
|
|
||||||
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;
|
|
||||||
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: () => _openExternalUrl(_infoUri),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
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<void> _showCustomUnavailableDialog() async {
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (dialogContext) => AlertDialog(
|
|
||||||
title: const Text('自定义线路'),
|
|
||||||
content: const Text('自定义线路为会员功能,当前版本暂未开放。'),
|
|
||||||
actions: [
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
||||||
child: const Text('好'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openExternalUrl(Uri uri) async {
|
|
||||||
try {
|
|
||||||
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
||||||
if (!opened) {
|
|
||||||
ToastHelper.failure('无法打开链接: $uri');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
ToastHelper.failure('无法打开链接: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runNetworkTest() async {
|
|
||||||
if (_networkLoading) return;
|
|
||||||
|
|
||||||
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<void> _runServiceTest() async {
|
|
||||||
if (_serviceLoading) return;
|
|
||||||
|
|
||||||
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(_primaryIpLookupUri)
|
|
||||||
.timeout(const Duration(seconds: 8));
|
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
||||||
if (data['success'] != false) {
|
|
||||||
final connection = data['connection'];
|
|
||||||
final connectionMap = connection is Map<String, dynamic>
|
|
||||||
? connection
|
|
||||||
: <String, dynamic>{};
|
|
||||||
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(_secondaryIpLookupUri)
|
|
||||||
.timeout(const Duration(seconds: 8));
|
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
||||||
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<int, _MonitorHeartbeat> heartbeats,
|
|
||||||
}) {
|
|
||||||
final heartbeat = heartbeats[monitorId];
|
|
||||||
return _ServiceItem(
|
|
||||||
label: label,
|
|
||||||
online: heartbeat?.online ?? false,
|
|
||||||
latencyMs: heartbeat?.pingMs,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<int, _MonitorHeartbeat>> _fetchMonitorHeartbeats(
|
|
||||||
String pageId,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final response = await http
|
|
||||||
.get(
|
|
||||||
Uri.parse('https://status.gongyun.org/api/monitor?pageId=$pageId'),
|
|
||||||
headers: _probeHeaders,
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(response.body);
|
|
||||||
if (decoded is! Map<String, dynamic>) return const {};
|
|
||||||
final data = decoded['data'];
|
|
||||||
if (data is! Map<String, dynamic>) return const {};
|
|
||||||
final heartbeatList = data['heartbeatList'];
|
|
||||||
if (heartbeatList is! Map<String, dynamic>) return const {};
|
|
||||||
|
|
||||||
final result = <int, _MonitorHeartbeat>{};
|
|
||||||
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<String, dynamic>) 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<int> 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<int> healthyStatusCodes = const {},
|
|
||||||
}) async {
|
|
||||||
final stopwatch = Stopwatch()..start();
|
|
||||||
try {
|
|
||||||
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 = _isHealthyStatus(
|
|
||||||
response.statusCode,
|
|
||||||
healthyStatusCodes,
|
|
||||||
);
|
|
||||||
return _ProbeResult(
|
|
||||||
reachable: isHealthy,
|
|
||||||
latencyMs: stopwatch.elapsedMilliseconds,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
stopwatch.stop();
|
|
||||||
return const _ProbeResult(reachable: false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _shouldRetryProbeWithGet(int statusCode) {
|
|
||||||
return statusCode == 403 || statusCode == 405 || statusCode == 501;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isHealthyStatus(int statusCode, Set<int> healthyStatusCodes) {
|
|
||||||
if (healthyStatusCodes.isNotEmpty) {
|
|
||||||
return healthyStatusCodes.contains(statusCode);
|
|
||||||
}
|
|
||||||
return statusCode >= 200 && statusCode < 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<_StabilityResult> _testStability(Uri uri) async {
|
|
||||||
final latencies = <int>[];
|
|
||||||
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<void>.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(
|
|
||||||
'当前网络 IP 地址、AS 与运营商信息通过第三方服务 ipwho.is 查询;如该服务不可用,将使用 ipapi.co 作为备用查询。',
|
|
||||||
),
|
|
||||||
_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<Widget> _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<Widget> 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import '../../../data/models/user_model.dart';
|
import '../../../data/models/user_model.dart';
|
||||||
import '../../../data/models/user_setting_model.dart';
|
import '../../../data/models/user_setting_model.dart';
|
||||||
import '../../../services/update_service.dart';
|
|
||||||
import '../../../services/user_setting_service.dart';
|
import '../../../services/user_setting_service.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/user_setting_provider.dart';
|
import '../../providers/user_setting_provider.dart';
|
||||||
@@ -18,7 +16,6 @@ import 'file_preferences_page.dart';
|
|||||||
import 'app_settings_page.dart';
|
import 'app_settings_page.dart';
|
||||||
import 'credit_history_page.dart';
|
import 'credit_history_page.dart';
|
||||||
import 'quick_access_settings_page.dart';
|
import 'quick_access_settings_page.dart';
|
||||||
import 'network_test_page.dart';
|
|
||||||
import '../../../router/app_router.dart';
|
import '../../../router/app_router.dart';
|
||||||
|
|
||||||
/// 设置主页
|
/// 设置主页
|
||||||
@@ -102,8 +99,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
icon: Icons.security_outlined,
|
icon: Icons.security_outlined,
|
||||||
title: '安全设置',
|
title: '安全设置',
|
||||||
subtitle: _securitySubtitle(settings),
|
subtitle: _securitySubtitle(settings),
|
||||||
onTap: () =>
|
onTap: () => _navigateTo(context, const SecuritySettingsPage()),
|
||||||
_navigateTo(context, const SecuritySettingsPage()),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -114,22 +110,19 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
icon: Icons.sync_outlined,
|
icon: Icons.sync_outlined,
|
||||||
title: '文件同步',
|
title: '文件同步',
|
||||||
subtitle: '本地与云端文件自动同步',
|
subtitle: '本地与云端文件自动同步',
|
||||||
onTap: () =>
|
onTap: () => Navigator.of(context).pushNamed(RouteNames.syncSettings),
|
||||||
Navigator.of(context).pushNamed(RouteNames.syncSettings),
|
|
||||||
),
|
),
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: Icons.apps_outlined,
|
icon: Icons.apps_outlined,
|
||||||
title: '快捷入口',
|
title: '快捷入口',
|
||||||
subtitle: '自定义概览页快捷目录',
|
subtitle: '自定义概览页快捷目录',
|
||||||
onTap: () =>
|
onTap: () => _navigateTo(context, const QuickAccessSettingsPage()),
|
||||||
_navigateTo(context, const QuickAccessSettingsPage()),
|
|
||||||
),
|
),
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
title: '文件偏好',
|
title: '文件偏好',
|
||||||
subtitle: '版本保留、视图同步、分享可见性',
|
subtitle: '版本保留、视图同步、分享可见性',
|
||||||
onTap: () =>
|
onTap: () => _navigateTo(context, const FilePreferencesPage()),
|
||||||
_navigateTo(context, const FilePreferencesPage()),
|
|
||||||
),
|
),
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: Icons.tune,
|
icon: Icons.tune,
|
||||||
@@ -137,12 +130,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
subtitle: '缓存、主题、语言',
|
subtitle: '缓存、主题、语言',
|
||||||
onTap: () => _navigateTo(context, const AppSettingsPage()),
|
onTap: () => _navigateTo(context, const AppSettingsPage()),
|
||||||
),
|
),
|
||||||
_SettingsTile(
|
|
||||||
icon: Icons.network_check,
|
|
||||||
title: '网络测试',
|
|
||||||
subtitle: '服务连通性测试与自定义线路(会员)',
|
|
||||||
onTap: () => _openNetworkTest(context),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// 财务区域(有数据时才显示)
|
// 财务区域(有数据时才显示)
|
||||||
@@ -155,7 +142,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
title: const Text('应用名称'),
|
title: const Text('应用名称'),
|
||||||
subtitle: const Text('公云存储'),
|
subtitle: const Text('公云存储'),
|
||||||
),
|
),
|
||||||
_buildVersionTile(),
|
ListTile(
|
||||||
|
leading: const Icon(Icons.tag),
|
||||||
|
title: const Text('版本号'),
|
||||||
|
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.copyright),
|
leading: const Icon(Icons.copyright),
|
||||||
title: const Text('License'),
|
title: const Text('License'),
|
||||||
@@ -167,8 +158,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
subtitle: const Text('gongyun_app'),
|
subtitle: const Text('gongyun_app'),
|
||||||
trailing: const Icon(Icons.open_in_new, size: 16),
|
trailing: const Icon(Icons.open_in_new, size: 16),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_openExternalUrl(
|
launchUrl(
|
||||||
Uri.parse('https://git.saont.net/gongyun/app'),
|
Uri.parse('https://git.saont.net/gongyun/app'),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -178,10 +170,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
subtitle: const Text('cloudreve4_flutter'),
|
subtitle: const Text('cloudreve4_flutter'),
|
||||||
trailing: const Icon(Icons.open_in_new, size: 16),
|
trailing: const Icon(Icons.open_in_new, size: 16),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_openExternalUrl(
|
launchUrl(
|
||||||
Uri.parse(
|
Uri.parse('https://github.com/LimoYuan/cloudreve4_flutter'),
|
||||||
'https://github.com/LimoYuan/cloudreve4_flutter',
|
mode: LaunchMode.externalApplication,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -197,10 +188,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 财务区域(存储包、积分、会员)
|
/// 财务区域(存储包、积分、会员)
|
||||||
List<Widget> _buildProSections(
|
List<Widget> _buildProSections(BuildContext context, UserSettingModel settings) {
|
||||||
BuildContext context,
|
|
||||||
UserSettingModel settings,
|
|
||||||
) {
|
|
||||||
final sections = <Widget>[];
|
final sections = <Widget>[];
|
||||||
final hasStoragePacks = settings.storagePacks.isNotEmpty;
|
final hasStoragePacks = settings.storagePacks.isNotEmpty;
|
||||||
final hasCredit = settings.credit > 0;
|
final hasCredit = settings.credit > 0;
|
||||||
@@ -209,8 +197,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
if (hasStoragePacks || hasCredit || hasMembership) {
|
if (hasStoragePacks || hasCredit || hasMembership) {
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
if (hasMembership) {
|
if (hasMembership) {
|
||||||
children.add(
|
children.add(ListTile(
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.workspace_premium_outlined),
|
leading: const Icon(Icons.workspace_premium_outlined),
|
||||||
title: const Text('会员'),
|
title: const Text('会员'),
|
||||||
subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'),
|
subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'),
|
||||||
@@ -218,45 +205,32 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
onPressed: () => _cancelMembership(context),
|
onPressed: () => _cancelMembership(context),
|
||||||
child: const Text('取消会员', style: TextStyle(color: Colors.red)),
|
child: const Text('取消会员', style: TextStyle(color: Colors.red)),
|
||||||
),
|
),
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (hasStoragePacks) {
|
if (hasStoragePacks) {
|
||||||
children.add(
|
children.add(ListTile(
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.inventory_2_outlined),
|
leading: const Icon(Icons.inventory_2_outlined),
|
||||||
title: Text('存储包 (${settings.storagePacks.length})'),
|
title: Text('存储包 (${settings.storagePacks.length})'),
|
||||||
subtitle: Text(_storagePackSummary(settings.storagePacks)),
|
subtitle: Text(_storagePackSummary(settings.storagePacks)),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _showStoragePacks(context, settings.storagePacks),
|
onTap: () => _showStoragePacks(context, settings.storagePacks),
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (hasCredit) {
|
if (hasCredit) {
|
||||||
children.add(
|
children.add(ListTile(
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.account_balance_wallet_outlined),
|
leading: const Icon(Icons.account_balance_wallet_outlined),
|
||||||
title: const Text('积分'),
|
title: const Text('积分'),
|
||||||
subtitle: Text('${settings.credit} 积分'),
|
subtitle: Text('${settings.credit} 积分'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _navigateTo(
|
onTap: () => _navigateTo(context, CreditHistoryPage(currentCredit: settings.credit)),
|
||||||
context,
|
));
|
||||||
CreditHistoryPage(currentCredit: settings.credit),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
sections.add(_buildSection(title: '财务', children: children));
|
sections.add(_buildSection(title: '财务', children: children));
|
||||||
}
|
}
|
||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildProfileCard(
|
Widget _buildProfileCard(BuildContext context, UserModel? user, UserSettingModel? settings, UserCapacityModel? capacity) {
|
||||||
BuildContext context,
|
|
||||||
UserModel? user,
|
|
||||||
UserSettingModel? settings,
|
|
||||||
UserCapacityModel? capacity,
|
|
||||||
) {
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
@@ -279,24 +253,17 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
user?.nickname ?? '未登录',
|
user?.nickname ?? '未登录',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
user?.email ?? '',
|
user?.email ?? '',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (user?.group != null) ...[
|
if (user?.group != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
horizontal: 8,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.primaryContainer,
|
color: colorScheme.primaryContainer,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -349,12 +316,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text('存储空间', style: theme.textTheme.bodySmall),
|
Text('存储空间', style: theme.textTheme.bodySmall),
|
||||||
Text(
|
Text('$usedText / $totalText', style: theme.textTheme.bodySmall?.copyWith(
|
||||||
'$usedText / $totalText',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
)),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -370,10 +334,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSection({
|
Widget _buildSection({required String title, required List<Widget> children}) {
|
||||||
required String title,
|
|
||||||
required List<Widget> children,
|
|
||||||
}) {
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -399,43 +360,17 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildVersionTile() {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: UpdateService.instance,
|
|
||||||
builder: (context, _) {
|
|
||||||
final hasUpdate = UpdateService.instance.hasUpdate;
|
|
||||||
return ListTile(
|
|
||||||
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),
|
|
||||||
onTap: hasUpdate ? () => _openDownloadsPage() : null,
|
|
||||||
trailing: hasUpdate ? const _UpdateAvailableBadge() : null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLogoutButton(BuildContext context, AuthProvider auth) {
|
Widget _buildLogoutButton(BuildContext context, AuthProvider auth) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _confirmLogout(context, auth),
|
onPressed: () => _confirmLogout(context, auth),
|
||||||
icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
|
icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
|
||||||
label: Text(
|
label: Text('退出登录', style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
'退出登录',
|
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
||||||
),
|
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
minimumSize: const Size(double.infinity, 48),
|
minimumSize: const Size(double.infinity, 48),
|
||||||
side: BorderSide(
|
side: BorderSide(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3)),
|
||||||
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -472,8 +407,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium),
|
child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium),
|
||||||
),
|
),
|
||||||
...packs.map(
|
...packs.map((pack) => Card(
|
||||||
(pack) => Card(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -482,14 +416,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(pack.name, style: Theme.of(ctx).textTheme.titleSmall),
|
||||||
pack.name,
|
Text(_formatBytes(pack.size), style: Theme.of(ctx).textTheme.labelLarge),
|
||||||
style: Theme.of(ctx).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_formatBytes(pack.size),
|
|
||||||
style: Theme.of(ctx).textTheme.labelLarge,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -500,19 +428,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
),
|
),
|
||||||
if (pack.isExpired) ...[
|
if (pack.isExpired) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text('已过期', style: TextStyle(color: Theme.of(ctx).colorScheme.error, fontSize: 12)),
|
||||||
'已过期',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(ctx).colorScheme.error,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
)),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -528,15 +449,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
title: const Text('取消会员'),
|
title: const Text('取消会员'),
|
||||||
content: const Text('确定要取消当前会员吗?取消后将失去会员权益。'),
|
content: const Text('确定要取消当前会员吗?取消后将失去会员权益。'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('取消'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
|
||||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
|
||||||
),
|
|
||||||
child: const Text('确认取消'),
|
child: const Text('确认取消'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -563,59 +479,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openExternalUrl(Uri uri) async {
|
|
||||||
try {
|
|
||||||
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
||||||
if (!opened) {
|
|
||||||
ToastHelper.failure('无法打开链接: $uri');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
ToastHelper.failure('无法打开链接: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openDownloadsPage() async {
|
|
||||||
try {
|
|
||||||
await UpdateService.instance.openDownloadsPage();
|
|
||||||
} catch (e) {
|
|
||||||
ToastHelper.failure('打开下载页面失败: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openNetworkTest(BuildContext context) async {
|
|
||||||
final agreed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (dialogContext) => AlertDialog(
|
|
||||||
title: const Text('会话信息授权'),
|
|
||||||
content: _NetworkTestConsentText(
|
|
||||||
onOpenPrivacy: () => _openExternalUrl(
|
|
||||||
Uri.parse('https://www.gongyun.org/policy/privacy.html'),
|
|
||||||
),
|
|
||||||
onOpenTerms: () => _openExternalUrl(
|
|
||||||
Uri.parse('https://www.gongyun.org/policy/terms.html'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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<void> _confirmLogout(BuildContext context, AuthProvider auth) async {
|
Future<void> _confirmLogout(BuildContext context, AuthProvider auth) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -623,15 +486,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
title: const Text('退出登录'),
|
title: const Text('退出登录'),
|
||||||
content: const Text('确定要退出当前账号吗?'),
|
content: const Text('确定要退出当前账号吗?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('取消'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error),
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
child: const Text('退出'),
|
child: const Text('退出'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -648,9 +506,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
String _formatBytes(int bytes) {
|
String _formatBytes(int bytes) {
|
||||||
if (bytes < 1024) return '$bytes B';
|
if (bytes < 1024) return '$bytes B';
|
||||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
if (bytes < 1024 * 1024 * 1024) {
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
||||||
}
|
|
||||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,140 +515,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UpdateAvailableBadge extends StatefulWidget {
|
|
||||||
const _UpdateAvailableBadge();
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_UpdateAvailableBadge> createState() => _UpdateAvailableBadgeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UpdateAvailableBadgeState extends State<_UpdateAvailableBadge>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late final AnimationController _controller;
|
|
||||||
late final Animation<double> _opacity;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 900),
|
|
||||||
)..repeat(reverse: true);
|
|
||||||
_opacity = Tween<double>(
|
|
||||||
begin: 0.3,
|
|
||||||
end: 1,
|
|
||||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
return FadeTransition(
|
|
||||||
opacity: _opacity,
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 Text.rich(
|
|
||||||
TextSpan(
|
|
||||||
children: [
|
|
||||||
const TextSpan(text: '您需同意'),
|
|
||||||
TextSpan(
|
|
||||||
text: '隐私政策',
|
|
||||||
style: linkStyle,
|
|
||||||
recognizer: _privacyRecognizer,
|
|
||||||
),
|
|
||||||
const TextSpan(text: '与'),
|
|
||||||
TextSpan(
|
|
||||||
text: '用户协议',
|
|
||||||
style: linkStyle,
|
|
||||||
recognizer: _termsRecognizer,
|
|
||||||
),
|
|
||||||
const TextSpan(
|
|
||||||
text:
|
|
||||||
'方可进入网络测试页。测试会访问公云存储服务,并通过第三方服务 ipwho.is 查询当前网络 IP、AS 与运营商信息;如 ipwho.is 不可用,将使用 ipapi.co 作为备用查询。',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsTile extends StatelessWidget {
|
class _SettingsTile extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
|
|||||||
@@ -1442,12 +1442,8 @@ class _OfficialDownloadDesktopWebViewState
|
|||||||
domStorageEnabled: true,
|
domStorageEnabled: true,
|
||||||
supportZoom: false,
|
supportZoom: false,
|
||||||
transparentBackground: true,
|
transparentBackground: true,
|
||||||
isInspectable: true,
|
|
||||||
cacheMode: desktop.CacheMode.LOAD_DEFAULT,
|
|
||||||
supportMultipleWindows: false,
|
|
||||||
useShouldOverrideUrlLoading: true,
|
useShouldOverrideUrlLoading: true,
|
||||||
useOnDownloadStart: true,
|
useOnDownloadStart: true,
|
||||||
useHybridComposition: true,
|
|
||||||
),
|
),
|
||||||
onWebViewCreated: (controller) {
|
onWebViewCreated: (controller) {
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
@@ -1461,18 +1457,6 @@ class _OfficialDownloadDesktopWebViewState
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onLoadStop: (_, url) => _installHook(),
|
onLoadStop: (_, url) => _installHook(),
|
||||||
shouldInterceptRequest: (_, request) async {
|
|
||||||
final url = request.url.toString();
|
|
||||||
if (widget.onNavigationUrl(url)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
onLoadStart: (_, url) {
|
|
||||||
if (url != null) {
|
|
||||||
widget.onNavigationUrl(url.toString());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shouldOverrideUrlLoading: (_, action) {
|
shouldOverrideUrlLoading: (_, action) {
|
||||||
final url = action.request.url?.toString();
|
final url = action.request.url?.toString();
|
||||||
if (url != null && widget.onNavigationUrl(url)) {
|
if (url != null && widget.onNavigationUrl(url)) {
|
||||||
|
|||||||
@@ -201,9 +201,6 @@ $body
|
|||||||
onLoadStop: (_, url) {
|
onLoadStop: (_, url) {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
},
|
},
|
||||||
onReceivedError: (_, request, error) {
|
|
||||||
if (mounted) setState(() => _loading = false);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../data/models/app_update_model.dart';
|
import '../../data/models/app_update_model.dart';
|
||||||
@@ -29,8 +31,6 @@ class _UpdatePromptState extends State<UpdatePrompt> {
|
|||||||
|
|
||||||
final update = await UpdateService.instance.checkForUpdate();
|
final update = await UpdateService.instance.checkForUpdate();
|
||||||
if (update == null || !mounted) return;
|
if (update == null || !mounted) return;
|
||||||
final shouldShow = await UpdateService.instance.shouldShowPrompt(update);
|
|
||||||
if (!shouldShow || !mounted) return;
|
|
||||||
|
|
||||||
_dialogVisible = true;
|
_dialogVisible = true;
|
||||||
try {
|
try {
|
||||||
@@ -41,58 +41,101 @@ class _UpdatePromptState extends State<UpdatePrompt> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showUpdateDialog(AppUpdateInfo update) async {
|
Future<void> _showUpdateDialog(AppUpdateInfo update) async {
|
||||||
|
var downloading = false;
|
||||||
|
var progress = 0.0;
|
||||||
|
String? status;
|
||||||
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
Future<void> openDownloadsPage() async {
|
return StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) {
|
||||||
|
final canDownload =
|
||||||
|
update.downloadUrl != null &&
|
||||||
|
(Platform.isAndroid || Platform.isWindows);
|
||||||
|
|
||||||
|
Future<void> startUpdate() async {
|
||||||
|
if (downloading || !canDownload) return;
|
||||||
|
|
||||||
|
setDialogState(() {
|
||||||
|
downloading = true;
|
||||||
|
progress = 0;
|
||||||
|
status = '正在下载更新...';
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await UpdateService.instance.openDownloadsPage();
|
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();
|
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ToastHelper.failure('打开下载页面失败: $e');
|
setDialogState(() {
|
||||||
|
downloading = false;
|
||||||
|
status = '下载或打开安装包失败';
|
||||||
|
});
|
||||||
|
ToastHelper.failure('更新失败: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> skipPrompt() async {
|
|
||||||
await UpdateService.instance.skipPromptForFiveDays(update);
|
|
||||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
final releaseNotes = (update.description ?? '').trim();
|
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text('发现新版本 ${update.version}'),
|
title: Text('发现新版本 ${update.version}'),
|
||||||
content: ConstrainedBox(
|
content: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 360),
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('新版本号:${update.version}'),
|
Text(update.title),
|
||||||
|
if ((update.description ?? '').isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'版本日志',
|
update.description!,
|
||||||
style: Theme.of(dialogContext).textTheme.titleSmall,
|
maxLines: 6,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(releaseNotes.isEmpty ? update.title : releaseNotes),
|
|
||||||
],
|
],
|
||||||
|
if (downloading) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: progress == 0 ? null : progress,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(status ?? ''),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: skipPrompt, child: const Text('跳过')),
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
onPressed: downloading
|
||||||
child: const Text('取消'),
|
? null
|
||||||
|
: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('稍后'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: downloading ? null : startUpdate,
|
||||||
|
child: const Text('立即更新'),
|
||||||
),
|
),
|
||||||
FilledButton(onPressed: openDownloadsPage, child: const Text('更新')),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -9,22 +9,11 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:xml/xml.dart';
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
import '../core/constants/storage_keys.dart';
|
|
||||||
import '../core/utils/app_logger.dart';
|
import '../core/utils/app_logger.dart';
|
||||||
import '../data/models/app_update_model.dart';
|
import '../data/models/app_update_model.dart';
|
||||||
import 'storage_service.dart';
|
|
||||||
|
|
||||||
class UpdateService extends ChangeNotifier {
|
class UpdateService {
|
||||||
UpdateService._()
|
UpdateService._();
|
||||||
: _dio = Dio(_dioOptions),
|
|
||||||
_packageInfoProvider = PackageInfo.fromPlatform;
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
UpdateService.test({
|
|
||||||
Dio? dio,
|
|
||||||
Future<PackageInfo> Function()? packageInfoProvider,
|
|
||||||
}) : _dio = dio ?? Dio(_dioOptions),
|
|
||||||
_packageInfoProvider = packageInfoProvider ?? PackageInfo.fromPlatform;
|
|
||||||
|
|
||||||
static final UpdateService instance = UpdateService._();
|
static final UpdateService instance = UpdateService._();
|
||||||
|
|
||||||
@@ -34,39 +23,28 @@ class UpdateService extends ChangeNotifier {
|
|||||||
static final Uri releasesPageUrl = Uri.parse(
|
static final Uri releasesPageUrl = Uri.parse(
|
||||||
'https://git.saont.net/gongyun/app/releases',
|
'https://git.saont.net/gongyun/app/releases',
|
||||||
);
|
);
|
||||||
static final Uri downloadsPageUrl = Uri.parse(
|
|
||||||
'https://www.gongyun.org/downloads.html',
|
|
||||||
);
|
|
||||||
|
|
||||||
static final BaseOptions _dioOptions = BaseOptions(
|
final Dio _dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
connectTimeout: const Duration(seconds: 12),
|
connectTimeout: const Duration(seconds: 12),
|
||||||
receiveTimeout: const Duration(seconds: 20),
|
receiveTimeout: const Duration(seconds: 20),
|
||||||
followRedirects: true,
|
followRedirects: true,
|
||||||
responseType: ResponseType.plain,
|
responseType: ResponseType.plain,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final Dio _dio;
|
|
||||||
final Future<PackageInfo> Function() _packageInfoProvider;
|
|
||||||
|
|
||||||
bool _checking = false;
|
bool _checking = false;
|
||||||
AppUpdateInfo? _availableUpdate;
|
|
||||||
|
|
||||||
AppUpdateInfo? get availableUpdate => _availableUpdate;
|
|
||||||
bool get hasUpdate => _availableUpdate != null;
|
|
||||||
|
|
||||||
Future<AppUpdateInfo?> checkForUpdate() async {
|
Future<AppUpdateInfo?> checkForUpdate() async {
|
||||||
if (_checking) return null;
|
if (_checking) return null;
|
||||||
|
if (!_supportsAutoDownload) return null;
|
||||||
|
|
||||||
_checking = true;
|
_checking = true;
|
||||||
try {
|
try {
|
||||||
final current = await _packageInfoProvider();
|
final current = await PackageInfo.fromPlatform();
|
||||||
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
||||||
if (latest == null ||
|
if (latest == null) return null;
|
||||||
_compareVersions(latest.version, current.version) <= 0) {
|
if (_compareVersions(latest.version, current.version) <= 0) return null;
|
||||||
_setAvailableUpdate(null);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
_setAvailableUpdate(latest);
|
|
||||||
return latest;
|
return latest;
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
||||||
@@ -76,34 +54,6 @@ class UpdateService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> 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<void> 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<File?> downloadUpdate(
|
Future<File?> downloadUpdate(
|
||||||
AppUpdateInfo update, {
|
AppUpdateInfo update, {
|
||||||
void Function(int received, int total)? onProgress,
|
void Function(int received, int total)? onProgress,
|
||||||
@@ -146,15 +96,6 @@ class UpdateService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openDownloadsPage() async {
|
|
||||||
if (!await launchUrl(
|
|
||||||
downloadsPageUrl,
|
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
)) {
|
|
||||||
throw Exception('无法打开下载页面: $downloadsPageUrl');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
||||||
final response = await _dio.getUri<String>(releasesRssUrl);
|
final response = await _dio.getUri<String>(releasesRssUrl);
|
||||||
final body = response.data;
|
final body = response.data;
|
||||||
@@ -178,6 +119,7 @@ class UpdateService extends ChangeNotifier {
|
|||||||
final update = await _buildUpdateInfo(release);
|
final update = await _buildUpdateInfo(release);
|
||||||
if (update == null) continue;
|
if (update == null) continue;
|
||||||
|
|
||||||
|
if (update.downloadUrl == null) continue;
|
||||||
return update;
|
return update;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +206,8 @@ class UpdateService extends ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows;
|
||||||
|
|
||||||
bool _isReleaseAttachment(Uri uri) {
|
bool _isReleaseAttachment(Uri uri) {
|
||||||
return uri.path.contains('/releases/download/');
|
return uri.path.contains('/releases/download/');
|
||||||
}
|
}
|
||||||
@@ -401,16 +345,6 @@ class UpdateService extends ChangeNotifier {
|
|||||||
return Uri.decodeComponent(segment);
|
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
|
@visibleForTesting
|
||||||
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::errors::{Result, SyncError};
|
use crate::errors::{Result, SyncError};
|
||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
use crate::server_error_code::api_code_to_error;
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -155,11 +154,12 @@ impl ApiClient {
|
|||||||
return Err(SyncError::Network(format!("HTTP {}", resp.status())));
|
return Err(SyncError::Network(format!("HTTP {}", resp.status())));
|
||||||
}
|
}
|
||||||
let api_resp: ApiResponse<serde_json::Value> = resp.json().await?;
|
let api_resp: ApiResponse<serde_json::Value> = resp.json().await?;
|
||||||
if api_resp.code == 0 {
|
if api_resp.code == 401 {
|
||||||
return Ok(api_resp.data.unwrap_or_default());
|
return Err(SyncError::Auth("Login required".into()));
|
||||||
|
}
|
||||||
|
if api_resp.code == 40004 {
|
||||||
|
return Err(SyncError::ObjectExisted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 40073 锁冲突需要特殊处理 data
|
|
||||||
if api_resp.code == 40073 {
|
if api_resp.code == 40073 {
|
||||||
let items = api_resp.data
|
let items = api_resp.data
|
||||||
.and_then(|d| d.as_array().cloned())
|
.and_then(|d| d.as_array().cloned())
|
||||||
@@ -177,11 +177,12 @@ impl ApiClient {
|
|||||||
.collect();
|
.collect();
|
||||||
return Err(SyncError::LockConflict { tokens: items });
|
return Err(SyncError::LockConflict { tokens: items });
|
||||||
}
|
}
|
||||||
|
if api_resp.code != 0 {
|
||||||
let msg = api_resp.msg
|
return Err(SyncError::Network(
|
||||||
.filter(|m| !m.is_empty())
|
api_resp.msg.unwrap_or_else(|| format!("错误码: {}", api_resp.code)),
|
||||||
.unwrap_or_default();
|
));
|
||||||
Err(api_code_to_error(api_resp.code, &msg))
|
}
|
||||||
|
Ok(api_resp.data.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发送带认证的请求,自动处理 401(刷新 token 后重试一次)
|
/// 发送带认证的请求,自动处理 401(刷新 token 后重试一次)
|
||||||
@@ -195,22 +196,9 @@ impl ApiClient {
|
|||||||
|
|
||||||
// 第一次尝试
|
// 第一次尝试
|
||||||
let token = self.token().await;
|
let token = self.token().await;
|
||||||
let resp = match request_builder(token)
|
let resp = request_builder(token)
|
||||||
.header("X-Cr-Client-Id", &client_id)
|
.header("X-Cr-Client-Id", &client_id)
|
||||||
.send()
|
.send().await?;
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"请求发送失败: kind={:?}, url={:?}, error={}",
|
|
||||||
e.is_connect(),
|
|
||||||
e.url().map(|u| u.as_str()),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let result = self.parse_response(resp).await;
|
let result = self.parse_response(resp).await;
|
||||||
|
|
||||||
if let Err(SyncError::Auth(_)) = result {
|
if let Err(SyncError::Auth(_)) = result {
|
||||||
@@ -218,22 +206,9 @@ impl ApiClient {
|
|||||||
self.refresh_access_token().await?;
|
self.refresh_access_token().await?;
|
||||||
// 用新 token 重试
|
// 用新 token 重试
|
||||||
let new_token = self.token().await;
|
let new_token = self.token().await;
|
||||||
let resp = match request_builder(new_token)
|
let resp = request_builder(new_token)
|
||||||
.header("X-Cr-Client-Id", &client_id)
|
.header("X-Cr-Client-Id", &client_id)
|
||||||
.send()
|
.send().await?;
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"重试请求发送失败: kind={:?}, url={:?}, error={}",
|
|
||||||
e.is_connect(),
|
|
||||||
e.url().map(|u| u.as_str()),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return self.parse_response(resp).await;
|
return self.parse_response(resp).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,22 +261,7 @@ impl ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for dir_uri in dirs_to_recurse {
|
for dir_uri in dirs_to_recurse {
|
||||||
let mut retry = 0u32;
|
self.list_all_files_recursive(&dir_uri, result).await?;
|
||||||
loop {
|
|
||||||
match self.list_all_files_recursive(&dir_uri, result).await {
|
|
||||||
Ok(()) => break,
|
|
||||||
Err(e) => {
|
|
||||||
retry += 1;
|
|
||||||
if retry > 3 {
|
|
||||||
tracing::error!("递归列出目录失败,跳过: {}: {}", dir_uri, e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let delay = crate::utils::retry_delay_ms(retry, 2000, 30000);
|
|
||||||
tracing::warn!("递归列出目录失败 (重试 {}/3): {}: {}, {}ms后重试", retry, dir_uri, e, delay);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -314,33 +274,6 @@ impl ApiClient {
|
|||||||
page: u32,
|
page: u32,
|
||||||
page_size: u32,
|
page_size: u32,
|
||||||
next_page_token: Option<&str>,
|
next_page_token: Option<&str>,
|
||||||
) -> Result<ListFilesResponse> {
|
|
||||||
let max_retries = 3u32;
|
|
||||||
let mut attempt = 0u32;
|
|
||||||
loop {
|
|
||||||
attempt += 1;
|
|
||||||
match self.list_files_page_inner(uri, page, page_size, next_page_token).await {
|
|
||||||
Ok(resp) => return Ok(resp),
|
|
||||||
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
|
|
||||||
Err(e) if attempt <= max_retries => {
|
|
||||||
let delay = crate::utils::retry_delay_ms(attempt, 2000, 30000);
|
|
||||||
tracing::warn!(
|
|
||||||
"列出文件失败 (重试 {}/{}): uri={}, error={}, {}ms后重试",
|
|
||||||
attempt, max_retries, uri, e, delay,
|
|
||||||
);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_files_page_inner(
|
|
||||||
&self,
|
|
||||||
uri: &str,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
next_page_token: Option<&str>,
|
|
||||||
) -> Result<ListFilesResponse> {
|
) -> Result<ListFilesResponse> {
|
||||||
let data = self.send_with_auth_retry(|token| {
|
let data = self.send_with_auth_retry(|token| {
|
||||||
let mut req = self.client
|
let mut req = self.client
|
||||||
@@ -456,56 +389,14 @@ impl ApiClient {
|
|||||||
let chunk_size = data.get("chunk_size")
|
let chunk_size = data.get("chunk_size")
|
||||||
.and_then(|c| c.as_u64())
|
.and_then(|c| c.as_u64())
|
||||||
.unwrap_or(10 * 1024 * 1024);
|
.unwrap_or(10 * 1024 * 1024);
|
||||||
let upload_urls: Vec<String> = data.get("upload_urls")
|
|
||||||
.and_then(|u| u.as_array())
|
|
||||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let storage_policy_type = data.get("storage_policy")
|
|
||||||
.and_then(|sp| sp.get("type"))
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
.unwrap_or("local")
|
|
||||||
.to_string();
|
|
||||||
let callback_secret = data.get("callback_secret")
|
|
||||||
.and_then(|s| s.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let file_name = uri.rsplit('/').next().unwrap_or(uri).to_string();
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"[{}] 创建上传会话: policy={}, urls={}, chunk_size={}",
|
|
||||||
file_name, storage_policy_type, upload_urls.len(), chunk_size,
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(UploadSession {
|
Ok(UploadSession {
|
||||||
session_id,
|
session_id,
|
||||||
chunk_size,
|
chunk_size,
|
||||||
upload_urls,
|
|
||||||
storage_policy_type,
|
|
||||||
callback_secret,
|
|
||||||
file_name,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn upload_chunk(
|
pub async fn upload_chunk(
|
||||||
&self,
|
|
||||||
session: &UploadSession,
|
|
||||||
index: u32,
|
|
||||||
data: &[u8],
|
|
||||||
file_size: u64,
|
|
||||||
task_id: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
if let Some(url) = session.chunk_upload_url(index as usize) {
|
|
||||||
// 远程存储策略:直接上传到外部 URL(OneDrive/S3/OSS 等)
|
|
||||||
self.upload_chunk_to_remote(url, data, index, file_size, session, task_id).await
|
|
||||||
} else {
|
|
||||||
// 本地存储策略:上传到 Cloudreve 服务端
|
|
||||||
self.upload_chunk_local(&session.session_id, index, data).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 本地存储:上传分片到 /file/upload/{session_id}/{index}
|
|
||||||
async fn upload_chunk_local(
|
|
||||||
&self,
|
&self,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
index: u32,
|
index: u32,
|
||||||
@@ -526,88 +417,6 @@ impl ApiClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 远程存储:上传分片到外部 URL(OneDrive/S3 等),无需 Cloudreve token
|
|
||||||
/// 必须带 Content-Range 头:bytes {start}-{end}/{total}
|
|
||||||
async fn upload_chunk_to_remote(
|
|
||||||
&self,
|
|
||||||
url: &str,
|
|
||||||
data: &[u8],
|
|
||||||
index: u32,
|
|
||||||
file_size: u64,
|
|
||||||
session: &UploadSession,
|
|
||||||
task_id: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let chunk_size = session.chunk_size;
|
|
||||||
let file_name = &session.file_name;
|
|
||||||
let start = index as u64 * chunk_size;
|
|
||||||
let end = start + data.len() as u64 - 1;
|
|
||||||
let content_range = format!("bytes {}-{}/{}", start, end, file_size);
|
|
||||||
let content_len = data.len().to_string();
|
|
||||||
|
|
||||||
tracing::debug!("[{}][{}] 远程存储上传分片 {}: Content-Range={}", task_id, file_name, index, content_range);
|
|
||||||
|
|
||||||
let resp = self.client
|
|
||||||
.put(url)
|
|
||||||
.header("Content-Length", &content_len)
|
|
||||||
.header("Content-Range", &content_range)
|
|
||||||
.body(data.to_vec())
|
|
||||||
.timeout(std::time::Duration::from_secs(300))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
tracing::warn!("[{}][{}] 远程存储上传失败: error={}", task_id, file_name, e);
|
|
||||||
SyncError::from(e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
let status = resp.status();
|
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(SyncError::UploadFailed(format!(
|
|
||||||
"远程存储返回 HTTP {}: {}",
|
|
||||||
status,
|
|
||||||
body.chars().take(200).collect::<String>()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 202 = 分片已接收,上传未完成,继续下一个分片
|
|
||||||
// 200/201 = 上传完成,文件已创建
|
|
||||||
let status = resp.status();
|
|
||||||
if status.as_u16() == 202 {
|
|
||||||
tracing::debug!("[{}][{}] 远程存储分片 {} 已接收(202),继续上传", task_id, file_name, index);
|
|
||||||
} else if status.as_u16() == 200 || status.as_u16() == 201 {
|
|
||||||
tracing::info!("[{}][{}] 远程存储上传完成({}), 分片 {}", task_id, file_name, status, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 远程存储上传完成后回调 Cloudreve 服务端
|
|
||||||
/// POST /callback/{storage_policy_type}/{session_id}/{callback_secret}
|
|
||||||
pub async fn callback_upload_complete(&self, session: &UploadSession, task_id: &str) -> Result<()> {
|
|
||||||
if session.callback_secret.is_empty() {
|
|
||||||
tracing::warn!("[{}][{}] 上传回调跳过: callback_secret 为空", task_id, session.file_name);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"{}/callback/{}/{}/{}",
|
|
||||||
self.base_url,
|
|
||||||
session.storage_policy_type,
|
|
||||||
session.session_id,
|
|
||||||
session.callback_secret,
|
|
||||||
);
|
|
||||||
tracing::info!("[{}][{}] 上传完成回调: policy={}, session={}", task_id, session.file_name, session.storage_policy_type, session.session_id);
|
|
||||||
|
|
||||||
self.send_with_auth_retry(|token| {
|
|
||||||
self.client
|
|
||||||
.post(&url)
|
|
||||||
.bearer_auth(&token)
|
|
||||||
}).await?;
|
|
||||||
|
|
||||||
tracing::info!("[{}][{}] 上传完成回调成功: policy={}, session={}", task_id, session.file_name, session.storage_policy_type, session.session_id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 下载 =====
|
// ===== 下载 =====
|
||||||
|
|
||||||
pub async fn get_download_url(&self, uris: &[&str]) -> Result<Vec<String>> {
|
pub async fn get_download_url(&self, uris: &[&str]) -> Result<Vec<String>> {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use std::error::Error as StdError;
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
@@ -15,18 +14,6 @@ pub enum SyncError {
|
|||||||
#[error("远程文件已存在")]
|
#[error("远程文件已存在")]
|
||||||
ObjectExisted,
|
ObjectExisted,
|
||||||
|
|
||||||
#[error("存储策略不允许: {0}")]
|
|
||||||
StoragePolicyDenied(String),
|
|
||||||
|
|
||||||
#[error("上传失败: {0}")]
|
|
||||||
UploadFailed(String),
|
|
||||||
|
|
||||||
#[error("文件未找到: {0}")]
|
|
||||||
FileNotFound(String),
|
|
||||||
|
|
||||||
#[error("权限不足: {0}")]
|
|
||||||
PermissionDenied(String),
|
|
||||||
|
|
||||||
#[error("文件锁定冲突")]
|
#[error("文件锁定冲突")]
|
||||||
LockConflict { tokens: Vec<LockConflictItem> },
|
LockConflict { tokens: Vec<LockConflictItem> },
|
||||||
|
|
||||||
@@ -69,39 +56,7 @@ impl From<r2d2::Error> for SyncError {
|
|||||||
|
|
||||||
impl From<reqwest::Error> for SyncError {
|
impl From<reqwest::Error> for SyncError {
|
||||||
fn from(e: reqwest::Error) -> Self {
|
fn from(e: reqwest::Error) -> Self {
|
||||||
let mut detail = String::new();
|
SyncError::Network(e.to_string())
|
||||||
if e.is_connect() {
|
|
||||||
detail.push_str("连接失败");
|
|
||||||
} else if e.is_timeout() {
|
|
||||||
detail.push_str("请求超时");
|
|
||||||
} else if e.is_request() {
|
|
||||||
detail.push_str("请求构建失败");
|
|
||||||
} else if e.is_body() {
|
|
||||||
detail.push_str("请求体错误");
|
|
||||||
} else if e.is_decode() {
|
|
||||||
detail.push_str("响应解码失败");
|
|
||||||
} else if e.is_redirect() {
|
|
||||||
detail.push_str("重定向过多");
|
|
||||||
}
|
|
||||||
let url = e.url().map(|u| u.to_string()).unwrap_or_default();
|
|
||||||
let source = StdError::source(&e)
|
|
||||||
.map(|s| format!(": {s}"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let msg = e.to_string();
|
|
||||||
// 如果 detail 为空,用 reqwest 原始消息
|
|
||||||
if detail.is_empty() {
|
|
||||||
SyncError::Network(if url.is_empty() {
|
|
||||||
format!("{msg}{source}")
|
|
||||||
} else {
|
|
||||||
format!("{msg} [{url}]{source}")
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
SyncError::Network(if url.is_empty() {
|
|
||||||
format!("{detail}: {msg}{source}")
|
|
||||||
} else {
|
|
||||||
format!("{detail}: {msg} [{url}]{source}")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ pub mod errors;
|
|||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod sync_db;
|
pub mod sync_db;
|
||||||
pub mod api_client;
|
pub mod api_client;
|
||||||
pub mod server_error_code;
|
|
||||||
pub mod fs_scanner;
|
pub mod fs_scanner;
|
||||||
pub mod conflict_resolver;
|
pub mod conflict_resolver;
|
||||||
pub mod transfer;
|
pub mod transfer;
|
||||||
|
|||||||
@@ -283,35 +283,6 @@ pub enum PlatformCallbackEvent {
|
|||||||
pub struct UploadSession {
|
pub struct UploadSession {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub chunk_size: u64,
|
pub chunk_size: u64,
|
||||||
/// 远程存储的上传 URL 列表(每个分片一个 URL)
|
|
||||||
/// 仅当 storage_policy_type != "local" 时有值
|
|
||||||
pub upload_urls: Vec<String>,
|
|
||||||
/// 存储策略类型:local / onedrive / s3 / oss / cos / etc.
|
|
||||||
pub storage_policy_type: String,
|
|
||||||
/// 回调密钥(远程存储上传完成后需要回调通知服务端)
|
|
||||||
pub callback_secret: String,
|
|
||||||
/// 上传文件名(仅用于日志标识,并发时区分不同文件)
|
|
||||||
pub file_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UploadSession {
|
|
||||||
/// 是否使用远程存储直接上传(非本地策略)
|
|
||||||
pub fn is_remote_storage(&self) -> bool {
|
|
||||||
self.storage_policy_type != "local" && !self.upload_urls.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取第 index 个分片的上传 URL
|
|
||||||
/// 远程存储:返回 upload_urls[index](不足则用最后一个)
|
|
||||||
/// 本地存储:返回 None,由调用方拼接 /file/upload/{session_id}/{index}
|
|
||||||
pub fn chunk_upload_url(&self, index: usize) -> Option<&str> {
|
|
||||||
if !self.is_remote_storage() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if self.upload_urls.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(self.upload_urls[index.min(self.upload_urls.len() - 1)].as_str())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== API 分页响应 =====
|
// ===== API 分页响应 =====
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
use crate::errors::SyncError;
|
|
||||||
|
|
||||||
/// Cloudreve 服务端错误码 → 中文描述映射
|
|
||||||
pub fn server_code_desc(code: i32) -> Option<&'static str> {
|
|
||||||
Some(match code {
|
|
||||||
// HTTP 语义码
|
|
||||||
203 => "部分操作未成功",
|
|
||||||
401 => "未登录",
|
|
||||||
403 => "无权限访问",
|
|
||||||
404 => "资源不存在",
|
|
||||||
409 => "资源冲突",
|
|
||||||
// 4xxxx 业务码
|
|
||||||
40001 => "参数错误",
|
|
||||||
40002 => "上传失败",
|
|
||||||
40003 => "文件夹创建失败",
|
|
||||||
40004 => "对象已存在",
|
|
||||||
40005 => "签名已过期",
|
|
||||||
40006 => "当前存储策略不允许",
|
|
||||||
40007 => "用户组不允许此操作",
|
|
||||||
40008 => "需要管理员权限",
|
|
||||||
40009 => "主节点未注册",
|
|
||||||
40010 => "需要绑定手机",
|
|
||||||
40011 => "上传会话已过期",
|
|
||||||
40012 => "无效的分片序号",
|
|
||||||
40013 => "无效的 Content-Length",
|
|
||||||
40014 => "批量源大小超限",
|
|
||||||
40016 => "父目录不存在",
|
|
||||||
40017 => "用户被封禁",
|
|
||||||
40018 => "用户未激活",
|
|
||||||
40019 => "功能未启用",
|
|
||||||
40020 => "凭据无效",
|
|
||||||
40021 => "用户不存在",
|
|
||||||
40022 => "两步验证码错误",
|
|
||||||
40023 => "登录会话不存在",
|
|
||||||
40026 => "验证码错误",
|
|
||||||
40027 => "验证码需要刷新",
|
|
||||||
40035 => "存储策略不存在",
|
|
||||||
40044 => "文件未找到",
|
|
||||||
40045 => "列出文件失败",
|
|
||||||
40049 => "文件过大",
|
|
||||||
40050 => "文件类型不允许",
|
|
||||||
40051 => "用户容量不足",
|
|
||||||
40052 => "非法对象名",
|
|
||||||
40053 => "根目录受保护",
|
|
||||||
40054 => "同名文件正在上传",
|
|
||||||
40055 => "元数据不匹配",
|
|
||||||
40057 => "可用存储策略已变更",
|
|
||||||
40071 => "签名无效",
|
|
||||||
40073 => "文件锁定冲突",
|
|
||||||
40074 => "URI 数量过多",
|
|
||||||
40075 => "锁令牌已过期",
|
|
||||||
40077 => "实体不存在",
|
|
||||||
40078 => "文件在回收站中",
|
|
||||||
40079 => "文件数量已达上限",
|
|
||||||
40081 => "批量操作未完全完成",
|
|
||||||
40082 => "仅所有者可操作",
|
|
||||||
// 5xxxx 服务端内部错误
|
|
||||||
50001 => "数据库操作失败",
|
|
||||||
50002 => "加密失败",
|
|
||||||
50004 => "IO 操作失败",
|
|
||||||
50006 => "缓存操作失败",
|
|
||||||
50007 => "回调失败",
|
|
||||||
50010 => "节点离线",
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将服务端业务错误码转为 SyncError
|
|
||||||
pub fn api_code_to_error(code: i32, msg: &str) -> SyncError {
|
|
||||||
let desc = server_code_desc(code);
|
|
||||||
let detail = if msg.is_empty() {
|
|
||||||
desc.unwrap_or("未知错误").to_string()
|
|
||||||
} else if let Some(d) = desc {
|
|
||||||
if msg != d {
|
|
||||||
format!("{}: {}", d, msg)
|
|
||||||
} else {
|
|
||||||
msg.to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
msg.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
match code {
|
|
||||||
401 => SyncError::Auth(detail),
|
|
||||||
40004 => SyncError::ObjectExisted,
|
|
||||||
40006 | 40035 | 40057 => SyncError::StoragePolicyDenied(detail),
|
|
||||||
40002 | 40011 | 40012 | 40013 | 40054 => SyncError::UploadFailed(detail),
|
|
||||||
40044 | 40077 => SyncError::FileNotFound(detail),
|
|
||||||
40017 | 40018 | 40007 | 40008 => SyncError::PermissionDenied(detail),
|
|
||||||
_ => SyncError::Network(format!("[{}] {}", code, detail)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ impl SyncEngine {
|
|||||||
let file_size = metadata.len();
|
let file_size = metadata.len();
|
||||||
match self.api.create_upload_session(remote_dcim_uri, file_size, false, None, None, None).await {
|
match self.api.create_upload_session(remote_dcim_uri, file_size, false, None, None, None).await {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
match crate::uploader::upload_file_chunked(&self.api, local_path, &session, "album").await {
|
match crate::uploader::upload_file_chunked(&self.api, local_path, &session).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let remote_uri = format!("{}/{}", remote_dcim_uri, file_name);
|
let remote_uri = format!("{}/{}", remote_dcim_uri, file_name);
|
||||||
let hash = crate::utils::quick_hash(local_path, file_size).await.unwrap_or_default();
|
let hash = crate::utils::quick_hash(local_path, file_size).await.unwrap_or_default();
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ impl SyncEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
RemoteFileEvent::Deleted { uri, name } => {
|
RemoteFileEvent::Deleted { uri, name } => {
|
||||||
// 清理过期抑制条目
|
|
||||||
let now = std::time::Instant::now();
|
|
||||||
self.suppress_paths.retain(|_, ts| now.duration_since(*ts).as_secs() < 30);
|
|
||||||
|
|
||||||
let relative = crate::diff::remote_relative_path(
|
let relative = crate::diff::remote_relative_path(
|
||||||
remote_root,
|
remote_root,
|
||||||
uri,
|
uri,
|
||||||
@@ -90,13 +86,6 @@ impl SyncEngine {
|
|||||||
);
|
);
|
||||||
tracing::info!("[远程事件] 删除: {}", relative);
|
tracing::info!("[远程事件] 删除: {}", relative);
|
||||||
|
|
||||||
// 被抑制的路径:上传失败清理远端碎片等场景,不应删除本地文件
|
|
||||||
if self.suppress_paths.contains_key(&relative) {
|
|
||||||
tracing::info!("[远程事件] 删除已抑制,跳过本地删除: {}", relative);
|
|
||||||
self.suppress_paths.remove(&relative);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let local_path = local_root.join(&relative);
|
let local_path = local_root.join(&relative);
|
||||||
if local_path.exists() {
|
if local_path.exists() {
|
||||||
if local_path.is_dir() {
|
if local_path.is_dir() {
|
||||||
|
|||||||
@@ -126,15 +126,9 @@ pub async fn upload_file(
|
|||||||
let chunk = &buf[..filled];
|
let chunk = &buf[..filled];
|
||||||
let mut chunk_retries = 0u32;
|
let mut chunk_retries = 0u32;
|
||||||
loop {
|
loop {
|
||||||
match api.upload_chunk(&session, index, chunk, local.size, task_id).await {
|
match api.upload_chunk(&session.session_id, index, chunk).await {
|
||||||
Ok(_) => break,
|
Ok(_) => break,
|
||||||
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
|
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
|
||||||
// 业务错误,重试无意义,直接失败
|
|
||||||
Err(e @ SyncError::StoragePolicyDenied(_))
|
|
||||||
| Err(e @ SyncError::UploadFailed(_))
|
|
||||||
| Err(e @ SyncError::FileNotFound(_))
|
|
||||||
| Err(e @ SyncError::PermissionDenied(_))
|
|
||||||
| Err(e @ SyncError::ObjectExisted) => return Err(e),
|
|
||||||
Err(e) if chunk_retries < max_retries => {
|
Err(e) if chunk_retries < max_retries => {
|
||||||
chunk_retries += 1;
|
chunk_retries += 1;
|
||||||
let delay = crate::utils::retry_delay_ms(chunk_retries, 1000, 30000);
|
let delay = crate::utils::retry_delay_ms(chunk_retries, 1000, 30000);
|
||||||
@@ -147,13 +141,6 @@ pub async fn upload_file(
|
|||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 远程存储策略:上传完成后回调 Cloudreve 服务端
|
|
||||||
if session.is_remote_storage() {
|
|
||||||
if let Err(e) = api.callback_upload_complete(&session, task_id).await {
|
|
||||||
tracing::warn!("[{}][{}] 上传完成回调失败: {}", task_id, session.file_name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传完成后获取远程文件信息
|
// 上传完成后获取远程文件信息
|
||||||
let remote_uri = file_uri.clone();
|
let remote_uri = file_uri.clone();
|
||||||
let (remote_file_id, remote_hash) = match api.get_file_info(&remote_uri).await {
|
let (remote_file_id, remote_hash) = match api.get_file_info(&remote_uri).await {
|
||||||
@@ -219,11 +206,6 @@ pub async fn retry_upload_session(
|
|||||||
}
|
}
|
||||||
return Err(SyncError::LockConflict { tokens });
|
return Err(SyncError::LockConflict { tokens });
|
||||||
}
|
}
|
||||||
// 业务错误,重试无意义,直接失败
|
|
||||||
Err(e @ SyncError::StoragePolicyDenied(_))
|
|
||||||
| Err(e @ SyncError::UploadFailed(_))
|
|
||||||
| Err(e @ SyncError::FileNotFound(_))
|
|
||||||
| Err(e @ SyncError::PermissionDenied(_)) => return Err(e),
|
|
||||||
Err(e) if attempt <= max_retries => {
|
Err(e) if attempt <= max_retries => {
|
||||||
let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000);
|
let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000);
|
||||||
tracing::warn!("[{}] 创建上传会话失败,{}ms后重试 ({}): {}", task_id, delay, attempt, e);
|
tracing::warn!("[{}] 创建上传会话失败,{}ms后重试 ({}): {}", task_id, delay, attempt, e);
|
||||||
@@ -291,11 +273,9 @@ pub async fn upload_file_chunked(
|
|||||||
api: &ApiClient,
|
api: &ApiClient,
|
||||||
local_path: &Path,
|
local_path: &Path,
|
||||||
session: &UploadSession,
|
session: &UploadSession,
|
||||||
task_id: &str,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let chunk_size = session.chunk_size as usize;
|
let chunk_size = session.chunk_size as usize;
|
||||||
let file = tokio::fs::File::open(local_path).await?;
|
let file = tokio::fs::File::open(local_path).await?;
|
||||||
let file_size = file.metadata().await.map(|m| m.len()).unwrap_or(0);
|
|
||||||
let mut reader = tokio::io::BufReader::new(file);
|
let mut reader = tokio::io::BufReader::new(file);
|
||||||
let mut buf = vec![0u8; chunk_size];
|
let mut buf = vec![0u8; chunk_size];
|
||||||
let mut index: u32 = 0;
|
let mut index: u32 = 0;
|
||||||
@@ -311,16 +291,9 @@ pub async fn upload_file_chunked(
|
|||||||
}
|
}
|
||||||
if filled == 0 { break; }
|
if filled == 0 { break; }
|
||||||
|
|
||||||
api.upload_chunk(session, index, &buf[..filled], file_size, task_id).await?;
|
api.upload_chunk(&session.session_id, index, &buf[..filled]).await?;
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 远程存储策略:上传完成后回调 Cloudreve 服务端
|
|
||||||
if session.is_remote_storage() {
|
|
||||||
if let Err(e) = api.callback_upload_complete(session, task_id).await {
|
|
||||||
tracing::warn!("[{}][{}] 上传完成回调失败: {}", task_id, session.file_name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ pub struct WorkerPool {
|
|||||||
file_locks: Arc<FileLockRegistry>,
|
file_locks: Arc<FileLockRegistry>,
|
||||||
ensured_dirs: Arc<DashMap<String, ()>>,
|
ensured_dirs: Arc<DashMap<String, ()>>,
|
||||||
event_sink: Arc<crate::event_sink::EventSink>,
|
event_sink: Arc<crate::event_sink::EventSink>,
|
||||||
/// 抑制路径:上传失败清理远端碎片时,防止 SSE 删除事件误删本地文件
|
|
||||||
suppress_paths: Arc<DashMap<String, std::time::Instant>>,
|
|
||||||
shutdown_token: std::sync::Mutex<CancellationToken>,
|
shutdown_token: std::sync::Mutex<CancellationToken>,
|
||||||
#[cfg(feature = "windows-cfapi")]
|
#[cfg(feature = "windows-cfapi")]
|
||||||
platform_adapter: std::sync::Mutex<Option<Arc<dyn PlaceholderCreator>>>,
|
platform_adapter: std::sync::Mutex<Option<Arc<dyn PlaceholderCreator>>>,
|
||||||
@@ -69,7 +67,6 @@ impl WorkerPool {
|
|||||||
file_locks,
|
file_locks,
|
||||||
ensured_dirs,
|
ensured_dirs,
|
||||||
event_sink,
|
event_sink,
|
||||||
suppress_paths: Arc::new(DashMap::new()),
|
|
||||||
shutdown_token: std::sync::Mutex::new(shutdown_token),
|
shutdown_token: std::sync::Mutex::new(shutdown_token),
|
||||||
#[cfg(feature = "windows-cfapi")]
|
#[cfg(feature = "windows-cfapi")]
|
||||||
platform_adapter: std::sync::Mutex::new(None),
|
platform_adapter: std::sync::Mutex::new(None),
|
||||||
@@ -136,7 +133,6 @@ impl WorkerPool {
|
|||||||
self.ensured_dirs.clone(),
|
self.ensured_dirs.clone(),
|
||||||
conflict_resolver,
|
conflict_resolver,
|
||||||
self.event_sink.clone(),
|
self.event_sink.clone(),
|
||||||
self.suppress_paths.clone(),
|
|
||||||
self.shutdown_token.lock().unwrap().clone(),
|
self.shutdown_token.lock().unwrap().clone(),
|
||||||
#[cfg(feature = "windows-cfapi")]
|
#[cfg(feature = "windows-cfapi")]
|
||||||
self.platform_adapter.lock().unwrap().clone(),
|
self.platform_adapter.lock().unwrap().clone(),
|
||||||
@@ -236,7 +232,6 @@ impl WorkerPool {
|
|||||||
let file_locks = self.file_locks.clone();
|
let file_locks = self.file_locks.clone();
|
||||||
let ensured_dirs = self.ensured_dirs.clone();
|
let ensured_dirs = self.ensured_dirs.clone();
|
||||||
let event_sink = self.event_sink.clone();
|
let event_sink = self.event_sink.clone();
|
||||||
let suppress_paths = self.suppress_paths.clone();
|
|
||||||
let shutdown_token = self.shutdown_token.lock().unwrap().clone();
|
let shutdown_token = self.shutdown_token.lock().unwrap().clone();
|
||||||
let active_workers = self.active_workers.clone();
|
let active_workers = self.active_workers.clone();
|
||||||
let active_upload_paths = self.active_upload_paths.clone();
|
let active_upload_paths = self.active_upload_paths.clone();
|
||||||
@@ -260,7 +255,6 @@ impl WorkerPool {
|
|||||||
ensured_dirs,
|
ensured_dirs,
|
||||||
conflict_resolver,
|
conflict_resolver,
|
||||||
event_sink,
|
event_sink,
|
||||||
suppress_paths,
|
|
||||||
shutdown_token,
|
shutdown_token,
|
||||||
#[cfg(feature = "windows-cfapi")]
|
#[cfg(feature = "windows-cfapi")]
|
||||||
platform_adapter,
|
platform_adapter,
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ pub struct Worker {
|
|||||||
ensured_dirs: Arc<DashMap<String, ()>>,
|
ensured_dirs: Arc<DashMap<String, ()>>,
|
||||||
conflict_resolver: ConflictResolver,
|
conflict_resolver: ConflictResolver,
|
||||||
event_sink: Arc<crate::event_sink::EventSink>,
|
event_sink: Arc<crate::event_sink::EventSink>,
|
||||||
/// 抑制路径:上传失败清理远端碎片时,防止 SSE 删除事件误删本地文件
|
|
||||||
suppress_paths: Arc<DashMap<String, std::time::Instant>>,
|
|
||||||
shutdown_token: CancellationToken,
|
shutdown_token: CancellationToken,
|
||||||
#[cfg(feature = "windows-cfapi")]
|
#[cfg(feature = "windows-cfapi")]
|
||||||
platform_adapter: Option<Arc<dyn PlaceholderCreator>>,
|
platform_adapter: Option<Arc<dyn PlaceholderCreator>>,
|
||||||
@@ -57,7 +55,6 @@ impl Worker {
|
|||||||
ensured_dirs: Arc<DashMap<String, ()>>,
|
ensured_dirs: Arc<DashMap<String, ()>>,
|
||||||
conflict_resolver: ConflictResolver,
|
conflict_resolver: ConflictResolver,
|
||||||
event_sink: Arc<crate::event_sink::EventSink>,
|
event_sink: Arc<crate::event_sink::EventSink>,
|
||||||
suppress_paths: Arc<DashMap<String, std::time::Instant>>,
|
|
||||||
shutdown_token: CancellationToken,
|
shutdown_token: CancellationToken,
|
||||||
#[cfg(feature = "windows-cfapi")] platform_adapter: Option<Arc<dyn PlaceholderCreator>>,
|
#[cfg(feature = "windows-cfapi")] platform_adapter: Option<Arc<dyn PlaceholderCreator>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -72,7 +69,6 @@ impl Worker {
|
|||||||
ensured_dirs,
|
ensured_dirs,
|
||||||
conflict_resolver,
|
conflict_resolver,
|
||||||
event_sink,
|
event_sink,
|
||||||
suppress_paths,
|
|
||||||
shutdown_token,
|
shutdown_token,
|
||||||
#[cfg(feature = "windows-cfapi")]
|
#[cfg(feature = "windows-cfapi")]
|
||||||
platform_adapter,
|
platform_adapter,
|
||||||
@@ -787,8 +783,6 @@ impl Worker {
|
|||||||
Some(&e.to_string()),
|
Some(&e.to_string()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// 清理远端碎片:强制解锁 + 删除
|
|
||||||
self.cleanup_failed_upload(&rel_path).await;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[{}] 上传任务异常: {}: {}", tid, rel_path, e);
|
tracing::error!("[{}] 上传任务异常: {}: {}", tid, rel_path, e);
|
||||||
@@ -804,8 +798,6 @@ impl Worker {
|
|||||||
Some(&e.to_string()),
|
Some(&e.to_string()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// 清理远端碎片:强制解锁 + 删除
|
|
||||||
self.cleanup_failed_upload(&rel_path).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1216,22 +1208,7 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传失败后清理远端碎片:删除远端可能已创建的部分文件
|
/// 7. 删除远程文件(Full 始终删除;MirrorWcf 仅在 WcfDeleteMode::SyncRemote 时删除)
|
||||||
// delete_files 内部遇到锁冲突(40073)会自动解锁后重试
|
|
||||||
// 删除前先插入 suppress_paths,防止 SSE 删除事件误删本地文件
|
|
||||||
async fn cleanup_failed_upload(&self, relative_path: &str) {
|
|
||||||
let remote_uri = format!("{}/{}", self.config.remote_root, relative_path);
|
|
||||||
let tid = &self.task_id;
|
|
||||||
|
|
||||||
// 先标记抑制,30s 内 SSE 删除事件不会删本地文件
|
|
||||||
self.suppress_paths.insert(relative_path.to_string(), std::time::Instant::now());
|
|
||||||
|
|
||||||
match self.api.delete_files(&[&remote_uri]).await {
|
|
||||||
Ok(_) => tracing::info!("[{}] 上传失败清理-已删除远端碎片: {}", tid, remote_uri),
|
|
||||||
Err(e) => tracing::debug!("[{}] 上传失败清理-删除失败(可能不存在): {}", tid, e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn step_delete_remote(&self, summary: &mut SyncSummary) {
|
async fn step_delete_remote(&self, summary: &mut SyncSummary) {
|
||||||
let should_delete = matches!(self.config.sync_mode, SyncMode::Full)
|
let should_delete = matches!(self.config.sync_mode, SyncMode::Full)
|
||||||
|| (matches!(self.config.sync_mode, SyncMode::MirrorWcf)
|
|| (matches!(self.config.sync_mode, SyncMode::MirrorWcf)
|
||||||
|
|||||||
+1
-1
@@ -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
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.3.5+5
|
version: 1.3.3+4
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.4
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user