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

@@ -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 && (