feat: web login page (#1058)

This commit is contained in:
Levi authored and GitHub committed 2025-07-16 01:02:25 +08:00
1 parent 2927d1ef2b
commit 4f7b8a05ad
22 files changed
+771 -43

No files matched your search

+37 -8
View File
@@ -3,13 +3,17 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart' as getx;
import '../app/routes/app_pages.dart';
import '../database/database.dart';
import '../util/util.dart';
import 'model/create_task.dart';
import 'model/create_task_batch.dart';
import 'model/downloader_config.dart';
import 'model/extension.dart';
import 'model/install_extension.dart';
import 'model/login.dart';
import 'model/request.dart';
import 'model/resolve_result.dart';
import 'model/result.dart';
@@ -43,12 +47,27 @@ class _Client {
dio.options.sendTimeout = const Duration(seconds: 5);
dio.options.connectTimeout = const Duration(seconds: 5);
dio.options.receiveTimeout = const Duration(seconds: 60);
dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {
if (apiToken.isNotEmpty) {
options.headers['X-Api-Token'] = apiToken;
}
handler.next(options);
}));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
if (apiToken.isNotEmpty) {
options.headers['X-Api-Token'] = apiToken;
}
if (Util.isWeb()) {
final token = Database.instance.getWebToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
}
handler.next(options);
},
onError: (error, handler) {
// Only web version has a login page
if (Util.isWeb() && error.response?.statusCode == 401) {
getx.Get.rootDelegate.offAndToNamed(Routes.LOGIN);
}
handler.next(error);
},
));
_instance!.dio = dio;
if (isUnixSocket) {
@@ -96,7 +115,8 @@ Future<T> _parse<T>(
} on DioException catch (e) {
if (e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionTimeout) {
e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError) {
throw TimeoutException("request timeout");
}
throw Exception(Result(code: 1000, msg: e.message));
@@ -216,6 +236,11 @@ Future<void> updateExtension(String identity) async {
() => _client.dio.post("api/v1/extensions/$identity/update"), null);
}
Future<String> login(LoginReq loginReq) async {
return _parse(() => _client.dio.post("api/web/login", data: loginReq),
(data) => data as String);
}
Future<Response<String>> proxyRequest<T>(String uri,
{data, Options? options}) async {
options ??= Options();
@@ -230,5 +255,9 @@ Future<Response<String>> proxyRequest<T>(String uri,
}
String join(String path) {
return "${_client.dio.options.baseUrl}/${Util.cleanPath(path)}";
final baseUrl = _client.dio.options.baseUrl;
final cleanBaseUrl = baseUrl.endsWith('/')
? baseUrl.substring(0, baseUrl.length - 1)
: baseUrl;
return "$cleanBaseUrl/${Util.cleanPath(path)}";
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'login.g.dart';
@JsonSerializable()
class LoginReq {
String username;
String password;
LoginReq({
required this.username,
required this.password,
});
factory LoginReq.fromJson(Map<String, dynamic> json) =>
_$LoginReqFromJson(json);
Map<String, dynamic> toJson() => _$LoginReqToJson(this);
}
+17
View File
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LoginReq _$LoginReqFromJson(Map<String, dynamic> json) => LoginReq(
username: json['username'] as String,
password: json['password'] as String,
);
Map<String, dynamic> _$LoginReqToJson(LoginReq instance) => <String, dynamic>{
'username': instance.username,
'password': instance.password,
};
@@ -16,6 +16,7 @@ import '../../../../api/model/extension.dart';
import '../../../../api/model/install_extension.dart';
import '../../../../api/model/switch_extension.dart';
import '../../../../api/model/update_extension_settings.dart';
import '../../../../database/database.dart';
import '../../../../util/message.dart';
import '../../../../util/util.dart';
import '../../../views/icon_button_loading.dart';
@@ -135,6 +136,10 @@ class ExtensionView extends GetView<ExtensionController> {
'/fs/extensions/${extension.identity}/${extension.icon}'),
width: 48,
height: 48,
headers: {
'Authorization':
'Bearer ${Database.instance.getWebToken()}'
},
)
: Image.file(
extension.devMode
@@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/login_controller.dart';
class LoginBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<LoginController>(
() => LoginController(),
);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../api/api.dart' as api;
import '../../../../api/api.dart';
import '../../../../api/model/login.dart';
import '../../../../database/database.dart';
import '../../../../util/message.dart';
import '../../../routes/app_pages.dart';
import '../../app/controllers/app_controller.dart';
class LoginController extends GetxController {
final formKey = GlobalKey<FormState>();
final usernameController = TextEditingController();
final passwordController = TextEditingController();
final isLoading = false.obs;
final passwordVisible = false.obs;
@override
void onClose() {
usernameController.dispose();
passwordController.dispose();
super.onClose();
}
void togglePasswordVisibility() {
passwordVisible.value = !passwordVisible.value;
}
Future<void> login() async {
if (!formKey.currentState!.validate()) {
return;
}
isLoading.value = true;
try {
final loginReq = LoginReq(
username: usernameController.text.trim(),
password: passwordController.text,
);
final token = await api.login(loginReq);
// Login successful, save the token
Database.instance.saveWebToken(token);
// Reload config
final controller = Get.put(AppController());
await controller.loadDownloaderConfig();
// Navigate to home page
Get.rootDelegate.offAndToNamed(Routes.HOME);
} catch (e) {
if (e is TimeoutException) {
showMessage('error'.tr, 'login_failed_network'.tr);
} else {
showMessage('error'.tr, 'login_failed'.tr);
}
} finally {
isLoading.value = false;
}
}
}
@@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import '../../../views/responsive_builder.dart';
import '../controllers/login_controller.dart';
class LoginView extends GetView<LoginController> {
const LoginView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isNarrow = ResponsiveBuilder.isNarrow(context);
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Get.theme.colorScheme.surface,
Get.theme.colorScheme.surface.withOpacity(0.8),
Get.theme.colorScheme.surface.withOpacity(0.9),
],
),
),
child: Center(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: isNarrow ? 16.0 : 32.0,
vertical: isNarrow ? 24.0 : 32.0,
),
child: Card(
elevation: 12,
shadowColor: Get.theme.colorScheme.shadow.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(isNarrow ? 20 : 24),
),
color: Get.theme.colorScheme.surface,
child: Container(
constraints: BoxConstraints(
maxWidth: isNarrow ? double.infinity : 420,
),
padding: EdgeInsets.all(isNarrow ? 24.0 : 40.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(isNarrow ? 20 : 24),
border: Border.all(
color: Get.theme.colorScheme.outline.withOpacity(0.1),
width: 1,
),
),
child: FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Form(
key: controller.formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo/Title Section
Container(
alignment: Alignment.center,
margin: EdgeInsets.only(bottom: isNarrow ? 32 : 48),
child: Column(
children: [
Container(
padding: EdgeInsets.all(isNarrow ? 8 : 12),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(isNarrow ? 20 : 24),
boxShadow: [
BoxShadow(
color: Get.theme.colorScheme.primary
.withOpacity(0.2),
blurRadius: isNarrow ? 16 : 24,
offset: Offset(0, isNarrow ? 8 : 12),
spreadRadius: isNarrow ? 1 : 2,
),
],
),
child: ClipRRect(
borderRadius:
BorderRadius.circular(isNarrow ? 16 : 20),
child: SvgPicture.asset(
'assets/icon/icon.svg',
width: isNarrow ? 56 : 72,
height: isNarrow ? 56 : 72,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 8),
Text(
'Gopeed',
style: Get.textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.w900,
color: Get.theme.colorScheme.onSurface,
letterSpacing: 2.0,
fontSize: isNarrow ? 28 : 36,
),
),
],
),
),
// Username Field
FocusTraversalOrder(
order: const NumericFocusOrder(1.0),
child: TextFormField(
controller: controller.usernameController,
autofillHints: const [
AutofillHints.username,
],
autofocus: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => controller.login(),
decoration: InputDecoration(
labelText: 'username'.tr,
labelStyle: TextStyle(
color: Get.theme.colorScheme.onSurface
.withOpacity(0.7),
fontWeight: FontWeight.w500,
fontSize: 16,
),
prefixIcon: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Get.theme.colorScheme.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.person_outline_rounded,
color: Get.theme.colorScheme.primary,
size: 20,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.2),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.3),
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.primary,
width: 2.5,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2.5,
),
),
filled: true,
fillColor: Get
.theme.colorScheme.surfaceContainerHighest
.withOpacity(0.8),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 20,
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'username_required'.tr;
}
return null;
},
),
),
SizedBox(height: isNarrow ? 16 : 24),
// Password Field
FocusTraversalOrder(
order: const NumericFocusOrder(2.0),
child: Obx(() => TextFormField(
controller: controller.passwordController,
autofillHints: const [
AutofillHints.password,
],
obscureText: !controller.passwordVisible.value,
decoration: InputDecoration(
labelText: 'password'.tr,
labelStyle: TextStyle(
color: Get.theme.colorScheme.onSurface
.withOpacity(0.7),
fontWeight: FontWeight.w500,
fontSize: 16,
),
prefixIcon: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Get.theme.colorScheme.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.lock_outline_rounded,
color: Get.theme.colorScheme.primary,
size: 20,
),
),
suffixIcon: IconButton(
icon: Icon(
controller.passwordVisible.value
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
color: Get.theme.colorScheme.onSurface
.withOpacity(0.6),
),
onPressed:
controller.togglePasswordVisibility,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.2),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.3),
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.primary,
width: 2.5,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2.5,
),
),
filled: true,
fillColor: Get
.theme.colorScheme.surfaceContainerHighest
.withOpacity(0.8),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 20,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'password_required'.tr;
}
return null;
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => controller.login(),
)),
),
SizedBox(height: isNarrow ? 24 : 32),
// Login Button
Obx(() => ElevatedButton(
onPressed: controller.isLoading.value
? null
: controller.login,
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(
vertical: isNarrow ? 16 : 18,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor: controller.isLoading.value
? Get.theme.disabledColor
: Get.theme.colorScheme.primary,
foregroundColor:
Get.theme.colorScheme.onPrimary,
elevation: controller.isLoading.value ? 0 : 4,
shadowColor: Get.theme.colorScheme.primary
.withOpacity(0.4),
),
child: controller.isLoading.value
? SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor:
AlwaysStoppedAnimation<Color>(Get
.theme.colorScheme.onPrimary),
),
)
: Text(
'login'.tr,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
)),
],
),
),
),
),
),
),
),
),
);
}
}
+8
View File
@@ -6,6 +6,8 @@ import '../modules/extension/bindings/extension_binding.dart';
import '../modules/extension/views/extension_view.dart';
import '../modules/home/bindings/home_binding.dart';
import '../modules/home/views/home_view.dart';
import '../modules/login/bindings/login_binding.dart';
import '../modules/login/views/login_view.dart';
import '../modules/redirect/bindings/redirect_binding.dart';
import '../modules/redirect/views/redirect_view.dart';
import '../modules/root/bindings/root_binding.dart';
@@ -63,6 +65,12 @@ class AppPages {
binding: SettingBinding(),
),
]),
GetPage(
name: _Paths.LOGIN,
page: () => const LoginView(),
binding: LoginBinding(),
transition: Transition.fadeIn,
),
GetPage(
name: _Paths.CREATE,
transition: Transition.downToUp,
@@ -7,6 +7,7 @@ abstract class Routes {
static const ROOT = _Paths.ROOT;
static const HOME = _Paths.HOME;
static const CREATE = _Paths.CREATE;
static const LOGIN = _Paths.LOGIN;
static const TASK = _Paths.HOME + _Paths.TASK;
static const TASK_FILES = TASK + _Paths.TASK_FILES;
static const EXTENSION = _Paths.HOME + _Paths.EXTENSION;
@@ -20,6 +21,7 @@ abstract class _Paths {
static const ROOT = '/';
static const HOME = '/home';
static const CREATE = '/create';
static const LOGIN = '/login';
static const TASK = '/task';
static const TASK_FILES = '/files';
static const EXTENSION = '/extension';
+9 -1
View File
@@ -6,10 +6,10 @@ import 'package:hive/hive.dart';
import 'entity.dart';
const String _startConfig = 'startConfig';
const String _lastRunningConfig = 'lastRunningConfig';
const String _windowState = 'windowState';
const String _bookmark = 'bookmark';
const String _createHistory = 'createHistory';
const String _webToken = 'webToken';
class Database {
static final Database _instance = Database._internal();
@@ -82,6 +82,14 @@ class Database {
});
}
void saveWebToken(String token) {
save<String>(_webToken, token);
}
String? getWebToken() {
return get<String>(_webToken, (json) => json.toString());
}
void saveCreateHistory(String url) {
var list = getCreateHistory() ?? [];
list.remove(url);
+7
View File
@@ -118,5 +118,12 @@ const enUS = {
'fileSelectedSize': 'Size: ',
'httpHeaderName': 'Header Name',
'httpHeaderValue': 'Header Value',
'login': 'Login',
'username_required': 'Please enter your username',
'password_required': 'Please enter your password',
'login_success': 'Login successful',
'login_failed': 'Login failed, please check your username and password',
'login_failed_network':
'Login failed, please check your network connection',
},
};
+6
View File
@@ -116,5 +116,11 @@ const zhCN = {
'fileSelectedSize': '大小:',
'httpHeaderName': '请求头名称',
'httpHeaderValue': '请求头值',
'login': '登录',
'username_required': '请输入用户名',
'password_required': '请输入密码',
'login_success': '登录成功',
'login_failed': '登录失败,请检查用户名和密码',
'login_failed_network': '登录失败,请检查网络连接',
}
};
+40
View File
@@ -448,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.23"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b
url: "https://pub.dev"
source: hosted
version: "2.0.17"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -803,6 +811,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -1329,6 +1345,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
url: "https://pub.dev"
source: hosted
version: "1.1.18"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
url: "https://pub.dev"
source: hosted
version: "1.1.16"
vector_math:
dependency: transitive
description:
+2
View File
@@ -77,6 +77,7 @@ dependencies:
git:
url: https://github.com/hui-z/flutter_install_plugin.git
ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
flutter_svg: ^2.0.17
dependency_overrides:
permission_handler_windows:
git:
@@ -113,6 +114,7 @@ flutter:
assets:
- assets/tray_icon/
- assets/extension/
- assets/icon/
# Browser extension native host binary, see https://github.com/GopeedLab/gopeed/tree/main/cmd/host
- assets/exec/