chore: 提交 AI 写作研究侧栏改动与前端开发环境变量
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fc7576fea3
commit
39d7dab3e2
2 files changed
+255
-120
No files matched your search
@@ -5,7 +5,7 @@
|
||||
VITE_USE_REAL_DATA=true
|
||||
|
||||
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
|
||||
VITE_MOCK_COP_TASK_DETAIL=false
|
||||
VITE_MOCK_COP_TASK_DETAIL=true
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, FileText, LayoutGrid, Loader2, Plus, Settings2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
FileText,
|
||||
Globe2,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
Newspaper,
|
||||
Plus,
|
||||
Search,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -22,23 +37,62 @@ import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
|
||||
import { useWritingForm } from "../../contexts/WritingFormContext";
|
||||
import { useAIWriting } from "../../contexts/AIWritingContext";
|
||||
|
||||
/**
|
||||
* 课题研究左栏(需求 0616 #5)。
|
||||
* - 上半「研究模板」= 研究模板实体(``useResearchTemplates``);点击一键预填整张写作表单
|
||||
* (文章类型 / 大纲 / 搜索来源 / 字数 / 受众 / 严格模式 / 主题)并展开配置。顶部「配置」
|
||||
* 入口进入模板配置页维护这些模板。
|
||||
* - 下半「笔记本」= 我的空间(``useStudioNotebooks``);点击在右侧内嵌打开该笔记本页。
|
||||
*/
|
||||
type TemplateVisual = {
|
||||
Icon: LucideIcon;
|
||||
iconClass: string;
|
||||
accentClass: string;
|
||||
};
|
||||
|
||||
const TEMPLATE_VISUALS: TemplateVisual[] = [
|
||||
{
|
||||
Icon: Globe2,
|
||||
iconClass: "bg-blue-50 text-blue-600",
|
||||
accentClass: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
Icon: Newspaper,
|
||||
iconClass: "bg-amber-50 text-amber-600",
|
||||
accentClass: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
Icon: Sparkles,
|
||||
iconClass: "bg-violet-50 text-violet-600",
|
||||
accentClass: "bg-violet-500",
|
||||
},
|
||||
{
|
||||
Icon: TrendingUp,
|
||||
iconClass: "bg-rose-50 text-rose-600",
|
||||
accentClass: "bg-rose-500",
|
||||
},
|
||||
{
|
||||
Icon: BookOpen,
|
||||
iconClass: "bg-emerald-50 text-emerald-600",
|
||||
accentClass: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
Icon: BarChart3,
|
||||
iconClass: "bg-cyan-50 text-cyan-600",
|
||||
accentClass: "bg-cyan-500",
|
||||
},
|
||||
];
|
||||
|
||||
function getTemplateVisual(tpl: ResearchTemplate, index: number): TemplateVisual {
|
||||
const text = `${tpl.name} ${tpl.description ?? ""} ${tpl.article_type_key ?? ""}`;
|
||||
if (/网摘|网络|热点|摘/.test(text)) return TEMPLATE_VISUALS[0];
|
||||
if (/周报|周刊|日报|月报/.test(text)) return TEMPLATE_VISUALS[1];
|
||||
if (/要讯|资讯|快讯|简报/.test(text)) return TEMPLATE_VISUALS[2];
|
||||
if (/行业|分析|市场|竞争/.test(text)) return TEMPLATE_VISUALS[3];
|
||||
if (/政策|解读|法规/.test(text)) return TEMPLATE_VISUALS[4];
|
||||
return TEMPLATE_VISUALS[index % TEMPLATE_VISUALS.length];
|
||||
}
|
||||
|
||||
export function ResearchSidebar({
|
||||
viewingNotebookId,
|
||||
onOpenNotebook,
|
||||
onOpenAllSpaces,
|
||||
}: {
|
||||
/** 当前在右侧查看的笔记本 id(高亮用)。 */
|
||||
viewingNotebookId?: number | null;
|
||||
/** 点击笔记本 → 在右侧内嵌打开该笔记本页(替换右侧内容)。 */
|
||||
onOpenNotebook: (notebookId: number) => void;
|
||||
/** 点击「所有空间」→ 在右侧内嵌打开空间列表页。 */
|
||||
onOpenAllSpaces: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
@@ -62,7 +116,7 @@ export function ResearchSidebar({
|
||||
} = useWritingForm();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 新建空间弹窗(与 workspace-nav-studio 同款)。
|
||||
const [templateSearch, setTemplateSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
@@ -72,6 +126,16 @@ export function ResearchSidebar({
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const keyword = templateSearch.trim().toLowerCase();
|
||||
if (!keyword) return templates;
|
||||
return templates.filter((tpl) =>
|
||||
[tpl.name, tpl.description, tpl.article_type_key]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword)),
|
||||
);
|
||||
}, [templates, templateSearch]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -92,8 +156,6 @@ export function ResearchSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
// 点击研究模板 → ①回填整张写作表单(保持写作态左栏显示与运行配置一致)
|
||||
// ②直接以模板预设进入 AI 写作流程(startWriting)。
|
||||
const handleTemplateClick = (tpl: ResearchTemplate) => {
|
||||
const intent = tpl.topic?.trim() ?? "";
|
||||
const articleType = tpl.article_type_key || "analysis";
|
||||
@@ -102,7 +164,6 @@ export function ResearchSidebar({
|
||||
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
|
||||
const writingMode = tpl.strict_mode ? "strict" : "loose";
|
||||
|
||||
// 回填表单上下文(写作态左栏据此展示)。
|
||||
if (intent) setIntent(intent);
|
||||
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
|
||||
if (outline) {
|
||||
@@ -115,7 +176,6 @@ export function ResearchSidebar({
|
||||
setStrictMode(tpl.strict_mode);
|
||||
setFormExpanded(true);
|
||||
|
||||
// 无主题/意图无法发起写作 → 仅回填表单,由用户补充后手动开始。
|
||||
if (!intent) return;
|
||||
void startWriting({
|
||||
userIntent: intent,
|
||||
@@ -130,116 +190,191 @@ export function ResearchSidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-64 shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
|
||||
{/* 上半:研究模板 */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
|
||||
<div className="px-2 mb-1.5 flex items-center justify-between">
|
||||
<h2 className="text-muted-foreground/70 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
研究模板
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/canvas/research-templates")}
|
||||
title="配置研究模板"
|
||||
aria-label="配置研究模板"
|
||||
className="text-muted-foreground/70 hover:text-foreground rounded p-0.5 transition-colors"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{templates.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">
|
||||
暂无研究模板,点右上角配置
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{templates.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplateClick(tpl)}
|
||||
title={tpl.description ?? tpl.name}
|
||||
className="flex items-start gap-2 rounded-lg px-2.5 py-1.5 text-left text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
|
||||
>
|
||||
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-medium">{tpl.name}</span>
|
||||
{tpl.description && (
|
||||
<span className="text-muted-foreground/70 line-clamp-1 block text-[11px]">
|
||||
{tpl.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<aside className="flex h-full w-[320px] shrink-0 flex-col overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)]">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4">
|
||||
<section className="mb-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/canvas/research-templates")}
|
||||
title="配置研究模板"
|
||||
aria-label="配置研究模板"
|
||||
className="group flex items-center gap-2 rounded-lg text-[16px] font-bold text-slate-500 transition-colors hover:text-slate-800"
|
||||
>
|
||||
<SlidersHorizontal className="size-4 text-slate-400" />
|
||||
<span>研究模板</span>
|
||||
<Settings2 className="size-3.5 text-slate-300 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
<div className="relative w-[150px]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={templateSearch}
|
||||
onChange={(event) => setTemplateSearch(event.target.value)}
|
||||
placeholder="搜索写作模板..."
|
||||
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50/80 pl-8 pr-3 text-[12px] text-slate-600 outline-none transition focus:border-blue-200 focus:bg-white focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下半:笔记本(我的空间) */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
|
||||
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
笔记本
|
||||
</h2>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* 新建空间 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setName("");
|
||||
setCreateError("");
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span>新建空间</span>
|
||||
</button>
|
||||
|
||||
{notebooksLoading ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">加载中…</p>
|
||||
) : notebooksError ? (
|
||||
<p className="px-2 py-1.5 text-xs text-amber-600">
|
||||
笔记本服务暂不可用(检查空间服务地址 / 账号)
|
||||
{templates.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
|
||||
暂无研究模板,点击标题配置
|
||||
</p>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
|
||||
暂无匹配模板
|
||||
</p>
|
||||
) : notebooks.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无笔记本</p>
|
||||
) : (
|
||||
notebooks.map((nb) => {
|
||||
const active = viewingNotebookId === nb.id;
|
||||
return (
|
||||
<button
|
||||
key={nb.id}
|
||||
type="button"
|
||||
onClick={() => onOpenNotebook(nb.id)}
|
||||
title={nb.description || nb.name}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">{nb.name}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
<div className="space-y-3">
|
||||
{filteredTemplates.map((tpl, index) => {
|
||||
const visual = getTemplateVisual(tpl, index);
|
||||
const Icon = visual.Icon;
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplateClick(tpl)}
|
||||
title={tpl.description ?? tpl.name}
|
||||
className="group relative flex w-full items-start gap-3 overflow-hidden rounded-[5px] bg-white px-4 py-4 text-left shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(30,64,175,0.08)] active:translate-y-0"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-12 shrink-0 items-center justify-center rounded-xl transition-colors",
|
||||
visual.iconClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6 stroke-[2.2]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 pt-0.5">
|
||||
<span className="block truncate text-[16px] font-bold leading-5 text-slate-900 transition-colors group-hover:text-blue-700">
|
||||
{tpl.name}
|
||||
</span>
|
||||
{tpl.description && (
|
||||
<span
|
||||
className="mt-1.5 overflow-hidden text-[13px] leading-6 text-slate-500"
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2,
|
||||
}}
|
||||
>
|
||||
{tpl.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-4 left-0 top-4 w-1 rounded-r-full opacity-0 transition-opacity group-hover:opacity-100",
|
||||
visual.accentClass,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 所有空间 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAllSpaces}
|
||||
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
|
||||
>
|
||||
<LayoutGrid className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span>所有空间</span>
|
||||
</button>
|
||||
</div>
|
||||
<section className="flex min-h-0 flex-1 flex-col border-t border-slate-100 pt-5">
|
||||
<div className="mb-3 flex items-center justify-between px-2">
|
||||
<h2 className="text-[15px] font-bold tracking-wide text-slate-400">
|
||||
笔记本
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setName("");
|
||||
setCreateError("");
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
title="新建写作空间"
|
||||
aria-label="新建写作空间"
|
||||
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
|
||||
>
|
||||
<Plus className="size-4 stroke-[2.5]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-1 pb-3">
|
||||
{notebooksLoading ? (
|
||||
<p className="px-3 py-2 text-xs text-slate-400">加载中...</p>
|
||||
) : notebooksError ? (
|
||||
<p className="rounded-xl bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
|
||||
笔记本服务暂不可用,请检查空间服务地址或账号
|
||||
</p>
|
||||
) : notebooks.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-slate-400">暂无笔记本</p>
|
||||
) : (
|
||||
notebooks.map((nb) => {
|
||||
const active = viewingNotebookId === nb.id;
|
||||
const sourceCount = Math.max(0, nb.source_count ?? 0);
|
||||
return (
|
||||
<button
|
||||
key={nb.id}
|
||||
type="button"
|
||||
onClick={() => onOpenNotebook(nb.id)}
|
||||
title={nb.description || nb.name}
|
||||
className={cn(
|
||||
"group relative flex min-h-14 w-full items-center gap-3 rounded-xl border bg-white px-3 py-2 text-left transition-all",
|
||||
active
|
||||
? "border-rose-100 bg-rose-50/35 text-slate-900 shadow-[0_6px_18px_rgba(244,63,94,0.06)]"
|
||||
: "border-slate-100/80 text-slate-600 hover:border-slate-200 hover:bg-slate-50/70",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute bottom-4 left-0 top-4 w-1 rounded-r-full bg-rose-500" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-lg",
|
||||
active ? "bg-rose-50 text-rose-600" : "bg-slate-100 text-slate-500",
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-4 stroke-[2.2]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[15px] font-medium leading-5">
|
||||
{nb.name}
|
||||
</span>
|
||||
{sourceCount > 0 && (
|
||||
<span className="mt-1 flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<FileText className="size-3" />
|
||||
已有 {sourceCount} 篇排版
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{sourceCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full border text-[12px] font-semibold",
|
||||
active
|
||||
? "border-rose-100 bg-rose-50 text-rose-600"
|
||||
: "border-slate-100 bg-slate-50 text-slate-400",
|
||||
)}
|
||||
>
|
||||
{sourceCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 px-1 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAllSpaces}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left text-[14px] font-bold text-slate-700 transition-colors hover:bg-slate-50 hover:text-slate-950"
|
||||
>
|
||||
<LayoutGrid className="size-4 text-rose-500 stroke-[2.3]" />
|
||||
<span>所有空间</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 新建空间弹窗 */}
|
||||
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
|
||||
Reference in new issue
Block a user