From 8659a2e66e9db3d5a53f49664f224b2f23019ba7 Mon Sep 17 00:00:00 2001 From: Booker Date: Wed, 27 May 2026 12:53:47 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20H5=20=E7=BA=BF?= =?UTF-8?q?=E8=B7=AF=E5=88=87=E6=8D=A2=E5=8A=9F=E8=83=BD=EF=BC=9B=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8A=A8=E6=80=81=E5=8A=A0=E8=BD=BD=E5=92=8C=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E4=B8=8D=E5=90=8C=E7=BA=BF=E8=B7=AF=E7=9A=84=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++ lib/config/app_config.dart | 137 ++++++++++++++++- lib/main.dart | 293 ++++++++++++++++++++++++++++++++++++- test/widget_test.dart | 36 +++++ 4 files changed, 472 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9df2ead..5a29859 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,22 @@ Flutter WebView 套壳 App,默认加载: https://h5-test.imharry.work/ ``` +## H5 线路切换 + +线路切换在 Flutter 套壳层完成,H5 页面不需要承载线路切换逻辑。每条线路都是一个独立 H5 地址,切换时 WebView 会直接加载被选中的地址。 + +默认线路配置在: + +```text +openim_common/lib/src/config.dart +``` + +也可以在打包时覆盖: + +```bash +flutter build apk --release --dart-define=H5_LINE_URLS=https://h5-one.example/,https://h5-two.example/ +``` + ## 本地打包 ```bash diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 7ef8e2d..79bf696 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -1,12 +1,35 @@ +import 'package:openim_common/openim_common.dart' as openim_common; + enum Environment { production, } +class H5Line { + const H5Line({ + required this.label, + required this.url, + }); + + final String label; + final String url; + + Uri get uri => Uri.parse(url); + + String get displayAddress { + final parsed = uri; + final host = parsed.hasPort ? '${parsed.host}:${parsed.port}' : parsed.host; + final path = parsed.path.isEmpty || parsed.path == '/' ? '' : parsed.path; + return '$host$path'; + } +} + class AppConfig { static const Environment currentEnvironment = Environment.production; static const String appName = '本地打包'; static const String appLogo = ''; static const String canonicalWebHost = 'h5-test.imharry.work'; + static const String _dartDefinedH5LineUrls = + String.fromEnvironment('H5_LINE_URLS'); static const Set legacyWebHosts = { 'h5-test.imharry.work', }; @@ -18,14 +41,34 @@ class AppConfig { }; static List get environmentHosts { - return _environmentHosts[currentEnvironment] ?? const []; + final generatedHosts = _normalizedUniqueUrls( + openim_common.Config.environmentHosts, + ); + if (generatedHosts.isNotEmpty) { + return generatedHosts; + } + + return _normalizedUniqueUrls(_environmentHosts[currentEnvironment] ?? []); } - static String homeUrl({String? appName, String? appLogo}) { - final host = - environmentHosts.isNotEmpty ? environmentHosts.first : canonicalWebHost; - final uri = Uri.parse(_normalizeHomeUrl(host)); - return uri.toString(); + static List get h5Lines { + final configuredUrls = _dartDefinedH5LineUrls.trim().isEmpty + ? environmentHosts + : _normalizedUniqueUrls(_dartDefinedH5LineUrls.split(',')); + final urls = configuredUrls.isEmpty + ? _normalizedUniqueUrls(const [canonicalWebHost]) + : configuredUrls; + + return [ + for (var index = 0; index < urls.length; index += 1) + H5Line(label: '线路${index + 1}', url: urls[index]), + ]; + } + + static String homeUrl({int lineIndex = 0, String? appName, String? appLogo}) { + final lines = h5Lines; + final safeIndex = _safeLineIndex(lineIndex, lines.length); + return lines[safeIndex].url; } static String withFreshShellParams(String url) { @@ -39,13 +82,14 @@ class AppConfig { } final host = uri.host.toLowerCase(); - return legacyWebHosts.contains(host) || _hasShellParams(uri); + return (legacyWebHosts.contains(host) && !_isConfiguredH5Host(host)) || + _hasShellParams(uri); } static String canonicalizeMainFrameUrl(String url) { final uri = _removeShellParams(Uri.parse(url)); final host = uri.host.toLowerCase(); - if (!legacyWebHosts.contains(host)) { + if (!legacyWebHosts.contains(host) || _isConfiguredH5Host(host)) { return uri.toString(); } @@ -57,6 +101,21 @@ class AppConfig { .toString(); } + static int? lineIndexForUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null) { + return null; + } + + final lines = h5Lines; + for (var index = 0; index < lines.length; index += 1) { + if (_isSameLine(lines[index].uri, uri)) { + return index; + } + } + return null; + } + static Uri _removeShellParams(Uri uri) { final queryParameters = Map.from(uri.queryParameters); queryParameters.remove('flutter_shell'); @@ -85,8 +144,70 @@ class AppConfig { fragmentParameters.containsKey('shell_app_logo'); } + static int _safeLineIndex(int index, int length) { + if (length <= 0 || index < 0 || index >= length) { + return 0; + } + return index; + } + + static bool _isConfiguredH5Host(String host) { + return h5Lines.any((line) => line.uri.host.toLowerCase() == host); + } + + static bool _isSameLine(Uri lineUri, Uri uri) { + if (lineUri.host.toLowerCase() != uri.host.toLowerCase()) { + return false; + } + if (lineUri.hasPort && lineUri.port != uri.port) { + return false; + } + if (lineUri.scheme.isNotEmpty && lineUri.scheme != uri.scheme) { + return false; + } + + final linePath = _pathWithTrailingSlash(lineUri.path); + final uriPath = _pathWithTrailingSlash(uri.path); + return linePath == '/' || uriPath.startsWith(linePath); + } + + static String _pathWithTrailingSlash(String path) { + if (path.isEmpty) { + return '/'; + } + return path.endsWith('/') ? path : '$path/'; + } + + static List _normalizedUniqueUrls(Iterable values) { + final urls = []; + final seen = {}; + + for (final value in values) { + final normalized = _tryNormalizeHomeUrl(value); + if (normalized == null || seen.contains(normalized)) { + continue; + } + urls.add(normalized); + seen.add(normalized); + } + + return List.unmodifiable(urls); + } + + static String? _tryNormalizeHomeUrl(String value) { + try { + return _normalizeHomeUrl(value); + } catch (_) { + return null; + } + } + static String _normalizeHomeUrl(String host) { final value = host.trim(); + if (value.isEmpty) { + throw const FormatException('Empty H5 line URL'); + } + final normalized = value.startsWith('http://') || value.startsWith('https://') ? value diff --git a/lib/main.dart b/lib/main.dart index f10b2ed..804260c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -94,17 +94,22 @@ class H5ShellPage extends StatefulWidget { class _H5ShellPageState extends State { late final WebViewController _controller; + late final List _h5Lines; int _progress = 0; String? _loadError; bool _showShellCover = true; bool _shellBrandingLoaded = false; + int _currentLineIndex = 0; Timer? _shellCoverFallbackTimer; late ShellBranding _shellBranding; + H5Line get _currentLine => _h5Lines[_currentLineIndex]; + @override void initState() { super.initState(); + _h5Lines = AppConfig.h5Lines; _shellBranding = widget.initialShellBranding; _controller = _buildController(); unawaited(_loadHome()); @@ -125,10 +130,14 @@ class _H5ShellPageState extends State { setState(() => _progress = progress); } }, - onPageStarted: (_) { + onPageStarted: (url) { _shellCoverFallbackTimer?.cancel(); + final lineIndex = AppConfig.lineIndexForUrl(url); if (mounted) { setState(() { + if (lineIndex != null) { + _currentLineIndex = lineIndex; + } _loadError = null; _progress = 0; _showShellCover = true; @@ -169,7 +178,7 @@ class _H5ShellPageState extends State { } String _freshHomeUrl() { - return AppConfig.homeUrl(); + return AppConfig.homeUrl(lineIndex: _currentLineIndex); } Future _handlePageFinished() async { @@ -256,10 +265,14 @@ class _H5ShellPageState extends State { Future _loadUrl(String url) async { final targetUrl = AppConfig.canonicalizeMainFrameUrl(url); + final lineIndex = AppConfig.lineIndexForUrl(targetUrl); if (mounted) { _shellCoverFallbackTimer?.cancel(); setState(() { + if (lineIndex != null) { + _currentLineIndex = lineIndex; + } _loadError = null; _progress = 0; _showShellCover = true; @@ -269,6 +282,41 @@ class _H5ShellPageState extends State { await _controller.loadRequest(Uri.parse(targetUrl)); } + Future _loadLine(int index) async { + final safeIndex = index < 0 || index >= _h5Lines.length ? 0 : index; + if (mounted) { + setState(() { + _currentLineIndex = safeIndex; + }); + } + + await _loadUrl(_h5Lines[safeIndex].url); + } + + void _showLineSwitcher() { + showModalBottomSheet( + context: context, + useSafeArea: true, + showDragHandle: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return _LineSwitcherSheet( + lines: _h5Lines, + currentIndex: _currentLineIndex, + onSelect: (index) { + Navigator.of(sheetContext).pop(); + if (index != _currentLineIndex) { + unawaited(_loadLine(index)); + } + }, + ); + }, + ); + } + Future _handleNavigationRequest( NavigationRequest request, ) async { @@ -306,6 +354,10 @@ class _H5ShellPageState extends State { @override Widget build(BuildContext context) { + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + final showLineSwitch = + !_showShellCover && _loadError == null && bottomInset == 0; + return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { @@ -337,6 +389,16 @@ class _H5ShellPageState extends State { message: _loadError!, onRetry: () => unawaited(_loadHome()), ), + if (showLineSwitch) + Positioned( + left: 20, + right: 20, + bottom: MediaQuery.paddingOf(context).bottom + 14, + child: _LineSwitchBar( + currentLine: _currentLine, + onSwitch: _showLineSwitcher, + ), + ), ], ), ), @@ -386,6 +448,233 @@ class _ErrorPanel extends StatelessWidget { } } +class _LineSwitchBar extends StatelessWidget { + const _LineSwitchBar({ + required this.currentLine, + required this.onSwitch, + }); + + final H5Line currentLine; + final VoidCallback onSwitch; + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: SizedBox( + width: double.infinity, + child: Material( + color: Colors.white.withValues(alpha: 0.94), + elevation: 8, + shadowColor: const Color(0xFF0F4C81).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(32), + child: InkWell( + onTap: onSwitch, + borderRadius: BorderRadius.circular(32), + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + border: Border.all(color: const Color(0xFFE1EAF5)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.sync_rounded, + size: 19, + color: _shellSubText, + ), + const SizedBox(width: 8), + const Text( + '当前: ', + style: TextStyle( + color: _shellSubText, + fontSize: 15, + height: 1.2, + fontWeight: FontWeight.w600, + letterSpacing: 0, + ), + ), + Flexible( + child: Text( + currentLine.label, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFF17233D), + fontSize: 16, + height: 1.2, + fontWeight: FontWeight.w700, + letterSpacing: 0, + ), + ), + ), + const SizedBox(width: 12), + const Text( + '切换', + style: TextStyle( + color: _shellAccent, + fontSize: 15, + height: 1.2, + fontWeight: FontWeight.w700, + letterSpacing: 0, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _LineSwitcherSheet extends StatelessWidget { + const _LineSwitcherSheet({ + required this.lines, + required this.currentIndex, + required this.onSelect, + }); + + final List lines; + final int currentIndex; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final sheetHeight = MediaQuery.sizeOf(context).height * 0.58; + final maxHeight = sheetHeight > 420 ? 420.0 : sheetHeight; + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '切换线路', + style: TextStyle( + color: Color(0xFF17233D), + fontSize: 18, + height: 1.2, + fontWeight: FontWeight.w700, + letterSpacing: 0, + ), + ), + const SizedBox(height: 14), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: ListView.separated( + shrinkWrap: true, + itemCount: lines.length, + separatorBuilder: (context, index) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final line = lines[index]; + final selected = index == currentIndex; + + return _LineOptionTile( + line: line, + selected: selected, + onTap: () => onSelect(index), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _LineOptionTile extends StatelessWidget { + const _LineOptionTile({ + required this.line, + required this.selected, + required this.onTap, + }); + + final H5Line line; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: selected ? const Color(0xFFEFF7FF) : Colors.white, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + constraints: const BoxConstraints(minHeight: 64), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected ? _shellAccent : const Color(0xFFE1EAF5), + ), + ), + child: Row( + children: [ + Icon( + selected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + color: selected ? _shellAccent : _shellSubText, + size: 22, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + line.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFF17233D), + fontSize: 16, + height: 1.2, + fontWeight: FontWeight.w700, + letterSpacing: 0, + ), + ), + const SizedBox(height: 4), + Text( + line.displayAddress, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: _shellSubText, + fontSize: 12, + height: 1.2, + fontWeight: FontWeight.w500, + letterSpacing: 0, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right_rounded, + color: _shellSubText, + size: 22, + ), + ], + ), + ), + ), + ); + } +} + class _ShellFallback extends StatelessWidget { const _ShellFallback({required this.progress}); diff --git a/test/widget_test.dart b/test/widget_test.dart index dc9ab82..26cf647 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -30,6 +30,26 @@ void main() { isFalse); }); + test('exposes configured H5 URLs as Flutter shell lines', () { + final lines = AppConfig.h5Lines; + + expect(lines, isNotEmpty); + expect(lines.first.label, '线路1'); + expect(lines.first.url, AppConfig.homeUrl(lineIndex: 0)); + expect(Uri.parse(lines.first.url).host, 'h5-test.imharry.work'); + }); + + test('falls back to the first H5 line for invalid line indexes', () { + expect(AppConfig.homeUrl(lineIndex: -1), AppConfig.h5Lines.first.url); + expect(AppConfig.homeUrl(lineIndex: 99), AppConfig.h5Lines.first.url); + }); + + test('matches runtime URLs back to their Flutter shell line', () { + final lineUrl = AppConfig.h5Lines.first.url; + + expect(AppConfig.lineIndexForUrl('${lineUrl}login'), 0); + }); + test('refreshes an H5 route URL without adding branding to the URL', () { final uri = Uri.parse( AppConfig.withFreshShellParams( @@ -48,6 +68,22 @@ void main() { isFalse); }); + test('keeps independent non-legacy H5 hosts when removing shell params', () { + final uri = Uri.parse( + AppConfig.canonicalizeMainFrameUrl( + 'https://line-two.example/login?shell_app_name=Old' + '&flutter_shell=1#shell_app_logo=old', + ), + ); + + expect(uri.host, 'line-two.example'); + expect(uri.path, '/login'); + expect(uri.queryParameters.containsKey('shell_app_name'), isFalse); + expect(uri.queryParameters.containsKey('flutter_shell'), isFalse); + expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'), + isFalse); + }); + test('rewrites legacy H5 host main-frame URLs to the canonical host', () { final uri = Uri.parse( AppConfig.canonicalizeMainFrameUrl(