feat: 会商链条启停与深度研究报告结构,并完善问答展示与 8BF 返回。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-19 20:37:34 +08:00
1 parent be4851555d
commit 0ce060693b
42 files changed
+5320 -3247

No files matched your search

+2 -2
View File
@@ -773,9 +773,9 @@ export default function ChatPage() {
!compactLanding &&
"-translate-y-[calc(50vh-96px)]",
compactLanding
? "max-w-(--container-width-xl)"
? "max-w-[calc(var(--container-width-xl)+150px)]"
: isLandingState
? "max-w-(--container-width-md)"
? "max-w-[calc(var(--container-width-md)+150px)]"
: "max-w-(--container-width-lg)",
)}
>
+10 -41
View File
@@ -79,12 +79,10 @@ import {
type MergedMenuNode,
} from "@/core/page-layout/menu-overrides";
import {
COMPACT_MENU_TYPES,
COMPACT_MENU_TYPE_LABELS,
getRuntimeCompactMenuType,
type CompactMenuOverride,
type CompactMenuPlacement,
type CompactMenuType,
} from "@/core/page-layout/compact-menu-overrides";
import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu";
import { injectLightApps } from "@/core/page-layout/use-sidebar-menu";
@@ -510,7 +508,7 @@ export default function MenuManagementPage() {
() => [
{
id: "workspace-compact-nav",
label: "简介模式菜单",
label: "简洁模式菜单",
icon: Settings,
children: WORKSPACE_COMPACT_NAV_ITEMS,
},
@@ -779,7 +777,7 @@ export default function MenuManagementPage() {
mode === "compact" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
简介模式
简洁模式
</button>
</div>
);
@@ -980,7 +978,8 @@ export default function MenuManagementPage() {
function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const runtimeType = getRuntimeCompactMenuType();
const [menuType, setMenuType] = useState<CompactMenuType>(runtimeType);
// 不再提供 A/B 切换:固定编辑当前网页(runtime-config.js VITE_COMPACT_MENU_TYPE)使用的那套。
const menuType = runtimeType;
const { data: overrides = [], isLoading } = useCompactMenuOverrides(menuType);
const { save } = useCompactMenuOverrideActions(menuType);
const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]);
@@ -1038,23 +1037,17 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const handleSave = async () => {
try {
await save.mutateAsync(currentOverrides);
toast.success(`简介菜单配置已保存(${COMPACT_MENU_TYPE_LABELS[menuType]})`);
toast.success(`简洁菜单配置已保存(${COMPACT_MENU_TYPE_LABELS[menuType]})`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
};
const handleReset = () => {
if (!confirm(`确定要重置${COMPACT_MENU_TYPE_LABELS[menuType]}的简介模式菜单为默认布局吗?保存后将清除该类型的自定义配置。`)) return;
if (!confirm(`确定要重置${COMPACT_MENU_TYPE_LABELS[menuType]}的简洁模式菜单为默认布局吗?保存后将清除该类型的自定义配置。`)) return;
setRows(compactRowsFromTree(baseTree, []));
};
const switchMenuType = (next: CompactMenuType) => {
if (next === menuType) return;
if (dirty && !confirm("当前类型有未保存的修改,切换后将丢失。确定切换吗?")) return;
setMenuType(next);
};
return (
<>
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
@@ -1066,10 +1059,10 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
<div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
<Sparkles className="size-3" />
仅简介模式生效
仅简洁模式生效
</span>
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
简介菜单管理
简洁菜单管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{rows.length} 个项目
</span>
@@ -1095,35 +1088,11 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
</button>
</div>
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-2xl border border-slate-200 bg-slate-50 p-1 text-sm font-semibold">
{COMPACT_MENU_TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => switchMenuType(type)}
className={`rounded-xl px-4 py-1.5 transition-all ${
menuType === type ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
{COMPACT_MENU_TYPE_LABELS[type]}
{runtimeType === type ? (
<span className={`ml-1.5 text-[10px] font-medium ${menuType === type ? "text-white/80" : "text-slate-400"}`}>
当前网页
</span>
) : null}
</button>
))}
</div>
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>
这里配置的是简介模式工作区左侧菜单,按类型 A / B 分套保存。两套网页在{" "}
<code className="rounded bg-slate-200/70 px-1 py-0.5 text-xs">runtime-config.js</code> 里填写{" "}
<code className="rounded bg-slate-200/70 px-1 py-0.5 text-xs">VITE_COMPACT_MENU_TYPE</code> 为 A 或 B
即可加载对应配置;全量模式菜单不受影响。当前网页正在使用
{COMPACT_MENU_TYPE_LABELS[runtimeType]}。
这里配置的是简洁模式工作区左侧菜单,保存后仅对当前网页使用的
{COMPACT_MENU_TYPE_LABELS[runtimeType]}生效(两套部署各改各的);全量模式菜单不受影响。
</span>
</p>
</div>
@@ -40,7 +40,6 @@ import {
MessageSquareText,
PanelRightOpen,
RotateCcw,
SlidersHorizontal,
Sparkles,
Telescope,
Trash2,
@@ -126,6 +125,7 @@ import {
ReportOutlineEditor,
} from "./ReportConfigCard";
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
const COLLECTOR_AGENT_ID = "deep-research-collector";
@@ -1764,9 +1764,15 @@ function DeepResearchWorkbenchInner() {
<ImagePlus className="h-3.5 w-3.5" />配图
</button>
)}
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setSettingsOpen(true)} disabled={Boolean(isFollowUp) || jobWriting}>
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" />高级配置
</Button>
<WorkflowConfigButton
variant="ghost"
className="h-7 px-2 text-xs"
disabled={Boolean(isFollowUp) || jobWriting}
appliedContent={advancedOptions.customOutline}
onApply={(content) =>
setAdvancedOptions((current) => ({ ...current, customOutline: content }))
}
/>
</div>
</div>
<PromptInput onSubmit={({ text }) => handleComposerSubmit(text)}>
@@ -2654,6 +2660,18 @@ function AdvancedResearchSettingsDialog({
onChange={(value) => onChange("customOutline", value)}
/>
{/* 工作流配置:报告结构库(后台 /api/report-structures),选中卡片即填充上方报告结构 */}
<div className="flex items-center justify-between gap-3 rounded-xl border border-border p-3">
<span className="text-xs leading-5 text-muted-foreground">
<span className="block text-sm font-medium text-foreground">报告结构库</span>
在「工作流配置」中管理并选择报告结构;选中卡片后自动填充到上方报告结构。
</span>
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(content) => onChange("customOutline", content)}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex cursor-pointer gap-3 rounded-xl border p-3">
<input
@@ -32,6 +32,7 @@ import {
type ResearchMode,
type ResearchTone,
} from "@/strategy-components/api/deep-research";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
const MODE_LABELS: Record<ResearchMode, string> = {
basic: "标准",
@@ -473,6 +474,12 @@ export function ReportConfigCard({
onChange={(value) => update("customOutline", value)}
/>
</div>
<div className="flex justify-end sm:col-span-2">
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(value) => update("customOutline", value)}
/>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
<input
type="checkbox"
@@ -0,0 +1,549 @@
"use client";
/**
* 工作流配置(报告结构库)弹窗 + 触发按钮。
*
* 深度研究「高级配置」区的人口:按钮打开大弹窗,弹窗内两视图——
* - 列表视图:报告结构卡片网格(后台 /api/report-structures,全局共享,
* 全新部署自动播种「通用分析结构 / 递进式论证结构」),支持 启用/停用 筛选
* 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示
* 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。
* - 详情视图:点击卡片即「选择」该结构(内容回填到高级配置的报告结构
* custom_outline),弹窗切换为展示 标题 + 接口内容,右上角「返回」回列表。
*/
import { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import {
ChevronLeft,
Loader2,
Pencil,
Plus,
Search,
Trash2,
Workflow,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
type ReportStructure,
type ReportStructureStatusFilter,
useReportStructures,
useReportStructureMutations,
} from "@/strategy-components/api/report-structures";
// ── shared helpers ───────────────────────────────────────────────────────────
function formatDateTime(iso: string | null): string {
if (!iso) return "—";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/** 创建用户名:本部署账号是邮箱,取 @ 前缀(与个人信息页「用户名」一致)。 */
function creatorLabel(structure: ReportStructure): string {
if (!structure.createdBy) return "—";
return structure.createdBy.split("@")[0] || structure.createdBy;
}
// ── main dialog ──────────────────────────────────────────────────────────────
export function WorkflowConfigDialog({
open,
onOpenChange,
/** 当前已应用的报告结构内容(高级配置的 custom_outline),用于高亮「当前使用」。 */
appliedContent,
/** 点击卡片选择结构时回填内容。 */
onApply,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
appliedContent?: string;
onApply?: (content: string) => void;
}) {
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
const [titleFilter, setTitleFilter] = useState("");
const [detailId, setDetailId] = useState<string | null>(null);
const [editorTarget, setEditorTarget] = useState<ReportStructure | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ReportStructure | null>(null);
// 启用/停用筛选走服务端;标题筛选在本地做(即时、免抖动)。
const listQuery = useReportStructures({ status: statusFilter });
const mutations = useReportStructureMutations();
// 重新打开时回到列表视图。
useEffect(() => {
if (open) setDetailId(null);
}, [open]);
const structures = listQuery.data ?? [];
const visible = useMemo(() => {
const needle = titleFilter.trim().toLowerCase();
if (!needle) return structures;
return structures.filter((s) => s.title.toLowerCase().includes(needle));
}, [structures, titleFilter]);
const appliedStructureId = useMemo(() => {
const trimmed = (appliedContent ?? "").trim();
if (!trimmed) return null;
return structures.find((s) => s.content.trim() === trimmed)?.id ?? null;
}, [appliedContent, structures]);
const detail = useMemo(
() => structures.find((s) => s.id === detailId) ?? null,
[structures, detailId],
);
const handleSelect = (structure: ReportStructure) => {
onApply?.(structure.content);
setDetailId(structure.id);
toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`);
};
const handleToggleEnabled = (structure: ReportStructure, enabled: boolean) => {
mutations.toggleEnabled.mutate(
{ id: structure.id, enabled },
{
onSuccess: () => toast.success(`「${structure.title}」已${enabled ? "启用" : "停用"}`),
onError: (err) => toast.error(err instanceof Error ? err.message : "操作失败"),
},
);
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await mutations.remove.mutateAsync(deleteTarget.id);
toast.success(`已删除「${deleteTarget.title}」`);
if (detailId === deleteTarget.id) setDetailId(null);
setDeleteTarget(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除失败");
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-5xl flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
>
{/* header:列表视图=标题+筛选+新增;详情视图=结构标题,右上角「返回」 */}
{!detail ? (
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold">工作流配置 · 报告结构</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
点击卡片选择报告结构(自动填充到报告结构配置);可启停、编辑、删除。
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="关闭"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={titleFilter}
onChange={(event) => setTitleFilter(event.target.value)}
placeholder="按标题筛选"
className="h-8 pl-8 text-xs"
/>
</div>
<Select
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as ReportStructureStatusFilter)}
>
<SelectTrigger className="h-8 w-28 shrink-0 text-xs" aria-label="启用状态筛选">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="enabled">已启用</SelectItem>
<SelectItem value="disabled">已停用</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Badge variant="outline" className="h-6 text-[10px]">{visible.length} 套</Badge>
<Button
size="sm"
className="h-8"
onClick={() => {
setEditorTarget(null);
setEditorOpen(true);
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />新增结构
</Button>
</div>
</div>
</div>
) : (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-4">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline" className="shrink-0">报告结构</Badge>
<h2 className="truncate text-base font-semibold">{detail.title}</h2>
{detail.id === appliedStructureId && (
<Badge className="shrink-0">当前使用</Badge>
)}
{!detail.enabled && <Badge variant="secondary" className="shrink-0">已停用</Badge>}
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setDetailId(null)}>
<ChevronLeft className="mr-1 h-4 w-4" />返回
</Button>
</div>
)}
{/* body */}
{!detail ? (
<ScrollArea className="min-h-0 flex-1">
<div className="p-5">
{listQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />加载中…
</div>
) : listQuery.isError ? (
<div className="py-16 text-center text-sm text-destructive">
{(listQuery.error as Error)?.message || "加载报告结构失败"}
</div>
) : visible.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-16 text-center text-sm text-muted-foreground">
<Workflow className="h-10 w-10 text-muted-foreground/30" />
{titleFilter || statusFilter !== "all" ? "没有符合条件的报告结构" : "暂无报告结构,点击右上角「新增结构」创建"}
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{visible.map((structure) => (
<StructureCard
key={structure.id}
structure={structure}
applied={structure.id === appliedStructureId}
onOpen={() => handleSelect(structure)}
onEdit={(event) => {
event.stopPropagation();
setEditorTarget(structure);
setEditorOpen(true);
}}
onDelete={(event) => {
event.stopPropagation();
setDeleteTarget(structure);
}}
onToggleEnabled={(enabled) => handleToggleEnabled(structure, enabled)}
toggling={mutations.toggleEnabled.isPending}
/>
))}
</div>
)}
</div>
</ScrollArea>
) : (
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto max-w-3xl p-5">
{detail.name && (
<p className="mb-1 text-xs text-muted-foreground">描述:{detail.name}</p>
)}
<p className="mb-4 text-xs text-muted-foreground">
最后修改:{formatDateTime(detail.updatedAt)} · 创建人:{creatorLabel(detail)}
</p>
<article className="prose prose-sm dark:prose-invert max-w-none rounded-xl border border-border bg-card p-5">
<ReactMarkdown components={{ img: () => null }}>{detail.content}</ReactMarkdown>
</article>
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
{/* 新增 / 编辑弹窗(嵌套) */}
<StructureEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
target={editorTarget}
onSubmit={async (values) => {
try {
if (editorTarget) {
await mutations.update.mutateAsync({ id: editorTarget.id, patch: values });
toast.success("报告结构已保存");
} else {
await mutations.create.mutateAsync(values);
toast.success("已新增报告结构(默认启用)");
}
setEditorOpen(false);
setEditorTarget(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
}}
submitting={mutations.create.isPending || mutations.update.isPending}
/>
{/* 删除二次确认(嵌套) */}
<Dialog open={deleteTarget !== null} onOpenChange={(next) => !next && setDeleteTarget(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>删除报告结构</DialogTitle>
<DialogDescription>
确认删除「{deleteTarget?.title}」?删除后不可恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={mutations.remove.isPending}>
取消
</Button>
<Button variant="destructive" onClick={() => void handleDelete()} disabled={mutations.remove.isPending}>
{mutations.remove.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// ── structure card ───────────────────────────────────────────────────────────
function StructureCard({
structure,
applied,
onOpen,
onEdit,
onDelete,
onToggleEnabled,
toggling,
}: {
structure: ReportStructure;
applied: boolean;
onOpen: () => void;
onEdit: (event: React.MouseEvent) => void;
onDelete: (event: React.MouseEvent) => void;
onToggleEnabled: (enabled: boolean) => void;
toggling: boolean;
}) {
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen();
}
}}
className={`group cursor-pointer rounded-xl border p-4 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${
applied
? "border-primary/50 bg-primary/5"
: "border-border bg-card hover:border-primary/30 hover:bg-accent/40"
}`}
>
<div className="mb-1.5 flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-semibold">{structure.title}</span>
{applied && <Badge className="h-5 shrink-0 text-[10px]">当前使用</Badge>}
<Badge variant={structure.enabled ? "default" : "outline"} className={`h-5 shrink-0 text-[10px] ${structure.enabled ? "" : "text-muted-foreground"}`}>
{structure.enabled ? "启用" : "停用"}
</Badge>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
title="编辑"
aria-label={`编辑 ${structure.title}`}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onEdit}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
title="删除"
aria-label={`删除 ${structure.title}`}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{structure.name && <p className="mb-2 truncate text-xs text-muted-foreground">{structure.name}</p>}
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-2.5">
<div className="min-w-0 text-[11px] leading-4 text-muted-foreground">
<p className="truncate">最后修改:{formatDateTime(structure.updatedAt)}</p>
<p className="truncate">创建人:{creatorLabel(structure)}</p>
</div>
<div
className="flex shrink-0 items-center gap-1.5"
onClick={(event) => event.stopPropagation()}
role="presentation"
>
<span className="text-[11px] text-muted-foreground">{structure.enabled ? "启用" : "停用"}</span>
<Switch
checked={structure.enabled}
disabled={toggling}
aria-label={`${structure.enabled ? "停用" : "启用"} ${structure.title}`}
onCheckedChange={onToggleEnabled}
/>
</div>
</div>
</div>
);
}
// ── editor dialog(新增 / 编辑共用;状态不允许填写,创建默认启用) ─────────────
function StructureEditorDialog({
open,
onOpenChange,
target,
onSubmit,
submitting,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
target: ReportStructure | null;
onSubmit: (values: { title: string; name: string; content: string }) => Promise<void>;
submitting: boolean;
}) {
const [title, setTitle] = useState("");
const [name, setName] = useState("");
const [content, setContent] = useState("");
useEffect(() => {
if (open) {
setTitle(target?.title ?? "");
setName(target?.name ?? "");
setContent(target?.content ?? "");
}
}, [open, target]);
const valid = title.trim().length > 0 && content.trim().length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] w-[calc(100vw-2rem)] max-w-2xl overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{target ? "编辑报告结构" : "新增报告结构"}</DialogTitle>
<DialogDescription>
{target ? "修改后将刷新「最后修改时间」。" : "新增的报告结构默认为启用状态。"}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<label className="block">
<span className="mb-1.5 block text-sm font-medium">标题</span>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={256}
placeholder="例如:行业研究通用结构"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">描述</span>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={256}
placeholder="可选,一句话描述这套结构的适用场景"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">接口内容</span>
<Textarea
value={content}
onChange={(event) => setContent(event.target.value)}
maxLength={20000}
placeholder={"报告结构正文,支持 Markdown 标题,例如:\n# 引言\n# 一、事实与背景\n## 1.1 研究范围"}
className="min-h-[260px] resize-y font-mono text-xs"
/>
<span className="mt-1 block text-[11px] text-muted-foreground">
选中卡片时,该内容会填充到高级配置的「报告结构」。
</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
取消
</Button>
<Button disabled={!valid || submitting} onClick={() => void onSubmit({ title: title.trim(), name: name.trim(), content: content })}>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── trigger button(自带弹窗开合状态) ────────────────────────────────────────
export function WorkflowConfigButton({
appliedContent,
onApply,
className,
variant = "outline",
size = "sm",
disabled = false,
}: {
appliedContent?: string;
onApply: (content: string) => void;
className?: string;
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant={variant} size={size} className={className} disabled={disabled} onClick={() => setOpen(true)}>
<Workflow className="mr-1.5 h-3.5 w-3.5" />
工作流配置
</Button>
<WorkflowConfigDialog
open={open}
onOpenChange={setOpen}
appliedContent={appliedContent}
onApply={onApply}
/>
</>
);
}