diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index cfd7e8c2..d80487c2 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -77,6 +77,7 @@ import { import { getAgent } from "@/core/agents/api"; import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; +import { useLiveAccount } from "@/core/auth/use-live-account"; import { useI18n } from "@/core/i18n/hooks"; import { useSkills } from "@/core/skills/hooks"; import { formatDateTime } from "@/core/utils/datetime"; @@ -246,6 +247,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar const storedAuth = useMemo(() => getStoredAuth(), []); const currentUserId = storedAuth?.user_id ?? null; + // 发布请求实际以「实时」token 执行 —— 用 live 账号来提示发布者并检测账号是否被切换。 + const live = useLiveAccount(); const isAdmin = storedAuth?.system_role === "admin"; const isBuiltin = agent.builtin; const isOwned = @@ -414,18 +417,38 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar setPublishPickerOpen(true); return; } + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, request: { published: !agent.published }, }); - toast.success(agent.published ? "已取消发布" : "已发布"); + toast.success( + agent.published ? "已取消发布" : `已发布(操作账号:${live.displayName})`, + ); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } } + // 发布操作以实时 token 执行。若会话中途账号被切换(常见于另一标签页登录了别的 + // 账号),先弹一次确认 —— 避免误以本人之外的账号执行发布。返回 false = 用户取消。 + function warnIfAccountSwitched(): boolean { + if (!live.switched) { + return true; + } + return window.confirm( + `当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` + + `本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`, + ); + } + async function handlePublishToSquare(squareId: string) { + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, @@ -433,7 +456,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar }); const sqName = publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场"; - toast.success(`已发布到「${sqName}」`); + toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`); setPublishPickerOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); @@ -1094,6 +1117,23 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar loading={updateAgent.isPending} onConfirm={(squareId) => void handlePublishToSquare(squareId)} description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。" + accountNotice={ +
+

+ 发布操作账号: + + 「{live.displayName}」 + +

+ {live.switched && ( +

+ ⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「 + {live.displayName}」。如需以「{live.mountDisplayName} + 」操作,请刷新页面确认。 +

+ )} +
+ } /> ); diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index 9a40afc3..1273088c 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -59,6 +59,7 @@ import { type UpdateAgentRequest, } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; +import { useLiveAccount } from "@/core/auth/use-live-account"; import { isAgentSkillCandidate } from "@/core/skills"; import { useSkills } from "@/core/skills/hooks"; import { usePublishSquares } from "@/core/system-settings/hooks"; @@ -156,6 +157,8 @@ export function AgentDetailPanel({ const [deleteOpen, setDeleteOpen] = useState(false); const [publishPickerOpen, setPublishPickerOpen] = useState(false); const [detailSkillName, setDetailSkillName] = useState(null); + // 发布以实时 token 执行 —— 用 live 账号提示发布者并检测账号是否被切换。 + const live = useLiveAccount(); const updateAgent = useUpdateAgent(); const deleteAgent = useDeleteAgent(); @@ -338,19 +341,39 @@ export function AgentDetailPanel({ setPublishPickerOpen(true); return; } + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, request: { published: !agent.published }, }); - toast.success(agent.published ? "已下架" : "已发布"); + toast.success( + agent.published ? "已下架" : `已发布(操作账号:${live.displayName})`, + ); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } } + // 发布以实时 token 执行。会话中途账号被切换(常见于另一标签页登录了别的账号)时 + // 先弹确认,避免误以本人之外的账号执行发布。返回 false = 用户取消。 + function warnIfAccountSwitched(): boolean { + if (!live.switched) { + return true; + } + return window.confirm( + `当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` + + `本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`, + ); + } + async function handlePublishToSquare(squareId: string) { if (!agent) return; + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, @@ -358,7 +381,7 @@ export function AgentDetailPanel({ }); const sqName = publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场"; - toast.success(`已发布到「${sqName}」`); + toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`); setPublishPickerOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); @@ -901,6 +924,23 @@ export function AgentDetailPanel({ loading={updateAgent.isPending} onConfirm={(squareId) => void handlePublishToSquare(squareId)} description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。" + accountNotice={ +
+

+ 发布操作账号: + + 「{live.displayName}」 + +

+ {live.switched && ( +

+ ⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「 + {live.displayName}」。如需以「{live.mountDisplayName} + 」操作,请刷新页面确认。 +

+ )} +
+ } /> ); diff --git a/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx b/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx index 65aa7a51..fd6fecc2 100644 --- a/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx +++ b/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { CheckIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -26,6 +26,8 @@ interface PublishSquarePickerDialogProps { loading?: boolean; title?: string; description?: string; + /** 可选提示节点,渲染在描述下方 —— 用于显示「本次发布将以哪个账号进行」及账号切换告警。 */ + accountNotice?: ReactNode; } /** A "publish to which square (发布广场)?" chooser. Squares are rendered as a @@ -39,6 +41,7 @@ export function PublishSquarePickerDialog({ loading, title = "选择发布广场", description = "请选择要把它发布到哪个广场。每个项目只能归属一个广场,不会重复出现。", + accountNotice, }: PublishSquarePickerDialogProps) { const [selected, setSelected] = useState( defaultSquareId || squares[0]?.id || "", @@ -58,6 +61,7 @@ export function PublishSquarePickerDialog({ {title} {description} + {accountNotice}
{squares.map((sq) => { const active = selected === sq.id; diff --git a/frontend-web/src/core/auth/use-live-account.ts b/frontend-web/src/core/auth/use-live-account.ts new file mode 100644 index 00000000..a9417694 --- /dev/null +++ b/frontend-web/src/core/auth/use-live-account.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; +import { isEmbedActive } from "@/core/embed/embed-session"; + +const AUTH_STORAGE_KEY = "deerflow.auth"; + +export interface LiveAccount { + /** 实时登录用户 id(随 storage/focus 刷新)。null = 未登录。 */ + userId: string | null; + /** 实时显示名(邮箱 @ 前缀,否则 user_id)。 */ + displayName: string; + /** 组件首次挂载时捕获的用户 id。 */ + mountUserId: string | null; + /** 组件首次挂载时捕获的显示名。 */ + mountDisplayName: string; + /** + * 当前登录账号与挂载时不一致 —— 即本视图加载后登录被切换过(通常是在另一个 + * 标签页登录了别的账号)。归属/发布类操作必须据此提醒,避免误归属。 + */ + switched: boolean; +} + +/** + * 跟踪「实时」登录账号,而不是挂载时缓存的值。 + * + * 鉴权 token 存在 localStorage(非嵌入态下被同浏览器所有标签页共享),所以在 + * 另一个标签页登录会**静默替换**本标签页 API 调用所用的 token。任何会决定归属的 + * 界面(创建/发布智能体)都必须显示请求实际将以哪个账号执行,并在会话中途被切换 + * 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。 + */ +export function useLiveAccount(): LiveAccount { + const mountRef = useRef<{ id: string | null; name: string } | null>(null); + if (mountRef.current === null) { + const auth = getStoredAuth(); + mountRef.current = { + id: auth?.user_id ?? null, + name: getAccountDisplayName(auth), + }; + } + const [live, setLive] = useState(() => mountRef.current!); + + useEffect(() => { + function refresh() { + const auth = getStoredAuth(); + const id = auth?.user_id ?? null; + const name = getAccountDisplayName(auth); + setLive((prev) => + prev.id === id && prev.name === name ? prev : { id, name }, + ); + } + // 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件 + // —— 只挂在正常态;focus/visibilitychange 兜底刷新两种形态都有效。 + function onStorage(e: StorageEvent) { + if (e.key === AUTH_STORAGE_KEY) { + refresh(); + } + } + if (!isEmbedActive()) { + window.addEventListener("storage", onStorage); + } + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", refresh); + refresh(); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", refresh); + }; + }, []); + + const mount = mountRef.current!; + return { + userId: live.id, + displayName: live.name, + mountUserId: mount.id, + mountDisplayName: mount.name, + switched: live.id !== null && mount.id !== null && live.id !== mount.id, + }; +} diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 4ab2564a..0906cd7c 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -48,6 +48,7 @@ import { updateAgent, } from "@/core/agents/api"; import { getStoredAuth } from "@/core/auth"; +import { useLiveAccount } from "@/core/auth/use-live-account"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { isAgentSkillCandidate, type Skill } from "@/core/skills"; @@ -96,6 +97,10 @@ export default function NewAgentPage() { const { skills, isLoading: isSkillsLoading } = useSkills(); const { models, isLoading: isModelsLoading } = useModels(); + // 创建请求的归属账号取自「实时」token(apiFetch 每次都重读 localStorage),而非 + // 本页加载时缓存的身份 —— 用 live 账号提示归属并检测账号是否在会话中途被切换。 + const live = useLiveAccount(); + // 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」, // 其他用户未发布的自定义技能不可选。 const storedAuth = useMemo(() => getStoredAuth(), []); @@ -287,13 +292,37 @@ export default function NewAgentPage() { ]); const handleSaveAgent = useCallback(async () => { + // 新建时归属账号 = 创建请求所用的实时 token 对应账号。若本页加载后登录被切换 + // (常见于另一标签页登录了别的账号),现在创建会把智能体静默归到新账号名下。 + // 先弹确认,避免「用 A 账号创建却归到 B 账号」。更新已有智能体不改归属,故跳过。 + if (!agentId && live.switched) { + const ok = window.confirm( + `当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` + + `现在创建将把该智能体归属到「${live.displayName}」名下。\n` + + `如需归属「${live.mountDisplayName}」,请取消并刷新页面后重新登录。\n\n仍要以「${live.displayName}」创建吗?`, + ); + if (!ok) { + return; + } + } const saved = await ensureAgent(); if (!saved) { return; } - toast.success(`智能体「${saved.name}」已保存`); + toast.success( + agentId + ? `智能体「${saved.name}」已保存` + : `智能体「${saved.name}」已创建(归属账号:${live.displayName})`, + ); router.push("/page/workspace/agents"); - }, [ensureAgent, router]); + }, [ + agentId, + ensureAgent, + live.displayName, + live.mountDisplayName, + live.switched, + router, + ]); const handleChatSubmit = useCallback( async (text: string) => { @@ -661,6 +690,25 @@ export default function NewAgentPage() {

{formError}

) : null} +
+

+ {agentId ? "当前登录账号:" : "创建后归属账号:"} + + 「{live.displayName}」 + +

+ {live.switched && ( +

+ ⚠ 本页加载时为「{live.mountDisplayName}」,现已切换为「 + {live.displayName}」。 + {agentId + ? "继续保存将以当前账号执行。" + : `继续创建将归属「${live.displayName}」。`} + 建议刷新页面确认账号。 +

+ )} +
+