diff --git a/frontend-web/src/open-canvas/api/ai-writing-article-types.ts b/frontend-web/src/open-canvas/api/ai-writing-article-types.ts new file mode 100644 index 00000000..68bddd13 --- /dev/null +++ b/frontend-web/src/open-canvas/api/ai-writing-article-types.ts @@ -0,0 +1,69 @@ +import { getBackendBaseURL } from '@/core/config'; +import { apiFetch } from '@/core/api'; + +export interface ArticleType { + id: string; + key: string; + label: string; + description: string | null; + prompt_hint: string | null; + default_word_count: number; + sort_order: number; + created_at: string; + updated_at: string; +} + +export interface CreateArticleTypeRequest { + key: string; + label: string; + description?: string; + prompt_hint?: string; + default_word_count?: number; + sort_order?: number; +} + +export interface UpdateArticleTypeRequest { + key?: string; + label?: string; + description?: string | null; + prompt_hint?: string | null; + default_word_count?: number; + sort_order?: number; +} + +const BASE = () => `${getBackendBaseURL()}/api/ai-writing/article-types`; + +export async function listArticleTypes(): Promise { + const resp = await apiFetch(BASE()); + if (!resp.ok) throw new Error(`获取文章类型失败: ${resp.status}`); + return resp.json(); +} + +export async function createArticleType(data: CreateArticleTypeRequest): Promise { + const resp = await apiFetch(BASE(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (resp.status === 409) throw new Error(`文章类型标识 "${data.key}" 已存在`); + if (!resp.ok) throw new Error(`创建文章类型失败: ${resp.status}`); + return resp.json(); +} + +export async function updateArticleType(id: string, data: UpdateArticleTypeRequest): Promise { + const resp = await apiFetch(`${BASE()}/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (resp.status === 409) throw new Error(`文章类型标识已存在`); + if (resp.status === 404) throw new Error(`文章类型不存在`); + if (!resp.ok) throw new Error(`更新文章类型失败: ${resp.status}`); + return resp.json(); +} + +export async function deleteArticleType(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/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index 4c860501..3beecdcd 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -14,6 +14,7 @@ import { getBackendBaseURL } from '@/core/config'; import { apiFetch } from '@/core/api'; import type { AIWritingRequest } from '../../types/ai-writing'; +import { useArticleTypes } from '../../hooks/useArticleTypes'; interface AIWritingFormProps { onSubmit: (req: AIWritingRequest) => void; @@ -25,22 +26,46 @@ interface ModelOption { displayName: string; } -const ARTICLE_TYPES: { value: AIWritingRequest['articleType']; label: string }[] = [ - { value: 'analysis', label: '分析报道' }, - { value: 'news', label: '新闻资讯' }, - { value: 'feature', label: '深度特稿' }, - { value: 'report', label: '调研报告' }, -]; +const INPUT_CLASS = + 'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' + + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' + + 'focus-visible:ring-ring disabled:opacity-50'; export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) { const [intent, setIntent] = useState(''); - const [articleType, setArticleType] = - useState('analysis'); + const [articleType, setArticleType] = useState('analysis'); const [audience, setAudience] = useState('普通读者'); - const [wordCount, setWordCount] = useState(800); + const [wordCountInput, setWordCountInput] = useState('800'); const [modelName, setModelName] = useState(''); const [models, setModels] = useState([]); + const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes(); + + // 初始化时加载文章类型 + useEffect(() => { + refreshTypes(); + }, [refreshTypes]); + + // 类型加载完成后:若当前选中值不在列表中则切换为第一项 + useEffect(() => { + if (articleTypes.length === 0) return; + const keys = articleTypes.map((t) => t.key); + if (!keys.includes(articleType)) { + const first = articleTypes[0]; + setArticleType(first.key); + setWordCountInput(String(first.default_word_count)); + } + }, [articleTypes]); // eslint-disable-line react-hooks/exhaustive-deps + + // 切换文章类型时自动填入该类型的默认字数 + function handleArticleTypeChange(key: string) { + setArticleType(key); + const found = articleTypes.find((t) => t.key === key); + if (found) { + setWordCountInput(String(found.default_word_count)); + } + } + useEffect(() => { apiFetch(`${getBackendBaseURL()}/api/models`) .then((r) => r.json()) @@ -57,12 +82,22 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) { } }) .catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + function handleWordCountBlur() { + const num = parseInt(wordCountInput, 10); + if (isNaN(num) || num < 100) { + setWordCountInput('800'); + } else { + setWordCountInput(String(num)); + } + } + function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!intent.trim()) return; + const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800); onSubmit({ userIntent: intent.trim(), articleType, @@ -91,15 +126,15 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) { setWordCount(Number(v))} + + setWordCountInput(e.target.value)} + onBlur={handleWordCountBlur} disabled={disabled} - > - - - - - {[500, 800, 1200, 2000, 3000].map((n) => ( - - {n} 字 - - ))} - - + /> @@ -132,7 +162,7 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) { setAudience(e.target.value)} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index c60296ce..ec63a4c3 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; -import { Plus, RotateCcw, Square } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { Plus, RotateCcw, Settings2, Square } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -35,6 +36,7 @@ const STATUS_LABEL: Record = { }; export function AIWritingPanel() { + const navigate = useNavigate(); const { setArtifact } = useCanvasGraphContext(); const { state, startWriting, submitIntervention, stopWriting, reset } = useAIWriting(); const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions(); @@ -82,6 +84,15 @@ export function AIWritingPanel() { {historySession.draft_title ?? historySession.title}
+ {showNewButton && ( + + + 文章类型配置 + +
+ + {/* 内容区 */} +
+ {loading && ( +

加载中…

+ )} + {!loading && error && ( +

{error}

+ )} + {!loading && !error && articleTypes.length === 0 && ( +
+ +

暂无文章类型

+

点击右上角"新增类型"创建第一个文章类型

+
+ )} + {!loading && articleTypes.length > 0 && ( +
+ {articleTypes.map((t) => ( + openEdit(t)} + onDelete={() => { setDeleteError(null); setDeleteTargetId(t.id); }} + /> + ))} +
+ )} +
+ + {/* 新增 / 编辑对话框 */} + { if (!saving) setDialogOpen(open); }}> + + + {editingId ? '编辑文章类型' : '新增文章类型'} + + +
+ + setField('key', e.target.value)} + disabled={!!editingId} + /> + {editingId && ( +

标识创建后不可修改

+ )} +
+ + + setField('label', e.target.value)} + /> + + + + setField('description', e.target.value)} + /> + + + +