Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
a642ba91fa
21 files changed
+1587
-47
No files matched your search
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user