diff --git a/web/src/components/auth/auth-session-hydrator.tsx b/web/src/components/auth/auth-session-hydrator.tsx index b6ec439aa..d9f43bb15 100644 --- a/web/src/components/auth/auth-session-hydrator.tsx +++ b/web/src/components/auth/auth-session-hydrator.tsx @@ -25,7 +25,7 @@ export function AuthSessionHydrator({ children }: { children: ReactNode }) { durationMs: performance.now() - startedAt, }); if (!payload.user) { - applyAnonymousSession(payload); + await applyAnonymousSession(payload); recordDiagnosticEvent({ category: "navigation", level: "info", @@ -48,9 +48,9 @@ export function AuthSessionHydrator({ children }: { children: ReactNode }) { }); preloadWorkspaceRoute(window.location.pathname); }) - .catch(() => { + .catch(async () => { if (!cancelled) { - applyAnonymousSession({ user: null }); + await applyAnonymousSession({ user: null }); recordDiagnosticEvent({ category: "navigation", level: "warning", @@ -68,11 +68,7 @@ export function AuthSessionHydrator({ children }: { children: ReactNode }) { return hydrated ? children : ; } -function applyAnonymousSession(payload: AuthSessionPayload) { - const store = useUserStore.getState(); - store.clearSession(); - store.setRuntimeLimits(payload.runtimeLimits); - store.setDrawingEngine(payload.drawingEngine); - store.setFeatures(payload.features); - store.setHydrated(true); +async function applyAnonymousSession(payload: AuthSessionPayload) { + const { applyUserSession } = await import("@/lib/user-session"); + await applyUserSession({ ...payload, user: null }); } diff --git a/web/src/components/auth/require-auth.tsx b/web/src/components/auth/require-auth.tsx index ee69e209c..75af4a617 100644 --- a/web/src/components/auth/require-auth.tsx +++ b/web/src/components/auth/require-auth.tsx @@ -4,12 +4,12 @@ import { Navigate, useLocation } from "react-router"; import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader"; import { useUserStore } from "@/stores/use-user-store"; -export function RequireAuth({ children }: { children: ReactNode }) { +export function RequireAuth({ children, allowAnonymous = false }: { children: ReactNode; allowAnonymous?: boolean }) { const location = useLocation(); const hydrated = useUserStore((state) => state.hydrated); const user = useUserStore((state) => state.user); if (!hydrated) return ; - if (!user) return ; + if (!user && !allowAnonymous) return ; return children; } diff --git a/web/src/components/layout/workspace-account-menu.tsx b/web/src/components/layout/workspace-account-menu.tsx index 6a92c3b86..d460dd4f9 100644 --- a/web/src/components/layout/workspace-account-menu.tsx +++ b/web/src/components/layout/workspace-account-menu.tsx @@ -1,8 +1,7 @@ import { Popover } from "antd"; import { Switch } from "@/components/ui/base/switch"; -import { LogIn, Moon, Sun } from "lucide-react"; +import { CircleUserRound, Moon, Sun } from "lucide-react"; import { useState } from "react"; -import { Link } from "react-router"; import { AppChangelogButton } from "@/components/layout/app-changelog-modal"; import { WorkspaceAccountCard } from "./workspace-account-card"; @@ -51,9 +50,5 @@ export function WorkspaceAccountMenu() { - ) : ( - - - - ); + ) : 游客; } diff --git a/web/src/components/layout/workspace-sidebar-nav.tsx b/web/src/components/layout/workspace-sidebar-nav.tsx index 8c72668bb..ad69892b4 100644 --- a/web/src/components/layout/workspace-sidebar-nav.tsx +++ b/web/src/components/layout/workspace-sidebar-nav.tsx @@ -70,7 +70,7 @@ function WorkspaceSidebarProfile({ collapsed, user }: { collapsed: boolean; user useEffect(() => setFailed(false), [avatarUrl]); if (!user) { - return 登录; + return
{!collapsed ? 游客模式 : null}
; } const avatar = avatarUrl && !failed ? setFailed(true)} /> : ; diff --git a/web/src/lib/guest-workspace.ts b/web/src/lib/guest-workspace.ts new file mode 100644 index 000000000..593554ab7 --- /dev/null +++ b/web/src/lib/guest-workspace.ts @@ -0,0 +1,3 @@ +export function isGuestWorkspacePath(pathname: string) { + return pathname === "/" || pathname === "/create"; +} diff --git a/web/src/pages/admin/settings/components/welcome-setting.tsx b/web/src/pages/admin/settings/components/welcome-setting.tsx index 4e35095b7..a33e68e28 100644 --- a/web/src/pages/admin/settings/components/welcome-setting.tsx +++ b/web/src/pages/admin/settings/components/welcome-setting.tsx @@ -48,7 +48,7 @@ export function WelcomeSetting() {
启用欢迎页 -

切换后立即保存。关闭后访问 /welcome 将跳转到首页。

+

切换后立即保存。关闭后访问 /welcome 将进入创作工作台;游客可浏览,生成时需登录。

{error ? (
{error} diff --git a/web/src/pages/create/creation-login-dialog.tsx b/web/src/pages/create/creation-login-dialog.tsx new file mode 100644 index 000000000..cfaff9e90 --- /dev/null +++ b/web/src/pages/create/creation-login-dialog.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { App, Button, Input } from "antd"; +import { ArrowRight, LockKeyhole, UserRound } from "lucide-react"; +import { Link } from "react-router"; + +import { AppModal } from "@/components/ui/product/app-modal"; +import { getAuthSession, login } from "@/services/api/auth"; + +export function CreationLoginDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const { message } = App.useApp(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) { + setUsername(""); + setPassword(""); + setSubmitting(false); + } + }, [open]); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (submitting) return; + setSubmitting(true); + try { + await login({ username: username.trim(), password }); + const { applyUserSession } = await import("@/lib/user-session"); + await applyUserSession(await getAuthSession()); + message.success("登录成功,请再次点击生成"); + onClose(); + } catch (error) { + message.error(error instanceof Error ? error.message : "登录失败"); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+

你的创作内容会保留。登录成功后,请再次点击生成。

+
void submit(event)}> + } + value={username} + onChange={(event) => setUsername(event.target.value)} + placeholder="用户名或邮箱" + autoComplete="username" + required + /> + } + value={password} + onChange={(event) => setPassword(event.target.value)} + placeholder="密码" + autoComplete="current-password" + required + /> + + +

还没有账号? 注册

+
+
+ ); +} diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 057edf59a..8d33e3ae3 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -35,6 +35,7 @@ import { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, Creation import { CreationAgentEntry } from "./creation-agent-entry"; import { createCreationSubmitGate } from "./creation-submit-gate"; import { creationVideoConfig } from "./creation-generation-config"; +import { CreationLoginDialog } from "./creation-login-dialog"; const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal }))); const loadCreationRuntime = () => import("./creation-runtime"); @@ -97,6 +98,9 @@ export default function CreatePage() { const [activeId, setActiveId] = useState(""); const activeIdRef = useRef(""); const [hydrated, setHydrated] = useState(false); + const userId = useUserStore((state) => state.user?.id || null); + const userSessionHydrated = useUserStore((state) => state.hydrated); + const [conversationScope, setConversationScope] = useState(null); const [mode, setMode] = useState(() => initialComposerPreferences.mode || defaultCreationMode); const [prompt, setPrompt] = useState(""); const [attachments, setAttachments] = useState([]); @@ -115,6 +119,7 @@ export default function CreatePage() { const [busy, setBusy] = useState(false); const [referenceReplacementBusy, setReferenceReplacementBusy] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); + const [loginDialogOpen, setLoginDialogOpen] = useState(false); const [libraryOpen, setLibraryOpen] = useState(false); const externalAssetSources = useExternalAssetSources(libraryOpen); const abortRef = useRef(null); @@ -256,20 +261,25 @@ export default function CreatePage() { }, [attachments, maxReferences, mentionReferences, mode, videoReferenceLimits]); useEffect(() => { + if (!userSessionHydrated) return; let cancelled = false; + const scope = getActiveUserScope(); + setHydrated(false); + setConversationScope(null); void loadCreationConversations().then((stored) => { - if (cancelled) return; + if (cancelled || getActiveUserScope() !== scope || (useUserStore.getState().user?.id || null) !== userId) return; const next = stored?.length ? stored : [newConversation()]; conversationsRef.current = next; setConversations(next); setActiveId(next[0].id); + setConversationScope(scope); setHydrated(true); }); return () => { cancelled = true; // 页面卸载只停止当前页面的状态更新,后台任务由任务中心继续执行,返回页面后再恢复状态。 }; - }, []); + }, [userId, userSessionHydrated]); useEffect(() => () => abortRef.current?.abort(), []); @@ -279,8 +289,8 @@ export default function CreatePage() { useEffect(() => { conversationsRef.current = conversations; - if (hydrated) void saveCreationConversations(conversations); - }, [conversations, hydrated]); + if (hydrated && userSessionHydrated && conversationScope === getActiveUserScope()) void saveCreationConversations(conversations); + }, [conversations, conversationScope, hydrated, userSessionHydrated]); useEffect(() => { if (!hydrated || !recoveryTaskKey || !pendingTaskIds.length) return; @@ -570,6 +580,12 @@ export default function CreatePage() { releaseSubmitGate(); return; } + if (!useUserStore.getState().user) { + setLoginDialogOpen(true); + releaseRetryLock(); + releaseSubmitGate(); + return; + } if (!selectedModel) { toast.warning(`请先在设置中配置${modeLabels[mode]}模型`); releaseRetryLock(); @@ -1066,6 +1082,7 @@ export default function CreatePage() {
}
setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} onRename={renameConversationTitle} /> + setLoginDialogOpen(false)} /> {libraryOpen ? import("@/pages/admin")); const AnalyticsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.AnalyticsPage }))); @@ -68,7 +69,7 @@ function AuthenticatedWorkspaceLayout() { const { pathname } = useLocation(); const isCanvasProjectRoute = pathname.startsWith("/canvas/"); const fallback = isCanvasProjectRoute ? : ; - return ; + return ; } /** @@ -104,8 +105,8 @@ export const router = createBrowserRouter([ element: , errorElement: , children: [ - { path: "/", element: {deferred()} }, - { path: "/create", element: {deferred()} }, + { path: "/", element: deferred() }, + { path: "/create", element: deferred() }, { path: "/tasks", element: ( diff --git a/web/test/guest-workspace-access.test.ts b/web/test/guest-workspace-access.test.ts new file mode 100644 index 000000000..1bbe3789d --- /dev/null +++ b/web/test/guest-workspace-access.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; + +import { isGuestWorkspacePath } from "../src/lib/guest-workspace"; + +describe("guest workspace route access", () => { + test("allows only the public creation workspace routes", () => { + expect(isGuestWorkspacePath("/")).toBe(true); + expect(isGuestWorkspacePath("/create")).toBe(true); + expect(isGuestWorkspacePath("/assets")).toBe(false); + expect(isGuestWorkspacePath("/admin")).toBe(false); + expect(isGuestWorkspacePath("/create/anything")).toBe(false); + }); +});