From 8171fc59b005fdcc71d97b799adcadb33b5e62d9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 23:23:47 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E9=87=8D=E5=BC=80=E7=8A=B6=E6=80=81=E6=AE=8B?= =?UTF-8?q?=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md | 1 + src/components/auth/AuthGate.test.tsx | 62 +++++++++++++++++++ src/components/auth/LoginScreen.tsx | 20 ++++++ 3 files changed, 83 insertions(+) diff --git a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md index 8eac1260..2f6412bb 100644 --- a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md +++ b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md @@ -112,6 +112,7 @@ - 用户主动关闭弹窗时,只关闭弹窗,不改变当前平台页面 - 不清空首页浏览状态 - 不自动跳转到其他 tab +- 登录弹窗下次重新打开时必须恢复初始表单状态:回到默认登录页签、关闭重置密码面板、清空密码 / 验证码 / 图形验证码 / 提示 / 倒计时等本次草稿状态;只允许保留“最近一次成功登录手机号”的本地回填能力。 --- diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index cb879e3b..5b793f01 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -312,6 +312,68 @@ test('auth gate shows sms send feedback in the login modal', async () => { expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy(); }); +test('login modal resets draft state every time it is reopened', async () => { + const user = userEvent.setup(); + + authMocks.getAuthLoginOptions.mockResolvedValue({ + availableLoginMethods: ['phone', 'password'], + }); + + render( + + + , + ); + + await user.click(await screen.findByRole('button', { name: '进入作品' })); + + const firstDialog = screen.getByRole('dialog', { name: '账号入口' }); + await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000'); + await user.click(within(firstDialog).getByRole('button', { name: '获取验证码' })); + + expect( + await within(firstDialog).findByText('短信请求已提交,验证码有效期约 5 分钟。'), + ).toBeTruthy(); + await user.type(within(firstDialog).getByLabelText('验证码'), '123456'); + await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' })); + await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd'); + await user.click(within(firstDialog).getByRole('button', { name: '忘记密码' })); + + expect( + screen.getByRole('dialog', { name: '重置密码' }), + ).toBeTruthy(); + + await user.click( + screen.getByRole('button', { name: '关闭登录弹窗' }), + ); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); + }); + + await user.click(screen.getByRole('button', { name: '进入作品' })); + + const reopenedDialog = screen.getByRole('dialog', { name: '账号入口' }); + expect( + within(reopenedDialog) + .getByRole('tab', { name: '短信登录' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect( + (within(reopenedDialog).getByLabelText('手机号') as HTMLInputElement).value, + ).toBe(''); + expect( + (within(reopenedDialog).getByLabelText('验证码') as HTMLInputElement).value, + ).toBe(''); + expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull(); + expect( + within(reopenedDialog).queryByText('短信请求已提交,验证码有效期约 5 分钟。'), + ).toBeNull(); + expect( + within(reopenedDialog).getByRole('button', { name: '获取验证码' }), + ).toBeTruthy(); +}); + test('auth gate separates sms and password login by tabs', async () => { const user = userEvent.setup(); diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 02aa9bb0..11634992 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -75,6 +75,26 @@ export function LoginScreen({ const wechatLoginEnabled = availableLoginMethods.includes('wechat'); const [activeLoginTab, setActiveLoginTab] = useState('phone'); + useEffect(() => { + if (!isOpen) { + return; + } + + // 每次重新打开弹窗都丢弃上一次未完成的表单草稿,只保留最近成功登录手机号回填。 + setIsResetPanelOpen(false); + setPhone(getStoredLastLoginPhone()); + setPassword(''); + setCode(''); + setResetPhone(''); + setResetCode(''); + setResetPasswordValue(''); + setCaptchaAnswer(''); + setCooldownSeconds(0); + setResetCooldownSeconds(0); + setHint(''); + setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password'); + }, [isOpen, phoneLoginEnabled]); + useEffect(() => { if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) { setActiveLoginTab('password'); From f43295b47110f858b333e1b23708f26fe0ace3fb Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 23:31:42 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E9=A6=96=E5=B1=8F=E5=9B=BE=E7=89=87=E7=AD=89=E5=BE=85=E9=97=A8?= =?UTF-8?q?=E9=97=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ROUTE_IMAGE_READY_GATE_2026-04-25.md | 25 ++ src/main.tsx | 8 +- src/routing/RouteImageReadyGate.test.ts | 44 ++++ src/routing/RouteImageReadyGate.tsx | 233 ++++++++++++++++++ src/routing/routeImageReadyGateUtils.ts | 111 +++++++++ 5 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md create mode 100644 src/routing/RouteImageReadyGate.test.ts create mode 100644 src/routing/RouteImageReadyGate.tsx create mode 100644 src/routing/routeImageReadyGateUtils.ts diff --git a/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md b/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md new file mode 100644 index 00000000..ddb9277d --- /dev/null +++ b/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md @@ -0,0 +1,25 @@ +# 路由首屏图片等待门闩 2026-04-25 + +## 背景 + +平台首页、作品详情、拼图运行态等页面会在首屏展示作品封面、角色图、场景背景图或拼图原图。此前页面主体可能先进入,图片随后分批出现,移动端尤其容易看到背景图闪入、封面占位与真实图片切换。 + +## 落地约束 + +1. 入口路由组件加载完成后,页面主体先挂载但保持不可见,继续让业务数据请求、图片请求和布局计算正常发生。 +2. 门闩扫描当前路由根节点内的 ``、`src/currentSrc` 和 CSS `background-image / border-image-source` 中的 `url(...)`。 +3. 扫描到的图片统一通过 `Image()` 预加载;图片成功、失败或超时都视为 settled,避免单张异常图片阻塞整站进入。 +4. 所有已发现图片 settled 且 DOM 短暂稳定后,再一次性显示页面主体。 +5. 门闩只负责前端呈现时机,不承接作品封面选择、资产读链路、玩法逻辑或后端数据裁决。 + +## 体验规则 + +- 等待态继续复用 `RouteLoadingScreen`,只显示简短加载文案,不在 UI 中追加规则说明。 +- 页面主体隐藏时使用 `visibility: hidden`,不能用 `display: none`,否则浏览器可能不触发布局与图片加载。 +- 图片加载失败不直接改写业务 UI;后续仍由原页面的兜底图、占位图或错误态处理。 + +## 涉及文件 + +- `src/routing/RouteImageReadyGate.tsx` +- `src/routing/RouteImageReadyGate.test.ts` +- `src/main.tsx` diff --git a/src/main.tsx b/src/main.tsx index 36380773..8728ced9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import {StrictMode, Suspense} from 'react'; import {createRoot} from 'react-dom/client'; import {resolveAppRoute} from './routing/appRoutes'; +import {RouteImageReadyGate} from './routing/RouteImageReadyGate'; import {RouteLoadingScreen} from './routing/RouteLoadingScreen'; type AppRoot = ReturnType; @@ -29,7 +30,12 @@ const RouteComponent = route.Component; root.render( }> - + + + , ); diff --git a/src/routing/RouteImageReadyGate.test.ts b/src/routing/RouteImageReadyGate.test.ts new file mode 100644 index 00000000..fd1bf04f --- /dev/null +++ b/src/routing/RouteImageReadyGate.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; + +import { + collectRouteImageUrls, + extractCssImageUrls, + normalizePreloadImageUrl, +} from './routeImageReadyGateUtils'; + +describe('RouteImageReadyGate image url helpers', () => { + it('extracts urls from layered CSS image values', () => { + expect( + extractCssImageUrls( + 'linear-gradient(#000,#111), url("/hero.png"), url("icons/card.webp")', + ), + ).toEqual(['/hero.png', 'icons/card.webp']); + }); + + it('normalizes preloadable urls against the current document', () => { + expect(normalizePreloadImageUrl('/cover.png')).toBe( + new URL('/cover.png', document.baseURI).href, + ); + expect(normalizePreloadImageUrl('data:image/png;base64,abc')).toBe( + 'data:image/png;base64,abc', + ); + expect(normalizePreloadImageUrl('')).toBeNull(); + }); + + it('collects img and CSS background urls from a route root', () => { + const root = document.createElement('section'); + root.innerHTML = ` + +
+
+ `; + + expect(collectRouteImageUrls(root)).toEqual([ + new URL('/images/card.png', document.baseURI).href, + new URL('/images/bg.webp', document.baseURI).href, + new URL('/ui/frame.png', document.baseURI).href, + ]); + }); +}); diff --git a/src/routing/RouteImageReadyGate.tsx b/src/routing/RouteImageReadyGate.tsx new file mode 100644 index 00000000..2613c2c8 --- /dev/null +++ b/src/routing/RouteImageReadyGate.tsx @@ -0,0 +1,233 @@ +import { type ReactNode, useEffect, useRef, useState } from 'react'; + +import { + collectRouteImageUrls, + hasCssImageUrlChange, + type ImagePreloadResult, +} from './routeImageReadyGateUtils'; +import { RouteLoadingScreen } from './RouteLoadingScreen'; + +type RouteImageReadyGateProps = { + children: ReactNode; + eyebrow: string; + text: string; +}; + +const IMAGE_GATE_QUIET_MS = 140; +const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260; +const IMAGE_PRELOAD_TIMEOUT_MS = 12000; + +const settledImageUrls = new Set(); +const imagePreloadTasks = new Map>(); + +function preloadImageUrl(url: string) { + const existingTask = imagePreloadTasks.get(url); + if (existingTask) { + return existingTask; + } + + const task = new Promise((resolve) => { + if (typeof Image === 'undefined') { + resolve({ url, status: 'loaded' }); + return; + } + + const image = new Image(); + let settled = false; + + const settle = (status: ImagePreloadResult['status']) => { + if (settled) { + return; + } + + settled = true; + window.clearTimeout(timeoutId); + settledImageUrls.add(url); + resolve({ url, status }); + }; + + const timeoutId = window.setTimeout(() => { + settle('timeout'); + }, IMAGE_PRELOAD_TIMEOUT_MS); + + image.onload = () => settle('loaded'); + image.onerror = () => settle('failed'); + image.decoding = 'async'; + image.src = url; + + if (image.complete) { + settle(image.naturalWidth > 0 ? 'loaded' : 'failed'); + } + }); + + imagePreloadTasks.set(url, task); + return task; +} + +/** + * 路由首屏图片门闩:业务页面先真实挂载但不可见, + * 等当前 DOM 中已发现的图片全部 settled 后再一次性显示。 + */ +export function RouteImageReadyGate({ + children, + eyebrow, + text, +}: RouteImageReadyGateProps) { + const rootRef = useRef(null); + const startTimeRef = useRef(0); + const scanTimerRef = useRef(null); + const revealTimerRef = useRef(null); + const scanVersionRef = useRef(0); + const [ready, setReady] = useState(false); + + useEffect(() => { + const root = rootRef.current; + if (!root) { + return; + } + + let disposed = false; + startTimeRef.current = window.performance.now(); + setReady(false); + + const clearScanTimer = () => { + if (scanTimerRef.current !== null) { + window.clearTimeout(scanTimerRef.current); + scanTimerRef.current = null; + } + }; + + const clearRevealTimer = () => { + if (revealTimerRef.current !== null) { + window.clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + }; + + const scheduleScan = () => { + clearScanTimer(); + scanTimerRef.current = window.setTimeout(runScan, IMAGE_GATE_QUIET_MS); + }; + + const scheduleReveal = (version: number) => { + clearRevealTimer(); + + const elapsed = window.performance.now() - startTimeRef.current; + const delay = Math.max( + IMAGE_GATE_QUIET_MS, + IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed, + ); + + revealTimerRef.current = window.setTimeout(() => { + if (disposed || version !== scanVersionRef.current) { + return; + } + + const pendingUrls = collectRouteImageUrls(root).filter( + (url) => !settledImageUrls.has(url), + ); + if (pendingUrls.length > 0) { + scheduleScan(); + return; + } + + setReady(true); + }, delay); + }; + + function runScan() { + if (disposed) { + return; + } + + const version = scanVersionRef.current + 1; + scanVersionRef.current = version; + const pendingUrls = collectRouteImageUrls(root).filter( + (url) => !settledImageUrls.has(url), + ); + + if (pendingUrls.length === 0) { + scheduleReveal(version); + return; + } + + // 已进入页面但新 DOM 批量挂载图片时,先回到等待态,避免图片逐张闪入。 + setReady(false); + void Promise.allSettled(pendingUrls.map(preloadImageUrl)).then(() => { + if (disposed || version !== scanVersionRef.current) { + return; + } + scheduleScan(); + }); + } + + const observer = new MutationObserver((mutations) => { + if (disposed) { + return; + } + + const shouldRescan = mutations.some((mutation) => { + if (mutation.type === 'childList') { + return ( + mutation.addedNodes.length > 0 || + mutation.removedNodes.length > 0 + ); + } + if (mutation.type !== 'attributes') { + return false; + } + if ( + mutation.attributeName === 'src' || + mutation.attributeName === 'srcset' + ) { + return true; + } + return mutation.attributeName === 'style' + ? hasCssImageUrlChange(mutation) + : false; + }); + + if (!shouldRescan) { + return; + } + + clearRevealTimer(); + scheduleScan(); + }); + + observer.observe(root, { + attributes: true, + attributeFilter: ['src', 'srcset', 'style'], + attributeOldValue: true, + childList: true, + subtree: true, + }); + scheduleScan(); + + return () => { + disposed = true; + observer.disconnect(); + clearScanTimer(); + clearRevealTimer(); + }; + }, []); + + return ( + <> +
+ {children} +
+ {!ready ? ( +
+ +
+ ) : null} + + ); +} diff --git a/src/routing/routeImageReadyGateUtils.ts b/src/routing/routeImageReadyGateUtils.ts new file mode 100644 index 00000000..4b9b290c --- /dev/null +++ b/src/routing/routeImageReadyGateUtils.ts @@ -0,0 +1,111 @@ +const CSS_IMAGE_URL_PATTERN = + /url\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\)/gu; + +export type ImagePreloadResult = { + url: string; + status: 'loaded' | 'failed' | 'timeout'; +}; + +export function extractCssImageUrls(value: string) { + const urls: string[] = []; + CSS_IMAGE_URL_PATTERN.lastIndex = 0; + + let match = CSS_IMAGE_URL_PATTERN.exec(value); + while (match) { + const rawUrl = match[1] ?? match[2] ?? match[3] ?? ''; + const normalizedRawUrl = rawUrl.trim(); + if (normalizedRawUrl) { + urls.push(normalizedRawUrl); + } + match = CSS_IMAGE_URL_PATTERN.exec(value); + } + + return urls; +} + +export function normalizePreloadImageUrl(rawUrl: string) { + const trimmedUrl = rawUrl.trim(); + if (!trimmedUrl || trimmedUrl === 'none' || trimmedUrl.startsWith('#')) { + return null; + } + + if ( + trimmedUrl.startsWith('data:') || + trimmedUrl.startsWith('blob:') || + trimmedUrl.startsWith('http://') || + trimmedUrl.startsWith('https://') + ) { + return trimmedUrl; + } + + const baseUrl = + typeof document === 'undefined' + ? 'http://localhost/' + : document.baseURI; + + try { + return new URL(trimmedUrl, baseUrl).href; + } catch { + return null; + } +} + +function addNormalizedImageUrl(urls: Set, rawUrl: string | null) { + if (!rawUrl) { + return; + } + + const normalizedUrl = normalizePreloadImageUrl(rawUrl); + if (normalizedUrl) { + urls.add(normalizedUrl); + } +} + +export function hasCssImageUrlChange(mutation: MutationRecord) { + if (!(mutation.target instanceof HTMLElement)) { + return false; + } + + const previousUrls = mutation.oldValue + ? extractCssImageUrls(mutation.oldValue) + : []; + const currentUrls = [ + ...extractCssImageUrls(mutation.target.style.backgroundImage), + ...extractCssImageUrls(mutation.target.style.borderImageSource), + ...extractCssImageUrls(mutation.target.style.listStyleImage), + ]; + + if (previousUrls.length !== currentUrls.length) { + return true; + } + + return currentUrls.some((url, index) => url !== previousUrls[index]); +} + +export function collectRouteImageUrls(root: HTMLElement) { + const urls = new Set(); + const elements = [root, ...Array.from(root.querySelectorAll('*'))]; + + root.querySelectorAll('img').forEach((image) => { + addNormalizedImageUrl(urls, image.currentSrc); + addNormalizedImageUrl(urls, image.getAttribute('src')); + }); + + elements.forEach((element) => { + const computedStyle = window.getComputedStyle(element); + [ + element.style.backgroundImage, + element.style.borderImageSource, + element.style.listStyleImage, + computedStyle.backgroundImage, + computedStyle.borderImageSource, + computedStyle.listStyleImage, + ].forEach((cssImageValue) => { + extractCssImageUrls(cssImageValue).forEach((url) => { + addNormalizedImageUrl(urls, url); + }); + }); + }); + + return Array.from(urls); +} From 5eb37d595b89554be08c8af0f1020ae2abe657e3 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 23:34:14 +0800 Subject: [PATCH 03/10] fix: add logged-out login entry --- ...ND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md | 3 + .../RpgEntryHomeView.recharge.test.tsx | 59 +++++++++++++++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 19 ++++-- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md index 2f6412bb..ce2bf5af 100644 --- a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md +++ b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md @@ -150,6 +150,8 @@ - 不再提供 `AuthGate` 层右上角固定悬浮的全局登录 / 账号信息入口 - 登录触发统一来自页面内受保护动作、个人页、存档页等明确入口 - 账号信息面板只通过页面内按钮打开,不在平台右上角常驻悬浮 +- 未登录移动端底部导航不展示“我的”时,平台页头必须保留一个直接可点的 `登录` 入口,避免用户只能通过受保护动作被动触发弹窗 +- 桌面端平台页头的账号胶囊在未登录时主文案必须直接显示 `登录`,不能只显示“进入账户”这类弱入口 ## 4.2 平台首页数据加载 @@ -222,3 +224,4 @@ 3. 未登录选择 RPG 创作类型时,直接弹出登录弹窗,登录后自动进入创作工作台。 4. 登录弹窗内没有介绍性大段文字,只剩必要输入与按钮。 5. 未登录态首页不会因个人接口失败而出现“读取个人看板失败”“读取作品库失败”之类报错。 +6. 未登录移动端首页页头存在明确 `登录` 入口,点击后打开同一个登录弹窗。 diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index c92bcda3..8613fecf 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -157,6 +157,55 @@ function renderProfileView(onRechargeSuccess = vi.fn()) { ); } +function renderLoggedOutHomeView(openLoginModal = vi.fn()) { + return render( + undefined), + musicVolume: 0.42, + setMusicVolume: vi.fn(), + platformTheme: 'light', + setPlatformTheme: vi.fn(), + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + }} + > + + , + ); +} + afterEach(() => { vi.clearAllMocks(); }); @@ -175,3 +224,13 @@ test('opens recharge modal and submits points product', async () => { await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1)); }); + +test('shows a reachable login entry in logged out mobile shell', async () => { + const user = userEvent.setup(); + const openLoginModal = vi.fn(); + + renderLoggedOutHomeView(openLoginModal); + await user.click(screen.getByRole('button', { name: '登录' })); + + expect(openLoginModal).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 0cdd2478..46104f65 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -10,6 +10,7 @@ import { Copy, Crown, House, + LogIn, MessageCircle, Pencil, Search, @@ -1896,8 +1897,18 @@ export function RpgEntryHomeView({ if (!isDesktopLayout) { return (
-
+
+ {!isAuthenticated ? ( + + ) : null}
@@ -1912,7 +1923,7 @@ export function RpgEntryHomeView({ }} >
{visibleTabs.map((tab) => ( - {authUi?.user?.displayName || '进入账户'} + {authUi?.user?.displayName || '登录'} - {authUi?.user ? publicUserCode : '登录后同步作品与进度'} + {authUi?.user ? publicUserCode : '账号入口'} From c5d783d3e65a659cf40fcd79dadf794b4dacda60 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 23:51:50 +0800 Subject: [PATCH 04/10] feat: add unified modal shell --- docs/design/README.md | 1 + .../UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md | 93 ++++++++ .../big-fish-result/BigFishResultView.tsx | 50 ++-- src/components/common/UnifiedModal.test.tsx | 58 +++++ src/components/common/UnifiedModal.tsx | 220 ++++++++++++++++++ .../PlatformEntryCreationTypeModal.tsx | 89 +++---- 6 files changed, 433 insertions(+), 78 deletions(-) create mode 100644 docs/design/UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md create mode 100644 src/components/common/UnifiedModal.test.tsx create mode 100644 src/components/common/UnifiedModal.tsx diff --git a/docs/design/README.md b/docs/design/README.md index aaac0fbf..e365c5e8 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,6 +11,7 @@ - [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。 - [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。 - [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。 +- [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。 - [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。 - [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。 - [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。 diff --git a/docs/design/UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md b/docs/design/UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md new file mode 100644 index 00000000..f18dd21e --- /dev/null +++ b/docs/design/UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md @@ -0,0 +1,93 @@ +# 统一模态窗口设计 2026-04-25 + +## 背景 + +当前前端已有两套稳定视觉资产: + +- 平台侧:`platform-overlay`、`platform-modal-shell`、`platform-auth-card` 等主题变量。 +- RPG 运行时:`pixel-nine-slice`、`pixel-modal-shell` 与 `UI_CHROME.modalPanel` 九宫格边框。 + +但弹窗结构仍分散在业务组件内,常见重复包括遮罩层、点击遮罩关闭、`role="dialog"`、`aria-modal`、移动端底部贴边、桌面居中、最大高度、滚动区域和关闭按钮。新增弹窗时容易出现 z-index、无障碍属性、移动端高度和视觉边界不一致。 + +## 目标 + +新增统一组件 `UnifiedModal`,只负责弹窗外壳和交互边界,不接管业务内容: + +- 统一遮罩、面板、标题区、内容区、底部区结构。 +- 支持平台风与像素风两种外观,不混用两套视觉资产。 +- 默认移动端优先,平台风移动端底部弹出、桌面居中;像素风保持游戏内居中弹窗。 +- 默认提供 `role="dialog"`、`aria-modal`、标题关联、Escape 关闭和遮罩点击关闭。 +- 支持禁用关闭,用于生成中、保存中等不可打断流程。 +- 支持 Portal 渲染到 `document.body`,避免被父层 `overflow` 裁剪。 + +## 非目标 + +- 不一次性迁移所有旧弹窗,避免运行时大面积回归。 +- 不把业务按钮、表单、状态文案放进通用组件。 +- 不改变现有主题变量、九宫格素材、平台和 RPG 的视觉风格。 +- 不新增第三方弹窗库。 + +## 组件接口 + +`UnifiedModal` 核心参数: + +| 参数 | 说明 | +| --- | --- | +| `open` | 是否显示。为 `false` 时返回 `null`。 | +| `variant` | `platform` 或 `pixel`。默认 `platform`。 | +| `title` | 标题,同时作为默认 `aria-label` 来源。 | +| `description` | 可选副标题,显示在标题下方。 | +| `children` | 主内容区。 | +| `footer` | 可选底部操作区。 | +| `onClose` | 关闭回调。 | +| `closeDisabled` | 禁止遮罩、Escape 和关闭按钮触发关闭。 | +| `closeOnBackdrop` | 是否允许点击遮罩关闭,默认允许。 | +| `showCloseButton` | 是否显示右上关闭按钮,默认显示。 | +| `size` | `sm`、`md`、`lg`、`xl`、`fullscreen`。 | +| `zIndexClassName` | z-index class,默认 `z-[90]`。 | +| `panelClassName` / `bodyClassName` / `footerClassName` | 局部样式扩展。 | +| `portal` | 是否渲染到 `document.body`,默认开启。 | + +## 使用边界 + +### 平台风弹窗 + +用于平台首页、登录注册、作品结果、创作工作台等非 RPG 运行时界面。 + +要求: + +- 使用 `variant="platform"`。 +- 面板使用 `platform-modal-shell` 主题变量。 +- 移动端优先底部贴边,大屏居中。 +- 不在弹窗内放功能说明式长文案,只放任务所需信息。 + +### 像素风弹窗 + +用于 RPG 运行时、地图、背包、角色详情、NPC 交易等游戏内面板。 + +要求: + +- 使用 `variant="pixel"`。 +- 面板使用 `pixel-nine-slice pixel-modal-shell`。 +- 默认使用 `getNineSliceStyle(UI_CHROME.modalPanel)`。 +- 标题、内容和底部仍由业务方控制,避免通用组件内写入玩法解释。 + +## 首批迁移 + +首批只迁移平台入口创作类型弹窗: + +- 文件:`src/components/platform-entry/PlatformEntryCreationTypeModal.tsx` +- 目的:验证平台风布局、关闭禁用、标题区、内容区与错误区都可由统一组件承载。 + +后续可按风险由低到高迁移: + +1. 结果页小弹窗:`PuzzleResultView`、`BigFishResultView`。 +2. 平台创作页编辑器弹窗:`RpgCreationEntityEditorShared` 内局部 `ModalShell`。 +3. RPG 运行时像素风弹窗:`RpgAdventurePanelOverlays`、`AdventureEntityModal`、`NpcModals`。 + +## 验收标准 + +- 新增弹窗优先使用 `UnifiedModal`,不再手写完整 overlay + panel 结构。 +- 迁移后的弹窗保留原有移动端和桌面布局。 +- 关闭按钮、遮罩关闭、Escape 行为一致,`closeDisabled` 时都不会关闭。 +- 类型检查、编码检查通过。 diff --git a/src/components/big-fish-result/BigFishResultView.tsx b/src/components/big-fish-result/BigFishResultView.tsx index 27447760..83829875 100644 --- a/src/components/big-fish-result/BigFishResultView.tsx +++ b/src/components/big-fish-result/BigFishResultView.tsx @@ -16,6 +16,7 @@ import type { BigFishSessionSnapshotResponse, ExecuteBigFishActionRequest, } from '../../../packages/shared/src/contracts/bigFish'; +import { UnifiedModal } from '../common/UnifiedModal'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type BigFishAssetStudioTarget = @@ -537,38 +538,37 @@ function BigFishResultErrorModal({ onClose: () => void; }) { return ( -
-
-
-
- -
-
-
- 发布失败 -
-
- {message} -
-
-
+ 知道了 + )} + footerClassName="border-t-0 px-5 pb-5 pt-0" + > +
+
+ +
+
+ {message} +
-
+ ); } diff --git a/src/components/common/UnifiedModal.test.tsx b/src/components/common/UnifiedModal.test.tsx new file mode 100644 index 00000000..29997ac7 --- /dev/null +++ b/src/components/common/UnifiedModal.test.tsx @@ -0,0 +1,58 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +import { UnifiedModal } from './UnifiedModal'; + +test('renders an accessible platform modal', () => { + render( + {}} portal={false}> +
窗口内容
+
, + ); + + expect(screen.getByRole('dialog', { name: '统一弹窗' })).toBeTruthy(); + expect(screen.getByText('窗口内容')).toBeTruthy(); +}); + +test('closes through backdrop and escape', () => { + const onClose = vi.fn(); + const { rerender } = render( + +
窗口内容
+
, + ); + + fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement); + expect(onClose).toHaveBeenCalledTimes(1); + + rerender( + +
窗口内容
+
, + ); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(2); +}); + +test('respects closeDisabled for every default close path', () => { + const onClose = vi.fn(); + render( + +
窗口内容
+
, + ); + + fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement); + fireEvent.keyDown(window, { key: 'Escape' }); + fireEvent.click(screen.getByRole('button', { name: '关闭' })); + + expect(onClose).not.toHaveBeenCalled(); +}); diff --git a/src/components/common/UnifiedModal.tsx b/src/components/common/UnifiedModal.tsx new file mode 100644 index 00000000..ed442562 --- /dev/null +++ b/src/components/common/UnifiedModal.tsx @@ -0,0 +1,220 @@ +import { X } from 'lucide-react'; +import { + type CSSProperties, + type ReactNode, + useEffect, + useId, +} from 'react'; +import { createPortal } from 'react-dom'; + +import { getNineSliceStyle, UI_CHROME } from '../../uiAssets'; + +type UnifiedModalVariant = 'platform' | 'pixel'; +type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; + +type UnifiedModalProps = { + open: boolean; + title: string; + description?: ReactNode; + children: ReactNode; + footer?: ReactNode; + onClose: () => void; + variant?: UnifiedModalVariant; + size?: UnifiedModalSize; + closeDisabled?: boolean; + closeOnBackdrop?: boolean; + showCloseButton?: boolean; + closeLabel?: string; + portal?: boolean; + zIndexClassName?: string; + overlayClassName?: string; + panelClassName?: string; + headerClassName?: string; + bodyClassName?: string; + footerClassName?: string; + panelStyle?: CSSProperties; +}; + +const PLATFORM_SIZE_CLASS: Record = { + sm: 'max-w-md', + md: 'max-w-xl', + lg: 'max-w-3xl', + xl: 'max-w-5xl', + fullscreen: 'max-w-[min(100vw,76rem)] sm:h-[min(92vh,60rem)]', +}; + +const PIXEL_SIZE_CLASS: Record = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-3xl', + xl: 'max-w-5xl', + fullscreen: 'max-w-[min(96vw,64rem)]', +}; + +function joinClassNames(...classNames: Array) { + return classNames.filter(Boolean).join(' '); +} + +function getPanelStyle( + variant: UnifiedModalVariant, + panelStyle: CSSProperties | undefined, +) { + if (variant !== 'pixel') { + return panelStyle; + } + + return { + ...getNineSliceStyle(UI_CHROME.modalPanel), + ...panelStyle, + }; +} + +function UnifiedModalContent({ + open, + title, + description, + children, + footer, + onClose, + variant = 'platform', + size = 'md', + closeDisabled = false, + closeOnBackdrop = true, + showCloseButton = true, + closeLabel = '关闭', + zIndexClassName = 'z-[90]', + overlayClassName, + panelClassName, + headerClassName, + bodyClassName, + footerClassName, + panelStyle, +}: Omit) { + const titleId = useId(); + const descriptionId = useId(); + + useEffect(() => { + if (!open || closeDisabled) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [closeDisabled, onClose, open]); + + if (!open) { + return null; + } + + const isPixel = variant === 'pixel'; + const sizeClassName = isPixel + ? PIXEL_SIZE_CLASS[size] + : PLATFORM_SIZE_CLASS[size]; + + const overlayClasses = isPixel + ? 'fixed inset-0 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4' + : 'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4'; + + const panelClasses = isPixel + ? 'pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]' + : 'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]'; + + const headerClasses = isPixel + ? 'flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4' + : 'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5'; + + const titleClasses = isPixel + ? 'truncate text-sm font-semibold text-white' + : 'text-base font-semibold text-[var(--platform-text-strong)]'; + + const descriptionClasses = isPixel + ? 'mt-1 text-xs leading-5 text-zinc-400' + : 'mt-1 text-xs leading-5 text-[var(--platform-text-base)]'; + + const bodyClasses = isPixel + ? 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5' + : 'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5'; + + const footerClasses = isPixel + ? 'flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4' + : 'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5'; + + const closeButtonClasses = isPixel + ? 'rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-45' + : 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45'; + + return ( +
{ + if ( + closeOnBackdrop && + !closeDisabled && + event.target === event.currentTarget + ) { + onClose(); + } + }} + > +
event.stopPropagation()} + > +
+
+
+ {title} +
+ {description ? ( +
+ {description} +
+ ) : null} +
+ {showCloseButton ? ( + + ) : null} +
+
+ {children} +
+ {footer ? ( +
+ {footer} +
+ ) : null} +
+
+ ); +} + +/** + * 统一模态窗口外壳。 + * 业务组件只传入标题、内容和操作区;遮罩、无障碍属性、Escape 与移动端布局在这里收口。 + */ +export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) { + if (!portal || typeof document === 'undefined') { + return ; + } + + return createPortal(, document.body); +} diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index 5dc2fddb..fdde7c8b 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -1,5 +1,6 @@ -import { ArrowRight, X } from 'lucide-react'; +import { ArrowRight } from 'lucide-react'; +import { UnifiedModal } from '../common/UnifiedModal'; import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes'; export interface PlatformEntryCreationTypeModalProps { @@ -79,58 +80,40 @@ export function PlatformEntryCreationTypeModal({ } return ( -
-
-
-
-
-
- 选择创作类型 -
-
- 先选玩法类型,再进入对应创作工作台。 -
-
- -
- -
-
- {PLATFORM_CREATION_TYPES.map((item) => ( - { - if (item.id === 'rpg') { - onSelectRpg(); - } - if (item.id === 'big-fish') { - onSelectBigFish(); - } - if (item.id === 'puzzle') { - onSelectPuzzle(); - } - }} - /> - ))} -
- - {error ? ( -
- {error} -
- ) : null} -
-
+ +
+ {PLATFORM_CREATION_TYPES.map((item) => ( + { + if (item.id === 'rpg') { + onSelectRpg(); + } + if (item.id === 'big-fish') { + onSelectBigFish(); + } + if (item.id === 'puzzle') { + onSelectPuzzle(); + } + }} + /> + ))}
-
+ + {error ? ( +
+ {error} +
+ ) : null} + ); } From c9a59f9edbfc4f2d905dffe4573945dfe559e9b7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 00:05:19 +0800 Subject: [PATCH 05/10] fix: clear creation hub cache on logout --- ...HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md | 26 +++++ docs/technical/README.md | 1 + src/components/auth/AuthGate.test.tsx | 63 +++++++++++- src/components/auth/AuthGate.tsx | 64 ++++++++++--- .../PlatformEntryFlowShellImpl.tsx | 64 ++++++++++++- ...gEntryFlowShell.agent.interaction.test.tsx | 95 +++++++++++++++++++ 6 files changed, 295 insertions(+), 18 deletions(-) create mode 100644 docs/technical/CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md diff --git a/docs/technical/CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md b/docs/technical/CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md new file mode 100644 index 00000000..399add56 --- /dev/null +++ b/docs/technical/CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md @@ -0,0 +1,26 @@ +# 创作中心退出登录私有缓存清理修复 2026-04-25 + +## 问题 + +点击退出登录后,页面未刷新时仍能切到创作中心,并看到上一位登录用户的作品。刷新页面后才恢复正常。 + +## 根因 + +1. `AuthGate` 的退出动作先等待 `/api/auth/logout` 完成,再通过全局鉴权事件重新 hydrate,期间前端 context 仍可能暴露旧用户。 +2. 平台创作入口里的 RPG works 会在 `canReadProtectedData=false` 时清空,但大鱼吃小鱼与拼图 works 是 `PlatformEntryFlowShellImpl` 内部 state,没有在退出登录时同步清空。 +3. 创作 Tab 会保持挂载以降低闪烁,因此私有作品数组只要留在内存里,就会继续被货架组件渲染。 + +## 修复口径 + +1. 用户触发退出当前设备或退出全部设备时,前端必须先本地收回 `user / canAccessProtectedData`,再等待后端吊销会话。 +2. `canReadProtectedData` 从 `true` 变为未登录态 `false` 时,创作中心必须清空所有私有作品缓存: + - RPG works / library 由 `useRpgEntryBootstrap` 清空。 + - Big Fish works、Puzzle works 由 `PlatformEntryFlowShellImpl` 清空。 + - 当前创作工作区、结果页、删除忙碌态与生成态一并复位。 +3. 公开广场与分类数据不受影响,仍按匿名公开接口读取。 + +## 验收 + +1. 点击退出登录后,不刷新页面进入创作 Tab,只能看到空作品货架,不再出现上一账号作品。 +2. 退出登录瞬间 `AuthUiContext.user` 为 `null`,`canAccessProtectedData=false`。 +3. 重新登录后按新账号重新拉取作品列表,不复用旧账号内存缓存。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 1a89ccef..8c68f645 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -14,6 +14,7 @@ - [CHARACTER_VISUAL_IP_MODERATION_FALLBACK_FIX_2026-04-25.md](./CHARACTER_VISUAL_IP_MODERATION_FALLBACK_FIX_2026-04-25.md):记录角色主形象遇到 DashScope `IPInfringementSuspect` 时自动改用原创安全 prompt 兜底重试的修复口径,并保留供应商原始错误便于排查。 - [CREATION_AGENT_IMMEDIATE_WAITING_DOTS_FIX_2026-04-25.md](./CREATION_AGENT_IMMEDIATE_WAITING_DOTS_FIX_2026-04-25.md):记录创作 Agent 用户发送消息后立刻展示三点等待动画的前端展示条件,避免首个 SSE token 到达前聊天区无反馈。 - [CREATION_AGENT_DOCUMENT_INPUT_UPLOAD_2026-04-25.md](./CREATION_AGENT_DOCUMENT_INPUT_UPLOAD_2026-04-25.md):冻结 Agent 创作页上传文本类文档并解析为输入框内容的前后端边界、接口、支持范围和验收标准。 +- [CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md](./CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md):记录退出登录后创作中心仍显示上一账号作品的前端缓存根因,并冻结退出时立即收回鉴权上下文、清空三类私有作品货架缓存的修复口径。 - [CREATION_AGENT_CLIENT_AND_FLOW_CONTROLLER_REUSE_2026-04-25.md](./CREATION_AGENT_CLIENT_AND_FLOW_CONTROLLER_REUSE_2026-04-25.md):冻结三类作品创作 Agent client 通用工厂与平台轻量流程 controller 的复用边界,明确本轮只收口 HTTP/SSE 骨架和大鱼/拼图会话流程,不合并 RPG 自动保存主链。 - [BACKEND_CREATION_AGENT_LLM_TURN_COMMONIZATION_2026-04-25.md](./BACKEND_CREATION_AGENT_LLM_TURN_COMMONIZATION_2026-04-25.md):冻结后端创作 Agent LLM turn 公共化边界,收口模型可用性检查、流式 JSON 回复抽取、最终 JSON 解析与中文错误映射,玩法 schema 和写回逻辑继续留在各自模块。 - [CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md](./CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md):冻结创作中心作品货架统一视图模型,先在前端归一 RPG、大鱼、拼图 works 的展示字段、筛选状态和卡片动作语义,不新增后端聚合接口。 diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 5b793f01..a661263b 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -17,6 +17,8 @@ const authMocks = vi.hoisted(() => ({ getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), loginWithPhoneCode: vi.fn(), + logoutAllAuthSessions: vi.fn(), + logoutAuthUser: vi.fn(), resetPassword: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), @@ -44,8 +46,8 @@ vi.mock('../../services/authService', () => ({ getCaptchaChallengeFromError: vi.fn(() => null), liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, - logoutAllAuthSessions: vi.fn(), - logoutAuthUser: vi.fn(), + logoutAllAuthSessions: authMocks.logoutAllAuthSessions, + logoutAuthUser: authMocks.logoutAuthUser, resetPassword: authMocks.resetPassword, revokeAuthSession: vi.fn(), sendPhoneLoginCode: authMocks.sendPhoneLoginCode, @@ -96,6 +98,8 @@ beforeEach(() => { authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); authMocks.authEntry.mockResolvedValue(mockUser); authMocks.changePassword.mockResolvedValue(mockUser); + authMocks.logoutAllAuthSessions.mockResolvedValue(undefined); + authMocks.logoutAuthUser.mockResolvedValue(undefined); authMocks.resetPassword.mockResolvedValue(mockUser); authMocks.sendPhoneLoginCode.mockResolvedValue({ cooldownSeconds: 60, @@ -139,6 +143,27 @@ function PlatformTabStateProbe() { ); } +function LogoutStateProbe() { + const authUi = useAuthUi(); + + return ( +
+
当前用户:{authUi?.user?.displayName ?? '未登录'}
+
+ 私有数据:{authUi?.canAccessProtectedData ? '可读取' : '不可读取'} +
+ +
+ ); +} + test('auth gate keeps platform content visible when phone login is available', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], @@ -276,6 +301,40 @@ test('auth state refresh keeps mounted platform content and local tab state', as expect(screen.getByText('当前Tab:创作')).toBeTruthy(); }); +test('logout withdraws user context before backend request finishes', async () => { + const user = userEvent.setup(); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + + let resolveLogout!: () => void; + const logoutPromise = new Promise((resolve) => { + resolveLogout = resolve; + }); + authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise); + + render( + + + , + ); + + expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); + expect(screen.getByText('私有数据:可读取')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '退出登录' })); + + expect(await screen.findByText('当前用户:未登录')).toBeTruthy(); + expect(screen.getByText('私有数据:不可读取')).toBeTruthy(); + expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveLogout(); + await logoutPromise; + }); +}); + test('auth gate shows sms send feedback in the login modal', async () => { const user = userEvent.setup(); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 195e4c03..f2a1c011 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -113,6 +113,50 @@ export function AuthGate({ children }: AuthGateProps) { setStatus('ready'); }, []); + const clearLocalAuthenticatedState = useCallback(() => { + // 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。 + // 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。 + pendingProtectedActionRef.current = null; + setUser(null); + setStatus('unauthenticated'); + setShowLoginModal(false); + setShowSettingsModal(false); + setInitialSettingsSection(null); + setSessions([]); + setAuditLogs([]); + setRiskBlocks([]); + setLoginCaptchaChallenge(null); + setBindCaptchaChallenge(null); + setChangePhoneCaptchaChallenge(null); + setError(''); + }, []); + + const logoutCurrentSession = useCallback(async () => { + clearLocalAuthenticatedState(); + try { + await logoutAuthUser(); + } catch (logoutError) { + setError( + logoutError instanceof Error + ? logoutError.message + : '退出登录失败,请刷新页面确认状态。', + ); + } + }, [clearLocalAuthenticatedState]); + + const logoutAllSessions = useCallback(async () => { + clearLocalAuthenticatedState(); + try { + await logoutAllAuthSessions(); + } catch (logoutError) { + setError( + logoutError instanceof Error + ? logoutError.message + : '退出全部设备失败,请刷新页面确认状态。', + ); + } + }, [clearLocalAuthenticatedState]); + const closeLoginModal = useCallback(() => { pendingProtectedActionRef.current = null; setShowLoginModal(false); @@ -400,10 +444,7 @@ export function AuthGate({ children }: AuthGateProps) { requireAuth, openSettingsModal, openAccountModal, - logout: async () => { - await logoutAuthUser(); - setShowSettingsModal(false); - }, + logout: logoutCurrentSession, musicVolume: settings.musicVolume, setMusicVolume: settings.setMusicVolume, platformTheme: settings.platformTheme, @@ -418,6 +459,7 @@ export function AuthGate({ children }: AuthGateProps) { openSettingsModal, readyUser, requireAuth, + logoutCurrentSession, status, settings.isHydratingSettings, settings.isPersistingSettings, @@ -494,9 +536,7 @@ export function AuthGate({ children }: AuthGateProps) { } }} onLogout={async () => { - await logoutAuthUser(); - setUser(null); - setStatus('unauthenticated'); + await logoutCurrentSession(); }} /> ); @@ -551,10 +591,7 @@ export function AuthGate({ children }: AuthGateProps) { settingsError={settings.settingsError} onClose={() => setShowSettingsModal(false)} onPlatformThemeChange={settings.setPlatformTheme} - onLogout={async () => { - await logoutAuthUser(); - setShowSettingsModal(false); - }} + onLogout={logoutCurrentSession} onRefreshRiskBlocks={async () => { setLoadingRiskBlocks(true); try { @@ -625,10 +662,7 @@ export function AuthGate({ children }: AuthGateProps) { ); } }} - onLogoutAll={async () => { - await logoutAllAuthSessions(); - setShowSettingsModal(false); - }} + onLogoutAll={logoutAllSessions} changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} onSendChangePhoneCode={async (phone, captcha) => { try { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index bd41e520..0756721b 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -373,6 +373,7 @@ export function PlatformEntryFlowShellImpl({ const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< string | null >(null); + const hadReadableProtectedDataRef = useRef(false); const hasInitialAgentSession = Boolean( readCustomWorldAgentUiState().activeSessionId, ); @@ -934,7 +935,13 @@ export function PlatformEntryFlowShellImpl({ const setIsPuzzleBusy = puzzleFlow.setIsBusy; const streamingPuzzleReplyText = puzzleFlow.streamingReplyText; const isStreamingPuzzleReply = puzzleFlow.isStreamingReply; - + const resetRpgSessionViewState = sessionController.resetSessionViewState; + const setRpgGeneratedCustomWorldProfile = + sessionController.setGeneratedCustomWorldProfile; + const setRpgCustomWorldError = sessionController.setCustomWorldError; + const persistRpgAgentUiState = sessionController.persistAgentUiState; + const resetAutoSaveTrackingToIdle = + autosaveCoordinator.resetAutoSaveTrackingToIdle; const openBigFishAgentWorkspace = useCallback(async () => { setBigFishRun(null); await bigFishFlow.openWorkspace(); @@ -946,6 +953,61 @@ export function PlatformEntryFlowShellImpl({ await puzzleFlow.openWorkspace(); }, [puzzleFlow]); + useEffect(() => { + if (platformBootstrap.canReadProtectedData) { + hadReadableProtectedDataRef.current = true; + return; + } + + if (authUi?.user || !hadReadableProtectedDataRef.current) { + return; + } + + hadReadableProtectedDataRef.current = false; + + // 创作中心只展示当前登录用户的私有作品。 + // 一旦退出登录或鉴权上下文被收回,三类作品缓存必须同步清空,不能等刷新页面。 + setShowCreationTypeModal(false); + setSelectedDetailEntry(null); + setBigFishWorks([]); + setBigFishRun(null); + setBigFishGenerationState(null); + setBigFishError(null); + setPuzzleOperation(null); + setPuzzleWorks([]); + setSelectedPuzzleDetail(null); + setPuzzleRun(null); + setPuzzleGenerationState(null); + setIsPuzzleNextLevelGenerating(false); + setPuzzleError(null); + setDeletingCreationWorkId(null); + resetRpgSessionViewState(); + setRpgGeneratedCustomWorldProfile(null); + setRpgCustomWorldError(null); + persistRpgAgentUiState(null, null); + resetAutoSaveTrackingToIdle(); + + if ( + selectionStage !== 'platform' && + selectionStage !== 'detail' && + selectionStage !== 'puzzle-gallery-detail' + ) { + setSelectionStage('platform'); + } + }, [ + authUi?.user, + platformBootstrap.canReadProtectedData, + persistRpgAgentUiState, + resetAutoSaveTrackingToIdle, + resetRpgSessionViewState, + selectionStage, + setBigFishError, + setPuzzleError, + setRpgCustomWorldError, + setRpgGeneratedCustomWorldProfile, + setSelectionStage, + ]); + const handleCreationHubCreateType = useCallback( (type: PlatformCreationTypeId) => { if (type === 'airp' || type === 'visual-novel') { diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index fd53f831..050403c5 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -1301,6 +1301,101 @@ test('clicking a public work while logged out routes through requireAuth', async expect(getRpgEntryWorldGalleryDetail).not.toHaveBeenCalled(); }); +test('creation hub clears all private work shelves immediately after logout state', async () => { + const user = userEvent.setup(); + const loggedInAuth = createAuthValue(); + const loggedOutAuth = createAuthValue({ + user: null, + canAccessProtectedData: false, + openLoginModal: () => {}, + requireAuth: () => {}, + }); + + vi.mocked(listRpgCreationWorks).mockResolvedValue([ + { + workId: 'draft:rpg-logout-cache-1', + sourceType: 'agent_session', + status: 'draft', + title: 'RPG 退出缓存作品', + subtitle: '登出后不应继续可见', + summary: '这条 RPG 私有作品只能在登录态展示。', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: '2026-04-25T10:00:00.000Z', + publishedAt: null, + stage: 'clarifying', + stageLabel: '补齐关键锚点', + playableNpcCount: 0, + landmarkCount: 0, + roleVisualReadyCount: 0, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: 'rpg-logout-cache-session', + profileId: null, + canResume: true, + canEnterWorld: false, + }, + ]); + vi.mocked(listBigFishWorks).mockResolvedValue({ + items: [ + { + workId: 'big-fish-logout-cache-1', + sourceSessionId: 'big-fish-logout-cache-session', + title: '大鱼退出缓存作品', + subtitle: '登出后不应继续可见', + summary: '这条大鱼私有作品只能在登录态展示。', + coverImageSrc: null, + status: 'draft', + updatedAt: '2026-04-25T10:05:00.000Z', + publishReady: false, + levelCount: 8, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + }, + ], + }); + vi.mocked(listPuzzleWorks).mockResolvedValue({ + items: [ + { + workId: 'puzzle-logout-cache-1', + profileId: 'puzzle-logout-cache-profile', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-logout-cache-session', + authorDisplayName: '测试玩家', + levelName: '拼图退出缓存作品', + summary: '这条拼图私有作品只能在登录态展示。', + themeTags: ['退出态'], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-04-25T10:10:00.000Z', + publishedAt: null, + playCount: 0, + publishReady: false, + }, + ], + }); + + const { rerender } = render(); + + await openCreationHub(user); + const createPanel = getPlatformTabPanel('create'); + + expect(await within(createPanel).findByText('RPG 退出缓存作品')).toBeTruthy(); + expect(await within(createPanel).findByText('大鱼退出缓存作品')).toBeTruthy(); + expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy(); + + rerender(); + + await waitFor(() => { + expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull(); + expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull(); + expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull(); + }); + expect(within(createPanel).getByText('还没有作品')).toBeTruthy(); +}); + test('published puzzle works appear on home and category public shelves', async () => { const user = userEvent.setup(); const publishedPuzzleWork = { From c4b9b8173fc857283752a38ae20f2514ff30e989 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 00:07:12 +0800 Subject: [PATCH 06/10] fix deploy env bom handling --- .../JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md | 31 ++++++++++ ..._RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md | 2 +- docs/technical/README.md | 1 + ...ND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md | 13 +++-- scripts/deploy-rust-remote.sh | 56 +++++++++++++++++-- scripts/jenkins-deploy-release.sh | 26 +++++++++ 6 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 docs/technical/JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md diff --git a/docs/technical/JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md b/docs/technical/JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md new file mode 100644 index 00000000..e6ccc00c --- /dev/null +++ b/docs/technical/JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md @@ -0,0 +1,31 @@ +# Jenkins 部署环境文件 BOM 修复 + +日期:`2026-04-25` + +## 1. 问题 + +Jenkins 部署阶段执行固定目录内的 `start.sh` 时失败: + +```text +/var/lib/jenkins/deploy/Genarrative/.env.local: line 1: VITE_LLM_BASE_URL=...: No such file or directory +``` + +根因是 `.env.local` 第一行包含 UTF-8 BOM。旧版 `start.sh` 直接 `source .env.local`,BOM 会成为变量名前缀,Bash 无法按赋值语句解析,进而把整行当作命令执行。日志末尾的 sudo 提示只是 hook 执行失败后的兜底提示,不是本次失败的真实根因。 + +## 2. 修复口径 + +1. 发布包构建脚本复制 `.env`、`.env.local` 到发布目录和 `web/` 目录后,统一移除 UTF-8 BOM 与 CRLF。 +2. Jenkins 部署脚本在移动发布产物前后,再次净化发布目录和固定部署目录中的 `.env`、`.env.local`,兼容已经构建出来但尚未部署成功的旧发布包。 +3. 新生成的 `start.sh` 不再直接 `source` 环境文件,而是按 `KEY=value` 子集解析、导出合法变量,并跳过空行、注释和不合法行。 +4. `start.sh` 仍保留 `.env` 先于 `.env.local` 的加载顺序,后加载的 `.env.local` 可以覆盖默认配置。 + +## 3. 运行边界 + +1. 环境文件应保持 UTF-8 文本,允许 UTF-8 BOM 和 CRLF,但部署脚本会在发布目录中消除它们。 +2. 环境变量名必须符合 `[A-Za-z_][A-Za-z0-9_]*`。 +3. 值支持不加引号、双引号和单引号;复杂 shell 表达式不会执行,避免把环境文件变成脚本入口。 +4. 业务密钥仍通过目标服务器环境变量或发布目录 `.env.local` 管理,不写入 Jenkinsfile。 + +## 4. 失败现场恢复 + +如果 Jenkins 已经生成了失败版本,可以在拉取本次脚本修复后直接重跑部署流水线。`scripts/jenkins-deploy-release.sh` 会在执行新版本 `start.sh` 前净化已有发布目录,因此不要求手工编辑服务器上的 `.env.local`。 diff --git a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md index 0a0c8f75..271a80c5 100644 --- a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md +++ b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md @@ -99,7 +99,7 @@ scripts/jenkins-deploy-release.sh \ 如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,第 1 步和第 4 步会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo,否则部署会直接失败,不会进入交互式密码提示。 -这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/`、`logs/`、`run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env`、`.env.local` 仍会以构建产物中的文件为准。 +这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/`、`logs/`、`run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env`、`.env.local` 仍会以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF,避免 `start.sh` 在 Bash 下把首行变量名误解析成命令。 ### 4.3 构建并部署 diff --git a/docs/technical/README.md b/docs/technical/README.md index 8c68f645..03f2ce4a 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -43,6 +43,7 @@ - [CREATION_HUB_CARD_ACTIONS_2026-04-22.md](./CREATION_HUB_CARD_ACTIONS_2026-04-22.md):冻结创作中心作品卡“体验 / 删除”入口的最小落地语义,明确 RPG 已发布作品软删除、卡片直达运行时,以及暂不扩草稿 / 拼图删除契约。 - [CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md](./CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md):记录创作中心点击类别后长时间停留在“正在开启”的根因与修复口径,收口前端创建会话启动超时、中文错误提示以及 Big Fish / 拼图代理上游超时兜底。 - [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本与 `/home/ubuntu/Genarrative-deploy/` 覆盖策略。 +- [JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md](./JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md):记录 Jenkins 部署时 `.env.local` 首行 UTF-8 BOM 导致 `start.sh` 加载失败的根因,并冻结发布包构建、部署脚本和启动脚本的环境文件净化规则。 - [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust`、`npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传和安全清库开关。 - [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 101 条 Axum 路由,并补充管理后台入口与管理接口索引,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。 - [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index 65a3f705..b950e74d 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -116,9 +116,9 @@ npm run deploy:rust:remote 3. 使用 Vite 构建前端 release 到目标目录的 `web/`。 4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。 5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。 -6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下。 +6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下;复制后统一移除 UTF-8 BOM 与 CRLF,避免目标服务器 Bash 加载环境文件失败。 7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。 -8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先加载发布目录根部的 `.env`、`.env.local`,再回退到构建时通过 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。 +8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env`、`.env.local`,兼容 UTF-8 BOM 与 CRLF,再回退到构建时通过 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。 9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/ ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。 发布包结构: @@ -160,10 +160,11 @@ cd build/ 1. 构建脚本会把仓库根目录已有的 `.env`、`.env.local` 一并复制进发布包,因此运行前必须确认这些文件内容适合被带入目标环境。 2. 如果仓库根目录不存在 `.env` 或 `.env.local`,脚本会打印跳过日志,但不会因此失败;此时 `start.sh` 仅使用构建时写入的默认值与运行时显式传入的环境变量。 -3. `start.sh` 默认不追加清理参数;只有显式执行 `./start.sh --clear-database` 才追加 `-c=on-conflict`,在 schema 冲突时清理旧模块数据后重发。 -4. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm;清库模式下会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。 -5. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置。 -6. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传。 +3. `start.sh` 只解析合法 `KEY=value` 环境行,支持不加引号、双引号和单引号;不执行复杂 shell 表达式,避免把环境文件变成脚本入口。 +4. `start.sh` 默认不追加清理参数;只有显式执行 `./start.sh --clear-database` 才追加 `-c=on-conflict`,在 schema 冲突时清理旧模块数据后重发。 +5. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm;清库模式下会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。 +6. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置。 +7. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传。 目标服务器最小要求: diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 49a33fee..cdda41ed 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -57,6 +57,19 @@ copy_required_file() { cp "${source_path}" "${target_path}" } +normalize_env_file() { + local env_file="$1" + local temp_file="${env_file}.tmp.$$" + + if [[ ! -f "${env_file}" ]]; then + return + fi + + # 发布环境文件可能由 Windows 编辑器保存,启动脚本只接受无 BOM、无 CRLF 的 KEY=value 文本。 + LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}" + mv "${temp_file}" "${env_file}" +} + copy_optional_file() { local source_path="$1" local target_path_a="$2" @@ -70,6 +83,8 @@ copy_optional_file() { cp "${source_path}" "${target_path_a}" cp "${source_path}" "${target_path_b}" + normalize_env_file "${target_path_a}" + normalize_env_file "${target_path_b}" echo "[deploy:rust] 已复制 ${label} -> ${target_path_a} 与 ${target_path_b}" } @@ -426,17 +441,47 @@ cd "${SCRIPT_DIR}" load_env_file() { local env_file="$1" + local line="" + local line_number=0 + local key="" + local value="" + local utf8_bom=$'\xef\xbb\xbf' if [[ ! -f "${env_file}" ]]; then return fi echo "[start] 加载环境文件: ${env_file}" - set -a - # 发布包内环境文件由当前构建脚本生成,允许在启动时作为默认环境源加载。 - # shellcheck disable=SC1090 - source "${env_file}" - set +a + + # 环境文件按 dotenv 的 KEY=value 子集解析,避免 BOM 被 shell 当成命令名执行。 + while IFS= read -r line || [[ -n "${line}" ]]; do + line_number=$((line_number + 1)) + if [[ "${line_number}" -eq 1 ]]; then + line="${line#"${utf8_bom}"}" + fi + line="${line%$'\r'}" + + if [[ "${line}" =~ ^[[:space:]]*$ || "${line}" =~ ^[[:space:]]*# ]]; then + continue + fi + + if [[ ! "${line}" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + echo "[start] 跳过不符合 KEY=value 的环境行: ${env_file}:${line_number}" >&2 + continue + fi + + key="${BASH_REMATCH[2]}" + value="${BASH_REMATCH[3]}" + if [[ "${#value}" -ge 2 && "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then + value="${value:1:${#value}-2}" + value="${value//\\\"/\"}" + elif [[ "${#value}" -ge 2 && "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then + value="${value:1:${#value}-2}" + fi + + printf -v "${key}" '%s' "${value}" + export "${key}" + done <"${env_file}" } usage() { @@ -655,6 +700,7 @@ cat >"${TARGET_DIR}/README.md" <"${temp_file}" + mv "${temp_file}" "${env_file}" +} + +normalize_release_env_files() { + local release_dir="$1" + + normalize_env_file "${release_dir}/.env" + normalize_env_file "${release_dir}/.env.local" + normalize_env_file "${release_dir}/web/.env" + normalize_env_file "${release_dir}/web/.env.local" +} + SOURCE_DIR="" DEPLOY_DIR="" CLEAR_DATABASE="0" @@ -125,6 +147,8 @@ if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then exit 1 fi +normalize_release_env_files "${SOURCE_DIR}" + if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}" run_hook "${DEPLOY_DIR}" "stop.sh" @@ -154,6 +178,8 @@ if [[ -f "${DEPLOY_DIR}/stop.sh" ]]; then chmod +x "${DEPLOY_DIR}/stop.sh" fi +normalize_release_env_files "${DEPLOY_DIR}" + echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}" if [[ "${CLEAR_DATABASE}" == "1" ]]; then echo "[jenkins-deploy] 以清库模式启动新版本" From 0a0f3f1bd8e42354243ad54882a87c55f454ffa8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 01:11:45 +0800 Subject: [PATCH 07/10] fix: restrict password login to existing phone accounts --- ...ND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md | 4 +- ...T_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md | 48 ++- .../PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md | 105 +++--- ...RD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md | 8 +- ...B_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md | 2 + ...TH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md | 24 +- docs/technical/SPACETIMEDB_TABLE_CATALOG.md | 2 +- packages/shared/src/contracts/auth.ts | 2 +- server-rs/crates/api-server/src/ai_tasks.rs | 10 +- server-rs/crates/api-server/src/app.rs | 349 ++++++------------ server-rs/crates/api-server/src/auth_me.rs | 1 + .../crates/api-server/src/auth_public_user.rs | 2 +- server-rs/crates/api-server/src/llm.rs | 10 +- .../crates/api-server/src/login_options.rs | 1 + .../crates/api-server/src/password_entry.rs | 8 +- .../api-server/src/password_management.rs | 2 +- .../api-server/src/runtime_browse_history.rs | 10 +- .../api-server/src/runtime_inventory.rs | 10 +- .../crates/api-server/src/runtime_profile.rs | 10 +- .../crates/api-server/src/runtime_save.rs | 10 +- .../crates/api-server/src/runtime_settings.rs | 10 +- .../src/runtime_story/compat/tests.rs | 10 +- server-rs/crates/api-server/src/state.rs | 53 +++ .../crates/api-server/src/story_battles.rs | 10 +- .../crates/api-server/src/story_sessions.rs | 10 +- server-rs/crates/module-auth/src/lib.rs | 94 ++--- server-rs/crates/shared-contracts/src/auth.rs | 16 +- src/components/auth/AuthGate.test.tsx | 47 ++- src/components/auth/AuthGate.tsx | 97 ++--- src/components/auth/LoginScreen.tsx | 51 ++- src/services/apiClient.ts | 72 +--- src/services/authService.test.ts | 93 +---- src/services/authService.ts | 86 +---- 33 files changed, 489 insertions(+), 778 deletions(-) diff --git a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md index ce2bf5af..53c56790 100644 --- a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md +++ b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md @@ -96,7 +96,9 @@ - 同时开放短信与密码登录时,面板顶部展示两个居中的文字页签,当前页签使用深色字重和短下划线强调。 - 只渲染当前页签对应的输入区;切换页签不弹出新面板,不展示二维码入口。 - `短信登录` 页签包含手机号、验证码、获取验证码和主按钮。 -- `密码登录` 页签包含手机号/邮箱、密码、主按钮和忘记密码入口。 +- `密码登录` 页签只包含手机号、密码、主按钮和忘记密码入口;不支持邮箱、用户名或叙世号。 +- 密码登录只是手机号验证码登录的补充方式:只有已登录并设置过密码的手机号账号才能使用,不能在密码页签创建账号。 +- `密码登录` 主按钮固定为 `登录`,不得使用 `注册/登录`。 - 未开放某个登录方式时不展示对应页签,避免用户进入不可用表单。 - 移动端页签保持等分点击区域,输入框与按钮宽度仍随弹窗收缩。 diff --git a/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md b/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md index bd6b3451..029c812f 100644 --- a/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md +++ b/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md @@ -188,14 +188,14 @@ MVP 阶段建议采用最稳妥规则: 1. 用户名密码注册 2. 游客正式入口 -3. 账号密码找回 +3. 邮箱登录 4. 实名认证 5. 社交好友体系 6. 多微信绑定同一账号 说明: -当前用户名密码模式可仅保留为开发环境兜底能力,不作为正式前台入口。 +密码登录不是注册入口,也不是邮箱入口;它只作为手机号验证码登录的补充方式。用户必须先通过手机号验证码登录形成正式账号,并在已登录账号中心设置过密码后,后续才能用“手机号 + 密码”登录。 --- @@ -388,6 +388,24 @@ MVP 阶段建议采用最稳妥规则: MVP 阶段不需要单独设置密码。 +## 6.1.1 密码登录补充方式 + +密码登录只补充手机号验证码登录,不建立新的账号体系。 + +落地规则: + +- 入参只允许 `phone` 和 `password`,不支持邮箱、用户名或叙世号。 +- 手机号不存在时,不创建账号,返回统一的登录失败。 +- 手机号存在但账号未设置过密码时,不允许密码登录。 +- 首次设置密码只能在已登录账号中心内完成;用户必须先通过手机号验证码或已绑定手机号的微信账号进入已登录态。 +- 忘记密码 / 重置密码必须先完成该手机号的短信验证码校验;手机号不存在时不创建账号。 + +前台约束: + +- 密码页签的账号输入框文案固定为 `手机号`。 +- 密码页签主按钮固定为 `登录`,不能出现 `注册/登录`。 +- 短信验证码页签可继续承担“手机号不存在时创建正式账号并登录”的能力,但按钮文案不应暗示密码注册。 + ## 6.2 微信登录 微信登录按终端拆分: @@ -611,7 +629,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含: 因此本期不是推翻重做,而是: 1. 保留 `users` 作为账号主表 -2. 废弃“用户名密码自动注册”作为正式入口 +2. 废弃“用户名密码自动注册”作为任何正式入口 3. 增加手机号与微信身份层 4. 增加验证码表与会话表 @@ -619,7 +637,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含: ## 8. 接口设计 -所有接口均由 Express 后端承接。 +所有接口均由 `server-rs` 后端承接。 ## 8.1 手机号登录相关 @@ -698,6 +716,28 @@ MVP 阶段建议至少提供一个轻量账号中心,包含: ## 8.3 会话与账号信息 +### `POST /api/auth/entry` + +用途: + +- 使用已设置密码的手机号账号登录 + +入参: + +- `phone` +- `password` + +出参: + +- `token` +- `user` + +约束: + +- 不支持邮箱、用户名或叙世号。 +- 不承担注册能力。 +- 只有已存在、已验证手机号、且 `passwordLoginEnabled=true` 的账号可以登录。 + ### `GET /api/auth/me` 返回建议扩展为: diff --git a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md index f5cfdcfb..6f45b1f1 100644 --- a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md +++ b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md @@ -1,6 +1,6 @@ -# 密码登录历史落地设计 +# 密码登录入口历史落地设计 -> 2026-04-24 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经设置密码的账号。密码修改与重置以 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准;本文中“密码自动建号”仅保留为历史基线说明,不再作为当前落地依据。 +> 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。 日期:`2026-04-21` @@ -8,17 +8,25 @@ 这份文档用于指导 `M2` 中以下两条任务的首版落地: -1. `实现密码登录` -2. `实现账号自动创建 / 幂等登录兼容策略` +1. `实现手机号密码登录` +2. `移除密码登录自动注册 / 自动建号语义` -目标是先把当前 Node 已经稳定运行的 `/api/auth/entry` 语义迁到 Rust 工作区,并冻结: +目标是把 `/api/auth/entry` 在 Rust 工作区冻结为手机号验证码账号的补充登录方式: -1. `api-server` 对外暴露的最小兼容接口。 -2. `module-auth` 负责的密码登录用例边界。 -3. 自动建号与并发幂等兼容规则。 +1. `api-server` 对外只暴露 `phone + password` 的最小接口。 +2. `module-auth` 只负责已存在手机号账号的密码校验。 +3. 密码入口不创建账号,不接收邮箱、用户名或叙世号。 4. 登录成功后与 JWT、refresh cookie 的衔接方式。 -## 2. 当前基线 +## 1.1 当前冻结结论 + +1. 密码登录不是注册入口。 +2. 密码登录是手机号验证码登录的补充方式。 +3. 只有已存在、已绑定手机号、并已设置密码的账号可以通过密码登录。 +4. 未知手机号、未设置密码、密码错误统一返回 `401 UNAUTHORIZED`,避免通过密码入口探测账号状态。 +5. 手机号验证码登录仍是新用户注册/首次登录的唯一入口。 + +## 2. 历史基线 当前 Node `/api/auth/entry` 主链已经具备如下语义: @@ -29,7 +37,7 @@ 5. 同时创建 refresh session,并把原始 refresh token 写入 HttpOnly cookie。 6. 并发创建同一用户名时,后到的请求会回退为“查已存在账号并校验密码”,不因唯一键冲突直接失败。 -这条链路既是当前前端匿名/游客恢复的基础,也是真实 `/api/auth/entry` contract 的既有事实,因此 Rust 首版必须兼容。 +这条链路曾经是前端匿名/游客恢复的基础。2026-04-25 起该历史语义已废弃,Rust 当前实现必须以“手机号账号已设置密码后登录”为准,不再兼容密码自动建号。 ## 3. 设计输入 @@ -41,12 +49,12 @@ 4. [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md) 5. [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md) -关键冻结点: +当前冻结点: 1. `password_hash` 当前继续由 `user_account` 承担,不进入 `auth_identity`。 2. `sub` 必须是稳定 `user_id`。 3. 登录成功后必须继续同时生成 access token 和 refresh session。 -4. 自动建号兼容必须保留,不能因为迁到 Rust 就删除。 +4. 密码登录不再保留自动建号兼容,旧开发游客自动建号链路必须迁出 `/api/auth/entry`。 ## 4. 首版落地范围 @@ -54,14 +62,14 @@ 1. `module-auth` 中的密码登录用例。 2. `api-server` 中的 `POST /api/auth/entry`。 -3. 用户名校验、密码哈希校验与自动建号。 +3. 手机号归一化、密码哈希校验与未设置密码拒绝。 4. 登录成功后的 access token 与 refresh cookie 主链打通。 本阶段明确不包含: 1. SpacetimeDB 真正的 `user_account` / `refresh_session` reducer 写入。 2. `/api/auth/me`、`/api/auth/logout`、`/api/auth/refresh` 的正式业务闭环。 -3. 手机验证码与微信登录链路。 +3. 新增邮箱登录或独立密码注册链路。 ## 5. crate 边界 @@ -69,9 +77,9 @@ 负责: -1. 用户名与密码的领域校验。 +1. 手机号与密码的领域校验。 2. 密码登录主用例。 -3. 自动建号与并发幂等兼容策略。 +3. 已存在手机号账号与已设置密码约束。 4. 输出登录成功所需的最小用户快照。 不负责: @@ -106,11 +114,11 @@ ### 6.1 请求体 -固定沿用当前 contract: +当前 contract: ```json { - "username": "guest_001", + "phone": "13800138000", "password": "secret123" } ``` @@ -124,9 +132,9 @@ "token": "", "user": { "id": "user_xxx", - "username": "guest_001", - "displayName": "guest_001", - "phoneNumberMasked": null, + "username": "phone_xxx", + "displayName": "138****8000", + "phoneNumberMasked": "138****8000", "loginMethod": "password", "bindingStatus": "active", "wechatBound": false @@ -136,11 +144,11 @@ 同时响应头必须写回 refresh cookie。 -## 7. 用户名与密码规则 +## 7. 手机号与密码规则 -当前阶段继续对齐 Node 基线: +当前阶段固定: -1. `username` 只允许 `3` 到 `24` 位字母、数字、下划线。 +1. `phone` 只接受中国大陆手机号,服务端统一归一化为 `E.164` 后查询。 2. `password` 长度必须在 `6` 到 `128` 位之间。 任一校验失败时: @@ -148,37 +156,30 @@ 1. 返回 `400 BAD_REQUEST` 2. 错误文案继续保持中文 -## 8. 自动建号与幂等兼容 +## 8. 登录校验规则 -### 8.1 自动建号 +### 8.1 未知手机号 -当 `username` 不存在时: +当 `phone` 归一化后找不到账号时: -1. 用当前请求里的 `password` 生成密码哈希。 -2. 创建一条本地账号。 -3. `display_name = username` -4. `login_provider = password` -5. `account_status = active` -6. `token_version = 1` +1. 返回 `401 UNAUTHORIZED`。 +2. 不创建账号。 +3. 不写 `password_hash`。 -### 8.2 已存在账号 +### 8.2 未设置密码 -当 `username` 已存在时: +当账号存在但 `password_login_enabled = false` 时: + +1. 返回 `401 UNAUTHORIZED`。 +2. 不区分“未设置密码”和“密码错误”的外部文案。 + +### 8.3 已设置密码 + +当账号存在且已设置密码时: 1. 校验密码哈希。 2. 校验失败返回 `401 UNAUTHORIZED`。 -3. 校验成功继续登录。 - -### 8.3 并发幂等兼容 - -若两个请求并发创建同一用户名: - -1. 允许其中一个请求先创建成功。 -2. 后一个请求若命中唯一键冲突,不直接失败。 -3. 后一个请求必须重新查询该用户名。 -4. 若查到账号,则按“已存在账号”路径继续校验密码。 - -这保证了当前前端重复调用 `/api/auth/entry` 时可以恢复同一账号,而不是随机失败。 +3. 校验成功签发 access token 与 refresh cookie。 ## 9. 首版存储策略 @@ -226,10 +227,10 @@ 当前阶段至少覆盖: -1. 首次密码登录自动建号成功。 -2. 同用户名同密码可重复登录同一账号。 -3. 同用户名不同密码返回 `401`。 -4. 非法用户名返回 `400`。 +1. 未知手机号密码登录返回 `401`,且不创建账号。 +2. 已登录手机号账号设置密码后可用 `phone + password` 登录。 +3. 同手机号错误密码返回 `401`。 +4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`。 5. 登录成功时返回 access token。 6. 登录成功时写回 refresh cookie。 @@ -239,7 +240,7 @@ 1. `module-auth` 不再只是 README,占位被真实 crate 实现替换。 2. `POST /api/auth/entry` 可在 Rust 侧独立跑通。 -3. 自动建号与幂等兼容行为可验证。 +3. 密码入口不注册、不接收邮箱/用户名的行为可验证。 4. JWT 与 refresh cookie 登录成功主链打通。 5. 文档、任务清单与测试同步完成。 diff --git a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md index bcca40d9..4a819b98 100644 --- a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md +++ b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md @@ -18,8 +18,8 @@ 沿用现有 `POST /api/auth/entry`: -1. 请求字段沿用 `username`、`password`,但前端固定把手机号填入 `username`。 -2. 后端优先按标准手机号归一化后查找账号,兼容历史用户名只作为开发游客兜底能力。 +1. 请求字段固定为 `phone`、`password`,前端只提交手机号。 +2. 后端只按标准手机号归一化后查找账号,不兼容邮箱、用户名、叙世号或历史开发游客标识。 3. 手机号不存在时返回 `401`,不创建账号。 4. 手机号存在但未设置密码时返回 `401`。 5. 校验成功后签发 access token,并写入 refresh cookie。 @@ -41,7 +41,7 @@ 1. 不需要 Bearer 登录态。 2. 请求字段:`phone`、`code`、`newPassword`。 3. 使用 `reset_password` 短信场景校验验证码。 -4. 手机号不存在时返回 `404`,避免用密码重置隐式注册账号。 +4. 手机号不存在时返回 `401`,避免用密码重置隐式注册账号,并避免泄露手机号注册状态。 5. 重置成功后签发新的 access token,并写入 refresh cookie,便于用户直接进入登录态。 ### 2.4 发送重置验证码 @@ -62,7 +62,7 @@ 登录弹窗不再拆独立注册页签: 1. 面板直接展示手机号和密码输入,用于已设置密码账号登录。 -2. 登录按钮文本固定为 `注册/登录`,避免用户在登录和首次进入之间做页面切换。 +2. 密码登录按钮文本固定为 `登录`,不允许暗示密码入口具备注册能力。 3. 忘记密码入口显示在登录按钮右下侧,点击后仍进入独立重置面板,不在当前表单下方展开。 4. 同一面板保留手机号验证码注册/登录能力,用于新用户自动注册和已注册用户免密码登录。 5. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码。 diff --git a/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md index e20f334f..8ad68e66 100644 --- a/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md +++ b/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md @@ -206,6 +206,8 @@ 1. 密码登录仍由 `user_account.password_hash` 承担 2. 本轮不引入 `password` provider identity +3. 密码登录只接受已绑定手机号的账号,不支持邮箱、用户名或叙世号作为登录身份 +4. 密码登录不创建账号,新账号只由手机号验证码登录创建 ### 9.2 `POST /api/auth/phone/login` diff --git a/docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md index 89fa903d..678736ef 100644 --- a/docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md +++ b/docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md @@ -18,7 +18,7 @@ 当前 Node 鉴权主链已经依赖 `users` 主表完成以下能力: -1. `POST /api/auth/entry`:用户名密码登录,不存在则自动创建账号 +1. `POST /api/auth/entry`:手机号密码登录,仅允许已存在且已设置密码的手机号账号登录 2. `POST /api/auth/phone/login`:手机号验证码登录,不存在则自动创建账号 3. `GET /api/auth/me`:读取当前账号基础信息 4. `POST /api/auth/logout`:提升 `token_version`,让当前 access token 失效 @@ -99,8 +99,9 @@ | 字段名 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `user_id` | `String` | 是 | 主键,继续沿用当前 `user_*` 前缀格式。 | -| `username` | `String` | 是 | 当前密码登录用户名;手机号/微信创建的系统账号同样要写入唯一用户名。 | -| `password_hash` | `String` | 是 | 密码登录校验字段;手机号/微信创建账号时继续写随机密码哈希,保持兼容。 | +| `username` | `String` | 是 | 系统账号名;不再作为前台密码登录标识,手机号/微信创建账号时仍写入唯一系统用户名。 | +| `password_hash` | `Option` | 否 | 用户显式设置或重置密码后才写入;手机号/微信新建账号默认不可用密码登录。 | +| `password_login_enabled` | `bool` | 是 | 是否允许密码登录;只有用户设置或重置密码后才为 `true`。 | | `token_version` | `u32` | 是 | access token 统一失效计数,默认 `1`。 | | `display_name` | `String` | 是 | 账号展示名;密码账号默认用户名,手机号账号默认脱敏手机号,微信待绑定账号默认微信昵称或“微信旅人”。 | | `login_provider` | `String` | 是 | 当前账号的主登录归属,枚举固定为 `password`、`phone`、`wechat`。 | @@ -131,9 +132,9 @@ ### 6.2 必须具备的查询索引 1. `username` - 作用:支撑 `POST /api/auth/entry` + 作用:系统账号唯一约束与内部排查,不作为前台密码登录入口 2. `primary_phone_e164` - 作用:支撑 `POST /api/auth/phone/login`、`POST /api/auth/phone/change` + 作用:支撑 `POST /api/auth/entry`、`POST /api/auth/phone/login`、`POST /api/auth/phone/change` 3. `account_status + updated_at` 作用:后续管理端、审计排查与禁用账号扫描 4. `merged_to_user_id` @@ -188,13 +189,12 @@ 写入规则: -1. 先按 `username` 查询 -2. 若不存在,则创建一条 `active` 账号 -3. `login_provider = password` -4. `display_name = username` -5. `primary_phone_e164 = null` -6. `phone_verified_at = null` -7. `last_login_at = 当前时间` +1. 只读取请求中的 `phone` 和 `password`。 +2. 先把 `phone` 归一化为 `primary_phone_e164` 后查询账号。 +3. 若手机号不存在,返回 `401`,不创建账号。 +4. 若账号存在但 `password_login_enabled = false` 或 `password_hash = null`,返回 `401`。 +5. 若账号存在且已设置密码,校验 `password_hash`。 +6. 校验成功后只更新登录会话与 `last_login_at`,不改变账号主归属。 ### 8.2 `POST /api/auth/phone/login` diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index cfe08a2e..bbd7f5c3 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -47,7 +47,7 @@ SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default'; ### `user_account` - 作用:用户账号主表,保存用户名、公开叙世号、手机号掩码、登录方式、密码登录开关和 token 版本。 -- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `phone_number_masked: Option`, `phone_number_e164: Option`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`。 +- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `phone_number_masked: Option`, `phone_number_e164: Option`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: Option`, `password_login_enabled: bool`, `token_version: u64`。 - 索引:`username`, `public_user_code`。 ```sql diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 00d2aeff..6d3996de 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -23,7 +23,7 @@ export type PublicUserSearchResponse = { }; export type AuthEntryRequest = { - username: string; + phone: string; password: string; }; diff --git a/server-rs/crates/api-server/src/ai_tasks.rs b/server-rs/crates/api-server/src/ai_tasks.rs index 9725820a..27b3c756 100644 --- a/server-rs/crates/api-server/src/ai_tasks.rs +++ b/server-rs/crates/api-server/src/ai_tasks.rs @@ -642,13 +642,9 @@ mod tests { async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "ai_tasks_user".to_string(), - password: "secret123".to_string(), - }) + .seed_test_phone_user_with_password("13800138100", "secret123") .await - .expect("seed login should succeed"); + .id; state } @@ -659,7 +655,7 @@ mod tests { session_id: "sess_ai_tasks".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("AI 任务用户".to_string()), diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index c3ca91df..90812a78 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1031,6 +1031,7 @@ pub fn build_router(state: AppState) -> Router { #[cfg(test)] mod tests { use axum::{ + Router, body::Body, http::{Request, StatusCode}, }; @@ -1048,6 +1049,40 @@ mod tests { use super::build_router; + const TEST_PASSWORD: &str = "secret123"; + async fn seed_phone_user_with_password( + state: &AppState, + phone_number: &str, + password: &str, + ) -> module_auth::AuthUser { + state + .seed_test_phone_user_with_password(phone_number, password) + .await + } + + async fn password_login_request( + app: Router, + phone_number: &str, + password: &str, + ) -> axum::response::Response { + app.oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": phone_number, + "password": password + }) + .to_string(), + )) + .expect("password login request should build"), + ) + .await + .expect("password login request should succeed") + } + #[tokio::test] async fn healthz_returns_legacy_compatible_payload_and_headers() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -1162,24 +1197,17 @@ mod tests { async fn internal_auth_claims_returns_verified_claims() { let config = AppConfig::default(); let state = AppState::new(config.clone()).expect("state should build"); - state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "guest_auth_debug".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("seed login should succeed"); + let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await; let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), + user_id: seed_user.id.clone(), session_id: "sess_auth_debug".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: seed_user.token_version, phone_verified: true, binding_status: BindingStatus::Active, - display_name: Some("测试用户".to_string()), + display_name: Some(seed_user.display_name.clone()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), @@ -1210,17 +1238,14 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); - assert_eq!( - payload["claims"]["sub"], - Value::String("user_00000001".to_string()) - ); + assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id)); assert_eq!( payload["claims"]["sid"], Value::String("sess_auth_debug".to_string()) ); assert_eq!( payload["claims"]["ver"], - Value::Number(serde_json::Number::from(1)) + Value::Number(serde_json::Number::from(seed_user.token_version)) ); } @@ -1293,26 +1318,21 @@ mod tests { } #[tokio::test] - async fn password_entry_creates_user_and_sets_refresh_cookie() { + async fn password_entry_rejects_unknown_phone_without_registration() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/entry") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "username": "guest_001", - "password": "secret123" - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); + let response = password_login_request(app, "13800138011", TEST_PASSWORD).await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138012", TEST_PASSWORD).await; + let app = build_router(state); + + let response = password_login_request(app, "13800138012", TEST_PASSWORD).await; assert_eq!(response.status(), StatusCode::OK); assert!( @@ -1332,9 +1352,10 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!(payload["user"]["id"], Value::String(seed_user.id)); assert_eq!( - payload["user"]["username"], - Value::String("guest_001".to_string()) + payload["user"]["loginMethod"], + Value::String("password".to_string()) ); assert!(payload["token"].as_str().is_some()); } @@ -1371,7 +1392,7 @@ mod tests { assert_eq!( payload["availableLoginMethods"], - serde_json::json!(["phone", "wechat"]) + serde_json::json!(["phone", "password", "wechat"]) ); } @@ -2232,7 +2253,9 @@ mod tests { #[tokio::test] async fn auth_sessions_returns_multi_device_session_fields() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await; + let app = build_router(state); let first_login_response = app .clone() @@ -2248,8 +2271,8 @@ mod tests { .header("x-client-instance-id", "chrome-instance-001") .body(Body::from( serde_json::json!({ - "username": "guest_sessions_api", - "password": "secret123" + "phone": "13800138013", + "password": TEST_PASSWORD }) .to_string(), )) @@ -2292,8 +2315,8 @@ mod tests { .header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger") .body(Body::from( serde_json::json!({ - "username": "guest_sessions_api", - "password": "secret123" + "phone": "13800138013", + "password": TEST_PASSWORD }) .to_string(), )) @@ -2346,27 +2369,13 @@ mod tests { } #[tokio::test] - async fn password_entry_reuses_same_user_for_same_credentials() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + async fn password_entry_reuses_same_user_for_same_phone() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138014", TEST_PASSWORD).await; + let app = build_router(state); - let first_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/entry") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "username": "guest_001", - "password": "secret123" - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("first request should succeed"); + let first_response = + password_login_request(app.clone(), "13800138014", TEST_PASSWORD).await; let first_body = first_response .into_body() .collect() @@ -2376,23 +2385,7 @@ mod tests { let first_payload: Value = serde_json::from_slice(&first_body).expect("first payload should be json"); - let second_response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/entry") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "username": "guest_001", - "password": "secret123" - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("second request should succeed"); + let second_response = password_login_request(app, "13800138014", TEST_PASSWORD).await; let second_body = second_response .into_body() .collect() @@ -2402,54 +2395,23 @@ mod tests { let second_payload: Value = serde_json::from_slice(&second_body).expect("second payload should be json"); + assert_eq!(first_payload["user"]["id"], Value::String(seed_user.id)); assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]); } #[tokio::test] async fn password_entry_rejects_wrong_password() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138015", TEST_PASSWORD).await; + let app = build_router(state); - app.clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/entry") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "username": "guest_001", - "password": "secret123" - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("seed request should succeed"); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/entry") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "username": "guest_001", - "password": "secret999" - }) - .to_string(), - )) - .expect("request should build"), - ) - .await - .expect("request should succeed"); + let response = password_login_request(app, "13800138015", "secret999").await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] - async fn password_entry_rejects_invalid_username() { + async fn password_entry_rejects_email_or_username_identifier() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app @@ -2460,8 +2422,8 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "username": "无效用户", - "password": "secret123" + "phone": "user@example.com", + "password": TEST_PASSWORD }) .to_string(), )) @@ -2481,24 +2443,17 @@ mod tests { ..AppConfig::default() }; let state = AppState::new(config).expect("state should build"); - state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "guest_001".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("seed login should succeed"); + let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await; let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), + user_id: seed_user.id.clone(), session_id: "sess_me_query".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: seed_user.token_version, phone_verified: false, binding_status: BindingStatus::Active, - display_name: Some("guest_001".to_string()), + display_name: Some(seed_user.display_name.clone()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), @@ -2529,13 +2484,10 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); - assert_eq!( - payload["user"]["id"], - Value::String("user_00000001".to_string()) - ); + assert_eq!(payload["user"]["id"], Value::String(seed_user.id)); assert_eq!( payload["availableLoginMethods"], - serde_json::json!(["phone", "wechat"]) + serde_json::json!(["phone", "password", "wechat"]) ); } @@ -2577,26 +2529,12 @@ mod tests { #[tokio::test] async fn refresh_session_rotates_cookie_and_returns_new_access_token() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138017", TEST_PASSWORD).await; + let app = build_router(state); - let login_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/entry") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "username": "guest_refresh", - "password": "secret123" - }) - .to_string(), - )) - .expect("login request should build"), - ) - .await - .expect("login request should succeed"); + let login_response = + password_login_request(app.clone(), "13800138017", TEST_PASSWORD).await; let first_cookie = login_response .headers() .get("set-cookie") @@ -2685,26 +2623,12 @@ mod tests { #[tokio::test] async fn logout_clears_cookie_and_invalidates_current_access_token() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138018", TEST_PASSWORD).await; + let app = build_router(state); - let login_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/entry") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "username": "guest_logout_api", - "password": "secret123" - }) - .to_string(), - )) - .expect("login request should build"), - ) - .await - .expect("login request should succeed"); + let login_response = + password_login_request(app.clone(), "13800138018", TEST_PASSWORD).await; let refresh_cookie = login_response .headers() .get("set-cookie") @@ -2773,26 +2697,12 @@ mod tests { #[tokio::test] async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138019", TEST_PASSWORD).await; + let app = build_router(state); - let login_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/entry") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "username": "guest_logout_no_cookie", - "password": "secret123" - }) - .to_string(), - )) - .expect("login request should build"), - ) - .await - .expect("login request should succeed"); + let login_response = + password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await; let login_body = login_response .into_body() .collect() @@ -2830,7 +2740,9 @@ mod tests { #[tokio::test] async fn logout_all_clears_cookie_and_invalidates_all_sessions() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138020", TEST_PASSWORD).await; + let app = build_router(state); let first_login_response = app .clone() @@ -2845,8 +2757,8 @@ mod tests { ) .body(Body::from( serde_json::json!({ - "username": "guest_logout_all_api", - "password": "secret123" + "phone": "13800138020", + "password": TEST_PASSWORD }) .to_string(), )) @@ -2884,8 +2796,8 @@ mod tests { .header("x-client-instance-id", "logout-all-instance-002") .body(Body::from( serde_json::json!({ - "username": "guest_logout_all_api", - "password": "secret123" + "phone": "13800138020", + "password": TEST_PASSWORD }) .to_string(), )) @@ -2976,26 +2888,12 @@ mod tests { #[tokio::test] async fn logout_all_succeeds_without_refresh_cookie_when_bearer_token_is_valid() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138021", TEST_PASSWORD).await; + let app = build_router(state); - let login_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/entry") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "username": "guest_logout_all_nc", - "password": "secret123" - }) - .to_string(), - )) - .expect("login request should build"), - ) - .await - .expect("login request should succeed"); + let login_response = + password_login_request(app.clone(), "13800138021", TEST_PASSWORD).await; let login_body = login_response .into_body() .collect() @@ -3079,26 +2977,11 @@ mod tests { config.admin_username = Some("root".to_string()); config.admin_password = Some("secret123".to_string()); let state = AppState::new(config).expect("state should build"); + seed_phone_user_with_password(&state, "13800138022", TEST_PASSWORD).await; let app = build_router(state.clone()); - let login_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/entry") - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "username": "guest_admin_forbidden", - "password": "secret123" - }) - .to_string(), - )) - .expect("login request should build"), - ) - .await - .expect("login should succeed"); + let login_response = + password_login_request(app.clone(), "13800138022", TEST_PASSWORD).await; let login_body = login_response .into_body() .collect() diff --git a/server-rs/crates/api-server/src/auth_me.rs b/server-rs/crates/api-server/src/auth_me.rs index b08bcf10..bab8c434 100644 --- a/server-rs/crates/api-server/src/auth_me.rs +++ b/server-rs/crates/api-server/src/auth_me.rs @@ -34,6 +34,7 @@ pub async fn auth_me( user: map_auth_user_payload(user.user), available_login_methods: build_available_login_methods( state.config.sms_auth_enabled, + true, state.config.wechat_auth_enabled, ), }, diff --git a/server-rs/crates/api-server/src/auth_public_user.rs b/server-rs/crates/api-server/src/auth_public_user.rs index f950ef03..c33c384d 100644 --- a/server-rs/crates/api-server/src/auth_public_user.rs +++ b/server-rs/crates/api-server/src/auth_public_user.rs @@ -64,7 +64,7 @@ fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppEr } module_auth::PasswordEntryError::Store(_) | module_auth::PasswordEntryError::PasswordHash(_) - | module_auth::PasswordEntryError::InvalidUsername + | module_auth::PasswordEntryError::InvalidPhoneNumber | module_auth::PasswordEntryError::InvalidPasswordLength | module_auth::PasswordEntryError::InvalidCredentials | module_auth::PasswordEntryError::UserNotFound => { diff --git a/server-rs/crates/api-server/src/llm.rs b/server-rs/crates/api-server/src/llm.rs index 69c34a5c..52425df7 100644 --- a/server-rs/crates/api-server/src/llm.rs +++ b/server-rs/crates/api-server/src/llm.rs @@ -257,13 +257,9 @@ mod tests { async fn seed_authenticated_state(config: AppConfig) -> AppState { let state = AppState::new(config).expect("state should build"); state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "llm_proxy_user".to_string(), - password: "secret123".to_string(), - }) + .seed_test_phone_user_with_password("13800138101", "secret123") .await - .expect("seed login should succeed"); + .id; state } @@ -274,7 +270,7 @@ mod tests { session_id: "sess_llm_proxy".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("LLM 代理用户".to_string()), diff --git a/server-rs/crates/api-server/src/login_options.rs b/server-rs/crates/api-server/src/login_options.rs index a5a060c1..f71f4d51 100644 --- a/server-rs/crates/api-server/src/login_options.rs +++ b/server-rs/crates/api-server/src/login_options.rs @@ -15,6 +15,7 @@ pub async fn auth_login_options( AuthLoginOptionsResponse { available_login_methods: build_available_login_methods( state.config.sms_auth_enabled, + true, state.config.wechat_auth_enabled, ), }, diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 3f307b12..743adcf3 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -29,7 +29,7 @@ pub async fn password_entry( let result = state .password_entry_service() .execute(PasswordEntryInput { - username: payload.username, + phone_number: payload.phone, password: payload.password, }) .await @@ -64,10 +64,10 @@ pub async fn password_entry( fn map_password_entry_error(error: PasswordEntryError) -> AppError { match error { - PasswordEntryError::InvalidUsername => AppError::from_status(StatusCode::BAD_REQUEST) + PasswordEntryError::InvalidPhoneNumber => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("手机号格式不正确") .with_details(json!({ - "field": "username", + "field": "phone", })), PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("密码长度需要在 6 到 128 位之间") @@ -77,7 +77,7 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError { PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("叙世号格式不正确") .with_details(json!({ - "field": "username", + "field": "phone", })), PasswordEntryError::InvalidCredentials => { AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误") diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index da56f4c6..cc75fbc2 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -100,7 +100,7 @@ pub async fn reset_password( fn map_password_management_error(error: PasswordEntryError) -> AppError { match error { - PasswordEntryError::InvalidUsername | PasswordEntryError::InvalidPublicUserCode => { + PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => { AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) } PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST) diff --git a/server-rs/crates/api-server/src/runtime_browse_history.rs b/server-rs/crates/api-server/src/runtime_browse_history.rs index e79eac8a..dd4a9027 100644 --- a/server-rs/crates/api-server/src/runtime_browse_history.rs +++ b/server-rs/crates/api-server/src/runtime_browse_history.rs @@ -422,13 +422,9 @@ mod tests { async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "browse_history_user".to_string(), - password: "secret123".to_string(), - }) + .seed_test_phone_user_with_password("13800138102", "secret123") .await - .expect("seed login should succeed"); + .id; state } @@ -439,7 +435,7 @@ mod tests { session_id: "sess_runtime_browse_history".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("浏览历史用户".to_string()), diff --git a/server-rs/crates/api-server/src/runtime_inventory.rs b/server-rs/crates/api-server/src/runtime_inventory.rs index 8827182e..456ed427 100644 --- a/server-rs/crates/api-server/src/runtime_inventory.rs +++ b/server-rs/crates/api-server/src/runtime_inventory.rs @@ -164,13 +164,9 @@ mod tests { async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "runtime_inventory_user".to_string(), - password: "secret123".to_string(), - }) + .seed_test_phone_user_with_password("13800138103", "secret123") .await - .expect("seed login should succeed"); + .id; state } @@ -181,7 +177,7 @@ mod tests { session_id: "sess_runtime_inventory".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("背包查询用户".to_string()), diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 3989d6f0..70d33b91 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -481,13 +481,9 @@ mod tests { async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "runtime_profile_user".to_string(), - password: "secret123".to_string(), - }) + .seed_test_phone_user_with_password("13800138104", "secret123") .await - .expect("seed login should succeed"); + .id; state } @@ -498,7 +494,7 @@ mod tests { session_id: "sess_runtime_profile".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("资料页用户".to_string()), diff --git a/server-rs/crates/api-server/src/runtime_save.rs b/server-rs/crates/api-server/src/runtime_save.rs index fc0adf58..6a3b2b43 100644 --- a/server-rs/crates/api-server/src/runtime_save.rs +++ b/server-rs/crates/api-server/src/runtime_save.rs @@ -380,13 +380,9 @@ mod tests { async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "runtime_save_user".to_string(), - password: "secret123".to_string(), - }) + .seed_test_phone_user_with_password("13800138105", "secret123") .await - .expect("seed login should succeed"); + .id; state } @@ -397,7 +393,7 @@ mod tests { session_id: "sess_runtime_save".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("存档用户".to_string()), diff --git a/server-rs/crates/api-server/src/runtime_settings.rs b/server-rs/crates/api-server/src/runtime_settings.rs index 699baa68..bb9337b3 100644 --- a/server-rs/crates/api-server/src/runtime_settings.rs +++ b/server-rs/crates/api-server/src/runtime_settings.rs @@ -340,13 +340,9 @@ mod tests { async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "runtime_settings_user".to_string(), - password: "secret123".to_string(), - }) + .seed_test_phone_user_with_password("13800138106", "secret123") .await - .expect("seed login should succeed"); + .id; state } @@ -357,7 +353,7 @@ mod tests { session_id: "sess_runtime_settings".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("设置用户".to_string()), diff --git a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs index 6df183b2..6f81077a 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs @@ -1986,13 +1986,9 @@ fn runtime_story_dialogue_current_story_keeps_continue_and_deferred_options() { async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "runtime_story_state_user".to_string(), - password: "secret123".to_string(), - }) + .seed_test_phone_user_with_password("13800138109", "secret123") .await - .expect("seed login should succeed"); + .id; state } @@ -2003,7 +1999,7 @@ fn issue_access_token(state: &AppState) -> String { session_id: "sess_runtime_story_state".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("运行时剧情状态用户".to_string()), diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 67dc068c..7e2b1b35 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -38,6 +38,7 @@ pub struct AppState { admin_runtime: Option, refresh_cookie_config: RefreshCookieConfig, oss_client: Option, + #[cfg_attr(test, allow(dead_code))] auth_store: InMemoryAuthStore, password_entry_service: PasswordEntryService, refresh_session_service: RefreshSessionService, @@ -96,6 +97,9 @@ pub enum AppStateInitError { impl AppState { pub fn new(config: AppConfig) -> Result { + #[cfg(test)] + let auth_store = InMemoryAuthStore::default(); + #[cfg(not(test))] let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone()) .map_err(AppStateInitError::AuthStore)?; Self::new_with_auth_store(config, auth_store) @@ -206,19 +210,27 @@ impl AppState { } pub async fn sync_auth_store_snapshot_to_spacetime(&self) -> Result<(), SpacetimeClientError> { + #[cfg(test)] + return Ok(()); + + #[cfg(not(test))] let snapshot_json = self .auth_store .export_snapshot_json() .map_err(SpacetimeClientError::Runtime)?; + #[cfg(not(test))] let updated_at_micros = i64::try_from( OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000, ) .map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?; + #[cfg(not(test))] self.spacetime_client .upsert_auth_store_snapshot(snapshot_json, updated_at_micros) .await?; // ????????????????????????????????? + #[cfg(not(test))] self.spacetime_client.import_auth_store_snapshot().await?; + #[cfg(not(test))] Ok(()) } @@ -401,6 +413,47 @@ impl AppState { #[cfg(test)] impl AppState { + pub(crate) async fn seed_test_phone_user_with_password( + &self, + phone_number: &str, + password: &str, + ) -> module_auth::AuthUser { + let now = OffsetDateTime::now_utc(); + self.phone_auth_service() + .send_code( + module_auth::SendPhoneCodeInput { + phone_number: phone_number.to_string(), + scene: module_auth::PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("test phone code should send"); + let user = self + .phone_auth_service() + .login( + module_auth::PhoneLoginInput { + phone_number: phone_number.to_string(), + verify_code: "123456".to_string(), + }, + now + time::Duration::seconds(1), + ) + .await + .expect("test phone login should create user") + .user; + let changed = self + .password_entry_service() + .change_password(module_auth::ChangePasswordInput { + user_id: user.id.clone(), + current_password: None, + new_password: password.to_string(), + }) + .await + .expect("test password should set"); + + changed.user + } + fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) { self.test_runtime_snapshot_store .lock() diff --git a/server-rs/crates/api-server/src/story_battles.rs b/server-rs/crates/api-server/src/story_battles.rs index 96f254df..6d16c668 100644 --- a/server-rs/crates/api-server/src/story_battles.rs +++ b/server-rs/crates/api-server/src/story_battles.rs @@ -797,13 +797,9 @@ mod tests { async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "story_battles_user".to_string(), - password: "secret123".to_string(), - }) + .seed_test_phone_user_with_password("13800138107", "secret123") .await - .expect("seed login should succeed"); + .id; state } @@ -814,7 +810,7 @@ mod tests { session_id: "sess_story_battles".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("战斗接口用户".to_string()), diff --git a/server-rs/crates/api-server/src/story_sessions.rs b/server-rs/crates/api-server/src/story_sessions.rs index 5ad5f507..6b3caa8a 100644 --- a/server-rs/crates/api-server/src/story_sessions.rs +++ b/server-rs/crates/api-server/src/story_sessions.rs @@ -384,13 +384,9 @@ mod tests { async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state - .password_entry_service() - .execute(module_auth::PasswordEntryInput { - username: "story_sessions_user".to_string(), - password: "secret123".to_string(), - }) + .seed_test_phone_user_with_password("13800138108", "secret123") .await - .expect("seed login should succeed"); + .id; state } @@ -401,7 +397,7 @@ mod tests { session_id: "sess_story_sessions".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 1, + token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("故事会话用户".to_string()), diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index d2f7df41..1952d725 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -18,8 +18,6 @@ use shared_kernel::{ use time::{Duration, OffsetDateTime}; use tracing::{info, warn}; -const USERNAME_MIN_LENGTH: usize = 3; -const USERNAME_MAX_LENGTH: usize = 24; const PASSWORD_MIN_LENGTH: usize = 6; const PASSWORD_MAX_LENGTH: usize = 128; const SMS_CODE_LENGTH: usize = 6; @@ -65,7 +63,7 @@ pub struct PublicUserSearchResult { #[derive(Clone, Debug, PartialEq, Eq)] pub struct PasswordEntryInput { - pub username: String, + pub phone_number: String, pub password: String, } @@ -315,7 +313,7 @@ pub struct AuthStoreSnapshotProcedureResult { #[derive(Clone, Debug, PartialEq, Eq)] pub enum PasswordEntryError { - InvalidUsername, + InvalidPhoneNumber, InvalidPasswordLength, InvalidPublicUserCode, InvalidCredentials, @@ -476,27 +474,16 @@ impl PasswordEntryService { input: PasswordEntryInput, ) -> Result { validate_password(&input.password)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number) + .map_err(|_| PasswordEntryError::InvalidPhoneNumber)?; + let Some(existing_user) = self + .store + .find_by_phone_number_for_password(&normalized_phone.e164)? + else { + return Err(PasswordEntryError::InvalidCredentials); + }; - // 登录面板现在固定使用手机号作为密码登录标识;先走手机号索引, - // 再保留历史用户名路径给开发游客和旧测试数据使用。 - if let Ok(normalized_phone) = normalize_mainland_china_phone_number(&input.username) { - let Some(existing_user) = self - .store - .find_by_phone_number_for_password(&normalized_phone.e164)? - else { - return Err(PasswordEntryError::InvalidCredentials); - }; - - return verify_stored_password_user(existing_user, &input.password).await; - } - - let username = normalize_username(&input.username)?; - - if let Some(existing_user) = self.store.find_by_username(&username)? { - return verify_stored_password_user(existing_user, &input.password).await; - } - - Err(PasswordEntryError::InvalidCredentials) + verify_stored_password_user(existing_user, &input.password).await } pub fn get_user_by_id( @@ -1232,17 +1219,6 @@ impl InMemoryAuthStore { .map_err(RefreshSessionError::Store) } - fn find_by_username( - &self, - username: &str, - ) -> Result, PasswordEntryError> { - let state = self - .inner - .lock() - .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; - Ok(state.users_by_username.get(username).cloned()) - } - fn find_by_user_id( &self, user_id: &str, @@ -2087,10 +2063,10 @@ impl AuthBindingStatus { impl fmt::Display for PasswordEntryError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"), + Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"), Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"), - Self::InvalidCredentials => f.write_str("用户名或密码错误"), + Self::InvalidCredentials => f.write_str("手机号或密码错误"), Self::UserNotFound => f.write_str("用户不存在"), Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), } @@ -2161,7 +2137,7 @@ impl Error for LogoutError {} fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError { match error { PasswordEntryError::Store(message) => RefreshSessionError::Store(message), - PasswordEntryError::InvalidUsername + PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials @@ -2176,7 +2152,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro match error { PasswordEntryError::Store(message) => PhoneAuthError::Store(message), PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message), - PasswordEntryError::InvalidUsername + PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials @@ -2187,7 +2163,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError { match error { PasswordEntryError::Store(message) => LogoutError::Store(message), - PasswordEntryError::InvalidUsername + PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials @@ -2215,21 +2191,6 @@ fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError } } -fn normalize_username(raw_username: &str) -> Result { - let username = normalize_required_string(raw_username).unwrap_or_default(); - let valid_length = - (USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count()); - let valid_chars = username - .chars() - .all(|character| character.is_ascii_alphanumeric() || character == '_'); - - if !valid_length || !valid_chars { - return Err(PasswordEntryError::InvalidUsername); - } - - Ok(username) -} - fn validate_password(password: &str) -> Result<(), PasswordEntryError> { let length = password.chars().count(); if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) { @@ -2255,7 +2216,10 @@ async fn verify_stored_password_user( } Ok(PasswordEntryResult { - user: existing_user.user, + user: AuthUser { + login_method: AuthLoginMethod::Password, + ..existing_user.user + }, created: false, }) } @@ -2501,7 +2465,7 @@ mod tests { let error = service .execute(PasswordEntryInput { - username: "guest_001".to_string(), + phone_number: "13800138000".to_string(), password: "secret123".to_string(), }) .await @@ -2516,7 +2480,7 @@ mod tests { let user = create_phone_login_user(store.clone(), "13800138000").await; let service = build_password_service(store); - let changed = service + service .change_password(ChangePasswordInput { user_id: user.id.clone(), current_password: None, @@ -2526,7 +2490,7 @@ mod tests { .expect("phone user should set first password"); let result = service .execute(PasswordEntryInput { - username: changed.user.username.clone(), + phone_number: "13800138000".to_string(), password: "secret123".to_string(), }) .await @@ -2534,7 +2498,7 @@ mod tests { assert!(!result.created); assert_eq!(result.user.id, user.id); - assert_eq!(result.user.login_method, AuthLoginMethod::Phone); + assert_eq!(result.user.login_method, AuthLoginMethod::Password); } #[tokio::test] @@ -2553,7 +2517,7 @@ mod tests { let error = service .execute(PasswordEntryInput { - username: user.username, + phone_number: "13800138001".to_string(), password: "secret999".to_string(), }) .await @@ -2651,18 +2615,18 @@ mod tests { } #[tokio::test] - async fn invalid_username_returns_bad_request_error() { + async fn password_entry_rejects_email_or_username_identifier() { let service = build_password_service(build_store()); let error = service .execute(PasswordEntryInput { - username: "坏用户名".to_string(), + phone_number: "user@example.com".to_string(), password: "secret123".to_string(), }) .await - .expect_err("invalid username should fail"); + .expect_err("email should fail"); - assert_eq!(error, PasswordEntryError::InvalidUsername); + assert_eq!(error, PasswordEntryError::InvalidPhoneNumber); } #[tokio::test] diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index bddb182b..faa7ba03 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -42,7 +42,7 @@ pub struct PublicUserSearchResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PasswordEntryRequest { - pub username: String, + pub phone: String, pub password: String, } @@ -193,12 +193,16 @@ pub struct WechatBindPhoneResponse { pub fn build_available_login_methods( sms_auth_enabled: bool, + password_auth_enabled: bool, wechat_auth_enabled: bool, ) -> Vec { - let mut methods = vec![AUTH_LOGIN_METHOD_PASSWORD.to_string()]; + let mut methods = Vec::new(); if sms_auth_enabled { methods.push(AUTH_LOGIN_METHOD_PHONE.to_string()); } + if password_auth_enabled { + methods.push(AUTH_LOGIN_METHOD_PASSWORD.to_string()); + } if wechat_auth_enabled { methods.push(AUTH_LOGIN_METHOD_WECHAT.to_string()); } @@ -212,13 +216,13 @@ mod tests { #[test] fn available_login_methods_keep_phone_then_wechat_order() { - let methods = build_available_login_methods(true, true); + let methods = build_available_login_methods(true, true, true); assert_eq!( methods, vec![ - AUTH_LOGIN_METHOD_PASSWORD.to_string(), AUTH_LOGIN_METHOD_PHONE.to_string(), + AUTH_LOGIN_METHOD_PASSWORD.to_string(), AUTH_LOGIN_METHOD_WECHAT.to_string() ] ); @@ -227,7 +231,7 @@ mod tests { #[test] fn password_entry_request_uses_camel_case_fields() { let payload = serde_json::to_value(PasswordEntryRequest { - username: "guest_001".to_string(), + phone: "13800138000".to_string(), password: "secret123".to_string(), }) .expect("payload should serialize"); @@ -235,7 +239,7 @@ mod tests { assert_eq!( payload, json!({ - "username": "guest_001", + "phone": "13800138000", "password": "secret123" }) ); diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index a661263b..24d4aa7b 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -13,7 +13,6 @@ const authMocks = vi.hoisted(() => ({ authEntry: vi.fn(), changePassword: vi.fn(), ensureStoredAccessToken: vi.fn(), - ensureAutoAuthUser: vi.fn(), getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), loginWithPhoneCode: vi.fn(), @@ -36,7 +35,6 @@ vi.mock('../../services/authService', () => ({ changePassword: authMocks.changePassword, changePhoneNumber: vi.fn(), consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult, - ensureAutoAuthUser: authMocks.ensureAutoAuthUser, getStoredLastLoginPhone: vi.fn(() => ''), getAuthAuditLogs: vi.fn(), getAuthLoginOptions: authMocks.getAuthLoginOptions, @@ -106,16 +104,13 @@ beforeEach(() => { expiresInSeconds: 300, }); authMocks.startWechatLogin.mockResolvedValue(undefined); - authMocks.ensureAutoAuthUser.mockResolvedValue({ - user: mockUser, - credentials: { - username: 'guest_tester', - password: 'auto_password', - }, - }); }); -function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => void }) { +function ProtectedActionButton({ + onAuthenticated, +}: { + onAuthenticated: () => void; +}) { const authUi = useAuthUi(); return ( @@ -178,7 +173,6 @@ test('auth gate keeps platform content visible when phone login is available', a expect(await screen.findByText('应用内容')).toBeTruthy(); expect(screen.queryByRole('button', { name: '登录' })).toBeNull(); expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull(); - expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled(); }); test('auth gate waits for access token refresh before exposing restored user content', async () => { @@ -220,7 +214,6 @@ test('auth gate does not auto-create a guest account when dev guest switch is no ); expect(await screen.findByText('应用内容')).toBeTruthy(); - expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled(); }); test('auth gate opens a login modal for protected actions and resumes after login', async () => { @@ -245,7 +238,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('验证码'), '123456'); - await user.click(within(dialog).getByRole('button', { name: '注册/登录' })); + await user.click(within(dialog).getByRole('button', { name: '登录' })); await waitFor(() => { expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith( @@ -388,24 +381,26 @@ test('login modal resets draft state every time it is reopened', async () => { const firstDialog = screen.getByRole('dialog', { name: '账号入口' }); await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000'); - await user.click(within(firstDialog).getByRole('button', { name: '获取验证码' })); + await user.click( + within(firstDialog).getByRole('button', { name: '获取验证码' }), + ); expect( - await within(firstDialog).findByText('短信请求已提交,验证码有效期约 5 分钟。'), + await within(firstDialog).findByText( + '短信请求已提交,验证码有效期约 5 分钟。', + ), ).toBeTruthy(); await user.type(within(firstDialog).getByLabelText('验证码'), '123456'); await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' })); await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd'); - await user.click(within(firstDialog).getByRole('button', { name: '忘记密码' })); - - expect( - screen.getByRole('dialog', { name: '重置密码' }), - ).toBeTruthy(); - await user.click( - screen.getByRole('button', { name: '关闭登录弹窗' }), + within(firstDialog).getByRole('button', { name: '忘记密码' }), ); + expect(screen.getByRole('dialog', { name: '重置密码' })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '关闭登录弹窗' })); + await waitFor(() => { expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); }); @@ -426,7 +421,9 @@ test('login modal resets draft state every time it is reopened', async () => { ).toBe(''); expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull(); expect( - within(reopenedDialog).queryByText('短信请求已提交,验证码有效期约 5 分钟。'), + within(reopenedDialog).queryByText( + '短信请求已提交,验证码有效期约 5 分钟。', + ), ).toBeNull(); expect( within(reopenedDialog).getByRole('button', { name: '获取验证码' }), @@ -465,9 +462,9 @@ test('auth gate separates sms and password login by tabs', async () => { ).toBe('true'); expect(within(dialog).queryByLabelText('验证码')).toBeNull(); - await user.type(within(dialog).getByLabelText('手机号/邮箱'), '13800000000'); + await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('密码'), 'passw0rd'); - await user.click(within(dialog).getByRole('button', { name: '注册/登录' })); + await user.click(within(dialog).getByRole('button', { name: '登录' })); await waitFor(() => { expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd'); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index f2a1c011..0e01ba41 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -24,7 +24,6 @@ import { changePassword, changePhoneNumber, consumeAuthCallbackResult, - ensureAutoAuthUser, getAuthAuditLogs, getAuthLoginOptions, getAuthRiskBlocks, @@ -42,10 +41,7 @@ import { startWechatLogin, } from '../../services/authService'; import { AccountModal } from './AccountModal'; -import { - AuthUiContext, - type PlatformSettingsSection, -} from './AuthUiContext'; +import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext'; import { BindPhoneScreen } from './BindPhoneScreen'; import { LoginScreen } from './LoginScreen'; @@ -61,11 +57,6 @@ type AuthStatus = | 'ready' | 'error'; -const allowDevGuestAutoAuth = - import.meta.env.DEV && - // 开发游客兜底必须显式开启,避免抢占正式手机号验证码登录入口。 - import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true'; - export function AuthGate({ children }: AuthGateProps) { const [status, setStatus] = useState('checking'); const [user, setUser] = useState(null); @@ -204,37 +195,6 @@ export function AuthGate({ children }: AuthGateProps) { useEffect(() => { let isActive = true; - const ensureAutoUser = async () => { - if (!isActive) { - return; - } - - setStatus('recovering'); - - try { - const { user: nextUser } = await ensureAutoAuthUser(); - if (!isActive) { - return; - } - - await ensureStoredAccessToken(); - activateReadyUser(nextUser); - setError(''); - } catch (autoAuthError) { - if (!isActive) { - return; - } - - setUser(null); - setStatus('error'); - setError( - autoAuthError instanceof Error - ? autoAuthError.message - : '自动登录失败,请稍后再试。', - ); - } - }; - const hydrate = async () => { const loadLoginOptions = async () => { const options = await getAuthLoginOptions(); @@ -253,15 +213,6 @@ export function AuthGate({ children }: AuthGateProps) { return; } - if ( - allowDevGuestAutoAuth && - options && - options.availableLoginMethods.length === 0 - ) { - await ensureAutoUser(); - return; - } - setUser(null); setStatus('unauthenticated'); } catch (optionsError) { @@ -269,11 +220,6 @@ export function AuthGate({ children }: AuthGateProps) { return; } - if (allowDevGuestAutoAuth) { - await ensureAutoUser(); - return; - } - setAvailableLoginMethods([]); setUser(null); setError( @@ -473,7 +419,9 @@ export function AuthGate({ children }: AuthGateProps) { if (status === 'checking' && !canKeepPlatformContentMounted) { return ( -
+
正在校验登录状态...
); @@ -481,8 +429,10 @@ export function AuthGate({ children }: AuthGateProps) { if (status === 'recovering' && !canKeepPlatformContentMounted) { return ( -
- 正在自动创建或恢复账号... +
+ 正在恢复登录状态...
); } @@ -500,7 +450,11 @@ export function AuthGate({ children }: AuthGateProps) { setSendingCode(true); setError(''); try { - const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha); + const result = await sendPhoneLoginCode( + phone, + 'bind_phone', + captcha, + ); setBindCaptchaChallenge(null); return result; } catch (sendError) { @@ -548,7 +502,9 @@ export function AuthGate({ children }: AuthGateProps) { !canKeepPlatformContentMounted ) { return ( -
+
登录状态异常 @@ -651,7 +607,9 @@ export function AuthGate({ children }: AuthGateProps) { try { await revokeAuthSession(sessionId); setSessions((current) => - current.filter((session) => session.sessionId !== sessionId), + current.filter( + (session) => session.sessionId !== sessionId, + ), ); setAuditLogs(await getAuthAuditLogs()); } catch (revokeError) { @@ -674,7 +632,8 @@ export function AuthGate({ children }: AuthGateProps) { setChangePhoneCaptchaChallenge(null); return result; } catch (sendError) { - const captchaChallenge = getCaptchaChallengeFromError(sendError); + const captchaChallenge = + getCaptchaChallengeFromError(sendError); if (captchaChallenge) { setChangePhoneCaptchaChallenge(captchaChallenge); } @@ -687,7 +646,10 @@ export function AuthGate({ children }: AuthGateProps) { setUser(nextUser); }} onChangePassword={async (currentPassword, newPassword) => { - const nextUser = await changePassword(currentPassword, newPassword); + const nextUser = await changePassword( + currentPassword, + newPassword, + ); setUser(nextUser); }} /> @@ -710,7 +672,8 @@ export function AuthGate({ children }: AuthGateProps) { setLoginCaptchaChallenge(null); return result; } catch (sendError) { - const captchaChallenge = getCaptchaChallengeFromError(sendError); + const captchaChallenge = + getCaptchaChallengeFromError(sendError); if (captchaChallenge) { setLoginCaptchaChallenge(captchaChallenge); } @@ -742,12 +705,12 @@ export function AuthGate({ children }: AuthGateProps) { setLoggingIn(false); } }} - onPasswordSubmit={async (username, password) => { + onPasswordSubmit={async (phone, password) => { setLoggingIn(true); setError(''); try { - const nextUser = await authEntry(username, password); - setStoredLastLoginPhone(username); + const nextUser = await authEntry(phone, password); + setStoredLastLoginPhone(phone); activateReadyUser(nextUser); } catch (loginError) { setError( diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 11634992..3c7577b8 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -34,7 +34,7 @@ type LoginScreenProps = { expiresInSeconds: number; }>; onPhoneSubmit: (phone: string, code: string) => Promise; - onPasswordSubmit: (username: string, password: string) => Promise; + onPasswordSubmit: (phone: string, password: string) => Promise; onResetPassword: ( phone: string, code: string, @@ -96,12 +96,20 @@ export function LoginScreen({ }, [isOpen, phoneLoginEnabled]); useEffect(() => { - if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) { + if ( + activeLoginTab === 'phone' && + !phoneLoginEnabled && + passwordLoginEnabled + ) { setActiveLoginTab('password'); return; } - if (activeLoginTab === 'password' && !passwordLoginEnabled && phoneLoginEnabled) { + if ( + activeLoginTab === 'password' && + !passwordLoginEnabled && + phoneLoginEnabled + ) { setActiveLoginTab('phone'); } }, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]); @@ -182,7 +190,9 @@ export function LoginScreen({ const result = await onSendCode(resetPhone, 'reset_password'); setResetCooldownSeconds(result.cooldownSeconds); }} - onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)} + onSubmit={() => + onResetPassword(resetPhone, resetCode, resetPasswordValue) + } /> ) : (
@@ -216,13 +226,14 @@ export function LoginScreen({ }} >
- +
); } @@ -572,6 +651,7 @@ function DesktopTrendingItem({ onClick: () => void; }) { const coverImage = resolvePlatformWorldCoverImage(entry); + const publicWorkCode = resolvePlatformPublicWorkCode(entry); const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2); return ( @@ -594,7 +674,9 @@ function DesktopTrendingItem({
{`${rank}`.padStart(2, '0')} - {formatPlatformWorldTime(entry.publishedAt)} + + {publicWorkCode ?? describePublicGalleryCardKind(entry)} +
{entry.worldName} @@ -1050,6 +1132,7 @@ export function RpgEntryHomeView({ }: RpgEntryHomeViewProps) { const authUi = useAuthUi(); const [desktopSearchKeyword, setDesktopSearchKeyword] = useState(''); + const [mobileSearchKeyword, setMobileSearchKeyword] = useState(''); const [isRechargeOpen, setIsRechargeOpen] = useState(false); const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>( 'points', @@ -1171,6 +1254,14 @@ export function RpgEntryHomeView({ void onSearchPublicCode(keyword); }; + const submitMobileSearch = () => { + const keyword = mobileSearchKeyword.trim(); + if (!keyword || !onSearchPublicCode || isSearchingPublicCode) { + return; + } + + void onSearchPublicCode(keyword); + }; const desktopHeroEntry = featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null; const desktopHeroCover = desktopHeroEntry @@ -1198,6 +1289,13 @@ export function RpgEntryHomeView({ const mobileHomeContent: ReactNode = (
+ + -
+
diff --git a/src/components/rpg-entry/RpgEntryWorldDetailView.tsx b/src/components/rpg-entry/RpgEntryWorldDetailView.tsx index 6a9b9a95..ae8c0ae2 100644 --- a/src/components/rpg-entry/RpgEntryWorldDetailView.tsx +++ b/src/components/rpg-entry/RpgEntryWorldDetailView.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, Copy } from 'lucide-react'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; @@ -8,6 +8,7 @@ import { buildPlatformWorldTags, describePlatformThemeLabel, formatPlatformWorldTime, + resolvePlatformPublicWorkCode, resolvePlatformWorldCoverImage, resolvePlatformWorldLeadPortrait, } from './rpgEntryWorldPresentation'; @@ -24,6 +25,14 @@ export interface RpgEntryWorldDetailViewProps { onUnpublish?: (() => void) | null; } +function copyText(value: string) { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + return; + } + + void navigator.clipboard.writeText(value); +} + function ActionButton({ label, onClick, @@ -67,6 +76,7 @@ export function RpgEntryWorldDetailView({ }: RpgEntryWorldDetailViewProps) { const coverImage = resolvePlatformWorldCoverImage(entry); const leadPortrait = resolvePlatformWorldLeadPortrait(entry); + const publicWorkCode = resolvePlatformPublicWorkCode(entry); const canStartGame = entry.visibility === 'published'; const previewCharacters = buildCustomWorldPlayableCharacters( entry.profile, @@ -128,6 +138,16 @@ export function RpgEntryWorldDetailView({ ? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}` : '仅自己可见'} + {publicWorkCode ? ( + + ) : null}
{entry.worldName} diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index a933b92f..8450e1b8 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -5,6 +5,7 @@ import type { import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; +import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode'; import type { CustomWorldProfile } from '../../types'; export type PlatformWorldCardLike = @@ -16,6 +17,7 @@ export type PlatformPuzzleGalleryCard = { sourceType: 'puzzle'; workId: string; profileId: string; + publicWorkCode: string; ownerUserId: string; authorDisplayName: string; worldName: string; @@ -51,6 +53,7 @@ export function mapPuzzleWorkToPlatformGalleryCard( sourceType: 'puzzle', workId: work.workId, profileId: work.profileId, + publicWorkCode: buildPuzzlePublicWorkCode(work.profileId), ownerUserId: work.ownerUserId, authorDisplayName: work.authorDisplayName, worldName: work.levelName, @@ -122,6 +125,16 @@ export function formatPlatformWorldTime(value: string | null) { }); } +export function resolvePlatformPublicWorkCode( + entry: PlatformWorldCardLike, +): string | null { + if (isPuzzleGalleryEntry(entry)) { + return entry.publicWorkCode; + } + + return entry.publicWorkCode; +} + export function describePlatformThemeLabel( themeMode: CustomWorldGalleryCard['themeMode'], ) { diff --git a/src/services/publicWorkCode.ts b/src/services/publicWorkCode.ts new file mode 100644 index 00000000..04f1b6ab --- /dev/null +++ b/src/services/publicWorkCode.ts @@ -0,0 +1,24 @@ +export function normalizePublicCodeText(value: string) { + return value + .trim() + .replace(/[^a-zA-Z0-9]/gu, '') + .toUpperCase(); +} + +export function buildPuzzlePublicWorkCode(profileId: string) { + const normalized = normalizePublicCodeText(profileId); + const fallback = normalized || '00000000'; + const suffix = fallback.slice(-8).padStart(8, '0'); + + return `PZ-${suffix}`; +} + +export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) { + const normalizedKeyword = normalizePublicCodeText(keyword); + + return ( + normalizedKeyword === + normalizePublicCodeText(buildPuzzlePublicWorkCode(profileId)) || + normalizedKeyword === normalizePublicCodeText(profileId) + ); +} diff --git a/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts b/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts index 62daa143..723b145e 100644 --- a/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts +++ b/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts @@ -127,14 +127,12 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛'); }); -test('buildRpgCreationPreviewFromSession reads draftProfile directly', () => { +test('buildRpgCreationPreviewFromSession prefers server result preview', () => { const profile = buildRpgCreationPreviewFromSession(sessionWithPreview); - expect(profile?.name).toBe('只作为 fallback 的本地草稿名'); - expect(profile?.name).not.toBe('服务端结果预览'); - expect(profile?.playableNpcs[0]?.imageSrc).toBe( - '/generated-characters/draft-playable-1/portrait.png', - ); + expect(profile?.name).toBe('服务端结果预览'); + expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。'); + expect(profile?.id).toBe('preview-profile-1'); }); test('buildRpgCreationPreviewFromSession does not require resultPreview', () => { diff --git a/src/services/rpg-creation/rpgCreationPreviewAdapter.ts b/src/services/rpg-creation/rpgCreationPreviewAdapter.ts index a6a8c33b..16222541 100644 --- a/src/services/rpg-creation/rpgCreationPreviewAdapter.ts +++ b/src/services/rpg-creation/rpgCreationPreviewAdapter.ts @@ -3,24 +3,26 @@ import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary import type { CustomWorldProfile } from '../../types'; export function buildCustomWorldProfileFromResultPreview( - resultPreview: CustomWorldAgentSessionSnapshot['resultPreview'] | null | undefined, + resultPreview: + | CustomWorldAgentSessionSnapshot['resultPreview'] + | null + | undefined, ): CustomWorldProfile | null { return normalizeCustomWorldProfileRecord(resultPreview?.preview ?? null); } -/** - * RPG 运行时直接读取 Agent session 的 draftProfile。 - * resultPreview 只作为质量/发布信息外壳,不再参与进入游戏 profile 的数据转换。 - */ export function buildCustomWorldProfileFromAgentSession( session: CustomWorldAgentSessionSnapshot | null | undefined, ): CustomWorldProfile | null { - return normalizeCustomWorldProfileRecord(session?.draftProfile ?? null); + return ( + buildCustomWorldProfileFromResultPreview(session?.resultPreview) ?? + normalizeCustomWorldProfileRecord(session?.draftProfile ?? null) + ); } /** * 这是工作包 A 提供的新命名兼容层。 - * 主入口保持命名稳定,但数据来源已经收敛为 draftProfile 单一真相源。 + * 主入口保持命名稳定,优先消费服务端 resultPreview,缺失时回退到 draftProfile。 */ export const rpgCreationPreviewAdapter = { buildPreviewFromSession: buildCustomWorldProfileFromAgentSession, From 7aabbcc10ce198ddda2fd1856d8fc4b4995de515 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 15:19:53 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E5=8F=B7=E5=A4=8D=E5=88=B6=E4=B8=8E=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E8=BF=94=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md | 14 +++-- ...ustomWorldCreationHub.interaction.test.tsx | 58 ++++++++++++++++++- .../custom-world-home/CustomWorldWorkCard.tsx | 34 ++++++++--- .../PlatformEntryFlowShellImpl.tsx | 42 +++++++++++--- .../PuzzleGalleryDetailView.test.tsx | 32 ++++++++++ .../PuzzleGalleryDetailView.tsx | 27 ++++++--- ...gEntryFlowShell.agent.interaction.test.tsx | 58 +++++++++++++++++++ .../RpgEntryHomeView.recharge.test.tsx | 27 ++++----- src/components/rpg-entry/RpgEntryHomeView.tsx | 50 +++------------- .../rpg-entry/RpgEntryWorldDetailView.tsx | 34 +++++++---- src/services/clipboard.ts | 53 +++++++++++++++++ 11 files changed, 328 insertions(+), 101 deletions(-) create mode 100644 src/services/clipboard.ts diff --git a/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md b/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md index aea71e9d..e36dafc2 100644 --- a/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md +++ b/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md @@ -2,20 +2,24 @@ ## 背景 -公开编号设计已要求广场作品卡和详情页展示 `CW / PZ` 作品号,并支持通过首页搜索入口打开公开作品。但当前移动端首页只有桌面端顶部搜索框,竖屏无法输入 `SY / CW / PZ` 编号;同时首页“最新发布”和桌面趋势卡片把发布时间放在显眼 badge 位置,异常时间字符串会被误认为作品号;创作页“我的作品”卡只展示作者和游玩数,没有可复制、可搜索的公开作品号。 +公开编号设计已要求详情页和创作中心展示 `CW / PZ` 作品号,并支持通过首页搜索入口打开公开作品。但当前移动端首页只有桌面端顶部搜索框,竖屏无法输入 `SY / CW / PZ` 编号;同时首页“最新发布”和桌面趋势卡片把发布时间放在显眼 badge 位置,异常时间字符串会被误认为作品号;创作页“我的作品”卡只展示作者和游玩数,没有可复制、可搜索的公开作品号。 ## 落地规则 1. 移动端首页在 Logo 下方提供紧凑搜索条,复用现有 `onSearchPublicCode` 行为,不新增页面或新系统。 -2. 广场作品卡的辅助 badge 优先展示作品号,点击作品号只复制,不打开详情;没有公开作品号时展示作品类型,不再用发布时间充当主 badge。 +2. 首页、分类、趋势等公开外部列表不直接展示作品号,卡片 badge 展示推荐、分类或作品类型,不再用发布时间充当主 badge。 3. RPG 与拼图详情页在已发布作品的辅助信息里展示作品号,并提供复制动作。 4. 创作页作品卡在已发布作品上展示作品号:RPG 使用后端 `publicWorkCode`;拼图当前没有独立公开号时,使用 `PZ-` + `profileId` 后 8 位作为前端展示与复制标识,后续若补后端拼图公开号再替换来源。 -5. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。 +5. 作品号复制统一使用兼容复制工具:优先 Clipboard API,权限失败时降级到隐藏文本框选区复制,并在按钮内短暂显示复制结果。 +6. 作品详情返回必须恢复打开详情前的平台来源 Tab;从分类进入回分类,从首页进入回首页,从创作中心进入回创作中心。 +7. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。 ## 验收 1. 399px 竖屏首页能直接看到并使用搜索入口。 -2. 首页公开作品卡左上角不再出现发布时间样式的疑似作品号。 +2. 首页公开作品卡左上角不再出现发布时间样式的疑似作品号,也不直接显示作品号。 3. RPG 详情页能看到 `作品号 CW...` 并可复制,拼图详情页能看到 `作品号 PZ...` 并可复制。 4. 创作页“我的作品”已发布卡能看到作品号,拼图卡不会只显示作者和游玩数。 -5. 桌面右侧趋势列表只显示排序和作品号或作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串。 +5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。 +6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制` 或 `复制失败`。 +7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。 diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 31a6ae81..614cd559 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -2,12 +2,20 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { expect, test } from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import { CustomWorldCreationHub } from './CustomWorldCreationHub'; const noopCreateType = () => {}; +const originalClipboard = navigator.clipboard; + +afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }); +}); const baseDraftItem: CustomWorldWorkSummary = { workId: 'draft:session-1', @@ -214,3 +222,51 @@ test('creation hub opens persisted rpg drafts by card click', async () => { expect(openedItems).toEqual([persistedDraft]); }); + +test('creation hub work code copy button copies without opening the card', async () => { + const user = userEvent.setup(); + const writeText = vi.fn(async () => undefined); + const onOpenPuzzleDetail = vi.fn(); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={onOpenPuzzleDetail} + />, + ); + + await user.click( + screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }), + ); + + expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1'); + expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); + expect(await screen.findByText('已复制')).toBeTruthy(); +}); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index c4c14c86..f04fa6cc 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -1,16 +1,10 @@ import { Copy } from 'lucide-react'; +import { useState } from 'react'; +import { copyTextToClipboard } from '../../services/clipboard'; import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import type { CreationWorkShelfItem } from './creationWorkShelf'; -function copyText(value: string) { - if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { - return; - } - - void navigator.clipboard.writeText(value); -} - function formatUpdatedAt(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) { @@ -49,6 +43,20 @@ export function CustomWorldWorkCard({ onDelete = null, deleteBusy = false, }: CustomWorldWorkCardProps) { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const copyPublicWorkCode = () => { + if (!item.publicWorkCode) { + return; + } + + void copyTextToClipboard(item.publicWorkCode).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + window.setTimeout(() => setCopyState('idle'), 1400); + }); + }; + return (
{ event.stopPropagation(); - copyText(item.publicWorkCode ?? ''); + copyPublicWorkCode(); + }} + onKeyDown={(event) => { + event.stopPropagation(); }} className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]" aria-label={`复制作品号 ${item.publicWorkCode}`} @@ -155,6 +166,11 @@ export function CustomWorldWorkCard({ 作品号 {item.publicWorkCode} + {copyState !== 'idle' ? ( + + {copyState === 'copied' ? '已复制' : '复制失败'} + + ) : null} ) : null}
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 8cb0b1bf..1fd9925b 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -101,7 +101,10 @@ import { } from '../rpg-entry/rpgEntryWorldPresentation'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; -import { PlatformEntryHomeView } from './PlatformEntryHomeView'; +import { + PlatformEntryHomeView, + type PlatformHomeTab, +} from './PlatformEntryHomeView'; import { buildCreationHubFallbackItems, normalizeAgentBackedProfile, @@ -119,6 +122,10 @@ type AgentResultPublishGateView = { publishReady: boolean; }; +type PuzzleDetailReturnTarget = { + tab: PlatformHomeTab; +}; + type AgentResultBlockerView = { code?: string; message: string; @@ -363,6 +370,8 @@ export function PlatformEntryFlowShellImpl({ >([]); const [selectedPuzzleDetail, setSelectedPuzzleDetail] = useState(null); + const [puzzleDetailReturnTarget, setPuzzleDetailReturnTarget] = + useState(null); const [puzzleRun, setPuzzleRun] = useState(null); const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false); const [puzzleGenerationState, setPuzzleGenerationState] = @@ -1393,14 +1402,19 @@ export function PlatformEntryFlowShellImpl({ ); const openPuzzleDetail = useCallback( - async (profileId: string) => { + async ( + profileId: string, + returnTarget: PuzzleDetailReturnTarget = { + tab: platformBootstrap.platformTab, + }, + ) => { setIsPuzzleBusy(true); setPuzzleError(null); try { const { item } = await getPuzzleGalleryDetail(profileId); setSelectedPuzzleDetail(item); - enterCreateTab(); + setPuzzleDetailReturnTarget(returnTarget); setSelectionStage('puzzle-gallery-detail'); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。')); @@ -1408,7 +1422,11 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleBusy(false); } }, - [enterCreateTab, resolvePuzzleErrorMessage, setSelectionStage], + [ + platformBootstrap.platformTab, + resolvePuzzleErrorMessage, + setSelectionStage, + ], ); const openPuzzleDraft = useCallback( @@ -1418,7 +1436,7 @@ export function PlatformEntryFlowShellImpl({ setSelectedPuzzleDetail(null); if (!item.sourceSessionId?.trim()) { if (item.publicationStatus === 'published') { - await openPuzzleDetail(item.profileId); + await openPuzzleDetail(item.profileId, { tab: 'create' }); return; } @@ -1495,7 +1513,9 @@ export function PlatformEntryFlowShellImpl({ throw new Error('未找到拼图作品。'); } - await openPuzzleDetail(matchedEntry.profileId); + await openPuzzleDetail(matchedEntry.profileId, { + tab: platformBootstrap.platformTab, + }); }; try { @@ -1543,6 +1563,7 @@ export function PlatformEntryFlowShellImpl({ [ detailNavigation, openPuzzleDetail, + platformBootstrap.platformTab, puzzleGalleryEntries, refreshPuzzleGallery, ], @@ -1765,7 +1786,9 @@ export function PlatformEntryFlowShellImpl({ onOpenCreateTypePicker={openCreationTypePicker} onOpenGalleryDetail={(entry) => { if (isPuzzleGalleryEntry(entry)) { - void openPuzzleDetail(entry.profileId); + void openPuzzleDetail(entry.profileId, { + tab: platformBootstrap.platformTab, + }); return; } @@ -2150,7 +2173,10 @@ export function PlatformEntryFlowShellImpl({ isBusy={isPuzzleBusy} error={puzzleError} onBack={() => { - enterCreateTab(); + platformBootstrap.setPlatformTab( + puzzleDetailReturnTarget?.tab ?? 'home', + ); + setPuzzleDetailReturnTarget(null); setSelectionStage('platform'); }} onEdit={ diff --git a/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx b/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx index 4e1d5ca1..ee194b50 100644 --- a/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx +++ b/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx @@ -64,3 +64,35 @@ test('shows and copies puzzle public work code in detail view', async () => { expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1'); }); + +test('falls back to legacy selection copy when clipboard api rejects', async () => { + const user = userEvent.setup(); + const writeText = vi.fn(async () => { + throw new Error('clipboard denied'); + }); + const execCommand = vi.fn(() => true); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + + render( + , + ); + + await user.click( + screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }), + ); + + expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1'); + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(await screen.findByText('已复制')).toBeTruthy(); +}); diff --git a/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx b/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx index 9afcb5ae..458d98d2 100644 --- a/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx +++ b/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx @@ -1,6 +1,8 @@ import { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react'; +import { useState } from 'react'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import { copyTextToClipboard } from '../../services/clipboard'; import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; @@ -13,14 +15,6 @@ type PuzzleGalleryDetailViewProps = { onStartGame: () => void; }; -function copyText(value: string) { - if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { - return; - } - - void navigator.clipboard.writeText(value); -} - /** * 拼图广场详情页。 * 展示最小信息并提供进入游戏动作,不扩展评论、收藏等非本轮需求。 @@ -34,6 +28,15 @@ export function PuzzleGalleryDetailView({ onStartGame, }: PuzzleGalleryDetailViewProps) { const publicWorkCode = buildPuzzlePublicWorkCode(item.profileId); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const copyPublicWorkCode = () => { + void copyTextToClipboard(publicWorkCode).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + window.setTimeout(() => setCopyState('idle'), 1400); + }); + }; return (
@@ -42,6 +45,7 @@ export function PuzzleGalleryDetailView({
diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 237352b6..0ce3b96f 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -1444,6 +1444,64 @@ test('published puzzle works appear on home and category public shelves', async ).toBeGreaterThan(0); }); +test('published puzzle detail returns to the source platform tab', async () => { + const user = userEvent.setup(); + const publishedPuzzleWork = { + workId: 'puzzle-work-public-1', + profileId: 'puzzle-profile-public-1', + ownerUserId: 'user-2', + sourceSessionId: null, + authorDisplayName: '拼图作者', + levelName: '星桥机关', + summary: '旋转碎片并接通星桥机关。', + themeTags: ['机关', '星桥'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-04-25T09:00:00.000Z', + publishedAt: '2026-04-25T09:00:00.000Z', + playCount: 3, + publishReady: true, + } satisfies PuzzleWorkSummary; + + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [publishedPuzzleWork], + }); + vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ + item: publishedPuzzleWork, + }); + + render(); + + await user.click(await screen.findByRole('button', { name: '分类' })); + await waitFor(() => { + expect(document.getElementById('platform-tab-panel-category')).toBeTruthy(); + }); + const categoryPanel = getPlatformTabPanel('category'); + expect( + within(categoryPanel).getAllByText('星桥机关').length, + ).toBeGreaterThan(0); + + await user.click( + within(categoryPanel).getByRole('button', { + name: /拼图关卡.*星桥机关/u, + }), + ); + expect( + await screen.findByRole('button', { name: '进入第 1 关' }), + ).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '返回' })); + + await waitFor(() => { + const returnedCategoryPanel = getPlatformTabPanel('category'); + expect(returnedCategoryPanel.getAttribute('aria-hidden')).toBe('false'); + expect( + within(returnedCategoryPanel).getAllByText('星桥机关').length, + ).toBeGreaterThan(0); + }); +}); + test('selecting RPG creation while logged out routes through requireAuth', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index e4caed65..de8c445a 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -95,8 +95,6 @@ vi.mock('../ResolvedAssetImage', () => ({ })); const originalMatchMedia = window.matchMedia; -const originalClipboard = navigator.clipboard; - const puzzlePublicEntry = { sourceType: 'puzzle', workId: 'puzzle-work-public-1', @@ -264,7 +262,7 @@ afterEach(() => { }); Object.defineProperty(navigator, 'clipboard', { configurable: true, - value: originalClipboard, + value: undefined, }); }); @@ -350,35 +348,32 @@ test('mobile home search submits public work code', async () => { expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1'); }); -test('public work code badge copies without opening gallery detail', async () => { +test('public gallery cards hide work code until detail is opened', async () => { const user = userEvent.setup(); - const writeText = vi.fn(async () => undefined); const onOpenGalleryDetail = vi.fn(); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], onOpenGalleryDetail, }); - await user.click( - screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }), - ); + expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); + expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' })) + .toBeNull(); - expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1'); - expect(onOpenGalleryDetail).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: /查看作品/u })); + + expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry); }); -test('desktop trending list shows public code instead of timestamp text', () => { +test('desktop trending list shows kind instead of work code or timestamp text', () => { mockDesktopLayout(); renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], }); - expect(screen.getAllByText('PZ-EPUBLIC1').length).toBeGreaterThan(0); + expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); + expect(screen.getAllByText('拼图').length).toBeGreaterThan(0); expect(screen.queryByText('1777110165.990127Z')).toBeNull(); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index c3765077..b55a0e50 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -23,7 +23,6 @@ import { } from 'lucide-react'; import { type ComponentType, - type KeyboardEvent, type ReactNode, useEffect, useMemo, @@ -54,7 +53,6 @@ import { describePlatformThemeLabel, formatPlatformWorldTime, isPuzzleGalleryEntry, - resolvePlatformPublicWorkCode, type PlatformPublicGalleryCard, type PlatformWorldCardLike, resolvePlatformWorldCoverImage, @@ -293,8 +291,6 @@ function WorldCard({ }) { const coverImage = resolvePlatformWorldCoverImage(entry); const leadPortrait = resolvePlatformWorldLeadPortrait(entry); - const publicWorkCode = resolvePlatformPublicWorkCode(entry); - const badgeLabel = publicWorkCode ?? badge; const tags = [ ...new Set( buildPlatformWorldTags(entry) @@ -303,25 +299,10 @@ function WorldCard({ ), ].slice(0, 3); - const openCardByKeyboard = (event: KeyboardEvent) => { - if (event.target !== event.currentTarget) { - return; - } - - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - - event.preventDefault(); - onClick(); - }; - return ( -
{coverImage ? ( @@ -342,25 +323,9 @@ function WorldCard({
- {publicWorkCode ? ( - - ) : ( - - {badgeLabel} - - )} + + {badge} + {metaLabel} @@ -395,7 +360,7 @@ function WorldCard({
-
+ ); } @@ -651,7 +616,6 @@ function DesktopTrendingItem({ onClick: () => void; }) { const coverImage = resolvePlatformWorldCoverImage(entry); - const publicWorkCode = resolvePlatformPublicWorkCode(entry); const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2); return ( @@ -675,7 +639,7 @@ function DesktopTrendingItem({
{`${rank}`.padStart(2, '0')} - {publicWorkCode ?? describePublicGalleryCardKind(entry)} + {describePublicGalleryCardKind(entry)}
diff --git a/src/components/rpg-entry/RpgEntryWorldDetailView.tsx b/src/components/rpg-entry/RpgEntryWorldDetailView.tsx index ae8c0ae2..7226a2ef 100644 --- a/src/components/rpg-entry/RpgEntryWorldDetailView.tsx +++ b/src/components/rpg-entry/RpgEntryWorldDetailView.tsx @@ -1,7 +1,9 @@ import { ArrowLeft, Copy } from 'lucide-react'; +import { useState } from 'react'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; +import { copyTextToClipboard } from '../../services/clipboard'; import type { CustomWorldProfile } from '../../types'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { @@ -25,14 +27,6 @@ export interface RpgEntryWorldDetailViewProps { onUnpublish?: (() => void) | null; } -function copyText(value: string) { - if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { - return; - } - - void navigator.clipboard.writeText(value); -} - function ActionButton({ label, onClick, @@ -77,6 +71,9 @@ export function RpgEntryWorldDetailView({ const coverImage = resolvePlatformWorldCoverImage(entry); const leadPortrait = resolvePlatformWorldLeadPortrait(entry); const publicWorkCode = resolvePlatformPublicWorkCode(entry); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); const canStartGame = entry.visibility === 'published'; const previewCharacters = buildCustomWorldPlayableCharacters( entry.profile, @@ -89,6 +86,16 @@ export function RpgEntryWorldDetailView({ .filter(Boolean), ), ].slice(0, 3); + const copyPublicWorkCode = () => { + if (!publicWorkCode) { + return; + } + + void copyTextToClipboard(publicWorkCode).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + window.setTimeout(() => setCopyState('idle'), 1400); + }); + }; return (
@@ -99,7 +106,7 @@ export function RpgEntryWorldDetailView({ className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]" > - 返回广场 + 返回
{entry.visibility === 'published' ? '已发布' : '草稿'} @@ -141,11 +148,18 @@ export function RpgEntryWorldDetailView({ {publicWorkCode ? ( ) : null}
diff --git a/src/services/clipboard.ts b/src/services/clipboard.ts new file mode 100644 index 00000000..3ba44864 --- /dev/null +++ b/src/services/clipboard.ts @@ -0,0 +1,53 @@ +export async function copyTextToClipboard(value: string) { + const text = value.trim(); + if (!text) { + return false; + } + + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // 部分内嵌浏览器会暴露 Clipboard API,但会因权限上下文拒绝写入,继续走兼容路径。 + } + } + + if (typeof document === 'undefined') { + return false; + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + document.body.appendChild(textarea); + + const selection = document.getSelection(); + const selectedRange = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + textarea.focus(); + textarea.select(); + + let copied = false; + try { + copied = + typeof document.execCommand === 'function' && + document.execCommand('copy'); + } catch { + copied = false; + } finally { + document.body.removeChild(textarea); + if (selection) { + selection.removeAllRanges(); + if (selectedRange) { + selection.addRange(selectedRange); + } + } + } + + return copied; +} From de2c49005f9f587de67a4f6dd4cd66f0a2042ae4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 15:32:36 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=E5=B7=B2=E5=8F=91=E5=B8=83=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E5=8F=AF=E4=BA=8C=E6=AC=A1=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...R_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md | 1 + ...ATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md | 12 +++ .../shared/src/contracts/rpgAgentActions.ts | 2 + server-rs/crates/api-server/src/big_fish.rs | 8 +- .../crates/api-server/src/custom_world.rs | 1 + .../crates/api-server/src/custom_world_ai.rs | 36 ++++----- server-rs/crates/api-server/src/puzzle.rs | 77 ++++++++++++------- .../crates/shared-contracts/src/runtime.rs | 1 + .../crates/spacetime-client/src/mapper.rs | 2 + .../crates/spacetime-module/src/puzzle.rs | 4 +- src/components/rpg-entry/rpgEntryShared.ts | 1 + src/routing/RouteImageReadyGate.test.ts | 45 ++++++++++- src/routing/RouteImageReadyGate.tsx | 41 +++++----- 13 files changed, 154 insertions(+), 77 deletions(-) diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md index a4f1ddac..927240ba 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md @@ -874,6 +874,7 @@ isCardDetailLoading: boolean; 1. 前端步骤名优先复用服务端 `phaseLabel` 的真实语义,不再单独发明一套四段式文案。 2. 如果服务端处于批处理阶段,顶部 `phaseLabel` / `phaseDetail` 继续直接显示当前批次信息。 3. 自动补主形象与幕背景图也属于草稿生成链路的一部分,不能在进度 UI 中被误折叠成“已完成”后的隐藏耗时。 +4. 进度页“已耗时”必须按服务端 operation 的创建时间 `startedAt` 与当前时间计算;刷新页面、恢复轮询或前端重挂载时不能重新从本地点击时间开始计时。只有旧 operation 缺少 `startedAt` 时,才允许使用本地记录的开始时间作为兜底。 ## 12.1 生成底稿时序 diff --git a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md index 54c436a8..7b239e39 100644 --- a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md @@ -135,6 +135,18 @@ 结果页不是一个只读总结页,而是拼图作品最小可编辑工作台。 +### 5.1.1 已发布作品二次编辑 + +创作者在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。 + +落地规则: + +1. 已发布拼图作品必须优先通过 `sourceSessionId` 恢复原 Agent session。 +2. 恢复后的结果页沿用原草稿、候选图、正式图、标题、摘要和标签;创作者可以继续改标题、摘要、标签,并重新生成或切换图片。 +3. 再次点击发布时不得创建新作品,必须覆盖同一个 `profileId / workId`。 +4. 覆盖发布只更新作品内容、更新时间、发布时间与广场投影;不得清零 `playCount`,不得改变作品归属。 +5. 如果历史作品缺少 `sourceSessionId`,前端只能退回作品详情,不伪造编辑 session。 + ## 5.2 运行时结论 拼图运行时应该是: diff --git a/packages/shared/src/contracts/rpgAgentActions.ts b/packages/shared/src/contracts/rpgAgentActions.ts index 417314a7..f6d4ceb9 100644 --- a/packages/shared/src/contracts/rpgAgentActions.ts +++ b/packages/shared/src/contracts/rpgAgentActions.ts @@ -66,6 +66,8 @@ export interface RpgAgentOperationRecord { phaseDetail: string; progress: number; error?: string | null; + /** 操作创建时间,草稿生成进度页用它计算总耗时。 */ + startedAt?: string | null; updatedAt?: string | null; } diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index dbc9acee..fb874774 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -434,13 +434,7 @@ pub async fn execute_big_fish_action( let now = current_utc_micros(); let session = match payload.action.trim() { "big_fish_compile_draft" => { - compile_big_fish_draft_with_all_assets( - &state, - session_id, - owner_user_id, - now, - ) - .await + compile_big_fish_draft_with_all_assets(&state, session_id, owner_user_id, now).await } "big_fish_generate_level_main_image" => { let asset_url = generate_big_fish_formal_asset( diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index ca34ed4d..09d161a1 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -2538,6 +2538,7 @@ fn map_custom_world_agent_operation_response( phase_detail: operation.phase_detail, progress: operation.progress, error: operation.error_message, + started_at: Some(timestamp_micros_to_rfc3339(operation.started_at_micros)), updated_at: Some(timestamp_micros_to_rfc3339(operation.updated_at_micros)), } } diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 4f3c47c8..5dda2b94 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -2548,26 +2548,24 @@ mod tests { name: Some("礁石神殿".to_string()), description: Some("古老礁石上的半沉神殿。".to_string()), }; - let manual_prompt = build_custom_world_scene_image_prompt( - SceneImagePromptParams { - profile: SceneImagePromptProfile { - name: profile_input.name.as_deref().unwrap_or_default(), - subtitle: profile_input.subtitle.as_deref().unwrap_or_default(), - tone: profile_input.tone.as_deref().unwrap_or_default(), - player_goal: profile_input.player_goal.as_deref().unwrap_or_default(), - summary: profile_input.summary.as_deref().unwrap_or_default(), - setting_text: profile_input.setting_text.as_deref().unwrap_or_default(), - }, - landmark: SceneImagePromptLandmark { - name: landmark.name.as_deref().unwrap_or_default(), - description: landmark.description.as_deref().unwrap_or_default(), - }, - user_prompt, - has_reference_image: false, - fallback_landmark_name: Some("礁石神殿"), - fallback_world_name: "雾海群岛", + let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams { + profile: SceneImagePromptProfile { + name: profile_input.name.as_deref().unwrap_or_default(), + subtitle: profile_input.subtitle.as_deref().unwrap_or_default(), + tone: profile_input.tone.as_deref().unwrap_or_default(), + player_goal: profile_input.player_goal.as_deref().unwrap_or_default(), + summary: profile_input.summary.as_deref().unwrap_or_default(), + setting_text: profile_input.setting_text.as_deref().unwrap_or_default(), }, - ); + landmark: SceneImagePromptLandmark { + name: landmark.name.as_deref().unwrap_or_default(), + description: landmark.description.as_deref().unwrap_or_default(), + }, + user_prompt, + has_reference_image: false, + fallback_landmark_name: Some("礁石神殿"), + fallback_world_name: "雾海群岛", + }); let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest { profile_id: Some("profile_001".to_string()), diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index c1ba06b7..8e9c120b 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -47,13 +47,14 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, - PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, - PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, - PuzzleWorkUpsertRecordInput, SpacetimeClientError, + PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, + PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, + PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, + SpacetimeClientError, }; use std::convert::Infallible; use tokio::time::sleep; @@ -1639,7 +1640,10 @@ async fn generate_puzzle_image_candidates( let mut items = Vec::with_capacity(generated.images.len()); for (index, image) in generated.images.into_iter().enumerate() { - let candidate_id = format!("{session_id}-candidate-{}", candidate_start_index + index + 1); + let candidate_id = format!( + "{session_id}-candidate-{}", + candidate_start_index + index + 1 + ); let asset = persist_puzzle_generated_asset( state, owner_user_id, @@ -1690,10 +1694,12 @@ async fn build_local_next_puzzle_run( })) })?; if current_level.status != "cleared" { - return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": "current level is not cleared", - }))); + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": "current level is not cleared", + })), + ); } if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? { @@ -1702,10 +1708,12 @@ async fn build_local_next_puzzle_run( let source_session_id = payload.source_session_id.unwrap_or_default(); if source_session_id.trim().is_empty() { - return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": "sourceSessionId is required when gallery has no next puzzle work", - }))); + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": "sourceSessionId is required when gallery has no next puzzle work", + })), + ); } let session = state .spacetime_client() @@ -1767,14 +1775,23 @@ async fn build_local_next_puzzle_run( let candidate = updated_session .draft .as_ref() - .and_then(|draft| draft.candidates.iter().find(|candidate| !candidate.image_src.is_empty())) + .and_then(|draft| { + draft + .candidates + .iter() + .find(|candidate| !candidate.image_src.is_empty()) + }) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": "现场生成后没有可用候选图", })) })?; - Ok(build_next_run_from_candidate(run, &updated_session, candidate)) + Ok(build_next_run_from_candidate( + run, + &updated_session, + candidate, + )) } async fn resolve_gallery_next_puzzle_work( @@ -1788,7 +1805,10 @@ async fn resolve_gallery_next_puzzle_work( .map_err(map_puzzle_client_error)?; Ok(items.into_iter().find(|item| { item.publication_status == "published" - && item.cover_image_src.as_ref().is_some_and(|value| !value.is_empty()) + && item + .cover_image_src + .as_ref() + .is_some_and(|value| !value.is_empty()) && !run.played_profile_ids.contains(&item.profile_id) })) } @@ -1836,7 +1856,9 @@ fn build_next_run_from_candidate( .map(|draft| format!("{} · 候选 {}", draft.level_name, level_index)) .unwrap_or_else(|| format!("候选拼图 {level_index}")), "当前草稿".to_string(), - draft.map(|draft| draft.theme_tags.clone()).unwrap_or_default(), + draft + .map(|draft| draft.theme_tags.clone()) + .unwrap_or_default(), Some(candidate.image_src.clone()), ) } @@ -1893,13 +1915,14 @@ fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord { } let pieces = (0..total) .map(|index| { - let current = positions - .get(index as usize) - .cloned() - .unwrap_or(PuzzleCellPositionRecord { - row: index / grid_size, - col: index % grid_size, - }); + let current = + positions + .get(index as usize) + .cloned() + .unwrap_or(PuzzleCellPositionRecord { + row: index / grid_size, + col: index % grid_size, + }); PuzzlePieceStateRecord { piece_id: format!("piece-{index}"), correct_row: index / grid_size, diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index b34c514d..c4539408 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -452,6 +452,7 @@ pub struct CustomWorldAgentOperationResponse { pub phase_detail: String, pub progress: u32, pub error: Option, + pub started_at: Option, pub updated_at: Option, } diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 33b00d92..2a801718 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -1837,6 +1837,7 @@ pub(crate) fn map_custom_world_agent_operation_snapshot( phase_detail: snapshot.phase_detail, progress: snapshot.progress, error_message: snapshot.error_message, + started_at_micros: snapshot.created_at_micros, updated_at_micros: snapshot.updated_at_micros, } } @@ -3721,6 +3722,7 @@ pub struct CustomWorldAgentOperationRecord { pub phase_detail: String, pub progress: u32, pub error_message: Option, + pub started_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 944d8a0a..36819917 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1384,7 +1384,9 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, publication_status: profile.publication_status, - play_count: profile.play_count, + // 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于 + // 广场消费数据,不能因为重新发布被清零。 + play_count: existing.play_count.max(profile.play_count), anchor_pack_json: serialize_json(&profile.anchor_pack), publish_ready: profile.publish_ready, created_at: existing.created_at, diff --git a/src/components/rpg-entry/rpgEntryShared.ts b/src/components/rpg-entry/rpgEntryShared.ts index 9f9f11f4..9b807cce 100644 --- a/src/components/rpg-entry/rpgEntryShared.ts +++ b/src/components/rpg-entry/rpgEntryShared.ts @@ -50,6 +50,7 @@ export function createFailedRpgEntryAgentOperation(params: { phaseDetail: params.error, progress: 0, error: params.error, + startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } diff --git a/src/routing/RouteImageReadyGate.test.ts b/src/routing/RouteImageReadyGate.test.ts index fd1bf04f..73d33bc6 100644 --- a/src/routing/RouteImageReadyGate.test.ts +++ b/src/routing/RouteImageReadyGate.test.ts @@ -1,12 +1,19 @@ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { collectRouteImageUrls, extractCssImageUrls, normalizePreloadImageUrl, } from './routeImageReadyGateUtils'; +import { RouteImageReadyGate } from './RouteImageReadyGate'; + +afterEach(() => { + vi.useRealTimers(); +}); describe('RouteImageReadyGate image url helpers', () => { it('extracts urls from layered CSS image values', () => { @@ -41,4 +48,40 @@ describe('RouteImageReadyGate image url helpers', () => { new URL('/ui/frame.png', document.baseURI).href, ]); }); + + it('reveals route content after a short cap when images stay pending', () => { + vi.useFakeTimers(); + + render( + createElement( + RouteImageReadyGate, + { + eyebrow: '正在载入游戏', + text: '正在载入冒险...', + }, + createElement( + 'section', + { + 'data-testid': 'route-content', + }, + createElement('img', { + src: '/generated-characters/slow-cover.png', + alt: 'slow cover', + }), + ), + ), + ); + + const content = screen.getByTestId('route-content'); + const visibilityGate = content.parentElement; + expect(visibilityGate?.getAttribute('aria-hidden')).toBe('true'); + expect(visibilityGate?.style.visibility).toBe('hidden'); + + act(() => { + vi.advanceTimersByTime(1600); + }); + + expect(visibilityGate?.getAttribute('aria-hidden')).toBe('false'); + expect(visibilityGate?.style.visibility).toBe('visible'); + }); }); diff --git a/src/routing/RouteImageReadyGate.tsx b/src/routing/RouteImageReadyGate.tsx index 2613c2c8..29634871 100644 --- a/src/routing/RouteImageReadyGate.tsx +++ b/src/routing/RouteImageReadyGate.tsx @@ -15,6 +15,7 @@ type RouteImageReadyGateProps = { const IMAGE_GATE_QUIET_MS = 140; const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260; +const IMAGE_GATE_MAX_BLOCK_MS = 1400; const IMAGE_PRELOAD_TIMEOUT_MS = 12000; const settledImageUrls = new Set(); @@ -66,7 +67,7 @@ function preloadImageUrl(url: string) { /** * 路由首屏图片门闩:业务页面先真实挂载但不可见, - * 等当前 DOM 中已发现的图片全部 settled 后再一次性显示。 + * 只等待短暂稳定窗口,不再把所有图片加载完成作为首屏硬阻塞。 */ export function RouteImageReadyGate({ children, @@ -78,6 +79,7 @@ export function RouteImageReadyGate({ const scanTimerRef = useRef(null); const revealTimerRef = useRef(null); const scanVersionRef = useRef(0); + const revealedRef = useRef(false); const [ready, setReady] = useState(false); useEffect(() => { @@ -88,6 +90,7 @@ export function RouteImageReadyGate({ let disposed = false; startTimeRef.current = window.performance.now(); + revealedRef.current = false; setReady(false); const clearScanTimer = () => { @@ -110,27 +113,26 @@ export function RouteImageReadyGate({ }; const scheduleReveal = (version: number) => { + if (revealedRef.current) { + return; + } + clearRevealTimer(); const elapsed = window.performance.now() - startTimeRef.current; - const delay = Math.max( + const preferredDelay = Math.max( IMAGE_GATE_QUIET_MS, IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed, ); + const maxRemainingDelay = Math.max(0, IMAGE_GATE_MAX_BLOCK_MS - elapsed); + const delay = Math.min(preferredDelay, maxRemainingDelay); revealTimerRef.current = window.setTimeout(() => { if (disposed || version !== scanVersionRef.current) { return; } - const pendingUrls = collectRouteImageUrls(root).filter( - (url) => !settledImageUrls.has(url), - ); - if (pendingUrls.length > 0) { - scheduleScan(); - return; - } - + revealedRef.current = true; setReady(true); }, delay); }; @@ -146,23 +148,18 @@ export function RouteImageReadyGate({ (url) => !settledImageUrls.has(url), ); - if (pendingUrls.length === 0) { - scheduleReveal(version); - return; + if (pendingUrls.length > 0) { + // 首屏慢加载的核心约束:图片可预热,但不能无限期阻塞页面主体可见。 + pendingUrls.forEach((url) => { + void preloadImageUrl(url); + }); } - // 已进入页面但新 DOM 批量挂载图片时,先回到等待态,避免图片逐张闪入。 - setReady(false); - void Promise.allSettled(pendingUrls.map(preloadImageUrl)).then(() => { - if (disposed || version !== scanVersionRef.current) { - return; - } - scheduleScan(); - }); + scheduleReveal(version); } const observer = new MutationObserver((mutations) => { - if (disposed) { + if (disposed || revealedRef.current) { return; }