diff --git a/frontend-web/src/open-canvas/api/ai-writing-research-templates.ts b/frontend-web/src/open-canvas/api/ai-writing-research-templates.ts new file mode 100644 index 00000000..05d9e1a8 --- /dev/null +++ b/frontend-web/src/open-canvas/api/ai-writing-research-templates.ts @@ -0,0 +1,75 @@ +import { getBackendBaseURL } from '@/core/config'; +import { apiFetch } from '@/core/api'; + +/** 课题研究模板:一组写作表单预设(需求 0616 #5)。 */ +export interface ResearchTemplate { + id: string; + name: string; + description: string | null; + /** 引用的文章类型 key(对应 ArticleType.key);null = 不设。 */ + article_type_key: string | null; + outline: string | null; + material_source: 'general' | 'notebook'; + word_count: number; + audience: string | null; + strict_mode: boolean; + topic: string | null; + sort_order: number; + created_at: string; + updated_at: string; +} + +export interface CreateResearchTemplateRequest { + name: string; + description?: string | null; + article_type_key?: string | null; + outline?: string | null; + material_source?: 'general' | 'notebook'; + word_count?: number; + audience?: string | null; + strict_mode?: boolean; + topic?: string | null; + sort_order?: number; +} + +export type UpdateResearchTemplateRequest = Partial; + +const BASE = () => `${getBackendBaseURL()}/api/ai-writing/research-templates`; + +export async function listResearchTemplates(): Promise { + const resp = await apiFetch(BASE()); + if (!resp.ok) throw new Error(`获取研究模板失败: ${resp.status}`); + return resp.json(); +} + +export async function createResearchTemplate( + data: CreateResearchTemplateRequest, +): Promise { + const resp = await apiFetch(BASE(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!resp.ok) throw new Error(`创建研究模板失败: ${resp.status}`); + return resp.json(); +} + +export async function updateResearchTemplate( + id: string, + data: UpdateResearchTemplateRequest, +): Promise { + const resp = await apiFetch(`${BASE()}/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (resp.status === 404) throw new Error(`研究模板不存在`); + if (!resp.ok) throw new Error(`更新研究模板失败: ${resp.status}`); + return resp.json(); +} + +export async function deleteResearchTemplate(id: string): Promise { + const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' }); + if (resp.status === 404) throw new Error(`研究模板不存在`); + if (!resp.ok) throw new Error(`删除研究模板失败: ${resp.status}`); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx b/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx index c7d0aeec..d464b872 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx @@ -1,7 +1,8 @@ "use client"; -import { BookOpen, FileText, LayoutGrid, Loader2, Plus } from "lucide-react"; +import { BookOpen, FileText, LayoutGrid, Loader2, Plus, Settings2 } from "lucide-react"; import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; @@ -16,15 +17,17 @@ import { createSearchSpace } from "@/core/studio/api"; import { useStudioNotebooks } from "@/core/studio/hooks"; import { cn } from "@/lib/utils"; -import { useArticleTypes } from "../../hooks/useArticleTypes"; +import { useResearchTemplates } from "../../hooks/useResearchTemplates"; +import type { ResearchTemplate } from "../../api/ai-writing-research-templates"; import { useWritingForm } from "../../contexts/WritingFormContext"; +import { useAIWriting } from "../../contexts/AIWritingContext"; /** * 课题研究左栏(需求 0616 #5)。 - * - 上半「研究模板」= 文章类型(``useArticleTypes``);点击预填表单(设置文章类型 + - * 默认字数 + 展开写作配置),沿用现有 articleType 设置逻辑。 - * - 下半「笔记本」= 我的空间(``useStudioNotebooks``);点击设为当前课题研究的素材来源 - * (materialSource='notebook' + 选中该本)。 + * - 上半「研究模板」= 研究模板实体(``useResearchTemplates``);点击一键预填整张写作表单 + * (文章类型 / 大纲 / 搜索来源 / 字数 / 受众 / 严格模式 / 主题)并展开配置。顶部「配置」 + * 入口进入模板配置页维护这些模板。 + * - 下半「笔记本」= 我的空间(``useStudioNotebooks``);点击在右侧内嵌打开该笔记本页。 */ export function ResearchSidebar({ viewingNotebookId, @@ -38,14 +41,25 @@ export function ResearchSidebar({ /** 点击「所有空间」→ 在右侧内嵌打开空间列表页。 */ onOpenAllSpaces: () => void; }) { - const { articleTypes, refresh } = useArticleTypes(); + const navigate = useNavigate(); + const { templates, refresh } = useResearchTemplates(); + const { startWriting } = useAIWriting(); const { notebooks, isLoading: notebooksLoading, isError: notebooksError, } = useStudioNotebooks(); - const { articleType, setArticleType, setWordCountInput, setFormExpanded } = - useWritingForm(); + const { + setIntent, + setArticleType, + setOutlineText, + setShowOutline, + setMaterialSource, + setWordCountInput, + setAudience, + setStrictMode, + setFormExpanded, + } = useWritingForm(); const queryClient = useQueryClient(); // 新建空间弹窗(与 workspace-nav-studio 同款)。 @@ -78,50 +92,86 @@ export function ResearchSidebar({ } }; - const handleTemplateClick = (key: string, defaultWordCount: number) => { - setArticleType(key); - if (defaultWordCount > 0) setWordCountInput(String(defaultWordCount)); + // 点击研究模板 → ①回填整张写作表单(保持写作态左栏显示与运行配置一致) + // ②直接以模板预设进入 AI 写作流程(startWriting)。 + const handleTemplateClick = (tpl: ResearchTemplate) => { + const intent = tpl.topic?.trim() ?? ""; + const articleType = tpl.article_type_key || "analysis"; + const outline = tpl.outline?.trim() || ""; + const audience = tpl.audience?.trim() || "普通读者"; + 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) { + setOutlineText(outline); + setShowOutline(true); + } + setMaterialSource(tpl.material_source); + setWordCountInput(String(wordCount)); + if (tpl.audience?.trim()) setAudience(audience); + setStrictMode(tpl.strict_mode); setFormExpanded(true); + + // 无主题/意图无法发起写作 → 仅回填表单,由用户补充后手动开始。 + if (!intent) return; + void startWriting({ + userIntent: intent, + userOutline: outline || undefined, + articleType, + targetAudience: audience, + wordCountTarget: wordCount, + keywordCount: 1, + writingMode, + materialSource: tpl.material_source, + }); }; return (