From 16ba5deb0eaaafb2397d25dfe2e54023d448a0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 22 Jun 2026 11:30:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(agents):=20=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E5=BD=92=E5=B1=9E/=E5=8F=91=E5=B8=83=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=89=8D=E5=A2=9E=E5=8A=A0=E3=80=8C=E8=B4=A6=E5=8F=B7=E5=B7=B2?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E3=80=8D=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 useLiveAccount 钩子:跟踪「实时」登录账号(随 storage/focus 刷新), 并与组件挂载时捕获的账号对比;token 存 localStorage 会被同浏览器其他标签页 共享,另一标签页登录会静默替换本页 API 所用账号 - agent-card / agent-detail-panel / publish-square-picker-dialog / NewAgentPage 接入该钩子,切换账号后对归属/发布类操作做提醒,避免误归属 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/agents/agent-card.tsx | 44 +++++++++- .../workspace/agents/agent-detail-panel.tsx | 44 +++++++++- .../publish-square-picker-dialog.tsx | 6 +- .../src/core/auth/use-live-account.ts | 82 +++++++++++++++++++ frontend-web/src/pages/NewAgentPage.tsx | 52 +++++++++++- 5 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 frontend-web/src/core/auth/use-live-account.ts 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}」。`} + 建议刷新页面确认账号。 +

+ )} +
+