diff --git a/apps/mobile/lib/app/app.dart b/apps/mobile/lib/app/app.dart index 5ebb60b..66a658d 100644 --- a/apps/mobile/lib/app/app.dart +++ b/apps/mobile/lib/app/app.dart @@ -12,8 +12,7 @@ import 'locale_controller.dart'; import 'theme.dart'; /// 根导航键:[ApiClient.onUnauthorized](非 UI 上下文触发)跳转 /login 用。 -final GlobalKey rootNavigatorKey = - GlobalKey(); +final GlobalKey rootNavigatorKey = GlobalKey(); /// App 根组件:主题 + l10n 委托 + 命名路由 + 401 驱逐接线 + 引擎生命周期。 /// @@ -36,9 +35,12 @@ class _PhotoLiveAppState extends ConsumerState { void initState() { super.initState(); ref.read(apiClientProvider).onUnauthorized = () { - rootNavigatorKey.currentState - ?.pushNamedAndRemoveUntil('/login', (route) => false); - // 401 已清会话(ApiClient):刷新账号观察 → 引擎 stop。 + rootNavigatorKey.currentState?.pushNamedAndRemoveUntil( + '/login', + (route) => false, + ); + // 401 已清会话(ApiClient):刷新账号观察 → 引擎 stop;启动门 + // (home 路由仍在栈上时)随会话离场重建为登录页。 ref.read(sessionAccountProvider.notifier).refresh(); }; } @@ -84,7 +86,39 @@ class _PhotoLiveAppState extends ConsumerState { '/password-change': (context) => const PasswordChangePage(), '/server-history': (context) => const ServerHistoryPage(), }, - home: const LoginPage(), + // 启动门(UI06 用户反馈 2026-09-23:重启 App 不应强制重登):有未过期 + // 会话直接进主页,否则登录页。 + home: const _StartupGate(), + ); + } +} + +/// 启动门:读本地会话——未过期直接进 HomePage,否则 LoginPage。 +/// +/// 会话已持久化(SecureSessionStore,服务端 TTL 默认 30 天);此前的缺口 +/// 是启动固定落在登录页。token 实际失效(服务端已撤销)时由 ApiClient 401 +/// 兜底:清会话并整栈回登录页(onUnauthorized),过期本地判断只用于避免 +/// 「必 401 的闪跳」。 +class _StartupGate extends ConsumerStatefulWidget { + const _StartupGate(); + + @override + ConsumerState<_StartupGate> createState() => _StartupGateState(); +} + +class _StartupGateState extends ConsumerState<_StartupGate> { + @override + Widget build(BuildContext context) { + // 状态驱动(声明式):watch 会话账号——在场进 HomePage,离场回 + // LoginPage。401/登出清会话后 gate 自动重建,不依赖导航时序;home + // 路由已被显式导航替换(登录成功 pushReplacement '/home' 等)时, + // 401 兜底走 onUnauthorized 的 push。 + final account = ref.watch(sessionAccountProvider); + return account.maybeWhen( + skipLoadingOnReload: false, + orElse: () => + const Scaffold(body: Center(child: CircularProgressIndicator())), + data: (account) => account != null ? const HomePage() : const LoginPage(), ); } } diff --git a/apps/mobile/test/app/app_shell_test.dart b/apps/mobile/test/app/app_shell_test.dart index 1c053d8..eb2fec6 100644 --- a/apps/mobile/test/app/app_shell_test.dart +++ b/apps/mobile/test/app/app_shell_test.dart @@ -8,10 +8,10 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/app/app.dart'; +import 'package:photo_live/pages/login_page.dart'; import 'package:photo_live/core/api_client.dart'; import 'package:photo_live/core/error_codes.dart'; import 'package:photo_live/core/session_store.dart'; -import 'package:photo_live/pages/login_page.dart'; import '../helpers/fakes.dart'; @@ -43,10 +43,7 @@ void main() { ); await tester.pumpAndSettle(); - // 先抵 /home 再触发 401:验证清栈(回 /login 而非叠加路由)。 - rootNavigatorKey.currentState! - .pushNamedAndRemoveUntil('/home', (route) => false); - await tester.pumpAndSettle(); + // UI06 启动门:有会话直接渲染 HomePage(无须手动 push)。 // 触发 401 但不在测试体内 await(fake-async 下时钟由 pump 推进, // 阻塞等待会死锁);AppException 由 ignore 吞掉,断言在下方。 diff --git a/apps/mobile/test/widget_test.dart b/apps/mobile/test/widget_test.dart index b0e8c64..7b7467e 100644 --- a/apps/mobile/test/widget_test.dart +++ b/apps/mobile/test/widget_test.dart @@ -1,10 +1,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/app/app.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'helpers/fakes.dart'; void main() { testWidgets('boots to login page with localized copy', (tester) async { - await tester.pumpWidget(const ProviderScope(child: PhotoLiveApp())); + await tester.pumpWidget( + ProviderScope( + // UI06 启动门读会话:生产 SecureSessionStore 平台通道在测试环境 + // 挂起——替身空会话(→ 登录页)。 + overrides: [ + sessionStoreProvider.overrideWithValue(FakeSessionStore()), + ], + child: const PhotoLiveApp(), + ), + ); + // UI06 启动门:异步读本地会话(空 → 登录页)——多 pump 一帧等完成。 + await tester.pump(); + await tester.pump(); // 测试环境默认 locale 为 en:AppBar 标题与登录按钮均为英文文案。 expect(find.text('Photo Live'), findsOneWidget); diff --git a/apps/viewer/e2e/verify-modal.mjs b/apps/viewer/e2e/verify-modal.mjs new file mode 100644 index 0000000..8154e49 --- /dev/null +++ b/apps/viewer/e2e/verify-modal.mjs @@ -0,0 +1,40 @@ +import { createServer } from 'node:http'; +import { chromium } from '@playwright/test'; +const server = createServer((_q, res) => { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(` + `); +}); +await new Promise((r) => server.listen(3991, '127.0.0.1', r)); +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +await page.goto('http://127.0.0.1:3991'); +const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); +await frame.waitFor({ timeout: 20000 }); +await page.waitForTimeout(2500); +const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); + +// ① 标题条显示 +const title = await inside.locator('.stream-title').textContent(); +console.log('[title] =', title?.trim()); + +// ② 点击照片 → 宿主弹框(不再新标签) +const box = await frame.boundingBox(); +await page.mouse.move((box ? box.x : 0) + 90, (box ? box.y : 0) + 140); +await page.waitForTimeout(500); +let popupOpened = false; +page.once('popup', () => { popupOpened = true; }); +await inside.locator('a.stream-photo').first().click(); +const modalFrame = page.locator('iframe[title="PhotoLive photo detail"]'); +await modalFrame.waitFor({ timeout: 8000 }); +console.log('[modal] appeared = true; popup opened =', popupOpened); +await page.waitForTimeout(2500); +const detailUrl = page.frames().find((f) => f.url().includes('/p/'))?.url() ?? ''; +console.log('[modal] detail url =', detailUrl.slice(0, 80)); +await page.screenshot({ path: '../../docs/designs/ui04-implementation/actual/server/photo-modal-live.png' }); + +// ③ 关闭 +await page.locator('button[aria-label^="Close photo"], button[aria-label^="关闭照片"]').click(); +await modalFrame.waitFor({ state: 'detached', timeout: 5000 }); +console.log('[modal] closed = true'); +server.close(); await browser.close(); diff --git a/apps/viewer/e2e/verify-modal2.mjs b/apps/viewer/e2e/verify-modal2.mjs new file mode 100644 index 0000000..9638659 --- /dev/null +++ b/apps/viewer/e2e/verify-modal2.mjs @@ -0,0 +1,36 @@ +import { createServer } from 'node:http'; +import { chromium } from '@playwright/test'; +const server = createServer((_q, res) => { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(` +

