From 6277e86d0f95bbca6031fc79827dfec8d5a064e0 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 7 Jul 2026 00:13:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20SSO=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E6=8C=89=E9=92=AE=20+=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=A3=80=E6=9F=A5(=E9=95=9C=E5=83=8F=20jiu)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSO: web_launch.openWebUserCenter → POST /v1/auth/web-ticket → 打开 app.yanmeiai.com/sso?t=…; 设置页加「用户中心(网页)」入口。自动更新: update_provider 查 /version 语义比对 + update_dialog 「发现新版本」;设置页「检查更新」由空 onTap 接上。加 url_launcher(原缺)。flutter analyze 通过。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- client/lib/l10n/app_text.dart | 8 ++ client/lib/l10n/strings_en.dart | 14 ++++ client/lib/l10n/strings_zh.dart | 14 ++++ client/lib/screens/settings_page.dart | 23 +++++- client/lib/services/web_launch.dart | 29 +++++++ client/lib/state/update_provider.dart | 105 ++++++++++++++++++++++++++ client/lib/widgets/update_dialog.dart | 81 ++++++++++++++++++++ client/pubspec.yaml | 1 + 8 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 client/lib/services/web_launch.dart create mode 100644 client/lib/state/update_provider.dart create mode 100644 client/lib/widgets/update_dialog.dart diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 3e880e4..a44177f 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -148,6 +148,14 @@ abstract class AppText { String get killSwitch; String get killSwitchSub; String get checkUpdate; + String get webUserCenter; // 「用户中心(网页)」入口(App→Web SSO 免登) + // 更新检查(设置页「检查更新」手动触发) + String updateAvailableTitle(String version); // 「发现新版本 vX.Y.Z」 + String get updateNotesFallback; // 无 release_notes 时的兜底文案 + String get updateLater; + String get updateDownload; + String get updateUpToDate; // 检查后:已是最新版本 + String get updateCheckFailed; // 检查失败(网络异常) // ── 套餐选择 ── String get choosePlan; diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index b8935f8..77282c0 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -235,6 +235,20 @@ class StringsEn extends AppText { String get killSwitchSub => 'Block traffic if the link drops'; @override String get checkUpdate => 'Check for updates'; + @override + String get webUserCenter => 'User Center (Web)'; + @override + String updateAvailableTitle(String version) => 'New version v$version available'; + @override + String get updateNotesFallback => 'This update includes fixes and stability improvements.'; + @override + String get updateLater => 'Later'; + @override + String get updateDownload => 'Download update'; + @override + String get updateUpToDate => 'You are up to date'; + @override + String get updateCheckFailed => 'Update check failed, try again later'; @override String get choosePlan => 'Choose plan'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 2be6386..ece73e3 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -234,6 +234,20 @@ class StringsZh extends AppText { String get killSwitchSub => '断线时阻断网络,防止泄露'; @override String get checkUpdate => '检查更新'; + @override + String get webUserCenter => '用户中心(网页)'; + @override + String updateAvailableTitle(String version) => '发现新版本 v$version'; + @override + String get updateNotesFallback => '本次更新修复了一些问题并提升了稳定性,建议尽快更新。'; + @override + String get updateLater => '稍后'; + @override + String get updateDownload => '下载更新'; + @override + String get updateUpToDate => '已是最新版本'; + @override + String get updateCheckFailed => '检查更新失败,请稍后重试'; @override String get choosePlan => '选择套餐'; diff --git a/client/lib/screens/settings_page.dart b/client/lib/screens/settings_page.dart index d43cda6..b96f8a6 100644 --- a/client/lib/screens/settings_page.dart +++ b/client/lib/screens/settings_page.dart @@ -9,9 +9,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../l10n/app_text.dart'; import '../pangolin_theme.dart'; +import '../services/web_launch.dart'; import '../state/app_providers.dart'; import '../state/settings_provider.dart'; +import '../state/update_provider.dart'; import '../widgets/pangolin_icons.dart'; +import '../widgets/pangolin_toast.dart'; +import '../widgets/update_dialog.dart'; + +/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示, +/// 有更新则弹 [showUpdateDialog]。 +Future _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async { + final info = await ref.read(updateCheckerProvider).check(); + if (!context.mounted) return; + if (info == null) { + showPangolinToast(context, t.updateCheckFailed); + return; + } + if (!info.hasUpdate) { + showPangolinToast(context, t.updateUpToDate); + return; + } + await showUpdateDialog(context, t, info); +} class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @@ -59,7 +79,8 @@ class SettingsPage extends ConsumerWidget { right: sw(isDark, (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light), ), _Row(title: t.protocol, right: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))), - _Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () {}), + _Row(title: t.webUserCenter, right: Icon(PangolinIcons.externalLink, size: 18, color: c.fg3), onTap: () => openWebUserCenter(ref)), + _Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () => _checkForUpdate(context, ref, t)), _Row(title: 'Version', last: true, right: Text(version, style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))), ]), ]), diff --git a/client/lib/services/web_launch.dart b/client/lib/services/web_launch.dart new file mode 100644 index 0000000..26645a8 --- /dev/null +++ b/client/lib/services/web_launch.dart @@ -0,0 +1,29 @@ +// web_launch.dart — App→Web 单点登录跳转(SSO 换票)。 +// +// 「用户中心(网页)」入口:先向控制面签一张短时单次票据 +// (POST /v1/auth/web-ticket,需登录),再打开 用户中心网页版 的 +// /sso?t=<票>&redirect=<路径> 落地页兑票登录——避免用户在网页端重新输入密码。 +// 签票失败(未登录/网络异常)时降级为直接打开目标页(未登录态浏览)。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../state/account_providers.dart'; + +/// 用户中心(网页版)基址。 +const String kWebUserCenterBaseUrl = 'https://app.yanmeiai.com'; + +/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。 +Future openWebUserCenter(WidgetRef ref, {String path = '/'}) async { + Uri target = Uri.parse('$kWebUserCenterBaseUrl$path'); + try { + final resp = await ref.read(apiClientProvider).postJson('/v1/auth/web-ticket'); + final ticket = resp['ticket'] as String?; + if (ticket != null && ticket.isNotEmpty) { + target = Uri.parse( + '$kWebUserCenterBaseUrl/sso?t=$ticket&redirect=${Uri.encodeComponent(path)}'); + } + } catch (_) { + // 签票失败(未登录/网络异常)降级:直接打开目标页(未登录态浏览)。 + } + await launchUrl(target, mode: LaunchMode.externalApplication); +} diff --git a/client/lib/state/update_provider.dart b/client/lib/state/update_provider.dart new file mode 100644 index 0000000..148fa12 --- /dev/null +++ b/client/lib/state/update_provider.dart @@ -0,0 +1,105 @@ +// update_provider.dart — 应用更新检查(轻量版,无后台轮询)。 +// +// 手动触发(设置页「检查更新」)向控制面 GET $kApiBaseUrl/version 拉取最新版本信息, +// 与本地版本(package_info_plus)做语义比较;有更新则由调用方(settings_page)弹窗 +// 展示(见 widgets/update_dialog.dart)。检查失败静默返回 null,不影响主流程。 +// +// 对照 jiu client/lib/providers/update_provider.dart,去掉了定时轮询 Timer 与 +// dismiss 状态——按需求这里只做「手动检查」,启动期自动检查留作后续 TODO。 +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../services/api_config.dart'; + +/// 一次更新检查的结果。 +class AppUpdateInfo { + const AppUpdateInfo({ + required this.latestVersion, + required this.buildNumber, + required this.forceUpdate, + required this.releaseNotes, + required this.downloadUrls, + required this.hasUpdate, + }); + + final String latestVersion; + final int buildNumber; + final bool forceUpdate; + final String releaseNotes; + final Map downloadUrls; + final bool hasUpdate; +} + +/// 无状态更新检查服务,供设置页「检查更新」按钮直接调用。 +final updateCheckerProvider = Provider((ref) => const UpdateChecker()); + +class UpdateChecker { + const UpdateChecker(); + + /// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。 + Future check() async { + try { + final resp = await http + .get(Uri.parse('$kApiBaseUrl/version')) + .timeout(const Duration(seconds: 8)); + if (resp.statusCode != 200) return null; + final data = jsonDecode(resp.body) as Map; + + final latestVersion = data['version'] as String? ?? '0.0.0'; + final buildNumber = (data['build_number'] as num?)?.toInt() ?? 0; + final forceUpdate = data['force_update'] as bool? ?? false; + final releaseNotes = data['release_notes'] as String? ?? ''; + final rawUrls = data['download_urls'] as Map? ?? const {}; + final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? '')); + + final info = await PackageInfo.fromPlatform(); + final hasUpdate = _isNewer(latestVersion, info.version); + + return AppUpdateInfo( + latestVersion: latestVersion, + buildNumber: buildNumber, + forceUpdate: forceUpdate, + releaseNotes: releaseNotes, + downloadUrls: downloadUrls, + hasUpdate: hasUpdate, + ); + } catch (_) { + return null; + } + } + + /// 语义化版本比较:latest > current → true。容忍 1.1.4-dev / 1.1.4+7 等后缀。 + bool _isNewer(String latest, String current) { + final l = _parse(latest); + final c = _parse(current); + for (var i = 0; i < 3; i++) { + if (l[i] > c[i]) return true; + if (l[i] < c[i]) return false; + } + return false; + } + + List _parse(String v) { + final parts = v.split('.').map((s) { + final m = RegExp(r'^\d+').firstMatch(s); + return m == null ? 0 : int.parse(m.group(0)!); + }).toList(); + while (parts.length < 3) { + parts.add(0); + } + return parts; + } +} + +/// 按当前平台从服务端 download_urls 里取对应下载直链。 +String? platformDownloadUrl(Map downloadUrls) { + if (Platform.isMacOS) return downloadUrls['macos']; + if (Platform.isWindows) return downloadUrls['windows']; + if (Platform.isIOS) return downloadUrls['ios']; + if (Platform.isAndroid) return downloadUrls['android']; + return downloadUrls['web']; +} diff --git a/client/lib/widgets/update_dialog.dart b/client/lib/widgets/update_dialog.dart new file mode 100644 index 0000000..c59b8a8 --- /dev/null +++ b/client/lib/widgets/update_dialog.dart @@ -0,0 +1,81 @@ +// update_dialog.dart — 「发现新版本」更新提示弹窗。 +// +// 只做「展示 + 引导下载」:点「下载更新」用 url_launcher 打开对应平台的下载直链 +// (浏览器下载),不做应用内静默安装/自动重启。force_update=true 时不可关闭 +// (无「稍后」按钮、点遮罩/返回也关不掉)。 +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../l10n/app_text.dart'; +import '../pangolin_theme.dart'; +import '../state/update_provider.dart'; +import 'pangolin_icons.dart'; + +/// 展示更新弹窗。调用方(设置页「检查更新」)在拿到 `info.hasUpdate == true` 时调用。 +Future showUpdateDialog(BuildContext context, AppText t, AppUpdateInfo info) { + return showDialog( + context: context, + barrierDismissible: !info.forceUpdate, + builder: (ctx) => PopScope( + canPop: !info.forceUpdate, + child: _UpdateDialog(t: t, info: info), + ), + ); +} + +class _UpdateDialog extends StatelessWidget { + const _UpdateDialog({required this.t, required this.info}); + final AppText t; + final AppUpdateInfo info; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return AlertDialog( + backgroundColor: c.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)), + title: Row(children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration(color: c.accentSubtle, shape: BoxShape.circle), + child: Icon(PangolinIcons.zap, size: 18, color: c.accent), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + t.updateAvailableTitle(info.latestVersion), + overflow: TextOverflow.ellipsis, + style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700), + ), + ), + ]), + content: SingleChildScrollView( + child: Text( + info.releaseNotes.isEmpty ? t.updateNotesFallback : info.releaseNotes, + style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5), + ), + ), + actions: [ + if (!info.forceUpdate) + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(t.updateLater, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), + ), + TextButton( + onPressed: () async { + final url = platformDownloadUrl(info.downloadUrls); + if (url != null && url.isNotEmpty) { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + if (context.mounted) Navigator.of(context).pop(); + }, + child: Text(t.updateDownload, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), + ), + ], + ); + } +} diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 2536857..5e8aed7 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: flutter_secure_storage: ^9.2.2 # JWT token 安全存储 + 稳定 device_id 持久化 shared_preferences: ^2.5.5 package_info_plus: ^9.0.1 + url_launcher: ^6.3.0 # 打开外部链接(用户中心 SSO 免登 / 更新下载页) device_info_plus: ^11.2.0 # 设备名/平台(「我的设备」上报) uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4) launch_at_startup: ^0.5.1