feat(agents): 智能体归属/发布操作前增加「账号已切换」提醒

- 新增 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) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-22 11:30:15 +08:00
1 parent 2b6424b3ff
commit 16ba5deb0e
5 files changed
+221 -7

No files matched your search

@@ -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={
<div className="text-xs">
<p className="text-muted-foreground">
发布操作账号:
<span className="text-foreground font-medium">
「{live.displayName}」
</span>
</p>
{live.switched && (
<p className="text-destructive mt-1">
⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「
{live.displayName}」。如需以「{live.mountDisplayName}
」操作,请刷新页面确认。
</p>
)}
</div>
}
/>
</>
);
@@ -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<string | null>(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={
<div className="text-xs">
<p className="text-muted-foreground">
发布操作账号:
<span className="text-foreground font-medium">
「{live.displayName}」
</span>
</p>
{live.switched && (
<p className="text-destructive mt-1">
⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「
{live.displayName}」。如需以「{live.mountDisplayName}
」操作,请刷新页面确认。
</p>
)}
</div>
}
/>
</>
);
@@ -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<string>(
defaultSquareId || squares[0]?.id || "",
@@ -58,6 +61,7 @@ export function PublishSquarePickerDialog({
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{accountNotice}
<div className="max-h-[50vh] space-y-1.5 overflow-y-auto py-1">
{squares.map((sq) => {
const active = selected === sq.id;