Android Feature Optimization and Updates
Build Android Release / Build Android (push) Failing after 7s
Build Android Release / Build Android (push) Failing after 7s
This commit is contained in:
@@ -19,8 +19,12 @@ class QuickAccessConfig {
|
|||||||
this.isDefault = false,
|
this.isDefault = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
QuickAccessConfig copyWith({String? label, String? path, IconData? icon, Color? color}) =>
|
QuickAccessConfig copyWith({
|
||||||
QuickAccessConfig(
|
String? label,
|
||||||
|
String? path,
|
||||||
|
IconData? icon,
|
||||||
|
Color? color,
|
||||||
|
}) => QuickAccessConfig(
|
||||||
id: id,
|
id: id,
|
||||||
label: label ?? this.label,
|
label: label ?? this.label,
|
||||||
icon: icon ?? this.icon,
|
icon: icon ?? this.icon,
|
||||||
@@ -32,10 +36,38 @@ class QuickAccessConfig {
|
|||||||
static const storageKey = 'quick_access_shortcuts_v2';
|
static const storageKey = 'quick_access_shortcuts_v2';
|
||||||
|
|
||||||
static const defaults = [
|
static const defaults = [
|
||||||
QuickAccessConfig(id: 'img', label: '图片', icon: LucideIcons.image, path: '/Images', color: Color(0xFFF0ABFC), isDefault: true),
|
QuickAccessConfig(
|
||||||
QuickAccessConfig(id: 'vid', label: '视频', icon: LucideIcons.video, path: '/Videos', color: Color(0xFFFCD34D), isDefault: true),
|
id: 'img',
|
||||||
QuickAccessConfig(id: 'doc', label: '文档', icon: LucideIcons.fileText, path: '/Documents', color: Color(0xFF93C5FD), isDefault: true),
|
label: '图片',
|
||||||
QuickAccessConfig(id: 'mus', label: '音乐', icon: LucideIcons.music, path: '/Music', color: Color(0xFF86EFAC), isDefault: true),
|
icon: LucideIcons.image,
|
||||||
|
path: 'cloudreve://my?category=image',
|
||||||
|
color: Color(0xFFF0ABFC),
|
||||||
|
isDefault: true,
|
||||||
|
),
|
||||||
|
QuickAccessConfig(
|
||||||
|
id: 'vid',
|
||||||
|
label: '视频',
|
||||||
|
icon: LucideIcons.video,
|
||||||
|
path: 'cloudreve://my?category=video',
|
||||||
|
color: Color(0xFFFCD34D),
|
||||||
|
isDefault: true,
|
||||||
|
),
|
||||||
|
QuickAccessConfig(
|
||||||
|
id: 'doc',
|
||||||
|
label: '文档',
|
||||||
|
icon: LucideIcons.fileText,
|
||||||
|
path: 'cloudreve://my?category=document',
|
||||||
|
color: Color(0xFF93C5FD),
|
||||||
|
isDefault: true,
|
||||||
|
),
|
||||||
|
QuickAccessConfig(
|
||||||
|
id: 'mus',
|
||||||
|
label: '音乐',
|
||||||
|
icon: LucideIcons.music,
|
||||||
|
path: 'cloudreve://my?category=audio',
|
||||||
|
color: Color(0xFF86EFAC),
|
||||||
|
isDefault: true,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
static const iconPool = <IconData>[
|
static const iconPool = <IconData>[
|
||||||
@@ -80,7 +112,9 @@ class QuickAccessConfig {
|
|||||||
orElse: () => LucideIcons.folder,
|
orElse: () => LucideIcons.folder,
|
||||||
)!;
|
)!;
|
||||||
return QuickAccessConfig(
|
return QuickAccessConfig(
|
||||||
id: json['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
id:
|
||||||
|
json['id'] as String? ??
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
label: json['label'] as String,
|
label: json['label'] as String,
|
||||||
icon: matchedIcon,
|
icon: matchedIcon,
|
||||||
path: json['path'] as String,
|
path: json['path'] as String,
|
||||||
@@ -92,7 +126,9 @@ class QuickAccessConfig {
|
|||||||
static List<QuickAccessConfig> parseSaved(String saved) {
|
static List<QuickAccessConfig> parseSaved(String saved) {
|
||||||
try {
|
try {
|
||||||
final list = jsonDecode(saved) as List<dynamic>;
|
final list = jsonDecode(saved) as List<dynamic>;
|
||||||
return list.map((e) => QuickAccessConfig.fromJson(e as Map<String, dynamic>)).toList();
|
return list
|
||||||
|
.map((e) => QuickAccessConfig.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return List.from(defaults);
|
return List.from(defaults);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// 登录配置模型,来自 GET /site/config/login
|
||||||
|
class LoginConfigModel {
|
||||||
|
final bool loginCaptcha;
|
||||||
|
final bool regCaptcha;
|
||||||
|
final bool forgetCaptcha;
|
||||||
|
final bool registerEnabled;
|
||||||
|
final String? tosUrl;
|
||||||
|
final String? privacyPolicyUrl;
|
||||||
|
|
||||||
|
const LoginConfigModel({
|
||||||
|
this.loginCaptcha = false,
|
||||||
|
this.regCaptcha = false,
|
||||||
|
this.forgetCaptcha = false,
|
||||||
|
this.registerEnabled = true,
|
||||||
|
this.tosUrl,
|
||||||
|
this.privacyPolicyUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LoginConfigModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LoginConfigModel(
|
||||||
|
loginCaptcha: json['login_captcha'] as bool? ?? false,
|
||||||
|
regCaptcha: json['reg_captcha'] as bool? ?? false,
|
||||||
|
forgetCaptcha: json['forget_captcha'] as bool? ?? false,
|
||||||
|
registerEnabled: json['register_enabled'] as bool? ?? true,
|
||||||
|
tosUrl: json['tos_url'] as String?,
|
||||||
|
privacyPolicyUrl: json['privacy_policy_url'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-14
@@ -18,6 +18,7 @@ import 'presentation/providers/upload_manager_provider.dart';
|
|||||||
import 'presentation/providers/download_manager_provider.dart';
|
import 'presentation/providers/download_manager_provider.dart';
|
||||||
import 'presentation/providers/user_setting_provider.dart';
|
import 'presentation/providers/user_setting_provider.dart';
|
||||||
import 'presentation/providers/admin_provider.dart';
|
import 'presentation/providers/admin_provider.dart';
|
||||||
|
import 'presentation/providers/quick_access_provider.dart';
|
||||||
import 'presentation/providers/theme_provider.dart';
|
import 'presentation/providers/theme_provider.dart';
|
||||||
import 'services/upload_service.dart';
|
import 'services/upload_service.dart';
|
||||||
import 'services/api_service.dart';
|
import 'services/api_service.dart';
|
||||||
@@ -70,7 +71,8 @@ void main() async {
|
|||||||
// 退出当前新启动的进程
|
// 退出当前新启动的进程
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
final String processName = await singleInstance.getProcessName(pid) ?? "Unknown";
|
final String processName =
|
||||||
|
await singleInstance.getProcessName(pid) ?? "Unknown";
|
||||||
final File? pidFile = await singleInstance.getPidFile(processName);
|
final File? pidFile = await singleInstance.getPidFile(processName);
|
||||||
int port = FlutterSingleInstance.port;
|
int port = FlutterSingleInstance.port;
|
||||||
|
|
||||||
@@ -80,11 +82,15 @@ void main() async {
|
|||||||
final Map<String, dynamic> data = jsonDecode(content);
|
final Map<String, dynamic> data = jsonDecode(content);
|
||||||
port = data['port'] ?? 0;
|
port = data['port'] ?? 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.e("Get FlutterSingleInstance port has error: ${e.toString()}");
|
AppLogger.e(
|
||||||
|
"Get FlutterSingleInstance port has error: ${e.toString()}",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AppLogger.i("processName: $processName \npid: $pid \npidFile: ${pidFile.path.toString()} \nSingleInstance RPC address:port: ${addr.address}:$port");
|
AppLogger.i(
|
||||||
|
"processName: $processName \npid: $pid \npidFile: ${pidFile.path.toString()} \nSingleInstance RPC address:port: ${addr.address}:$port",
|
||||||
|
);
|
||||||
|
|
||||||
FlutterSingleInstance.onFocus = (metadata) async {
|
FlutterSingleInstance.onFocus = (metadata) async {
|
||||||
AppLogger.i("收到唤醒信号: $metadata");
|
AppLogger.i("收到唤醒信号: $metadata");
|
||||||
@@ -134,18 +140,22 @@ class CloudreveApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return
|
return MultiProvider(
|
||||||
MultiProvider(
|
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (_) => ThemeProvider()..init()),
|
ChangeNotifierProvider(create: (_) => ThemeProvider()..init()),
|
||||||
ChangeNotifierProvider(create: (_) => AuthProvider()..init()),
|
ChangeNotifierProvider(create: (_) => AuthProvider()..init()),
|
||||||
ChangeNotifierProvider(create: (_) => FileManagerProvider()),
|
ChangeNotifierProvider(create: (_) => FileManagerProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => NavigationProvider()),
|
ChangeNotifierProvider(create: (_) => NavigationProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => UploadService()),
|
ChangeNotifierProvider(create: (_) => UploadService()),
|
||||||
ChangeNotifierProvider(create: (_) => UploadManagerProvider()..initialize()),
|
ChangeNotifierProvider(
|
||||||
ChangeNotifierProvider(create: (_) => DownloadManagerProvider()..initialize()),
|
create: (_) => UploadManagerProvider()..initialize(),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (_) => DownloadManagerProvider()..initialize(),
|
||||||
|
),
|
||||||
ChangeNotifierProvider(create: (_) => UserSettingProvider()),
|
ChangeNotifierProvider(create: (_) => UserSettingProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => AdminProvider()),
|
ChangeNotifierProvider(create: (_) => AdminProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => QuickAccessProvider()..load()),
|
||||||
],
|
],
|
||||||
child: const AppView(),
|
child: const AppView(),
|
||||||
);
|
);
|
||||||
@@ -179,7 +189,9 @@ class AppView extends StatelessWidget {
|
|||||||
Widget currentWidget = child;
|
Widget currentWidget = child;
|
||||||
if (Platform.isWindows || Platform.isLinux) {
|
if (Platform.isWindows || Platform.isLinux) {
|
||||||
currentWidget = Material(
|
currentWidget = Material(
|
||||||
color: themeProvider.isDark ? Colors.black.withValues(alpha: 0.92) : Colors.white.withValues(alpha: 0.92),
|
color: themeProvider.isDark
|
||||||
|
? Colors.black.withValues(alpha: 0.92)
|
||||||
|
: Colors.white.withValues(alpha: 0.92),
|
||||||
child: ValueListenableBuilder<bool>(
|
child: ValueListenableBuilder<bool>(
|
||||||
valueListenable: videoFullscreenNotifier,
|
valueListenable: videoFullscreenNotifier,
|
||||||
builder: (context, isVideoFullscreen, child) {
|
builder: (context, isVideoFullscreen, child) {
|
||||||
@@ -190,13 +202,9 @@ class AppView extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 32,
|
height: 32,
|
||||||
child: DragToMoveArea(
|
child: DragToMoveArea(child: DesktopTitleBar()),
|
||||||
child: DesktopTitleBar(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: child!,
|
|
||||||
),
|
),
|
||||||
|
Expanded(child: child!),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
class CaptchaWebConfig {
|
||||||
|
final String type;
|
||||||
|
final String displayName;
|
||||||
|
final String? siteKey;
|
||||||
|
final String? instanceUrl;
|
||||||
|
final String? assetServer;
|
||||||
|
|
||||||
|
const CaptchaWebConfig._({
|
||||||
|
required this.type,
|
||||||
|
required this.displayName,
|
||||||
|
this.siteKey,
|
||||||
|
this.instanceUrl,
|
||||||
|
this.assetServer,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CaptchaWebConfig.recaptchaV2({
|
||||||
|
required String siteKey,
|
||||||
|
String displayName = 'reCAPTCHA V2',
|
||||||
|
}) : this._(type: 'recaptcha', displayName: displayName, siteKey: siteKey);
|
||||||
|
|
||||||
|
const CaptchaWebConfig.turnstile({
|
||||||
|
required String siteKey,
|
||||||
|
String displayName = 'Cloudflare Turnstile',
|
||||||
|
}) : this._(type: 'turnstile', displayName: displayName, siteKey: siteKey);
|
||||||
|
|
||||||
|
const CaptchaWebConfig.cap({
|
||||||
|
required String instanceUrl,
|
||||||
|
required String siteKey,
|
||||||
|
String? assetServer,
|
||||||
|
String displayName = 'Cap',
|
||||||
|
}) : this._(
|
||||||
|
type: 'cap',
|
||||||
|
displayName: displayName,
|
||||||
|
instanceUrl: instanceUrl,
|
||||||
|
siteKey: siteKey,
|
||||||
|
assetServer: assetServer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CaptchaChallengePage extends StatefulWidget {
|
||||||
|
final CaptchaWebConfig config;
|
||||||
|
final String baseUrl;
|
||||||
|
|
||||||
|
const CaptchaChallengePage({
|
||||||
|
super.key,
|
||||||
|
required this.config,
|
||||||
|
required this.baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CaptchaChallengePage> createState() => _CaptchaChallengePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||||
|
late final WebViewController _controller;
|
||||||
|
bool _isLoading = true;
|
||||||
|
int _progress = 0;
|
||||||
|
String? _errorMessage;
|
||||||
|
String? _statusText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_controller = WebViewController()
|
||||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..setBackgroundColor(Colors.transparent)
|
||||||
|
..addJavaScriptChannel(
|
||||||
|
'CaptchaBridge',
|
||||||
|
onMessageReceived: (message) {
|
||||||
|
_handleBridgeMessage(message.message);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onProgress: (progress) {
|
||||||
|
if (mounted) setState(() => _progress = progress);
|
||||||
|
},
|
||||||
|
onPageFinished: (_) {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
},
|
||||||
|
onWebResourceError: (error) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = '${error.errorCode}: ${error.description}'
|
||||||
|
.trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_loadCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCaptcha() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
_statusText = null;
|
||||||
|
_progress = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
final html = _buildHtml(widget.config);
|
||||||
|
await _controller.loadHtmlString(html, baseUrl: widget.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleBridgeMessage(String rawMessage) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(rawMessage);
|
||||||
|
if (decoded is! Map) return;
|
||||||
|
|
||||||
|
final type = decoded['type']?.toString();
|
||||||
|
|
||||||
|
if (type == 'success') {
|
||||||
|
final token = decoded['token']?.toString() ?? '';
|
||||||
|
if (token.isNotEmpty && mounted) {
|
||||||
|
Navigator.of(context).pop(token);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == 'progress') {
|
||||||
|
final progress = decoded['progress']?.toString();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_statusText = progress == null ? '正在验证...' : '正在验证... $progress';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == 'error') {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = decoded['message']?.toString() ?? '验证码加载失败';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == 'expired') {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_statusText = '验证码已过期,请重新验证';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 忽略非 JSON 消息。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildHtml(CaptchaWebConfig config) {
|
||||||
|
switch (config.type) {
|
||||||
|
case 'turnstile':
|
||||||
|
return _buildTurnstileHtml(config.siteKey!);
|
||||||
|
case 'recaptcha':
|
||||||
|
return _buildRecaptchaHtml(config.siteKey!);
|
||||||
|
case 'cap':
|
||||||
|
return _buildCapHtml(
|
||||||
|
instanceUrl: config.instanceUrl!,
|
||||||
|
siteKey: config.siteKey!,
|
||||||
|
assetServer: config.assetServer,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return _buildErrorHtml('不支持的验证码类型: ${config.type}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _baseHtml({
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required String script,
|
||||||
|
}) {
|
||||||
|
final safeTitle = const HtmlEscape().convert(title);
|
||||||
|
return '''
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>$safeTitle</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
#widget {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 78px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
cap-widget {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="card">
|
||||||
|
<h1>$safeTitle</h1>
|
||||||
|
<p>完成验证后会自动返回登录页。</p>
|
||||||
|
$body
|
||||||
|
<div id="status" class="status">正在加载验证码...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function sendBridge(payload) {
|
||||||
|
try {
|
||||||
|
CaptchaBridge.postMessage(JSON.stringify(payload));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
function markStatus(text, isError) {
|
||||||
|
var el = document.getElementById('status');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || '';
|
||||||
|
el.className = isError ? 'status error' : 'status';
|
||||||
|
}
|
||||||
|
function solved(token) {
|
||||||
|
markStatus('验证完成,正在返回...', false);
|
||||||
|
sendBridge({ type: 'success', token: token });
|
||||||
|
}
|
||||||
|
function failed(message) {
|
||||||
|
markStatus(message || '验证码加载失败', true);
|
||||||
|
sendBridge({ type: 'error', message: message || '验证码加载失败' });
|
||||||
|
}
|
||||||
|
function expired() {
|
||||||
|
markStatus('验证码已过期,请重新验证', true);
|
||||||
|
sendBridge({ type: 'expired' });
|
||||||
|
}
|
||||||
|
$script
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildTurnstileHtml(String siteKey) {
|
||||||
|
return _baseHtml(
|
||||||
|
title: 'Cloudflare Turnstile',
|
||||||
|
body: '<div id="widget"></div>',
|
||||||
|
script:
|
||||||
|
'''
|
||||||
|
function onTurnstileLoad() {
|
||||||
|
try {
|
||||||
|
turnstile.render('#widget', {
|
||||||
|
sitekey: '${_js(siteKey)}',
|
||||||
|
callback: function(token) { solved(token); },
|
||||||
|
'error-callback': function() { failed('Turnstile 验证失败,请重试'); },
|
||||||
|
'expired-callback': function() { expired(); }
|
||||||
|
});
|
||||||
|
markStatus('请完成人机验证', false);
|
||||||
|
} catch (e) {
|
||||||
|
failed(e && e.message ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
|
||||||
|
<script>
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildRecaptchaHtml(String siteKey) {
|
||||||
|
return _baseHtml(
|
||||||
|
title: 'reCAPTCHA V2',
|
||||||
|
body: '<div id="widget"></div>',
|
||||||
|
script:
|
||||||
|
'''
|
||||||
|
function onRecaptchaLoad() {
|
||||||
|
try {
|
||||||
|
grecaptcha.render('widget', {
|
||||||
|
sitekey: '${_js(siteKey)}',
|
||||||
|
callback: function(token) { solved(token); },
|
||||||
|
'expired-callback': function() { expired(); },
|
||||||
|
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
|
||||||
|
});
|
||||||
|
markStatus('请完成人机验证', false);
|
||||||
|
} catch (e) {
|
||||||
|
failed(e && e.message ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
|
||||||
|
<script>
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildCapHtml({
|
||||||
|
required String instanceUrl,
|
||||||
|
required String siteKey,
|
||||||
|
String? assetServer,
|
||||||
|
}) {
|
||||||
|
final endpoint = _capEndpoint(instanceUrl, siteKey);
|
||||||
|
final scriptUrl = _capWidgetScript(assetServer);
|
||||||
|
final safeEndpoint = const HtmlEscape().convert(endpoint);
|
||||||
|
final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
|
||||||
|
|
||||||
|
return _baseHtml(
|
||||||
|
title: 'Cap',
|
||||||
|
body:
|
||||||
|
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
|
||||||
|
script:
|
||||||
|
'''
|
||||||
|
window.CAP_DISABLE_HAPTICS = true;
|
||||||
|
const cap = document.getElementById('cap');
|
||||||
|
if (cap) {
|
||||||
|
cap.addEventListener('solve', function(e) {
|
||||||
|
solved(e.detail && e.detail.token ? e.detail.token : '');
|
||||||
|
});
|
||||||
|
cap.addEventListener('progress', function(e) {
|
||||||
|
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
|
||||||
|
markStatus('正在验证... ' + progress, false);
|
||||||
|
sendBridge({ type: 'progress', progress: progress });
|
||||||
|
});
|
||||||
|
cap.addEventListener('error', function(e) {
|
||||||
|
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
|
||||||
|
failed(message);
|
||||||
|
});
|
||||||
|
markStatus('请完成人机验证', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module" src="$safeScriptUrl"></script>
|
||||||
|
<script>
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildErrorHtml(String message) {
|
||||||
|
return _baseHtml(
|
||||||
|
title: '验证码错误',
|
||||||
|
body:
|
||||||
|
'<div id="widget" class="error">${const HtmlEscape().convert(message)}</div>',
|
||||||
|
script: 'failed(${jsonEncode(message)});',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capEndpoint(String instanceUrl, String siteKey) {
|
||||||
|
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
||||||
|
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
||||||
|
return '$trimmedInstance/$trimmedSiteKey/';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capWidgetScript(String? assetServer) {
|
||||||
|
final asset = assetServer?.trim();
|
||||||
|
if (asset != null && asset.isNotEmpty) {
|
||||||
|
if (asset.startsWith('http://') || asset.startsWith('https://')) {
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.toLowerCase() == 'jsdelivr') {
|
||||||
|
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.toLowerCase() == 'unpkg') {
|
||||||
|
return 'https://unpkg.com/cap-widget';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _js(String input) {
|
||||||
|
return input
|
||||||
|
.replaceAll(r'\\', r'\\\\')
|
||||||
|
.replaceAll("'", r"\\'")
|
||||||
|
.replaceAll('\\n', r'\\n')
|
||||||
|
.replaceAll('\\r', r'\\r');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final title = widget.config.displayName;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(title),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: '刷新验证码',
|
||||||
|
onPressed: _loadCaptcha,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(3),
|
||||||
|
child: _progress > 0 && _progress < 100
|
||||||
|
? LinearProgressIndicator(value: _progress / 100)
|
||||||
|
: const SizedBox(height: 3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
WebViewWidget(controller: _controller),
|
||||||
|
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||||
|
if (_errorMessage != null)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.all(12),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_statusText != null)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(12),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.92),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(_statusText!, textAlign: TextAlign.center),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
|
import 'package:cloudreve4_flutter/data/models/login_config_model.dart';
|
||||||
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
|
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
|
||||||
|
import 'package:cloudreve4_flutter/services/captcha_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../../../core/validators/string_validator.dart';
|
import '../../../core/validators/string_validator.dart';
|
||||||
import '../../../services/auth_service.dart';
|
import '../../../services/auth_service.dart';
|
||||||
|
import '../../../services/server_service.dart';
|
||||||
import '../../widgets/toast_helper.dart';
|
import '../../widgets/toast_helper.dart';
|
||||||
|
|
||||||
class ForgotPasswordPage extends StatefulWidget {
|
class ForgotPasswordPage extends StatefulWidget {
|
||||||
const ForgotPasswordPage({super.key});
|
final LoginConfigModel loginConfig;
|
||||||
|
|
||||||
|
const ForgotPasswordPage({
|
||||||
|
super.key,
|
||||||
|
this.loginConfig = const LoginConfigModel(),
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
|
State<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
|
||||||
@@ -18,6 +26,21 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.loginConfig.forgetCaptcha) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final server = ServerService.instance.currentServer;
|
||||||
|
if (server != null) {
|
||||||
|
CaptchaService.instance.loadCaptcha(server.baseUrl).then((_) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
@@ -27,14 +50,26 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
|||||||
Future<void> _sendResetEmail() async {
|
Future<void> _sendResetEmail() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final captcha = CaptchaService.instance;
|
||||||
|
if (widget.loginConfig.forgetCaptcha && !captcha.isWebCaptchaVerified) {
|
||||||
|
ToastHelper.failure('请先完成人机验证');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final captchaParams = widget.loginConfig.forgetCaptcha
|
||||||
|
? captcha.getCaptchaParams()
|
||||||
|
: <String, String>{};
|
||||||
|
|
||||||
await AuthService.instance.sendResetPasswordEmail(
|
await AuthService.instance.sendResetPasswordEmail(
|
||||||
email: _emailController.text.trim(),
|
email: _emailController.text.trim(),
|
||||||
|
captcha: captchaParams['captcha'],
|
||||||
|
ticket: captchaParams['ticket'],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -43,6 +78,10 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
if (widget.loginConfig.forgetCaptcha) {
|
||||||
|
await captcha.refreshCaptcha();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
setState(() => _errorMessage = e.toString());
|
setState(() => _errorMessage = e.toString());
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -53,6 +92,7 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final captcha = CaptchaService.instance;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('忘记密码')),
|
appBar: AppBar(title: const Text('忘记密码')),
|
||||||
@@ -91,10 +131,17 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
|||||||
prefixIcon: Icon(LucideIcons.mail),
|
prefixIcon: Icon(LucideIcons.mail),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.loginConfig.forgetCaptcha) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
captcha.buildCaptchaInput(context),
|
||||||
|
],
|
||||||
if (_errorMessage != null) ...[
|
if (_errorMessage != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.errorContainer,
|
color: theme.colorScheme.errorContainer,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -121,7 +168,9 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Text('发送重置邮件'),
|
: const Text('发送重置邮件'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import 'package:lucide_icons/lucide_icons.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../../core/exceptions/app_exception.dart';
|
import '../../../core/exceptions/app_exception.dart';
|
||||||
import '../../../core/validators/string_validator.dart';
|
import '../../../core/validators/string_validator.dart';
|
||||||
|
import '../../../data/models/login_config_model.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
import '../../../services/api_service.dart';
|
||||||
|
import '../../../services/auth_service.dart';
|
||||||
|
import '../../../services/captcha_service.dart';
|
||||||
import '../../../services/server_service.dart';
|
import '../../../services/server_service.dart';
|
||||||
import '../../../router/app_router.dart';
|
import '../../../router/app_router.dart';
|
||||||
import 'forgot_password_page.dart';
|
import 'forgot_password_page.dart';
|
||||||
@@ -27,11 +31,15 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
bool _rememberMe = false;
|
bool _rememberMe = false;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
LoginConfigModel _loginConfig = const LoginConfigModel();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadRememberedInfo();
|
_loadRememberedInfo();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_loadLoginConfig();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -57,21 +65,57 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadLoginConfig() async {
|
||||||
|
final server = ServerService.instance.currentServer;
|
||||||
|
if (server == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ApiService.instance.setBaseUrl(server.baseUrl);
|
||||||
|
final config = await AuthService.instance.getLoginConfig().timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loginConfig = config);
|
||||||
|
|
||||||
|
if (config.loginCaptcha) {
|
||||||
|
await CaptchaService.instance.loadCaptcha(server.baseUrl);
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
} else {
|
||||||
|
CaptchaService.instance.clearCaptcha();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _login() async {
|
Future<void> _login() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final captcha = CaptchaService.instance;
|
||||||
|
if (_loginConfig.loginCaptcha && !captcha.isWebCaptchaVerified) {
|
||||||
|
ToastHelper.failure('请先完成人机验证');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final success = await authProvider.passwordLogin(
|
final captchaParams = _loginConfig.loginCaptcha
|
||||||
|
? captcha.getCaptchaParams()
|
||||||
|
: <String, String>{};
|
||||||
|
|
||||||
|
final success = await authProvider
|
||||||
|
.passwordLogin(
|
||||||
email: _emailController.text.trim(),
|
email: _emailController.text.trim(),
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
rememberMe: _rememberMe,
|
rememberMe: _rememberMe,
|
||||||
).timeout(
|
captcha: captchaParams['captcha'],
|
||||||
const Duration(seconds: 5),
|
ticket: captchaParams['ticket'],
|
||||||
|
)
|
||||||
|
.timeout(
|
||||||
|
const Duration(seconds: 15),
|
||||||
onTimeout: () => throw Exception('请求超时'),
|
onTimeout: () => throw Exception('请求超时'),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -83,6 +127,11 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
if (mounted) navigator.pushReplacementNamed(RouteNames.home);
|
if (mounted) navigator.pushReplacementNamed(RouteNames.home);
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
|
if (_loginConfig.loginCaptcha) {
|
||||||
|
await captcha.refreshCaptcha();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
final errorMessage = authProvider.errorMessage;
|
final errorMessage = authProvider.errorMessage;
|
||||||
if (errorMessage != null && errorMessage.isNotEmpty) {
|
if (errorMessage != null && errorMessage.isNotEmpty) {
|
||||||
final errorMsg = _parseErrorMessage(errorMessage);
|
final errorMsg = _parseErrorMessage(errorMessage);
|
||||||
@@ -99,6 +148,11 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
|
if (_loginConfig.loginCaptcha) {
|
||||||
|
await captcha.refreshCaptcha();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
final errorMsg = _parseErrorMessage(e.toString());
|
final errorMsg = _parseErrorMessage(e.toString());
|
||||||
ToastHelper.failure(errorMsg);
|
ToastHelper.failure(errorMsg);
|
||||||
}
|
}
|
||||||
@@ -153,6 +207,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final captcha = CaptchaService.instance;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@@ -168,10 +223,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
Center(child: _buildLogo()),
|
Center(child: _buildLogo()),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text('公云存储', style: theme.textTheme.headlineMedium),
|
||||||
'公云存储',
|
|
||||||
style: theme.textTheme.headlineMedium,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Card(
|
Card(
|
||||||
@@ -196,7 +248,8 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
hintText: '请输入邮箱地址',
|
hintText: '请输入邮箱地址',
|
||||||
prefixIcon: Icon(LucideIcons.mail),
|
prefixIcon: Icon(LucideIcons.mail),
|
||||||
),
|
),
|
||||||
onFieldSubmitted: (_) => _focusNode.requestFocus(),
|
onFieldSubmitted: (_) =>
|
||||||
|
_focusNode.requestFocus(),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -219,18 +272,27 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() => _obscurePassword = !_obscurePassword);
|
setState(
|
||||||
|
() =>
|
||||||
|
_obscurePassword = !_obscurePassword,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onFieldSubmitted: (_) => _login(),
|
onFieldSubmitted: (_) => _login(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
if (_loginConfig.loginCaptcha) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
captcha.buildCaptchaInput(context),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// 记住我
|
// 记住我
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => setState(() => _rememberMe = !_rememberMe),
|
onTap: () =>
|
||||||
|
setState(() => _rememberMe = !_rememberMe),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -240,7 +302,9 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
height: 24,
|
height: 24,
|
||||||
child: Checkbox(
|
child: Checkbox(
|
||||||
value: _rememberMe,
|
value: _rememberMe,
|
||||||
onChanged: (v) => setState(() => _rememberMe = v ?? false),
|
onChanged: (v) => setState(
|
||||||
|
() => _rememberMe = v ?? false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -259,7 +323,10 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const ForgotPasswordPage(),
|
builder: (context) =>
|
||||||
|
ForgotPasswordPage(
|
||||||
|
loginConfig: _loginConfig,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -269,7 +336,9 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const RegisterPage(),
|
builder: (context) => RegisterPage(
|
||||||
|
loginConfig: _loginConfig,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -293,7 +362,9 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Text('登录'),
|
: const Text('登录'),
|
||||||
),
|
),
|
||||||
@@ -356,17 +427,17 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
|
|||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
_shakeAnimation = TweenSequence<double>([
|
_shakeAnimation =
|
||||||
|
TweenSequence<double>([
|
||||||
TweenSequenceItem(tween: Tween(begin: 0, end: 10), weight: 1),
|
TweenSequenceItem(tween: Tween(begin: 0, end: 10), weight: 1),
|
||||||
TweenSequenceItem(tween: Tween(begin: 10, end: -10), weight: 1),
|
TweenSequenceItem(tween: Tween(begin: 10, end: -10), weight: 1),
|
||||||
TweenSequenceItem(tween: Tween(begin: -10, end: 8), weight: 1),
|
TweenSequenceItem(tween: Tween(begin: -10, end: 8), weight: 1),
|
||||||
TweenSequenceItem(tween: Tween(begin: 8, end: -8), weight: 1),
|
TweenSequenceItem(tween: Tween(begin: 8, end: -8), weight: 1),
|
||||||
TweenSequenceItem(tween: Tween(begin: -8, end: 4), weight: 1),
|
TweenSequenceItem(tween: Tween(begin: -8, end: 4), weight: 1),
|
||||||
TweenSequenceItem(tween: Tween(begin: 4, end: 0), weight: 1),
|
TweenSequenceItem(tween: Tween(begin: 4, end: 0), weight: 1),
|
||||||
]).animate(CurvedAnimation(
|
]).animate(
|
||||||
parent: _shakeController,
|
CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut),
|
||||||
curve: Curves.easeInOut,
|
);
|
||||||
));
|
|
||||||
_controller.addListener(_onTextChanged);
|
_controller.addListener(_onTextChanged);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
@@ -396,13 +467,15 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
|
|||||||
|
|
||||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||||
try {
|
try {
|
||||||
final success = await authProvider.twoFactorLogin(
|
final success = await authProvider
|
||||||
|
.twoFactorLogin(
|
||||||
otp: code,
|
otp: code,
|
||||||
sessionId: widget.sessionId,
|
sessionId: widget.sessionId,
|
||||||
email: widget.email,
|
email: widget.email,
|
||||||
password: widget.password,
|
password: widget.password,
|
||||||
rememberMe: widget.rememberMe,
|
rememberMe: widget.rememberMe,
|
||||||
).timeout(
|
)
|
||||||
|
.timeout(
|
||||||
const Duration(seconds: 5),
|
const Duration(seconds: 5),
|
||||||
onTimeout: () => throw Exception('请求超时'),
|
onTimeout: () => throw Exception('请求超时'),
|
||||||
);
|
);
|
||||||
@@ -493,9 +566,7 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
inputFormatters: [
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
|
||||||
],
|
|
||||||
onSubmitted: (_) => _submit(),
|
onSubmitted: (_) => _submit(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -503,7 +574,9 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
|
onPressed: _isSubmitting
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(false),
|
||||||
child: const Text('取消'),
|
child: const Text('取消'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
import 'package:cloudreve4_flutter/data/models/login_config_model.dart';
|
||||||
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
|
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
|
||||||
|
import 'package:cloudreve4_flutter/services/captcha_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../../../core/validators/string_validator.dart';
|
import '../../../core/validators/string_validator.dart';
|
||||||
import '../../../services/auth_service.dart';
|
import '../../../services/auth_service.dart';
|
||||||
|
import '../../../services/server_service.dart';
|
||||||
import '../../widgets/toast_helper.dart';
|
import '../../widgets/toast_helper.dart';
|
||||||
|
|
||||||
class RegisterPage extends StatefulWidget {
|
class RegisterPage extends StatefulWidget {
|
||||||
const RegisterPage({super.key});
|
final LoginConfigModel loginConfig;
|
||||||
|
|
||||||
|
const RegisterPage({super.key, this.loginConfig = const LoginConfigModel()});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RegisterPage> createState() => _RegisterPageState();
|
State<RegisterPage> createState() => _RegisterPageState();
|
||||||
@@ -22,6 +27,21 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.loginConfig.regCaptcha) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final server = ServerService.instance.currentServer;
|
||||||
|
if (server != null) {
|
||||||
|
CaptchaService.instance.loadCaptcha(server.baseUrl).then((_) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
@@ -38,16 +58,28 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final captcha = CaptchaService.instance;
|
||||||
|
if (widget.loginConfig.regCaptcha && !captcha.isWebCaptchaVerified) {
|
||||||
|
ToastHelper.failure('请先完成人机验证');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final captchaParams = widget.loginConfig.regCaptcha
|
||||||
|
? captcha.getCaptchaParams()
|
||||||
|
: <String, String>{};
|
||||||
|
|
||||||
final response = await AuthService.instance.signUp(
|
final response = await AuthService.instance.signUp(
|
||||||
email: _emailController.text.trim(),
|
email: _emailController.text.trim(),
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
|
captcha: captchaParams['captcha'],
|
||||||
|
ticket: captchaParams['ticket'],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -58,6 +90,10 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
if (widget.loginConfig.regCaptcha) {
|
||||||
|
await captcha.refreshCaptcha();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
setState(() => _errorMessage = e.toString());
|
setState(() => _errorMessage = e.toString());
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -68,6 +104,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final captcha = CaptchaService.instance;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('注册')),
|
appBar: AppBar(title: const Text('注册')),
|
||||||
@@ -109,10 +146,14 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
prefixIcon: const Icon(LucideIcons.lock),
|
prefixIcon: const Icon(LucideIcons.lock),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_obscurePassword ? LucideIcons.eye : LucideIcons.eyeOff,
|
_obscurePassword
|
||||||
|
? LucideIcons.eye
|
||||||
|
: LucideIcons.eyeOff,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
onPressed: () => setState(
|
||||||
|
() => _obscurePassword = !_obscurePassword,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -122,7 +163,9 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
obscureText: _obscureConfirmPassword,
|
obscureText: _obscureConfirmPassword,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) return '请确认密码';
|
if (value == null || value.isEmpty) return '请确认密码';
|
||||||
if (value != _passwordController.text) return '两次输入的密码不一致';
|
if (value != _passwordController.text) {
|
||||||
|
return '两次输入的密码不一致';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -131,17 +174,29 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
prefixIcon: const Icon(LucideIcons.lock),
|
prefixIcon: const Icon(LucideIcons.lock),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_obscureConfirmPassword ? LucideIcons.eye : LucideIcons.eyeOff,
|
_obscureConfirmPassword
|
||||||
|
? LucideIcons.eye
|
||||||
|
: LucideIcons.eyeOff,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
|
onPressed: () => setState(
|
||||||
|
() => _obscureConfirmPassword =
|
||||||
|
!_obscureConfirmPassword,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (widget.loginConfig.regCaptcha) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
captcha.buildCaptchaInput(context),
|
||||||
|
],
|
||||||
if (_errorMessage != null) ...[
|
if (_errorMessage != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.errorContainer,
|
color: theme.colorScheme.errorContainer,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -168,7 +223,9 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Text('注册'),
|
: const Text('注册'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
|
||||||
|
import '../../../core/utils/date_utils.dart' as date_utils;
|
||||||
|
import '../../../core/utils/file_type_utils.dart';
|
||||||
|
import '../../../data/models/file_model.dart';
|
||||||
|
import '../../../router/app_router.dart';
|
||||||
|
import '../../../services/file_service.dart';
|
||||||
|
import '../../widgets/thumbnail_image.dart';
|
||||||
|
import '../../widgets/toast_helper.dart';
|
||||||
|
|
||||||
|
/// 快捷入口分类页面参数。
|
||||||
|
///
|
||||||
|
/// category 对应 Cloudreve V4 文件 URI 查询条件:
|
||||||
|
/// image / video / audio / document。
|
||||||
|
class CategoryFilesPageArgs {
|
||||||
|
final String category;
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const CategoryFilesPageArgs({
|
||||||
|
required this.category,
|
||||||
|
required this.title,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CategoryFilesPageArgs.fromMap(Map<String, dynamic> map) {
|
||||||
|
return CategoryFilesPageArgs(
|
||||||
|
category: map['category'] as String,
|
||||||
|
title: map['title'] as String,
|
||||||
|
icon: map['icon'] as IconData? ?? LucideIcons.file,
|
||||||
|
color: map['color'] as Color? ?? const Color(0xFF64748B),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分类文件瀑布流页面。
|
||||||
|
///
|
||||||
|
/// 使用 Cloudreve V4 的文件 URI 查询:
|
||||||
|
/// cloudreve://my?category=image
|
||||||
|
/// cloudreve://my?category=video
|
||||||
|
/// cloudreve://my?category=audio
|
||||||
|
/// cloudreve://my?category=document
|
||||||
|
class CategoryFilesPage extends StatefulWidget {
|
||||||
|
final CategoryFilesPageArgs args;
|
||||||
|
|
||||||
|
const CategoryFilesPage({super.key, required this.args});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CategoryFilesPage> createState() => _CategoryFilesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||||
|
final _fileService = FileService();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
|
||||||
|
final List<FileModel> _files = [];
|
||||||
|
String? _nextPageToken;
|
||||||
|
String? _contextHint;
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadFiles(refresh: true);
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant CategoryFilesPage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.args.category != widget.args.category) {
|
||||||
|
_loadFiles(refresh: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_nextPageToken == null || _isLoading || _isLoadingMore) return;
|
||||||
|
final position = _scrollController.position;
|
||||||
|
if (position.pixels >= position.maxScrollExtent - 320) {
|
||||||
|
_loadFiles(refresh: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFiles({required bool refresh}) async {
|
||||||
|
if (refresh) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_nextPageToken = null;
|
||||||
|
_contextHint = null;
|
||||||
|
_files.clear();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingMore = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _fileService.listFilesByCategory(
|
||||||
|
category: widget.args.category,
|
||||||
|
pageSize: 100,
|
||||||
|
nextPageToken: refresh ? null : _nextPageToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
final filesData = response['files'] as List<dynamic>? ?? const [];
|
||||||
|
final pagination =
|
||||||
|
response['pagination'] as Map<String, dynamic>? ?? const {};
|
||||||
|
final newFiles = filesData
|
||||||
|
.map((item) => FileModel.fromJson(item as Map<String, dynamic>))
|
||||||
|
.where((file) => file.isFile)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
if (refresh) {
|
||||||
|
_files
|
||||||
|
..clear()
|
||||||
|
..addAll(newFiles);
|
||||||
|
} else {
|
||||||
|
final existingIds = _files.map((e) => e.id).toSet();
|
||||||
|
_files.addAll(
|
||||||
|
newFiles.where((file) => !existingIds.contains(file.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextPageToken = pagination['next_token'] as String?;
|
||||||
|
_contextHint = response['context_hint'] as String?;
|
||||||
|
_isLoading = false;
|
||||||
|
_isLoadingMore = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
_isLoadingMore = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() => _loadFiles(refresh: true);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final args = widget.args;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(args.title),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.refreshCw),
|
||||||
|
tooltip: '刷新',
|
||||||
|
onPressed: _refresh,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_errorMessage != null && _files.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
|
||||||
|
Icon(
|
||||||
|
LucideIcons.alertTriangle,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Theme.of(context).hintColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _refresh,
|
||||||
|
icon: const Icon(LucideIcons.refreshCw, size: 16),
|
||||||
|
label: const Text('重试'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_files.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
|
||||||
|
Icon(widget.args.icon, size: 52, color: widget.args.color),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'没有找到${widget.args.title}',
|
||||||
|
style: TextStyle(color: Theme.of(context).hintColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
final columnCount = _columnCountForWidth(width);
|
||||||
|
final spacing = width >= 720 ? 14.0 : 10.0;
|
||||||
|
final horizontalPadding = width >= 720 ? 16.0 : 10.0;
|
||||||
|
final columnWidth =
|
||||||
|
(width - horizontalPadding * 2 - spacing * (columnCount - 1)) /
|
||||||
|
columnCount;
|
||||||
|
|
||||||
|
final columns = List.generate(columnCount, (_) => <FileModel>[]);
|
||||||
|
final heights = List.generate(columnCount, (_) => 0.0);
|
||||||
|
|
||||||
|
for (final file in _files) {
|
||||||
|
final targetIndex = _indexOfMin(heights);
|
||||||
|
columns[targetIndex].add(file);
|
||||||
|
heights[targetIndex] +=
|
||||||
|
_estimatedTileHeight(file, columnWidth) + spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
controller: _scrollController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
horizontalPadding,
|
||||||
|
10,
|
||||||
|
horizontalPadding,
|
||||||
|
24,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
_buildSummaryHeader(context),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < columnCount; i++) ...[
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (final file in columns[i])
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: spacing),
|
||||||
|
child: _CategoryFileTile(
|
||||||
|
file: file,
|
||||||
|
contextHint: _contextHint,
|
||||||
|
category: widget.args.category,
|
||||||
|
accentColor: widget.args.color,
|
||||||
|
onTap: () => _openFile(context, file),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (i != columnCount - 1) SizedBox(width: spacing),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_isLoadingMore) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
] else if (_nextPageToken != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _loadFiles(refresh: false),
|
||||||
|
icon: const Icon(LucideIcons.chevronsDown, size: 16),
|
||||||
|
label: const Text('加载更多'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryHeader(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final color = widget.args.color;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(
|
||||||
|
alpha: theme.brightness == Brightness.dark ? 0.14 : 0.10,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.20)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(widget.args.icon, color: color, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
widget.args.title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${_files.length} 项',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _columnCountForWidth(double width) {
|
||||||
|
if (width < 460) return 2;
|
||||||
|
if (width < 720) return 3;
|
||||||
|
if (width < 1100) return 4;
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _indexOfMin(List<double> values) {
|
||||||
|
var index = 0;
|
||||||
|
var minValue = values[0];
|
||||||
|
|
||||||
|
for (var i = 1; i < values.length; i++) {
|
||||||
|
if (values[i] < minValue) {
|
||||||
|
minValue = values[i];
|
||||||
|
index = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _estimatedTileHeight(FileModel file, double width) {
|
||||||
|
final ext = FileTypeUtils.getExtension(file.name);
|
||||||
|
if (widget.args.category == 'audio') return 112;
|
||||||
|
if (widget.args.category == 'document') return 124;
|
||||||
|
if (ext == 'psd' || ext == 'psb') return width * 1.18 + 54;
|
||||||
|
|
||||||
|
// 用文件名 hash 做轻微错落,避免完全像普通网格。
|
||||||
|
final variance = (file.name.hashCode.abs() % 46).toDouble();
|
||||||
|
return width * 0.92 + 54 + variance;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openFile(BuildContext context, FileModel file) {
|
||||||
|
if (FileTypeUtils.isImage(file.name)) {
|
||||||
|
Navigator.of(context).pushNamed(RouteNames.imagePreview, arguments: file);
|
||||||
|
} else if (FileTypeUtils.isPdf(file.name)) {
|
||||||
|
Navigator.of(context).pushNamed(RouteNames.pdfPreview, arguments: file);
|
||||||
|
} else if (FileTypeUtils.isVideo(file.name)) {
|
||||||
|
Navigator.of(context).pushNamed(RouteNames.videoPreview, arguments: file);
|
||||||
|
} else if (FileTypeUtils.isAudio(file.name)) {
|
||||||
|
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
|
||||||
|
} else if (FileTypeUtils.isMarkdown(file.name)) {
|
||||||
|
Navigator.of(
|
||||||
|
context,
|
||||||
|
).pushNamed(RouteNames.markdownPreview, arguments: file);
|
||||||
|
} else if (FileTypeUtils.isTextCode(file.name)) {
|
||||||
|
Navigator.of(
|
||||||
|
context,
|
||||||
|
).pushNamed(RouteNames.documentPreview, arguments: file);
|
||||||
|
} else {
|
||||||
|
ToastHelper.info(
|
||||||
|
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryFileTile extends StatelessWidget {
|
||||||
|
final FileModel file;
|
||||||
|
final String? contextHint;
|
||||||
|
final String category;
|
||||||
|
final Color accentColor;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _CategoryFileTile({
|
||||||
|
required this.file,
|
||||||
|
required this.contextHint,
|
||||||
|
required this.category,
|
||||||
|
required this.accentColor,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isMedia = category == 'image' || category == 'video';
|
||||||
|
final isAudio = category == 'audio';
|
||||||
|
final isDocument = category == 'document';
|
||||||
|
|
||||||
|
final ext = FileTypeUtils.getExtension(file.name);
|
||||||
|
final isPsd = ext == 'psd' || ext == 'psb';
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.dividerColor.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: isMedia || isPsd ? 1 : 1.45,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
ThumbnailImage(
|
||||||
|
file: file,
|
||||||
|
contextHint: contextHint,
|
||||||
|
borderRadius: 0,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 7,
|
||||||
|
left: 7,
|
||||||
|
child: _TypeBadge(
|
||||||
|
icon: _badgeIcon(),
|
||||||
|
label: _badgeLabel(ext),
|
||||||
|
color: accentColor,
|
||||||
|
compact: isMedia,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (category == 'video')
|
||||||
|
const Center(child: _PlayOverlay()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
file.name,
|
||||||
|
maxLines: isAudio || isDocument ? 2 : 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'${FileTypeUtils.getFileTypeDescription(file.name)} · ${date_utils.DateUtils.formatFileSize(file.size)}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.hintColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _badgeIcon() {
|
||||||
|
switch (category) {
|
||||||
|
case 'image':
|
||||||
|
return LucideIcons.image;
|
||||||
|
case 'video':
|
||||||
|
return LucideIcons.video;
|
||||||
|
case 'audio':
|
||||||
|
return LucideIcons.music;
|
||||||
|
case 'document':
|
||||||
|
return LucideIcons.fileText;
|
||||||
|
default:
|
||||||
|
return LucideIcons.file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _badgeLabel(String ext) {
|
||||||
|
if (ext == 'psd' || ext == 'psb') return ext.toUpperCase();
|
||||||
|
switch (category) {
|
||||||
|
case 'image':
|
||||||
|
return '图片';
|
||||||
|
case 'video':
|
||||||
|
return '视频';
|
||||||
|
case 'audio':
|
||||||
|
return '音乐';
|
||||||
|
case 'document':
|
||||||
|
return '文档';
|
||||||
|
default:
|
||||||
|
return '文件';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypeBadge extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
const _TypeBadge({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
this.compact = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.92),
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 7, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white, size: 12),
|
||||||
|
if (!compact) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayOverlay extends StatelessWidget {
|
||||||
|
const _PlayOverlay();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.35),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(10),
|
||||||
|
child: Icon(LucideIcons.play, color: Colors.white, size: 22),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,130 +1,34 @@
|
|||||||
import 'package:cloudreve4_flutter/core/constants/quick_access_defaults.dart';
|
|
||||||
import 'package:cloudreve4_flutter/main.dart' show routeObserver;
|
|
||||||
import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart';
|
|
||||||
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
|
|
||||||
import 'package:cloudreve4_flutter/services/storage_service.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class QuickAccessGrid extends StatefulWidget {
|
import '../../../../core/constants/quick_access_defaults.dart';
|
||||||
|
import '../../../../router/app_router.dart';
|
||||||
|
import '../../../providers/file_manager_provider.dart';
|
||||||
|
import '../../../providers/navigation_provider.dart';
|
||||||
|
import '../../../providers/quick_access_provider.dart';
|
||||||
|
import '../../files/category_files_page.dart';
|
||||||
|
|
||||||
|
/// 首页快捷入口。
|
||||||
|
///
|
||||||
|
/// 默认四个入口不再跳转到固定文件夹,而是调用 Cloudreve 分类搜索:
|
||||||
|
/// 图片 / 视频 / 文档 / 音乐。
|
||||||
|
/// 用户自定义的目录快捷入口会跳转到文件管理器对应路径。
|
||||||
|
class QuickAccessGrid extends StatelessWidget {
|
||||||
final bool fillHeight;
|
final bool fillHeight;
|
||||||
|
|
||||||
const QuickAccessGrid({super.key, this.fillHeight = false});
|
const QuickAccessGrid({super.key, this.fillHeight = false});
|
||||||
|
|
||||||
@override
|
|
||||||
State<QuickAccessGrid> createState() => _QuickAccessGridState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _QuickAccessGridState extends State<QuickAccessGrid> with RouteAware {
|
|
||||||
List<QuickAccessConfig> _items = [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadShortcuts();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didPopNext() {
|
|
||||||
_loadShortcuts();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
routeObserver.unsubscribe(this);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadShortcuts() async {
|
|
||||||
// 先尝试 v2 格式
|
|
||||||
var saved = await StorageService.instance.getString(QuickAccessConfig.storageKey);
|
|
||||||
if (saved != null && saved.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
if (mounted) setState(() => _items = QuickAccessConfig.parseSaved(saved));
|
|
||||||
return;
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
// 迁移 v1 格式
|
|
||||||
final v1 = await StorageService.instance.getString('quick_access_shortcuts');
|
|
||||||
if (v1 != null && v1.isNotEmpty) {
|
|
||||||
final migrated = QuickAccessConfig.migrateV1(v1);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _items = migrated);
|
|
||||||
await StorageService.instance.setString(
|
|
||||||
QuickAccessConfig.storageKey,
|
|
||||||
QuickAccessConfig.serialize(migrated),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mounted) setState(() => _items = List.from(QuickAccessConfig.defaults));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _navigateTo(String path) {
|
|
||||||
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
|
|
||||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
|
||||||
navProvider.setIndex(1);
|
|
||||||
fileManager.enterFolder(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _editShortcut(int index) async {
|
|
||||||
final item = _items[index];
|
|
||||||
final labelController = TextEditingController(text: item.label);
|
|
||||||
final pathController = TextEditingController(text: item.path);
|
|
||||||
|
|
||||||
final result = await showDialog<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: Text('编辑 "${item.label}"'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
TextField(
|
|
||||||
controller: labelController,
|
|
||||||
decoration: const InputDecoration(labelText: '名称'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: pathController,
|
|
||||||
decoration: const InputDecoration(labelText: '目录路径', hintText: '例如: /Images'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
|
|
||||||
FilledButton(onPressed: () => Navigator.of(ctx).pop(pathController.text), child: const Text('确定')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result != null && result.isNotEmpty) {
|
|
||||||
setState(() {
|
|
||||||
_items[index] = item.copyWith(path: result);
|
|
||||||
});
|
|
||||||
await _save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _save() async {
|
|
||||||
await StorageService.instance.setString(
|
|
||||||
QuickAccessConfig.storageKey,
|
|
||||||
QuickAccessConfig.serialize(_items),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final items = context.select<QuickAccessProvider, List<QuickAccessConfig>>(
|
||||||
|
(p) => p.items,
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: widget.fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 4, bottom: 14),
|
padding: const EdgeInsets.only(left: 4, bottom: 14),
|
||||||
@@ -132,17 +36,22 @@ class _QuickAccessGridState extends State<QuickAccessGrid> with RouteAware {
|
|||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
|
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('快捷入口', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
|
Text(
|
||||||
|
'快捷入口',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
..._buildRows(),
|
..._buildRows(context, items),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildRows() {
|
List<Widget> _buildRows(BuildContext context, List<QuickAccessConfig> items) {
|
||||||
final total = _items.length;
|
final total = items.length;
|
||||||
if (total == 0) return [];
|
if (total == 0) return [];
|
||||||
|
|
||||||
final maxCols = total > 6 ? 3 : 2;
|
final maxCols = total > 6 ? 3 : 2;
|
||||||
@@ -159,135 +68,137 @@ class _QuickAccessGridState extends State<QuickAccessGrid> with RouteAware {
|
|||||||
if (j > 0) rowItems.add(const SizedBox(width: gap));
|
if (j > 0) rowItems.add(const SizedBox(width: gap));
|
||||||
rowItems.add(
|
rowItems.add(
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _AccessChip(
|
child: _QuickAccessButton(
|
||||||
item: _items[index],
|
item: items[index],
|
||||||
onTap: () => _navigateTo(_items[index].path),
|
onTap: () => _onTap(context, items[index]),
|
||||||
onLongPress: () => _editShortcut(index),
|
|
||||||
expanded: true,
|
|
||||||
fillHeight: widget.fillHeight,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final row = Row(children: rowItems);
|
final row = Row(children: rowItems);
|
||||||
rows.add(widget.fillHeight ? Expanded(child: row) : row);
|
rows.add(fillHeight ? Expanded(child: row) : row);
|
||||||
if (i + maxCols < total) {
|
if (i + maxCols < total) {
|
||||||
rows.add(const SizedBox(height: gap));
|
rows.add(const SizedBox(height: gap));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onTap(BuildContext context, QuickAccessConfig item) {
|
||||||
|
if (item.path.startsWith('cloudreve://my?category=')) {
|
||||||
|
_openCategory(context, item);
|
||||||
|
} else {
|
||||||
|
_openDirectory(context, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openCategory(BuildContext context, QuickAccessConfig item) {
|
||||||
|
final args = _argsForItem(item);
|
||||||
|
Navigator.of(context).pushNamed(RouteNames.categoryFiles, arguments: args);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openDirectory(BuildContext context, QuickAccessConfig item) {
|
||||||
|
final dirPath = item.path.startsWith('/') ? item.path : '/${item.path}';
|
||||||
|
final navProvider = context.read<NavigationProvider>();
|
||||||
|
final fileManager = context.read<FileManagerProvider>();
|
||||||
|
navProvider.setIndex(1);
|
||||||
|
fileManager.enterFolder(dirPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryFilesPageArgs _argsForItem(QuickAccessConfig item) {
|
||||||
|
switch (item.id) {
|
||||||
|
case 'img':
|
||||||
|
return CategoryFilesPageArgs(
|
||||||
|
category: 'image',
|
||||||
|
title: '图片',
|
||||||
|
icon: LucideIcons.image,
|
||||||
|
color: item.color,
|
||||||
|
);
|
||||||
|
case 'vid':
|
||||||
|
return CategoryFilesPageArgs(
|
||||||
|
category: 'video',
|
||||||
|
title: '视频',
|
||||||
|
icon: LucideIcons.video,
|
||||||
|
color: item.color,
|
||||||
|
);
|
||||||
|
case 'doc':
|
||||||
|
return CategoryFilesPageArgs(
|
||||||
|
category: 'document',
|
||||||
|
title: '文档',
|
||||||
|
icon: LucideIcons.fileText,
|
||||||
|
color: item.color,
|
||||||
|
);
|
||||||
|
case 'mus':
|
||||||
|
return CategoryFilesPageArgs(
|
||||||
|
category: 'audio',
|
||||||
|
title: '音乐',
|
||||||
|
icon: LucideIcons.music,
|
||||||
|
color: item.color,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return CategoryFilesPageArgs(
|
||||||
|
category: 'document',
|
||||||
|
title: item.label,
|
||||||
|
icon: item.icon,
|
||||||
|
color: item.color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 渐变胶囊
|
class _QuickAccessButton extends StatelessWidget {
|
||||||
class _AccessChip extends StatefulWidget {
|
|
||||||
final QuickAccessConfig item;
|
final QuickAccessConfig item;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onLongPress;
|
|
||||||
final bool expanded;
|
|
||||||
final bool fillHeight;
|
|
||||||
|
|
||||||
const _AccessChip({
|
const _QuickAccessButton({required this.item, required this.onTap});
|
||||||
required this.item,
|
|
||||||
required this.onTap,
|
|
||||||
required this.onLongPress,
|
|
||||||
this.expanded = false,
|
|
||||||
this.fillHeight = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_AccessChip> createState() => _AccessChipState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AccessChipState extends State<_AccessChip> with SingleTickerProviderStateMixin {
|
|
||||||
bool _hovered = false;
|
|
||||||
late final AnimationController _controller;
|
|
||||||
late final Animation<double> _scaleAnim;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 120),
|
|
||||||
lowerBound: 0.94,
|
|
||||||
upperBound: 1.0,
|
|
||||||
)..value = 1.0;
|
|
||||||
_scaleAnim = _controller.view;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final item = widget.item;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
final gradient = LinearGradient(
|
final foreground = isDark ? Colors.white : item.color.darken(0.52);
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: item.color.withValues(alpha: isDark ? 0.20 : 0.24),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
item.color.withValues(alpha: isDark ? 0.34 : 0.72),
|
||||||
|
item.color.withValues(alpha: isDark ? 0.18 : 0.42),
|
||||||
|
],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [item.color, item.color.darken(0.12)],
|
|
||||||
);
|
|
||||||
|
|
||||||
final child = AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
width: widget.expanded ? double.infinity : null,
|
|
||||||
height: widget.fillHeight ? double.infinity : null,
|
|
||||||
alignment: widget.expanded ? Alignment.center : null,
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: widget.expanded ? 0 : 18,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(18),
|
||||||
gradient: gradient,
|
border: Border.all(color: item.color.withValues(alpha: 0.28)),
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: item.color.withValues(alpha: _hovered ? 0.4 : 0.18),
|
|
||||||
blurRadius: _hovered ? 16 : 8,
|
|
||||||
offset: Offset(0, _hovered ? 6 : 3),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: widget.expanded ? MainAxisSize.max : MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(item.icon, size: 18, color: Colors.white),
|
Icon(item.icon, color: foreground, size: 22),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 9),
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
item.label,
|
item.label,
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 13,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: foreground,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
|
||||||
return MouseRegion(
|
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTapDown: (_) => _controller.reverse(),
|
|
||||||
onTapUp: (_) {
|
|
||||||
_controller.forward();
|
|
||||||
widget.onTap();
|
|
||||||
},
|
|
||||||
onTapCancel: () => _controller.forward(),
|
|
||||||
onLongPress: widget.onLongPress,
|
|
||||||
child: AnimatedBuilder(
|
|
||||||
animation: _scaleAnim,
|
|
||||||
builder: (context, _) => Transform.scale(scale: _scaleAnim.value, child: child),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,56 +1,162 @@
|
|||||||
import 'package:cloudreve4_flutter/core/constants/quick_access_defaults.dart';
|
import 'package:cloudreve4_flutter/core/constants/quick_access_defaults.dart';
|
||||||
import 'package:cloudreve4_flutter/services/storage_service.dart';
|
import 'package:cloudreve4_flutter/presentation/providers/quick_access_provider.dart';
|
||||||
import 'package:cloudreve4_flutter/presentation/widgets/toast_helper.dart';
|
import 'package:cloudreve4_flutter/presentation/widgets/toast_helper.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class QuickAccessSettingsPage extends StatefulWidget {
|
class QuickAccessSettingsPage extends StatelessWidget {
|
||||||
const QuickAccessSettingsPage({super.key});
|
const QuickAccessSettingsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<QuickAccessSettingsPage> createState() => _QuickAccessSettingsPageState();
|
Widget build(BuildContext context) {
|
||||||
}
|
final theme = Theme.of(context);
|
||||||
|
final provider = context.watch<QuickAccessProvider>();
|
||||||
|
|
||||||
class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
|
return Scaffold(
|
||||||
List<QuickAccessConfig> _items = [];
|
appBar: AppBar(title: const Text('快捷入口')),
|
||||||
bool _isLoaded = false;
|
body: ListView(
|
||||||
|
children: [
|
||||||
@override
|
Padding(
|
||||||
void initState() {
|
padding: const EdgeInsets.all(16),
|
||||||
super.initState();
|
child: Text(
|
||||||
_loadConfig();
|
'自定义概览页中显示的快捷目录入口。默认入口不可删除,但可编辑路径和调整顺序。',
|
||||||
}
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.hintColor,
|
||||||
Future<void> _loadConfig() async {
|
),
|
||||||
var saved = await StorageService.instance.getString(QuickAccessConfig.storageKey);
|
),
|
||||||
if (saved != null && saved.isNotEmpty) {
|
),
|
||||||
try {
|
if (provider.isLoaded)
|
||||||
if (mounted) setState(() { _items = QuickAccessConfig.parseSaved(saved); _isLoaded = true; });
|
...List.generate(provider.items.length, (index) {
|
||||||
return;
|
final item = provider.items[index];
|
||||||
} catch (_) {}
|
return Card(
|
||||||
}
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
// 迁移 v1
|
child: ListTile(
|
||||||
final v1 = await StorageService.instance.getString('quick_access_shortcuts');
|
leading: Container(
|
||||||
if (v1 != null && v1.isNotEmpty) {
|
width: 40,
|
||||||
final migrated = QuickAccessConfig.migrateV1(v1);
|
height: 40,
|
||||||
if (mounted) {
|
decoration: BoxDecoration(
|
||||||
setState(() { _items = migrated; _isLoaded = true; });
|
color: item.color.withValues(alpha: 0.2),
|
||||||
await _save();
|
borderRadius: BorderRadius.circular(10),
|
||||||
}
|
),
|
||||||
return;
|
child: Icon(
|
||||||
}
|
item.icon,
|
||||||
if (mounted) setState(() { _items = List.from(QuickAccessConfig.defaults); _isLoaded = true; });
|
size: 20,
|
||||||
}
|
color: item.color.darken(0.3),
|
||||||
|
),
|
||||||
Future<void> _save() async {
|
),
|
||||||
await StorageService.instance.setString(
|
title: Row(
|
||||||
QuickAccessConfig.storageKey,
|
children: [
|
||||||
QuickAccessConfig.serialize(_items),
|
Text(item.label),
|
||||||
|
if (item.isDefault) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary.withValues(
|
||||||
|
alpha: 0.1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'默认',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
item.path,
|
||||||
|
style: TextStyle(color: theme.hintColor, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(LucideIcons.chevronUp, size: 18),
|
||||||
|
onPressed: index > 0
|
||||||
|
? () => provider.moveItem(index, index - 1)
|
||||||
|
: null,
|
||||||
|
tooltip: '上移',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(LucideIcons.chevronDown, size: 18),
|
||||||
|
onPressed: index < provider.items.length - 1
|
||||||
|
? () => provider.moveItem(index, index + 1)
|
||||||
|
: null,
|
||||||
|
tooltip: '下移',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(LucideIcons.pencil, size: 16),
|
||||||
|
onPressed: () => _editItem(context, provider, index),
|
||||||
|
tooltip: '编辑',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
if (!item.isDefault)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
LucideIcons.trash2,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
provider.deleteItem(index);
|
||||||
|
ToastHelper.success('快捷入口已删除');
|
||||||
|
},
|
||||||
|
tooltip: '删除',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
icon: const Icon(LucideIcons.plus, size: 18),
|
||||||
|
label: const Text('新增快捷入口'),
|
||||||
|
onPressed: () => _addItem(context, provider),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(LucideIcons.rotateCcw, size: 16),
|
||||||
|
label: const Text('恢复默认'),
|
||||||
|
onPressed: () {
|
||||||
|
provider.resetToDefaults();
|
||||||
|
ToastHelper.success('已恢复默认设置');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _editItem(int index) async {
|
Future<void> _editItem(
|
||||||
final item = _items[index];
|
BuildContext context,
|
||||||
|
QuickAccessProvider provider,
|
||||||
|
int index,
|
||||||
|
) async {
|
||||||
|
final item = provider.items[index];
|
||||||
final labelController = TextEditingController(text: item.label);
|
final labelController = TextEditingController(text: item.label);
|
||||||
final pathController = TextEditingController(text: item.path);
|
final pathController = TextEditingController(text: item.path);
|
||||||
|
|
||||||
@@ -63,19 +169,30 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
|
|||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: labelController,
|
controller: labelController,
|
||||||
decoration: const InputDecoration(labelText: '名称', hintText: '例如: 图片'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: '名称',
|
||||||
|
hintText: '例如: 图片',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: pathController,
|
controller: pathController,
|
||||||
decoration: const InputDecoration(labelText: '目录路径', hintText: '例如: /Images'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: '目录路径',
|
||||||
|
hintText: '例如: /Images',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(_EditResult(labelController.text, pathController.text)),
|
onPressed: () => Navigator.of(
|
||||||
|
ctx,
|
||||||
|
).pop(_EditResult(labelController.text, pathController.text)),
|
||||||
child: const Text('确定'),
|
child: const Text('确定'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -83,18 +200,21 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
setState(() {
|
provider.updateItem(
|
||||||
_items[index] = item.copyWith(
|
index,
|
||||||
|
item.copyWith(
|
||||||
label: result.label.isNotEmpty ? result.label : item.label,
|
label: result.label.isNotEmpty ? result.label : item.label,
|
||||||
path: result.path.isNotEmpty ? result.path : item.path,
|
path: result.path.isNotEmpty ? result.path : item.path,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
_save();
|
|
||||||
ToastHelper.success('快捷入口已更新');
|
ToastHelper.success('快捷入口已更新');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addItem() async {
|
Future<void> _addItem(
|
||||||
|
BuildContext context,
|
||||||
|
QuickAccessProvider provider,
|
||||||
|
) async {
|
||||||
final labelController = TextEditingController();
|
final labelController = TextEditingController();
|
||||||
final pathController = TextEditingController();
|
final pathController = TextEditingController();
|
||||||
IconData selectedIcon = LucideIcons.folder;
|
IconData selectedIcon = LucideIcons.folder;
|
||||||
@@ -114,48 +234,82 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
|
|||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: labelController,
|
controller: labelController,
|
||||||
decoration: const InputDecoration(labelText: '名称', hintText: '例如: 图片'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: '名称',
|
||||||
|
hintText: '例如: 图片',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: pathController,
|
controller: pathController,
|
||||||
decoration: const InputDecoration(labelText: '目录路径', hintText: '例如: /Images'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: '目录路径',
|
||||||
|
hintText: '例如: /Images',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('图标', style: Theme.of(ctx).textTheme.bodySmall?.copyWith(color: Theme.of(ctx).hintColor)),
|
Text(
|
||||||
|
'图标',
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(ctx).hintColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: QuickAccessConfig.iconPool.map((icon) {
|
children: QuickAccessConfig.iconPool.map((icon) {
|
||||||
final isSelected = icon.codePoint == selectedIcon.codePoint;
|
final isSelected =
|
||||||
|
icon.codePoint == selectedIcon.codePoint;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => setDialogState(() => selectedIcon = icon),
|
onTap: () =>
|
||||||
|
setDialogState(() => selectedIcon = icon),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? Theme.of(ctx).colorScheme.primary.withValues(alpha: 0.15) : null,
|
color: isSelected
|
||||||
|
? Theme.of(ctx).colorScheme.primary
|
||||||
|
.withValues(alpha: 0.15)
|
||||||
|
: null,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: isSelected
|
border: isSelected
|
||||||
? Border.all(color: Theme.of(ctx).colorScheme.primary, width: 2)
|
? Border.all(
|
||||||
: Border.all(color: Theme.of(ctx).dividerColor),
|
color: Theme.of(ctx).colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
)
|
||||||
|
: Border.all(
|
||||||
|
color: Theme.of(ctx).dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: isSelected
|
||||||
|
? Theme.of(ctx).colorScheme.primary
|
||||||
|
: Theme.of(ctx).hintColor,
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 20, color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('颜色', style: Theme.of(ctx).textTheme.bodySmall?.copyWith(color: Theme.of(ctx).hintColor)),
|
Text(
|
||||||
|
'颜色',
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(ctx).hintColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: QuickAccessConfig.colorPool.map((color) {
|
children: QuickAccessConfig.colorPool.map((color) {
|
||||||
final isSelected = color.toARGB32() == selectedColor.toARGB32();
|
final isSelected =
|
||||||
|
color.toARGB32() == selectedColor.toARGB32();
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => setDialogState(() => selectedColor = color),
|
onTap: () =>
|
||||||
|
setDialogState(() => selectedColor = color),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
@@ -163,11 +317,18 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
|
|||||||
color: color.withValues(alpha: 0.6),
|
color: color.withValues(alpha: 0.6),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: isSelected
|
border: isSelected
|
||||||
? Border.all(color: color.darken(0.2), width: 3)
|
? Border.all(
|
||||||
|
color: color.darken(0.2),
|
||||||
|
width: 3,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: isSelected
|
child: isSelected
|
||||||
? Icon(LucideIcons.check, size: 18, color: color.darken(0.3))
|
? Icon(
|
||||||
|
LucideIcons.check,
|
||||||
|
size: 18,
|
||||||
|
color: color.darken(0.3),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -177,14 +338,19 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(_AddResult(
|
onPressed: () => Navigator.of(ctx).pop(
|
||||||
|
_AddResult(
|
||||||
labelController.text,
|
labelController.text,
|
||||||
pathController.text,
|
pathController.text,
|
||||||
selectedIcon,
|
selectedIcon,
|
||||||
selectedColor,
|
selectedColor,
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
child: const Text('添加'),
|
child: const Text('添加'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -195,152 +361,18 @@ class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result != null && result.label.isNotEmpty && result.path.isNotEmpty) {
|
if (result != null && result.label.isNotEmpty && result.path.isNotEmpty) {
|
||||||
setState(() {
|
provider.addItem(
|
||||||
_items.add(QuickAccessConfig(
|
QuickAccessConfig(
|
||||||
id: 'custom_${DateTime.now().millisecondsSinceEpoch}',
|
id: 'custom_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
label: result.label,
|
label: result.label,
|
||||||
icon: result.icon,
|
icon: result.icon,
|
||||||
path: result.path,
|
path: result.path,
|
||||||
color: result.color,
|
color: result.color,
|
||||||
));
|
),
|
||||||
});
|
);
|
||||||
_save();
|
|
||||||
ToastHelper.success('快捷入口已添加');
|
ToastHelper.success('快捷入口已添加');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _moveItem(int from, int to) {
|
|
||||||
if (from < 0 || from >= _items.length || to < 0 || to >= _items.length || from == to) return;
|
|
||||||
setState(() {
|
|
||||||
final item = _items.removeAt(from);
|
|
||||||
_items.insert(to, item);
|
|
||||||
});
|
|
||||||
_save();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _deleteItem(int index) {
|
|
||||||
if (_items[index].isDefault) return;
|
|
||||||
setState(() {
|
|
||||||
_items.removeAt(index);
|
|
||||||
});
|
|
||||||
_save();
|
|
||||||
ToastHelper.success('快捷入口已删除');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('快捷入口')),
|
|
||||||
body: ListView(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Text(
|
|
||||||
'自定义概览页中显示的快捷目录入口。默认入口不可删除,但可编辑路径和调整顺序。',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_isLoaded)
|
|
||||||
...List.generate(_items.length, (index) {
|
|
||||||
final item = _items[index];
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
||||||
child: ListTile(
|
|
||||||
leading: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: item.color.withValues(alpha: 0.2),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Icon(item.icon, size: 20, color: item.color.darken(0.3)),
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Text(item.label),
|
|
||||||
if (item.isDefault) ...[
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text('默认', style: TextStyle(fontSize: 10, color: theme.colorScheme.primary, fontWeight: FontWeight.w600)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Text(item.path, style: TextStyle(color: theme.hintColor, fontSize: 12)),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// 上移
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(LucideIcons.chevronUp, size: 18),
|
|
||||||
onPressed: index > 0 ? () => _moveItem(index, index - 1) : null,
|
|
||||||
tooltip: '上移',
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
// 下移
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(LucideIcons.chevronDown, size: 18),
|
|
||||||
onPressed: index < _items.length - 1 ? () => _moveItem(index, index + 1) : null,
|
|
||||||
tooltip: '下移',
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
// 编辑
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(LucideIcons.pencil, size: 16),
|
|
||||||
onPressed: () => _editItem(index),
|
|
||||||
tooltip: '编辑',
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
// 删除(默认不可删)
|
|
||||||
if (!item.isDefault)
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(LucideIcons.trash2, size: 16, color: theme.colorScheme.error),
|
|
||||||
onPressed: () => _deleteItem(index),
|
|
||||||
tooltip: '删除',
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton.icon(
|
|
||||||
icon: const Icon(LucideIcons.plus, size: 18),
|
|
||||||
label: const Text('新增快捷入口'),
|
|
||||||
onPressed: _addItem,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
icon: const Icon(LucideIcons.rotateCcw, size: 16),
|
|
||||||
label: const Text('恢复默认'),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() { _items = List.from(QuickAccessConfig.defaults); });
|
|
||||||
_save();
|
|
||||||
ToastHelper.success('已恢复默认设置');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EditResult {
|
class _EditResult {
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
required String email,
|
required String email,
|
||||||
required String password,
|
required String password,
|
||||||
bool rememberMe = false,
|
bool rememberMe = false,
|
||||||
|
String? captcha,
|
||||||
|
String? ticket,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
setState(AuthState.loading);
|
setState(AuthState.loading);
|
||||||
@@ -141,6 +143,8 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
final response = await AuthService.instance.passwordLogin(
|
final response = await AuthService.instance.passwordLogin(
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
|
captcha: captcha,
|
||||||
|
ticket: ticket,
|
||||||
);
|
);
|
||||||
AppLogger.d('AuthProvider 登录成功: $response');
|
AppLogger.d('AuthProvider 登录成功: $response');
|
||||||
// 保存登录信息到当前服务器(包含完整 user 和 token)
|
// 保存登录信息到当前服务器(包含完整 user 和 token)
|
||||||
@@ -257,7 +261,9 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用刷新 token 接口
|
// 调用刷新 token 接口
|
||||||
final newToken = await AuthService.instance.refreshToken(currentToken.refreshToken);
|
final newToken = await AuthService.instance.refreshToken(
|
||||||
|
currentToken.refreshToken,
|
||||||
|
);
|
||||||
|
|
||||||
// 更新用户信息中的 token
|
// 更新用户信息中的 token
|
||||||
final updatedUser = _user!.copyWith(token: newToken);
|
final updatedUser = _user!.copyWith(token: newToken);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../core/constants/quick_access_defaults.dart';
|
||||||
|
import '../../services/storage_service.dart';
|
||||||
|
|
||||||
|
class QuickAccessProvider extends ChangeNotifier {
|
||||||
|
List<QuickAccessConfig> _items = List.from(QuickAccessConfig.defaults);
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
|
List<QuickAccessConfig> get items => _items;
|
||||||
|
bool get isLoaded => _isLoaded;
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
if (_isLoaded) return;
|
||||||
|
|
||||||
|
final saved = await StorageService.instance.getString(
|
||||||
|
QuickAccessConfig.storageKey,
|
||||||
|
);
|
||||||
|
if (saved != null && saved.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
_items = QuickAccessConfig.parseSaved(saved);
|
||||||
|
_isLoaded = true;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移 v1
|
||||||
|
final v1 = await StorageService.instance.getString(
|
||||||
|
'quick_access_shortcuts',
|
||||||
|
);
|
||||||
|
if (v1 != null && v1.isNotEmpty) {
|
||||||
|
_items = QuickAccessConfig.migrateV1(v1);
|
||||||
|
_isLoaded = true;
|
||||||
|
await _save();
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_items = List.from(QuickAccessConfig.defaults);
|
||||||
|
_isLoaded = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addItem(QuickAccessConfig item) async {
|
||||||
|
_items = [..._items, item];
|
||||||
|
await _save();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateItem(int index, QuickAccessConfig item) async {
|
||||||
|
_items = [..._items]..[index] = item;
|
||||||
|
await _save();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> moveItem(int from, int to) async {
|
||||||
|
if (from < 0 ||
|
||||||
|
from >= _items.length ||
|
||||||
|
to < 0 ||
|
||||||
|
to >= _items.length ||
|
||||||
|
from == to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final list = [..._items];
|
||||||
|
final item = list.removeAt(from);
|
||||||
|
list.insert(to, item);
|
||||||
|
_items = list;
|
||||||
|
await _save();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteItem(int index) async {
|
||||||
|
_items = [..._items]..removeAt(index);
|
||||||
|
await _save();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resetToDefaults() async {
|
||||||
|
_items = List.from(QuickAccessConfig.defaults);
|
||||||
|
await _save();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
await StorageService.instance.setString(
|
||||||
|
QuickAccessConfig.storageKey,
|
||||||
|
QuickAccessConfig.serialize(_items),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,10 @@ class FileBreadcrumb extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.scaffoldBackgroundColor,
|
color: theme.scaffoldBackgroundColor,
|
||||||
border: Border(
|
border: Border(
|
||||||
top: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5), width: 1),
|
top: BorderSide(
|
||||||
|
color: theme.dividerColor.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -43,15 +46,20 @@ class FileBreadcrumb extends StatelessWidget {
|
|||||||
for (int i = 0; i < pathParts.length; i++) ...[
|
for (int i = 0; i < pathParts.length; i++) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Icon(LucideIcons.chevronRight, size: 16, color: theme.hintColor.withValues(alpha: 0.5)),
|
child: Icon(
|
||||||
|
LucideIcons.chevronRight,
|
||||||
|
size: 16,
|
||||||
|
color: theme.hintColor.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildBreadcrumbItem(
|
_buildBreadcrumbItem(
|
||||||
context,
|
context,
|
||||||
name: pathParts[i],
|
name: _decodePathSegment(pathParts[i]),
|
||||||
path: '/${pathParts.sublist(0, i + 1).join('/')}',
|
path: '/${pathParts.sublist(0, i + 1).join('/')}',
|
||||||
icon: null,
|
icon: null,
|
||||||
primaryColor: colorScheme.primary,
|
primaryColor: colorScheme.primary,
|
||||||
onTap: () => onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'),
|
onTap: () =>
|
||||||
|
onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -60,6 +68,22 @@ class FileBreadcrumb extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _decodePathSegment(String value) {
|
||||||
|
var decoded = value;
|
||||||
|
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
try {
|
||||||
|
final next = Uri.decodeComponent(decoded);
|
||||||
|
if (next == decoded) break;
|
||||||
|
decoded = next;
|
||||||
|
} on FormatException {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildBreadcrumbItem(
|
Widget _buildBreadcrumbItem(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String name,
|
required String name,
|
||||||
@@ -81,8 +105,7 @@ class FileBreadcrumb extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (icon != null)
|
if (icon != null) Icon(icon, size: 16, color: primaryColor),
|
||||||
Icon(icon, size: 16, color: primaryColor),
|
|
||||||
if (icon != null) const SizedBox(width: 5),
|
if (icon != null) const SizedBox(width: 5),
|
||||||
Text(
|
Text(
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../presentation/pages/preview/video_preview_page.dart';
|
|||||||
import '../presentation/pages/preview/audio_preview_page.dart';
|
import '../presentation/pages/preview/audio_preview_page.dart';
|
||||||
import '../presentation/pages/preview/document_preview_page.dart';
|
import '../presentation/pages/preview/document_preview_page.dart';
|
||||||
import '../presentation/pages/preview/markdown_preview_page.dart';
|
import '../presentation/pages/preview/markdown_preview_page.dart';
|
||||||
|
import '../presentation/pages/files/category_files_page.dart';
|
||||||
import '../data/models/file_model.dart';
|
import '../data/models/file_model.dart';
|
||||||
|
|
||||||
/// 路由名称
|
/// 路由名称
|
||||||
@@ -33,6 +34,7 @@ class RouteNames {
|
|||||||
static const String audioPreview = '/audio-preview';
|
static const String audioPreview = '/audio-preview';
|
||||||
static const String documentPreview = '/document-preview';
|
static const String documentPreview = '/document-preview';
|
||||||
static const String markdownPreview = '/markdown-preview';
|
static const String markdownPreview = '/markdown-preview';
|
||||||
|
static const String categoryFiles = '/category-files';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 应用路由
|
/// 应用路由
|
||||||
@@ -189,6 +191,33 @@ class AppRouter {
|
|||||||
builder: (context) => MarkdownPreviewPage(file: file),
|
builder: (context) => MarkdownPreviewPage(file: file),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case RouteNames.categoryFiles:
|
||||||
|
final args = settings.arguments;
|
||||||
|
if (args is CategoryFilesPageArgs) {
|
||||||
|
return MaterialPageRoute(
|
||||||
|
settings: settings,
|
||||||
|
builder: (context) => CategoryFilesPage(args: args),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args is Map<String, dynamic>) {
|
||||||
|
return MaterialPageRoute(
|
||||||
|
settings: settings,
|
||||||
|
builder: (context) =>
|
||||||
|
CategoryFilesPage(args: CategoryFilesPageArgs.fromMap(args)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return MaterialPageRoute(
|
||||||
|
settings: settings,
|
||||||
|
builder: (context) => const CategoryFilesPage(
|
||||||
|
args: CategoryFilesPageArgs(
|
||||||
|
category: 'image',
|
||||||
|
title: '图片',
|
||||||
|
icon: Icons.image,
|
||||||
|
color: Color(0xFFF0ABFC),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
settings: settings,
|
settings: settings,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import '../data/models/login_config_model.dart';
|
||||||
import '../data/models/user_model.dart';
|
import '../data/models/user_model.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
import '../core/utils/app_logger.dart';
|
import '../core/utils/app_logger.dart';
|
||||||
@@ -24,16 +25,73 @@ class AuthService {
|
|||||||
return response as Map<String, bool>;
|
return response as Map<String, bool>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取登录配置(是否需要验证码、是否允许注册等)
|
||||||
|
Future<LoginConfigModel> getLoginConfig() async {
|
||||||
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
|
'/site/config/login',
|
||||||
|
noAuth: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = response['data'];
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
return LoginConfigModel.fromJson(data);
|
||||||
|
}
|
||||||
|
if (data is Map) {
|
||||||
|
return LoginConfigModel.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoginConfigModel.fromJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取图形验证码
|
||||||
|
Future<Map<String, String>> getCaptcha() async {
|
||||||
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
|
'/site/captcha',
|
||||||
|
noAuth: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = response['data'] is Map
|
||||||
|
? Map<String, dynamic>.from(response['data'] as Map)
|
||||||
|
: response;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'image': data['image'] as String? ?? '',
|
||||||
|
'ticket': data['ticket'] as String? ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取站点基础配置,用于判断验证码类型。
|
||||||
|
Future<Map<String, dynamic>> getBasicSiteConfig() async {
|
||||||
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
|
'/site/config/basic',
|
||||||
|
noAuth: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
AppLogger.d('AuthService -> 站点基础配置响应: $response');
|
||||||
|
|
||||||
|
final data = response['data'];
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (data is Map) {
|
||||||
|
return Map<String, dynamic>.from(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
/// 密码登录
|
/// 密码登录
|
||||||
Future<LoginResponseModel> passwordLogin({
|
Future<LoginResponseModel> passwordLogin({
|
||||||
required String email,
|
required String email,
|
||||||
required String password,
|
required String password,
|
||||||
String? captcha,
|
String? captcha,
|
||||||
|
String? ticket,
|
||||||
}) async {
|
}) async {
|
||||||
final data = <String, dynamic>{
|
final data = <String, dynamic>{
|
||||||
'email': email,
|
'email': email,
|
||||||
'password': password,
|
'password': password,
|
||||||
...captcha != null ? {'captcha': captcha} : {},
|
if (captcha != null && captcha.isNotEmpty) 'captcha': captcha,
|
||||||
|
if (ticket != null && ticket.isNotEmpty) 'ticket': ticket,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await ApiService.instance.post<Map<String, dynamic>>(
|
final response = await ApiService.instance.post<Map<String, dynamic>>(
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../presentation/pages/auth/captcha_challenge_page.dart';
|
||||||
|
import '../presentation/widgets/toast_helper.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
import '../services/server_service.dart';
|
||||||
|
import 'api_service.dart';
|
||||||
|
|
||||||
|
/// 验证码服务(单例)
|
||||||
|
///
|
||||||
|
/// 管理验证码的加载、状态和 UI 构建,供登录/注册/忘记密码页面共享。
|
||||||
|
class CaptchaService {
|
||||||
|
CaptchaService._internal();
|
||||||
|
static final CaptchaService _instance = CaptchaService._internal();
|
||||||
|
static CaptchaService get instance => _instance;
|
||||||
|
|
||||||
|
final TextEditingController captchaController = TextEditingController();
|
||||||
|
|
||||||
|
String? _captchaType;
|
||||||
|
String? _recaptchaSiteKey;
|
||||||
|
String? _turnstileSiteKey;
|
||||||
|
String? _capInstanceUrl;
|
||||||
|
String? _capSiteKey;
|
||||||
|
String? _capAssetServer;
|
||||||
|
|
||||||
|
String? _captchaImage;
|
||||||
|
String? _captchaTicket;
|
||||||
|
String? _captchaToken;
|
||||||
|
bool _isLoadingCaptcha = false;
|
||||||
|
|
||||||
|
bool get isLoadingCaptcha => _isLoadingCaptcha;
|
||||||
|
String? get captchaImage => _captchaImage;
|
||||||
|
String? get captchaTicket => _captchaTicket;
|
||||||
|
String? get captchaToken => _captchaToken;
|
||||||
|
|
||||||
|
bool get isWebCaptcha => captchaWebConfig != null;
|
||||||
|
|
||||||
|
CaptchaWebConfig? get captchaWebConfig {
|
||||||
|
final type = _normalizedCaptchaType;
|
||||||
|
if (type == 'turnstile' &&
|
||||||
|
_turnstileSiteKey != null &&
|
||||||
|
_turnstileSiteKey!.isNotEmpty) {
|
||||||
|
return CaptchaWebConfig.turnstile(
|
||||||
|
siteKey: _turnstileSiteKey!,
|
||||||
|
displayName: 'Cloudflare Turnstile',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == 'recaptcha' &&
|
||||||
|
_recaptchaSiteKey != null &&
|
||||||
|
_recaptchaSiteKey!.isNotEmpty) {
|
||||||
|
return CaptchaWebConfig.recaptchaV2(
|
||||||
|
siteKey: _recaptchaSiteKey!,
|
||||||
|
displayName: 'reCAPTCHA V2',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == 'cap' &&
|
||||||
|
_capInstanceUrl != null &&
|
||||||
|
_capInstanceUrl!.isNotEmpty &&
|
||||||
|
_capSiteKey != null &&
|
||||||
|
_capSiteKey!.isNotEmpty) {
|
||||||
|
return CaptchaWebConfig.cap(
|
||||||
|
instanceUrl: _capInstanceUrl!,
|
||||||
|
siteKey: _capSiteKey!,
|
||||||
|
assetServer: _capAssetServer,
|
||||||
|
displayName: 'Cap',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _normalizedCaptchaType {
|
||||||
|
final raw = (_captchaType ?? '').trim().toLowerCase();
|
||||||
|
if (raw == 'recaptcha_v2' ||
|
||||||
|
raw == 'recaptchav2' ||
|
||||||
|
raw == 'google' ||
|
||||||
|
raw == 'google_recaptcha' ||
|
||||||
|
raw == 'google-recaptcha') {
|
||||||
|
return 'recaptcha';
|
||||||
|
}
|
||||||
|
if (raw == 'cloudflare_turnstile' || raw == 'cloudflare-turnstile') {
|
||||||
|
return 'turnstile';
|
||||||
|
}
|
||||||
|
if (raw == 'image' || raw == 'graphic' || raw == 'captcha') {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载验证码配置和图片
|
||||||
|
Future<void> loadCaptcha(String baseUrl) async {
|
||||||
|
if (_isLoadingCaptcha) return;
|
||||||
|
|
||||||
|
_isLoadingCaptcha = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ApiService.instance.setBaseUrl(baseUrl);
|
||||||
|
|
||||||
|
Map<String, dynamic> config = <String, dynamic>{};
|
||||||
|
|
||||||
|
try {
|
||||||
|
config = await AuthService.instance.getBasicSiteConfig().timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final captchaType = _normalizeCaptchaType(
|
||||||
|
(config['captcha_type'] ?? config['captchaType'] ?? config['captcha'])
|
||||||
|
?.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final recaptchaKey = _firstNonEmptyString(config, const [
|
||||||
|
'captcha_ReCaptchaKey',
|
||||||
|
'captcha_re_captcha_key',
|
||||||
|
'captchaReCaptchaKey',
|
||||||
|
'recaptcha_site_key',
|
||||||
|
'recaptchaSiteKey',
|
||||||
|
'recaptcha_key',
|
||||||
|
'reCaptchaKey',
|
||||||
|
]);
|
||||||
|
|
||||||
|
final turnstileSiteKey = _firstNonEmptyString(config, const [
|
||||||
|
'turnstile_site_id',
|
||||||
|
'turnstileSiteId',
|
||||||
|
'turnstile_site_key',
|
||||||
|
'turnstileSiteKey',
|
||||||
|
]);
|
||||||
|
|
||||||
|
final capInstanceUrl = _firstNonEmptyString(config, const [
|
||||||
|
'captcha_cap_instance_url',
|
||||||
|
'captchaCapInstanceUrl',
|
||||||
|
'cap_instance_url',
|
||||||
|
'capInstanceUrl',
|
||||||
|
]);
|
||||||
|
|
||||||
|
final capSiteKey = _firstNonEmptyString(config, const [
|
||||||
|
'captcha_cap_site_key',
|
||||||
|
'captchaCapSiteKey',
|
||||||
|
'cap_site_key',
|
||||||
|
'capSiteKey',
|
||||||
|
]);
|
||||||
|
|
||||||
|
final capAssetServer = _firstNonEmptyString(config, const [
|
||||||
|
'captcha_cap_asset_server',
|
||||||
|
'captchaCapAssetServer',
|
||||||
|
'cap_asset_server',
|
||||||
|
'capAssetServer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
final isExternalCaptcha =
|
||||||
|
captchaType == 'turnstile' ||
|
||||||
|
captchaType == 'recaptcha' ||
|
||||||
|
captchaType == 'cap';
|
||||||
|
|
||||||
|
if (isExternalCaptcha) {
|
||||||
|
_captchaType = captchaType;
|
||||||
|
_recaptchaSiteKey = recaptchaKey;
|
||||||
|
_turnstileSiteKey = turnstileSiteKey;
|
||||||
|
_capInstanceUrl = capInstanceUrl;
|
||||||
|
_capSiteKey = capSiteKey;
|
||||||
|
_capAssetServer = capAssetServer;
|
||||||
|
_captchaToken = null;
|
||||||
|
|
||||||
|
_captchaImage = null;
|
||||||
|
_captchaTicket = null;
|
||||||
|
captchaController.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final captcha = await AuthService.instance.getCaptcha();
|
||||||
|
|
||||||
|
_captchaType = captchaType.isEmpty ? 'normal' : captchaType;
|
||||||
|
_recaptchaSiteKey = null;
|
||||||
|
_turnstileSiteKey = null;
|
||||||
|
_capInstanceUrl = null;
|
||||||
|
_capSiteKey = null;
|
||||||
|
_capAssetServer = null;
|
||||||
|
_captchaToken = null;
|
||||||
|
|
||||||
|
_captchaImage = captcha['image'];
|
||||||
|
_captchaTicket = captcha['ticket'];
|
||||||
|
captchaController.clear();
|
||||||
|
} catch (_) {
|
||||||
|
clearCaptcha();
|
||||||
|
} finally {
|
||||||
|
_isLoadingCaptcha = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新加载图形验证码图片
|
||||||
|
Future<void> refreshCaptcha() async {
|
||||||
|
final server = ServerService.instance.currentServer;
|
||||||
|
if (server == null) return;
|
||||||
|
await loadCaptcha(server.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跳转到 Web 验证码页面
|
||||||
|
Future<void> openCaptchaChallenge(BuildContext context) async {
|
||||||
|
final server = ServerService.instance.currentServer;
|
||||||
|
final config = captchaWebConfig;
|
||||||
|
|
||||||
|
if (server == null || config == null) {
|
||||||
|
ToastHelper.failure('验证码配置无效');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = await Navigator.of(context).push<String>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
CaptchaChallengePage(config: config, baseUrl: server.baseUrl),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
_captchaToken = token;
|
||||||
|
ToastHelper.success('人机验证完成');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空所有验证码状态
|
||||||
|
void clearCaptcha() {
|
||||||
|
_captchaType = null;
|
||||||
|
_recaptchaSiteKey = null;
|
||||||
|
_turnstileSiteKey = null;
|
||||||
|
_capInstanceUrl = null;
|
||||||
|
_capSiteKey = null;
|
||||||
|
_capAssetServer = null;
|
||||||
|
_captchaToken = null;
|
||||||
|
_captchaImage = null;
|
||||||
|
_captchaTicket = null;
|
||||||
|
captchaController.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取验证码参数(用于提交登录/注册/忘记密码请求)
|
||||||
|
///
|
||||||
|
/// 返回 `{captcha: ..., ticket: ...}` 或空 Map(无需验证码时)。
|
||||||
|
Map<String, String> getCaptchaParams() {
|
||||||
|
if (isWebCaptcha) {
|
||||||
|
if (_captchaToken == null || _captchaToken!.isEmpty) return {};
|
||||||
|
return {'captcha': _captchaToken!, 'ticket': _captchaToken!};
|
||||||
|
}
|
||||||
|
|
||||||
|
final userInput = captchaController.text.trim();
|
||||||
|
if (userInput.isEmpty &&
|
||||||
|
(_captchaTicket == null || _captchaTicket!.isEmpty)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'captcha': userInput, 'ticket': _captchaTicket ?? ''};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web 验证码是否已通过
|
||||||
|
bool get isWebCaptchaVerified =>
|
||||||
|
!isWebCaptcha || (_captchaToken != null && _captchaToken!.isNotEmpty);
|
||||||
|
|
||||||
|
/// 构建验证码输入 Widget
|
||||||
|
Widget buildCaptchaInput(BuildContext context) {
|
||||||
|
if (isWebCaptcha) {
|
||||||
|
final config = captchaWebConfig;
|
||||||
|
final displayName = config?.displayName ?? '人机验证';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _isLoadingCaptcha
|
||||||
|
? null
|
||||||
|
: () => openCaptchaChallenge(context),
|
||||||
|
icon: Icon(
|
||||||
|
_captchaToken == null
|
||||||
|
? Icons.verified_user_outlined
|
||||||
|
: Icons.verified,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
_captchaToken == null
|
||||||
|
? '点击完成 $displayName'
|
||||||
|
: '$displayName 已完成,点击重新验证',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'当前验证码类型:$displayName',
|
||||||
|
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget captchaPreview;
|
||||||
|
|
||||||
|
if (_isLoadingCaptcha) {
|
||||||
|
captchaPreview = const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
);
|
||||||
|
} else if (_captchaImage != null && _captchaImage!.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final base64Part = _captchaImage!.contains(',')
|
||||||
|
? _captchaImage!.split(',').last
|
||||||
|
: _captchaImage!;
|
||||||
|
|
||||||
|
captchaPreview = Image.memory(
|
||||||
|
base64Decode(base64Part),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
captchaPreview = const Text('刷新');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
captchaPreview = const Text('刷新');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: captchaController,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '验证码',
|
||||||
|
hintText: '请输入验证码',
|
||||||
|
prefixIcon: Icon(Icons.verified_user_outlined),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
final needCaptcha =
|
||||||
|
_captchaTicket != null && _captchaTicket!.isNotEmpty;
|
||||||
|
|
||||||
|
if (needCaptcha && (value == null || value.trim().isEmpty)) {
|
||||||
|
return '请输入验证码';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
InkWell(
|
||||||
|
onTap: _isLoadingCaptcha ? null : refreshCaptcha,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: 130,
|
||||||
|
height: 56,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: captchaPreview,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeCaptchaType(String? rawType) {
|
||||||
|
final value = (rawType ?? '').trim().toLowerCase();
|
||||||
|
if (value.isEmpty) return 'normal';
|
||||||
|
|
||||||
|
if (value == 'image' || value == 'graphic' || value == 'captcha') {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == 'recaptcha_v2' ||
|
||||||
|
value == 'recaptchav2' ||
|
||||||
|
value == 'google' ||
|
||||||
|
value == 'google_recaptcha' ||
|
||||||
|
value == 'google-recaptcha') {
|
||||||
|
return 'recaptcha';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == 'cloudflare_turnstile' || value == 'cloudflare-turnstile') {
|
||||||
|
return 'turnstile';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _firstNonEmptyString(Map<String, dynamic> source, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = source[key];
|
||||||
|
if (value == null) continue;
|
||||||
|
final text = value.toString().trim();
|
||||||
|
if (text.isNotEmpty) return text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import '../core/utils/file_utils.dart';
|
|||||||
|
|
||||||
/// 文件服务
|
/// 文件服务
|
||||||
class FileService {
|
class FileService {
|
||||||
|
|
||||||
/// 列出文件
|
/// 列出文件
|
||||||
Future<Map<String, dynamic>> listFiles({
|
Future<Map<String, dynamic>> listFiles({
|
||||||
required String uri,
|
required String uri,
|
||||||
@@ -23,12 +22,38 @@ class FileService {
|
|||||||
'next_page_token': nextPageToken,
|
'next_page_token': nextPageToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await ApiService.instance
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
.get<Map<String, dynamic>>('/file', queryParameters: params);
|
'/file',
|
||||||
|
queryParameters: params,
|
||||||
|
);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按 Cloudreve V4 预设分类列出文件。
|
||||||
|
///
|
||||||
|
/// category 可用值:image / video / audio / document
|
||||||
|
Future<Map<String, dynamic>> listFilesByCategory({
|
||||||
|
required String category,
|
||||||
|
int page = 0,
|
||||||
|
int? pageSize,
|
||||||
|
String? orderBy,
|
||||||
|
String? orderDirection,
|
||||||
|
String? nextPageToken,
|
||||||
|
}) {
|
||||||
|
final normalizedCategory = category.trim().toLowerCase();
|
||||||
|
final categoryUri = 'cloudreve://my?category=$normalizedCategory';
|
||||||
|
|
||||||
|
return listFiles(
|
||||||
|
uri: categoryUri,
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize,
|
||||||
|
orderBy: orderBy,
|
||||||
|
orderDirection: orderDirection,
|
||||||
|
nextPageToken: nextPageToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建文件/文件夹
|
/// 创建文件/文件夹
|
||||||
Future<Map<String, dynamic>> createFile({
|
Future<Map<String, dynamic>> createFile({
|
||||||
required String uri,
|
required String uri,
|
||||||
@@ -43,8 +68,10 @@ class FileService {
|
|||||||
'metadata': ?metadata,
|
'metadata': ?metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await ApiService.instance
|
final response = await ApiService.instance.post<Map<String, dynamic>>(
|
||||||
.post<Map<String, dynamic>>('/file/create', data: data);
|
'/file/create',
|
||||||
|
data: data,
|
||||||
|
);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -150,9 +177,7 @@ class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 恢复文件(从回收站)
|
/// 恢复文件(从回收站)
|
||||||
Future<void> restoreFiles({
|
Future<void> restoreFiles({required List<String> uris}) async {
|
||||||
required List<String> uris,
|
|
||||||
}) async {
|
|
||||||
final data = <String, dynamic>{
|
final data = <String, dynamic>{
|
||||||
'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(),
|
'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(),
|
||||||
};
|
};
|
||||||
@@ -171,8 +196,10 @@ class FileService {
|
|||||||
'page_size': pageSize,
|
'page_size': pageSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await ApiService.instance
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
.get<Map<String, dynamic>>('/file', queryParameters: params);
|
'/file',
|
||||||
|
queryParameters: params,
|
||||||
|
);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -195,8 +222,10 @@ class FileService {
|
|||||||
'page_size': pageSize,
|
'page_size': pageSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await ApiService.instance
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
.get<Map<String, dynamic>>('/file', queryParameters: params);
|
'/file',
|
||||||
|
queryParameters: params,
|
||||||
|
);
|
||||||
|
|
||||||
AppLogger.d('Search files ---------> : $response');
|
AppLogger.d('Search files ---------> : $response');
|
||||||
return response;
|
return response;
|
||||||
@@ -216,8 +245,10 @@ class FileService {
|
|||||||
'folder_summary': folderSummary,
|
'folder_summary': folderSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await ApiService.instance
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
.get<Map<String, dynamic>>('/file/info', queryParameters: params);
|
'/file/info',
|
||||||
|
queryParameters: params,
|
||||||
|
);
|
||||||
AppLogger.d("getFileInfo --> $response");
|
AppLogger.d("getFileInfo --> $response");
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1770,6 +1770,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
webview_flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: webview_flutter
|
||||||
|
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.13.1"
|
||||||
|
webview_flutter_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_android
|
||||||
|
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.12.0"
|
||||||
|
webview_flutter_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_platform_interface
|
||||||
|
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.15.1"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_wkwebview
|
||||||
|
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.25.1"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ dependencies:
|
|||||||
logger: ^2.7.0
|
logger: ^2.7.0
|
||||||
image_picker: ^1.2.2
|
image_picker: ^1.2.2
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
|
webview_flutter: ^4.13.0
|
||||||
|
|
||||||
# 桌面端
|
# 桌面端
|
||||||
window_manager: ^0.5.0
|
window_manager: ^0.5.0
|
||||||
|
|||||||
Reference in New Issue
Block a user