fix: clear creation hub cache on logout
This commit is contained in:
@@ -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. 重新登录后按新账号重新拉取作品列表,不复用旧账号内存缓存。
|
||||||
@@ -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 兜底重试的修复口径,并保留供应商原始错误便于排查。
|
- [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_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_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 自动保存主链。
|
- [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 和写回逻辑继续留在各自模块。
|
- [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 的展示字段、筛选状态和卡片动作语义,不新增后端聚合接口。
|
- [CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md](./CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md):冻结创作中心作品货架统一视图模型,先在前端归一 RPG、大鱼、拼图 works 的展示字段、筛选状态和卡片动作语义,不新增后端聚合接口。
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const authMocks = vi.hoisted(() => ({
|
|||||||
getAuthLoginOptions: vi.fn(),
|
getAuthLoginOptions: vi.fn(),
|
||||||
getCurrentAuthUser: vi.fn(),
|
getCurrentAuthUser: vi.fn(),
|
||||||
loginWithPhoneCode: vi.fn(),
|
loginWithPhoneCode: vi.fn(),
|
||||||
|
logoutAllAuthSessions: vi.fn(),
|
||||||
|
logoutAuthUser: vi.fn(),
|
||||||
resetPassword: vi.fn(),
|
resetPassword: vi.fn(),
|
||||||
sendPhoneLoginCode: vi.fn(),
|
sendPhoneLoginCode: vi.fn(),
|
||||||
startWechatLogin: vi.fn(),
|
startWechatLogin: vi.fn(),
|
||||||
@@ -44,8 +46,8 @@ vi.mock('../../services/authService', () => ({
|
|||||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||||
liftAuthRiskBlock: vi.fn(),
|
liftAuthRiskBlock: vi.fn(),
|
||||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||||
logoutAllAuthSessions: vi.fn(),
|
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||||
logoutAuthUser: vi.fn(),
|
logoutAuthUser: authMocks.logoutAuthUser,
|
||||||
resetPassword: authMocks.resetPassword,
|
resetPassword: authMocks.resetPassword,
|
||||||
revokeAuthSession: vi.fn(),
|
revokeAuthSession: vi.fn(),
|
||||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||||
@@ -96,6 +98,8 @@ beforeEach(() => {
|
|||||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
||||||
authMocks.authEntry.mockResolvedValue(mockUser);
|
authMocks.authEntry.mockResolvedValue(mockUser);
|
||||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||||
|
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
|
||||||
|
authMocks.logoutAuthUser.mockResolvedValue(undefined);
|
||||||
authMocks.resetPassword.mockResolvedValue(mockUser);
|
authMocks.resetPassword.mockResolvedValue(mockUser);
|
||||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||||
cooldownSeconds: 60,
|
cooldownSeconds: 60,
|
||||||
@@ -139,6 +143,27 @@ function PlatformTabStateProbe() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LogoutStateProbe() {
|
||||||
|
const authUi = useAuthUi();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>当前用户:{authUi?.user?.displayName ?? '未登录'}</div>
|
||||||
|
<div>
|
||||||
|
私有数据:{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void authUi?.logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||||
availableLoginMethods: ['phone'],
|
availableLoginMethods: ['phone'],
|
||||||
@@ -276,6 +301,40 @@ test('auth state refresh keeps mounted platform content and local tab state', as
|
|||||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
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<void>((resolve) => {
|
||||||
|
resolveLogout = resolve;
|
||||||
|
});
|
||||||
|
authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthGate>
|
||||||
|
<LogoutStateProbe />
|
||||||
|
</AuthGate>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
test('auth gate shows sms send feedback in the login modal', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,50 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
setStatus('ready');
|
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(() => {
|
const closeLoginModal = useCallback(() => {
|
||||||
pendingProtectedActionRef.current = null;
|
pendingProtectedActionRef.current = null;
|
||||||
setShowLoginModal(false);
|
setShowLoginModal(false);
|
||||||
@@ -400,10 +444,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
requireAuth,
|
requireAuth,
|
||||||
openSettingsModal,
|
openSettingsModal,
|
||||||
openAccountModal,
|
openAccountModal,
|
||||||
logout: async () => {
|
logout: logoutCurrentSession,
|
||||||
await logoutAuthUser();
|
|
||||||
setShowSettingsModal(false);
|
|
||||||
},
|
|
||||||
musicVolume: settings.musicVolume,
|
musicVolume: settings.musicVolume,
|
||||||
setMusicVolume: settings.setMusicVolume,
|
setMusicVolume: settings.setMusicVolume,
|
||||||
platformTheme: settings.platformTheme,
|
platformTheme: settings.platformTheme,
|
||||||
@@ -418,6 +459,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
openSettingsModal,
|
openSettingsModal,
|
||||||
readyUser,
|
readyUser,
|
||||||
requireAuth,
|
requireAuth,
|
||||||
|
logoutCurrentSession,
|
||||||
status,
|
status,
|
||||||
settings.isHydratingSettings,
|
settings.isHydratingSettings,
|
||||||
settings.isPersistingSettings,
|
settings.isPersistingSettings,
|
||||||
@@ -494,9 +536,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLogout={async () => {
|
onLogout={async () => {
|
||||||
await logoutAuthUser();
|
await logoutCurrentSession();
|
||||||
setUser(null);
|
|
||||||
setStatus('unauthenticated');
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -551,10 +591,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
settingsError={settings.settingsError}
|
settingsError={settings.settingsError}
|
||||||
onClose={() => setShowSettingsModal(false)}
|
onClose={() => setShowSettingsModal(false)}
|
||||||
onPlatformThemeChange={settings.setPlatformTheme}
|
onPlatformThemeChange={settings.setPlatformTheme}
|
||||||
onLogout={async () => {
|
onLogout={logoutCurrentSession}
|
||||||
await logoutAuthUser();
|
|
||||||
setShowSettingsModal(false);
|
|
||||||
}}
|
|
||||||
onRefreshRiskBlocks={async () => {
|
onRefreshRiskBlocks={async () => {
|
||||||
setLoadingRiskBlocks(true);
|
setLoadingRiskBlocks(true);
|
||||||
try {
|
try {
|
||||||
@@ -625,10 +662,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLogoutAll={async () => {
|
onLogoutAll={logoutAllSessions}
|
||||||
await logoutAllAuthSessions();
|
|
||||||
setShowSettingsModal(false);
|
|
||||||
}}
|
|
||||||
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
|
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
|
||||||
onSendChangePhoneCode={async (phone, captcha) => {
|
onSendChangePhoneCode={async (phone, captcha) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const hadReadableProtectedDataRef = useRef(false);
|
||||||
const hasInitialAgentSession = Boolean(
|
const hasInitialAgentSession = Boolean(
|
||||||
readCustomWorldAgentUiState().activeSessionId,
|
readCustomWorldAgentUiState().activeSessionId,
|
||||||
);
|
);
|
||||||
@@ -934,7 +935,13 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const setIsPuzzleBusy = puzzleFlow.setIsBusy;
|
const setIsPuzzleBusy = puzzleFlow.setIsBusy;
|
||||||
const streamingPuzzleReplyText = puzzleFlow.streamingReplyText;
|
const streamingPuzzleReplyText = puzzleFlow.streamingReplyText;
|
||||||
const isStreamingPuzzleReply = puzzleFlow.isStreamingReply;
|
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 () => {
|
const openBigFishAgentWorkspace = useCallback(async () => {
|
||||||
setBigFishRun(null);
|
setBigFishRun(null);
|
||||||
await bigFishFlow.openWorkspace();
|
await bigFishFlow.openWorkspace();
|
||||||
@@ -946,6 +953,61 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
await puzzleFlow.openWorkspace();
|
await puzzleFlow.openWorkspace();
|
||||||
}, [puzzleFlow]);
|
}, [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(
|
const handleCreationHubCreateType = useCallback(
|
||||||
(type: PlatformCreationTypeId) => {
|
(type: PlatformCreationTypeId) => {
|
||||||
if (type === 'airp' || type === 'visual-novel') {
|
if (type === 'airp' || type === 'visual-novel') {
|
||||||
|
|||||||
@@ -1301,6 +1301,101 @@ test('clicking a public work while logged out routes through requireAuth', async
|
|||||||
expect(getRpgEntryWorldGalleryDetail).not.toHaveBeenCalled();
|
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(<TestWrapper authValue={loggedInAuth} />);
|
||||||
|
|
||||||
|
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(<TestWrapper authValue={loggedOutAuth} />);
|
||||||
|
|
||||||
|
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 () => {
|
test('published puzzle works appear on home and category public shelves', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const publishedPuzzleWork = {
|
const publishedPuzzleWork = {
|
||||||
|
|||||||
Reference in New Issue
Block a user