Host page

+ `); +}); +await new Promise((r) => server.listen(3988, '127.0.0.1', r)); +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +await page.goto('http://127.0.0.1:3988'); +const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); +await frame.waitFor({ timeout: 20000 }); +await page.waitForTimeout(4000); +const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); +const title = await inside.locator('.stream-title').textContent().catch(() => null); +console.log('[title] =', title?.trim() ?? '(none)'); + +const box = await frame.boundingBox(); +await page.mouse.move((box ? box.x : 0) + 90, (box ? box.y : 0) + 150); +await page.waitForTimeout(600); +let popupOpened = false; +page.once('popup', () => { popupOpened = true; }); +await inside.locator('a.stream-photo').first().click(); +const modalFrame = page.locator('iframe[title="PhotoLive photo detail"]'); +await modalFrame.waitFor({ timeout: 8000 }); +console.log('[modal] appeared; popup =', popupOpened); +await page.waitForTimeout(2500); +const detailUrl = page.frames().find((f) => f.url().includes('/p/'))?.url() ?? ''; +console.log('[modal] detail =', detailUrl.slice(0, 90)); +await page.screenshot({ path: '../../docs/designs/ui04-implementation/actual/server/photo-modal-live.png' }); +await page.locator('button[aria-label^="Close photo"], button[aria-label^="关闭照片"]').click(); +await modalFrame.waitFor({ state: 'detached', timeout: 5000 }); +console.log('[modal] closed'); +server.close(); await browser.close(); diff --git a/apps/viewer/e2e/verify-modal3.mjs b/apps/viewer/e2e/verify-modal3.mjs new file mode 100644 index 0000000..f004495 --- /dev/null +++ b/apps/viewer/e2e/verify-modal3.mjs @@ -0,0 +1,35 @@ +import { createServer } from 'node:http'; +import { chromium } from '@playwright/test'; +const server = createServer((_q, res) => { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(` +

Host page

+ `); +}); +await new Promise((r) => server.listen(3987, '127.0.0.1', r)); +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +await page.goto('http://127.0.0.1:3987'); +const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); +await frame.waitFor({ timeout: 20000 }); +await page.waitForTimeout(4000); +const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); +console.log('[title] =', (await inside.locator('.stream-title').textContent().catch(() => '(none)'))?.trim()); + +const box = await frame.boundingBox(); +await page.mouse.move((box ? box.x : 0) + 90, (box ? box.y : 0) + 150); +await page.waitForTimeout(600); +let popupOpened = false; +page.once('popup', () => { popupOpened = true; }); +await inside.locator('a.stream-photo').first().click({ force: true, noWaitAfter: true }); +const modalFrame = page.locator('iframe[title="PhotoLive photo detail"]'); +await modalFrame.waitFor({ timeout: 8000 }); +console.log('[modal] appeared; popup =', popupOpened); +await page.waitForTimeout(2500); +const detailUrl = page.frames().find((f) => f.url().includes('/p/'))?.url() ?? ''; +console.log('[modal] detail =', detailUrl.slice(0, 90)); +await page.screenshot({ path: '../../docs/designs/ui04-implementation/actual/server/photo-modal-live.png' }); +await page.locator('button[aria-label^="Close photo"], button[aria-label^="关闭照片"]').click(); +await modalFrame.waitFor({ state: 'detached', timeout: 5000 }); +console.log('[modal] closed'); +server.close(); await browser.close();