This commit is contained in:
fengxiangdebaozi committed 2026-06-05 18:58:37 +08:00
commit a642ba91fa
21 files changed
+1587 -47

No files matched your search

+6
View File
@@ -17,6 +17,7 @@ import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
@@ -63,6 +64,11 @@ export function App() {
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only roundtable result viewer — no authentication. */}
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage />}
/>
{/* Public read-only scheduled-task runs viewer — no authentication. */}
<Route
path="/public/scheduled-tasks/:taskId"
@@ -0,0 +1,266 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
import { isIMEComposing } from "@/lib/ime";
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
function openDraft(
draft: DraftMeta,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。
window.dispatchEvent(
new CustomEvent("roundtable:open-draft", { detail: draft.id }),
);
} else {
// 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。
sessionStorage.setItem("roundtable:pending-draft", draft.id);
router.push(ROUNDTABLE_PATH);
}
}
/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */
function StatusBadge({ draft }: { draft: DraftMeta }) {
if (draft.status === "running") {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
进行中
</span>
);
}
if (draft.status === "awaiting_input") {
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
}
if (draft.status === "error") {
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
}
return (
<span className="text-[10px] font-medium text-muted-foreground">
第 {draft.furthestStep} 步
</span>
);
}
export function RecentRoundtableList() {
const router = useRouter();
const pathname = usePathname();
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true",
);
// 重命名对话框状态。
const [renameId, setRenameId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
// 分享对话框 —— 正在分享的草稿 id。
const [shareId, setShareId] = useState<string | null>(null);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
const handleRenameSubmit = useCallback(async () => {
const id = renameId;
const title = renameValue.trim();
if (!id || !title) {
setRenameId(null);
return;
}
try {
await rename(id, title);
toast.success("✏️ 已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
setRenameValue("");
}
}, [renameId, renameValue, rename]);
const handleDelete = useCallback(
async (draft: DraftMeta) => {
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
try {
await remove(draft.id);
toast.success("🗑 已删除会商记录");
} catch {
toast.error("删除失败,请重试");
}
},
[remove],
);
if (!loading && drafts.length === 0) return null;
const isOnPage = pathname === ROUNDTABLE_PATH;
return (
<>
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>多智能体会商</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && drafts.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
drafts.slice(0, 10).map((draft) => (
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openDraft(draft, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{draft.title || "未命名草稿"}
</span>
<div className="flex items-center gap-1 mt-0.5">
<StatusBadge draft={draft} />
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(draft.updatedAt)}
</span>
</div>
</div>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background top-2"
>
<MoreHorizontal />
<span className="sr-only">更多</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-44 rounded-lg"
side="right"
align="start"
>
<DropdownMenuItem
onSelect={() => {
setRenameId(draft.id);
setRenameValue(draft.title || "");
}}
>
<Pencil className="text-muted-foreground" />
<span>重命名</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
<Share2 className="text-muted-foreground" />
<span>分享</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
<Trash2 className="text-muted-foreground" />
<span>删除</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* 重命名对话框 */}
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>重命名会商</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
void handleRenameSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameId(null)}>
取消
</Button>
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 分享对话框 */}
<ShareRoundtableDraftDialog
draftId={shareId}
open={shareId !== null}
onOpenChange={(o) => {
if (!o) setShareId(null);
}}
/>
</>
);
}
@@ -0,0 +1,322 @@
"use client";
import {
CheckIcon,
CopyIcon,
DownloadIcon,
ExternalLinkIcon,
Loader2,
Trash2Icon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { DraftShare, DraftShareMode, DraftSharePreview } from "@/core/roundtable-draft-shares/api";
import { buildPublicDraftShareUrl, extractShareCode } from "@/core/roundtable-draft-shares/api";
import {
useCreateDraftShare,
useImportDraftShare,
usePreviewDraftShare,
useRevokeDraftShare,
} from "@/core/roundtable-draft-shares/hooks";
import { cn } from "@/lib/utils";
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Dialog that produces a share link for a single roundtable draft. Two modes:
*
* - `view` — mints a public, read-only link that anyone can open without logging
* in; it renders the draft's Step 3 result report (方案总览).
* - `import` — mints a short code; a recipient enters it in the import dialog to
* copy the whole 会商 draft into their own account.
*/
export function ShareRoundtableDraftDialog({
draftId,
open,
onOpenChange,
}: {
draftId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const createShare = useCreateDraftShare();
const revokeShare = useRevokeDraftShare();
const [mode, setMode] = useState<DraftShareMode>("view");
const [share, setShare] = useState<DraftShare | null>(null);
const [copied, setCopied] = useState(false);
// Reset to the default mode (read-only link) every time the dialog opens.
useEffect(() => {
if (open) setMode("view");
}, [open, draftId]);
// Mint / reuse the link whenever the dialog opens or the mode changes.
useEffect(() => {
if (!open || !draftId) return;
setShare(null);
setCopied(false);
createShare
.mutateAsync({ draftId, mode })
.then(setShare)
.catch((err) => toast.error(errorMessage(err)));
// createShare is stable enough; re-running only on open/draftId/mode is intended.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, draftId, mode]);
const isView = mode === "view";
const publicUrl = isView && share ? buildPublicDraftShareUrl(share.share_code) : "";
async function handleCopy() {
if (!share) return;
const text = isView ? publicUrl : share.share_code;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success(isView ? "链接已复制" : "分享码已复制");
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("复制到剪贴板失败");
}
}
async function handleRevoke() {
if (!share || !window.confirm("撤销后此分享立即失效,确定吗?")) return;
try {
await revokeShare.mutateAsync(share.share_code);
toast.success("已撤销分享");
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>分享会商</DialogTitle>
<DialogDescription>
{isView
? "生成一个只读链接,任何人无需登录都能查看这次会商生成的结果报告。"
: "生成一个分享码,对方在「导入会商」里输入它即可把这份会商复制到自己的账号继续研讨。"}
</DialogDescription>
</DialogHeader>
{/* Mode selector */}
<div className="bg-muted flex gap-1 rounded-lg p-1">
{(
[
{ value: "view" as const, label: "只读链接" },
{ value: "import" as const, label: "导入副本" },
]
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
mode === option.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
{!share ? (
<div className="text-muted-foreground flex items-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
正在生成分享…
</div>
) : (
<div className="space-y-3">
<div>
<p className="text-muted-foreground mb-1 text-xs">
{isView ? "只读链接" : "分享码"}
</p>
<div className="flex items-center gap-2">
{isView ? (
<Input
readOnly
value={publicUrl}
onFocus={(e) => e.currentTarget.select()}
className="flex-1 font-mono text-xs"
/>
) : (
<code className="bg-muted flex-1 rounded-md px-3 py-2 text-center font-mono text-lg tracking-[0.3em]">
{share.share_code}
</code>
)}
<Button variant="outline" onClick={() => void handleCopy()}>
{copied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
{isView ? "复制链接" : "复制码"}
</Button>
</div>
</div>
{isView ? (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
拿到链接的人都能查看这次会商的结果报告(只读)。若会商尚未生成结果报告,对方会看到提示。撤销后链接立即失效。
</p>
<Button
variant="outline"
size="sm"
onClick={() => window.open(publicUrl, "_blank", "noopener")}
>
<ExternalLinkIcon className="size-3.5" />
在新标签页打开
</Button>
</div>
) : (
<p className="text-muted-foreground text-xs">
把这个分享码发给对方,他们在「导入会商」里输入即可复制一份。撤销后分享码立即失效。
</p>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={revokeShare.isPending}
onClick={() => void handleRevoke()}
>
<Trash2Icon className="size-3.5" />
撤销分享
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
/** Dialog where a user pastes a share code to import someone's roundtable draft. */
export function ImportRoundtableDraftDialog({
open,
onOpenChange,
onImported,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called with the new local draft id after a successful import. */
onImported?: (draftId: string | null) => void;
}) {
const [input, setInput] = useState("");
const [preview, setPreview] = useState<DraftSharePreview | null>(null);
const previewShare = usePreviewDraftShare();
const importShare = useImportDraftShare();
// Reset state whenever the dialog closes.
useEffect(() => {
if (!open) {
setInput("");
setPreview(null);
}
}, [open]);
async function handlePreview() {
const code = extractShareCode(input);
if (!code) {
toast.error("请输入分享码");
return;
}
try {
setPreview(await previewShare.mutateAsync(code));
} catch (err) {
setPreview(null);
toast.error(errorMessage(err));
}
}
async function handleImport() {
const code = preview?.share_code ?? extractShareCode(input);
if (!code) {
toast.error("请输入分享码");
return;
}
try {
const result = await importShare.mutateAsync(code);
if (result.imported > 0) {
toast.success("导入成功,已加入「多智能体会商」历史");
} else {
toast.info("这份会商之前已导入过");
}
onImported?.(result.draft_id);
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>导入会商</DialogTitle>
<DialogDescription>
输入对方分享的「导入副本」分享码,把这份会商复制到你自己的账号。
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => {
setInput(e.target.value);
setPreview(null);
}}
placeholder="粘贴分享码或链接"
className="font-mono"
/>
<Button
variant="outline"
onClick={() => void handlePreview()}
disabled={previewShare.isPending || !input.trim()}
>
{previewShare.isPending && <Loader2 className="size-4 animate-spin" />}
预览
</Button>
</div>
{preview && (
<div className="space-y-2 rounded-lg border p-3 text-sm">
<p className="font-medium">{preview.title || "未命名会商"}</p>
<p className="text-muted-foreground text-xs">
来自用户 {preview.shared_by}
</p>
<Button
className="w-full"
onClick={() => void handleImport()}
disabled={importShare.isPending}
>
{importShare.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<DownloadIcon className="size-4" />
)}
{importShare.isPending ? "正在导入…" : "导入到我的账号"}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
@@ -46,6 +47,7 @@ export function WorkspaceSidebar({
<SidebarContent>
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
{isSidebarOpen && <RecentChatList />}
{isSidebarOpen && <RecentRoundtableList />}
{isSidebarOpen && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
@@ -0,0 +1,131 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
// Share codes share the same alphabet/shape as conversation shares, so reuse the
// extractor instead of duplicating the regex.
export { extractShareCode } from "@/core/thread-shares/api";
/**
* Roundtable-draft share modes:
* - `import` — recipient (a logged-in user) copies the 会商 draft into their own
* account and can keep working on the copy.
* - `view` — a public, read-only page anyone can open without logging in; it
* renders the draft's Step 3 result report (方案总览 HTML).
*/
export type DraftShareMode = "import" | "view";
export interface DraftShare {
share_code: string;
owner_user_id: string;
draft_id: string;
revoked: boolean;
mode: DraftShareMode;
created_at: string | null;
}
/** Read-only roundtable-result snapshot returned by the public view-share endpoint. */
export interface PublicDraftShareSnapshot {
share_code: string;
title: string;
mode: string;
/** Self-contained Step 3 result report HTML (empty when no report exists). */
html: string;
summary: string | null;
generated_at: string | null;
has_report: boolean;
created_at: string | null;
}
export interface DraftSharePreview {
share_code: string;
shared_by: string;
title: string;
}
export interface DraftImportResult {
imported: number;
skipped: number;
draft_id: string | null;
}
const baseUrl = () => `${getBackendBaseURL()}/api/roundtable-draft-shares`;
async function parseError(response: Response, fallback: string): Promise<Error> {
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
const detail = data.detail;
const message =
typeof detail === "string" && detail
? detail
: `${fallback} (HTTP ${response.status})`;
return new Error(message);
}
/** Create (or reuse) a share code for one roundtable draft in the given mode. */
export async function createDraftShare(
draftId: string,
mode: DraftShareMode = "import",
): Promise<DraftShare> {
const response = await apiFetch(baseUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft_id: draftId, mode }),
});
if (!response.ok) throw await parseError(response, "创建分享失败");
return response.json() as Promise<DraftShare>;
}
/** List the current user's own roundtable-draft share links. */
export async function listDraftShares(): Promise<DraftShare[]> {
const response = await apiFetch(baseUrl());
if (!response.ok) throw await parseError(response, "加载分享列表失败");
const json = (await response.json()) as { shares: DraftShare[] };
return json.shares;
}
/** Revoke a share link so it can no longer be previewed, imported, or viewed. */
export async function revokeDraftShare(shareCode: string): Promise<void> {
const response = await apiFetch(`${baseUrl()}/${encodeURIComponent(shareCode)}`, {
method: "DELETE",
});
if (!response.ok) throw await parseError(response, "撤销分享失败");
}
/** Preview a share link before importing it. */
export async function previewDraftShare(shareCode: string): Promise<DraftSharePreview> {
const response = await apiFetch(
`${baseUrl()}/${encodeURIComponent(shareCode)}/preview`,
);
if (!response.ok) throw await parseError(response, "无法读取该分享");
return response.json() as Promise<DraftSharePreview>;
}
/** Import the roundtable draft behind a share link into the current account. */
export async function importDraftShare(shareCode: string): Promise<DraftImportResult> {
const response = await apiFetch(
`${baseUrl()}/${encodeURIComponent(shareCode)}/import`,
{ method: "POST" },
);
if (!response.ok) throw await parseError(response, "导入失败");
return response.json() as Promise<DraftImportResult>;
}
/**
* Fetch the public read-only snapshot of a `view`-mode roundtable share.
*
* Uses a plain `fetch` (no auth header) — the `/api/public/` endpoint is
* intentionally world-readable so anyone with the link can open it.
*/
export async function getPublicDraftShareSnapshot(
shareCode: string,
): Promise<PublicDraftShareSnapshot> {
const response = await fetch(
`${getBackendBaseURL()}/api/public/roundtable-shares/${encodeURIComponent(shareCode)}`,
);
if (!response.ok) throw await parseError(response, "无法打开该分享");
return response.json() as Promise<PublicDraftShareSnapshot>;
}
/** Build the shareable public URL for a `view`-mode roundtable share code. */
export function buildPublicDraftShareUrl(shareCode: string): string {
// The app uses HashRouter, so the public viewer lives at `#/roundtable-share/<code>`.
return `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}`;
}
@@ -0,0 +1,61 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createDraftShare,
importDraftShare,
listDraftShares,
previewDraftShare,
revokeDraftShare,
type DraftShareMode,
} from "./api";
export function useDraftShares(enabled = true) {
return useQuery({
queryKey: ["roundtable-draft-shares"],
queryFn: listDraftShares,
enabled,
});
}
export function useCreateDraftShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
draftId,
mode = "import",
}: {
draftId: string;
mode?: DraftShareMode;
}) => createDraftShare(draftId, mode),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["roundtable-draft-shares"] });
},
});
}
export function useRevokeDraftShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (shareCode: string) => revokeDraftShare(shareCode),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["roundtable-draft-shares"] });
},
});
}
export function usePreviewDraftShare() {
return useMutation({
mutationFn: (shareCode: string) => previewDraftShare(shareCode),
});
}
export function useImportDraftShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (shareCode: string) => importDraftShare(shareCode),
onSuccess: () => {
// Imported drafts show up in the user's roundtable history sidebar.
void queryClient.invalidateQueries({ queryKey: ["roundtable-drafts"] });
},
});
}
@@ -0,0 +1,83 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useParams } from "react-router-dom";
import { getPublicDraftShareSnapshot } from "@/core/roundtable-draft-shares/api";
/**
* Public, read-only roundtable-result viewer.
*
* Reachable at `#/roundtable-share/<code>` with NO authentication — anyone with
* the link can open it. It renders the Step 3「结果绘制」report (a self-contained
* HTML document) inside a sandboxed iframe with no `allow-scripts`, mirroring the
* scheduled-task HTML-page preview, so a shared report can never execute code in
* the app origin.
*/
export default function PublicRoundtableSharePage() {
const { shareCode } = useParams<{ shareCode: string }>();
const { data, isLoading, isError } = useQuery({
queryKey: ["public-roundtable-share", shareCode],
queryFn: () => getPublicDraftShareSnapshot(shareCode!),
enabled: !!shareCode,
retry: false,
refetchOnWindowFocus: false,
});
if (isLoading) {
return (
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载分享内容…
</div>
);
}
if (isError || !data) {
return (
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-lg font-medium">分享不可用</p>
<p className="text-muted-foreground text-sm">
该分享链接不存在、已被撤销,或不是只读分享。
</p>
</div>
);
}
return (
<div className="bg-background flex h-screen flex-col">
<header className="flex shrink-0 flex-col gap-1 border-b px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold">
{data.title || "未命名会商"}
</h1>
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-xs">
只读分享
</span>
</div>
<p className="text-muted-foreground text-xs">
这是一份多智能体会商的结果报告快照(只读),你可以查看全部内容,但无法继续研讨。
</p>
</header>
<main className="min-h-0 flex-1">
{data.has_report ? (
<iframe
// No `allow-scripts` — the report is self-contained (inline CSS + inline
// SVG charts) and must never run as a script in the app origin.
sandbox=""
title={data.title || "会商结果报告"}
srcDoc={data.html}
className="size-full border-0 bg-white"
/>
) : (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm">
<p className="text-base font-medium text-foreground">暂无结果报告</p>
<p>这次会商还没有生成第三步「结果绘制」的方案报告。</p>
</div>
)}
</main>
</div>
);
}
@@ -9,7 +9,6 @@ import {
Eye,
FileText,
Loader2,
Printer,
RefreshCw,
Sparkles,
} from "lucide-react";
@@ -335,16 +334,6 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
<Sparkles className="w-3.5 h-3.5" />
结果绘制
</button>
<button
type="button"
onClick={() => {
if (typeof window !== "undefined") window.print();
}}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
>
<Printer className="w-3.5 h-3.5 text-slate-500" />
打印 / 另存 PDF
</button>
<button
type="button"
onClick={() => setReloadKey((k) => k + 1)}
@@ -0,0 +1,50 @@
/**
* 侧边栏「多智能体会商」记录用的轻量草稿列表 hook。
*
* 与页面内的 {@link useDraftPersistence} 不同 —— 那个负责完整的「存 / 加载 / 重命名 /
* 删除」编排,依赖 Step1/Step2 快照。侧边栏只需要只读地展示最近草稿 + 重命名 / 删除,
* 所以这里单独包一层,结构对齐 `useAIWritingSessions`(`{ drafts, loading, refresh,
* rename, remove }`)。
*/
import { useCallback, useState } from "react";
import {
deleteDraft,
listDrafts,
updateDraft,
type DraftMeta,
} from "@/roundtable-planning/api/drafts";
export function useRoundtableDrafts() {
const [drafts, setDrafts] = useState<DraftMeta[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listDrafts();
setDrafts(data);
} catch {
// 历史记录非关键,静默忽略
} finally {
setLoading(false);
}
}, []);
const rename = useCallback(async (id: string, title: string) => {
// 只发 title —— 与页面自动保存的「不带 title」互补,是改标题的唯一入口。
const updated = await updateDraft(id, { title });
if (updated) {
setDrafts((prev) =>
prev.map((d) => (d.id === id ? { ...d, title: updated.title } : d)),
);
}
}, []);
const remove = useCallback(async (id: string) => {
await deleteDraft(id);
setDrafts((prev) => prev.filter((d) => d.id !== id));
}, []);
return { drafts, loading, refresh, rename, remove };
}
@@ -36,6 +36,7 @@ import {
Clipboard,
Clock,
Cpu,
Download,
History,
ListOrdered,
Loader2,
@@ -54,6 +55,7 @@ import { cn } from "@/lib/utils";
import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { ImportRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
@@ -99,6 +101,8 @@ export default function RoundtablePlanningPage() {
const [isChainListOpen, setIsChainListOpen] = useState<boolean>(false);
// 历史下拉点击「进行中」任务 → 打开派活链路流程图弹窗(订阅该作业 SSE)。
const [chainModalJobId, setChainModalJobId] = useState<string | null>(null);
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -442,6 +446,30 @@ export default function RoundtablePlanningPage() {
loadedFromDraftRefHolder.current = drafts.loadedFromDraftRef.current;
});
// ─── 从侧边栏「多智能体会商」记录进入 ───────────────────────
// 侧边栏点击某条草稿时:不在本页 → 把 id 存进 sessionStorage 再跳转,挂载时消费;
// 已在本页 → 直接派 `roundtable:open-draft` 事件。两条路径都走 handleLoadDraft。
// 用 ref 持有最新 handleLoadDraft,让下面的 effect 保持「挂载只跑一次」,避免
// handleLoadDraft 身份变化导致重复消费 / 重复加载。
const handleLoadDraftRef = useRef(drafts.handleLoadDraft);
useEffect(() => {
handleLoadDraftRef.current = drafts.handleLoadDraft;
}, [drafts.handleLoadDraft]);
useEffect(() => {
const pending = sessionStorage.getItem("roundtable:pending-draft");
if (pending) {
sessionStorage.removeItem("roundtable:pending-draft");
handleLoadDraftRef.current(pending);
}
const onOpenDraft = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
if (id) handleLoadDraftRef.current(id);
};
window.addEventListener("roundtable:open-draft", onOpenDraft);
return () => window.removeEventListener("roundtable:open-draft", onOpenDraft);
}, []);
// ─── 角色面板转场 ────────────────────────────────────────
// Step 1: 仅指挥员 + 任务理解智能体两行(状态跟随 intent init)。
// Step 2: 完整席位(loadedFromDraft 时直接 "在线",否则 "正在上线")。
@@ -660,6 +688,16 @@ export default function RoundtablePlanningPage() {
}}
/>
{/* 导入会商弹窗:输入分享码 → 复制副本 → 加载到当前页面继续研讨 */}
<ImportRoundtableDraftDialog
open={isImportOpen}
onOpenChange={setIsImportOpen}
onImported={(draftId) => {
void drafts.refresh();
if (draftId) drafts.handleLoadDraft(draftId);
}}
/>
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
<DispatchChainJobModal
jobId={chainModalJobId}
@@ -718,42 +756,6 @@ export default function RoundtablePlanningPage() {
新建任务
</button>
{currentStep !== 2 && (
<>
<button
id="start-clear-btn"
onClick={() => {
setCurrentStep(1);
triggerToast("已切换至第一阶段:人机问答意图澄清");
}}
className="bg-primary text-white hover:bg-[var(--brand-900)] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-primary/10 active:scale-95"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
开始澄清
</button>
<button
id="human-intervention-btn"
onClick={() => {
setRoles((prev) =>
prev.map((r) =>
r.id === 1
? { ...r, status: r.status === "在线" ? "等待激活" : "在线" }
: r,
),
);
triggerToast("指挥专家状态变更!人工介入切换");
}}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Users className="w-3.5 h-3.5 text-slate-500 dark:text-muted-foreground" />
人工介入
</button>
</>
)}
<button
id="save-draft-header-btn"
onClick={drafts.handleManualSave}
@@ -763,6 +765,19 @@ export default function RoundtablePlanningPage() {
保存草稿
</button>
{/* 「导入会商」按钮先隐藏(保留代码 + 弹窗逻辑,便于以后恢复)。 */}
{false && (
<button
id="import-roundtable-btn"
onClick={() => setIsImportOpen(true)}
title="输入分享码,把别人分享的会商复制到我的账号"
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Download className="w-3.5 h-3.5 text-slate-500" />
导入会商
</button>
)}
<HistoryDropdown
innerRef={drafts.historyDropdownRef}
isOpen={drafts.isHistoryOpen}