feat: 会商链条启停与深度研究报告结构,并完善问答展示与 8BF 返回。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
be4851555d
commit
0ce060693b
42 files changed
+5320
-3247
No files matched your search
@@ -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)",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user