文章类型添加

This commit is contained in:
董宏凯 committed 2026-05-17 19:50:52 +08:00
1 parent 918e65d8d3
commit a0a6227368
14 files changed
+1089 -57

No files matched your search

@@ -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<ArticleType[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取文章类型失败: ${resp.status}`);
return resp.json();
}
export async function createArticleType(data: CreateArticleTypeRequest): Promise<ArticleType> {
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<ArticleType> {
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<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (resp.status === 404) throw new Error(`文章类型不存在`);
if (!resp.ok) throw new Error(`删除文章类型失败: ${resp.status}`);
}
@@ -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<AIWritingRequest['articleType']>('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<ModelOption[]>([]);
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) {
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
<Select
value={articleType}
onValueChange={(v) => setArticleType(v as AIWritingRequest['articleType'])}
disabled={disabled}
onValueChange={handleArticleTypeChange}
disabled={disabled || typesLoading}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
</SelectTrigger>
<SelectContent>
{ARTICLE_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value} className="text-xs">
{articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-xs">
{t.label}
</SelectItem>
))}
@@ -108,23 +143,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标字数</label>
<Select
value={String(wordCount)}
onValueChange={(v) => setWordCount(Number(v))}
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
placeholder="800"
value={wordCountInput}
onChange={(e) => setWordCountInput(e.target.value)}
onBlur={handleWordCountBlur}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[500, 800, 1200, 2000, 3000].map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{n} 字
</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
@@ -132,7 +162,7 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className="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"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
@@ -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<string, string> = {
};
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}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建对话
@@ -108,6 +119,15 @@ export function AIWritingPanel() {
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span className="text-sm font-semibold shrink-0">AI 智能写作</span>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
{showNewButton && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
@@ -0,0 +1,49 @@
import { useCallback, useState } from 'react';
import {
listArticleTypes,
createArticleType,
updateArticleType,
deleteArticleType,
} from '../api/ai-writing-article-types';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
export function useArticleTypes() {
const [articleTypes, setArticleTypes] = useState<ArticleType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listArticleTypes();
setArticleTypes(data);
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
const create = useCallback(async (data: CreateArticleTypeRequest): Promise<ArticleType> => {
const created = await createArticleType(data);
setArticleTypes((prev) => [...prev, created].sort((a, b) => a.sort_order - b.sort_order));
return created;
}, []);
const update = useCallback(async (id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> => {
const updated = await updateArticleType(id, data);
setArticleTypes((prev) =>
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
);
return updated;
}, []);
const remove = useCallback(async (id: string): Promise<void> => {
await deleteArticleType(id);
setArticleTypes((prev) => prev.filter((t) => t.id !== id));
}, []);
return { articleTypes, loading, error, refresh, create, update, remove };
}
@@ -0,0 +1,431 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, FileText, Pencil, Plus, Settings2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useArticleTypes } from '../hooks/useArticleTypes';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
// ── 样式常量 ──────────────────────────────────────────────────────────────────
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 disabled:bg-muted';
const TEXTAREA_CLASS =
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring resize-none';
// ── 表单状态类型 ───────────────────────────────────────────────────────────────
interface FormState {
key: string;
label: string;
description: string;
prompt_hint: string;
default_word_count: string;
sort_order: string;
}
const EMPTY_FORM: FormState = {
key: '',
label: '',
description: '',
prompt_hint: '',
default_word_count: '800',
sort_order: '0',
};
// ── 主页面 ────────────────────────────────────────────────────────────────────
export default function ArticleTypesConfigPage() {
const navigate = useNavigate();
const { articleTypes, loading, error, refresh, create, update, remove } = useArticleTypes();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
refresh();
}, [refresh]);
// ── 对话框操作 ──────────────────────────────────────────────────────────────
function openCreate() {
setEditingId(null);
setForm(EMPTY_FORM);
setFormError(null);
setDialogOpen(true);
}
function openEdit(t: ArticleType) {
setEditingId(t.id);
setForm({
key: t.key,
label: t.label,
description: t.description ?? '',
prompt_hint: t.prompt_hint ?? '',
default_word_count: String(t.default_word_count),
sort_order: String(t.sort_order),
});
setFormError(null);
setDialogOpen(true);
}
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
async function handleSave() {
const trimmedKey = form.key.trim();
const trimmedLabel = form.label.trim();
const wordCount = parseInt(form.default_word_count, 10);
if (!trimmedKey) { setFormError('标识不能为空'); return; }
if (!trimmedLabel) { setFormError('名称不能为空'); return; }
if (isNaN(wordCount) || wordCount < 100) { setFormError('默认字数不能小于 100'); return; }
setSaving(true);
setFormError(null);
try {
if (editingId) {
const req: UpdateArticleTypeRequest = {
label: trimmedLabel,
description: form.description.trim() || null,
prompt_hint: form.prompt_hint.trim() || null,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await update(editingId, req);
} else {
const req: CreateArticleTypeRequest = {
key: trimmedKey,
label: trimmedLabel,
description: form.description.trim() || undefined,
prompt_hint: form.prompt_hint.trim() || undefined,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await create(req);
}
setDialogOpen(false);
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
// ── 删除操作 ────────────────────────────────────────────────────────────────
async function handleDelete() {
if (!deleteTargetId) return;
setDeleting(true);
setDeleteError(null);
try {
await remove(deleteTargetId);
setDeleteTargetId(null);
} catch (e) {
setDeleteError(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleting(false);
}
}
// ── 渲染 ────────────────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
{/* 页面头部 */}
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => navigate(-1)}
title="返回"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<Settings2 className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold flex-1">文章类型配置</span>
<Button size="sm" className="h-7 px-3 text-xs gap-1.5" onClick={openCreate}>
<Plus className="size-3.5" />
新增类型
</Button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-4">
{loading && (
<p className="text-sm text-muted-foreground text-center py-12">加载中…</p>
)}
{!loading && error && (
<p className="text-sm text-destructive text-center py-12">{error}</p>
)}
{!loading && !error && articleTypes.length === 0 && (
<div className="text-center py-12 space-y-2">
<Settings2 className="h-8 w-8 mx-auto text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无文章类型</p>
<p className="text-xs text-muted-foreground">点击右上角"新增类型"创建第一个文章类型</p>
</div>
)}
{!loading && articleTypes.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{articleTypes.map((t) => (
<ArticleTypeCard
key={t.id}
type={t}
onEdit={() => openEdit(t)}
onDelete={() => { setDeleteError(null); setDeleteTargetId(t.id); }}
/>
))}
</div>
)}
</div>
{/* 新增 / 编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!saving) setDialogOpen(open); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? '编辑文章类型' : '新增文章类型'}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1">
<FormField label="标识 (key)" required>
<input
className={INPUT_CLASS}
placeholder="如 news、analysis(传给 AI 的唯一标识,仅限字母数字下划线)"
value={form.key}
onChange={(e) => setField('key', e.target.value)}
disabled={!!editingId}
/>
{editingId && (
<p className="text-[11px] text-muted-foreground mt-0.5">标识创建后不可修改</p>
)}
</FormField>
<FormField label="名称" required>
<input
className={INPUT_CLASS}
placeholder="如 新闻资讯、深度特稿"
value={form.label}
onChange={(e) => setField('label', e.target.value)}
/>
</FormField>
<FormField label="说明">
<input
className={INPUT_CLASS}
placeholder="简短描述,帮助用户了解该类型的适用场景"
value={form.description}
onChange={(e) => setField('description', e.target.value)}
/>
</FormField>
<FormField label="写作风格提示词">
<textarea
className={TEXTAREA_CLASS}
rows={3}
placeholder="传入 AI 的写作风格说明,如:以新闻报道风格写作,注重 5W1H 要素,语言简洁客观…"
value={form.prompt_hint}
onChange={(e) => setField('prompt_hint', e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="默认字数(字)" required>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
value={form.default_word_count}
onChange={(e) => setField('default_word_count', e.target.value)}
/>
</FormField>
<FormField label="排序权重">
<input
type="number"
min={0}
className={INPUT_CLASS}
placeholder="0"
value={form.sort_order}
onChange={(e) => setField('sort_order', e.target.value)}
/>
</FormField>
</div>
{formError && (
<p className="text-xs text-destructive">{formError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(false)}
disabled={saving}
>
取消
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog
open={deleteTargetId !== null}
onOpenChange={(open) => { if (!deleting && !open) setDeleteTargetId(null); }}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<div className="py-2 space-y-2">
<p className="text-sm text-muted-foreground">
删除后不可恢复。已使用该类型创建的历史写作不受影响,但新写作将无法选择此类型。
</p>
{deleteError && (
<p className="text-xs text-destructive">{deleteError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTargetId(null)}
disabled={deleting}
>
取消
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? '删除中…' : '确认删除'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── 文章类型卡片 ──────────────────────────────────────────────────────────────
function ArticleTypeCard({
type,
onEdit,
onDelete,
}: {
type: ArticleType;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div className="rounded-lg border bg-card p-4 flex flex-col gap-2.5 hover:border-primary/30 transition-colors">
{/* 标题行 */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">{type.label}</span>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-muted text-muted-foreground border">
{type.key}
</span>
</div>
{type.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{type.description}</p>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onEdit}
title="编辑"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive/70 hover:text-destructive"
onClick={onDelete}
title="删除"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 写作提示词预览 */}
{type.prompt_hint && (
<div className="rounded-md bg-muted/50 px-2.5 py-2">
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{type.prompt_hint}
</p>
</div>
)}
{/* 底部元信息 */}
<div className="flex items-center gap-3 pt-0.5 border-t">
<span className="text-[11px] text-muted-foreground flex items-center gap-1 pt-2">
<FileText className="h-3 w-3" />
默认 {type.default_word_count} 字
</span>
<span className="text-[11px] text-muted-foreground pt-2">
排序 {type.sort_order}
</span>
</div>
</div>
);
}
// ── 表单字段包装 ──────────────────────────────────────────────────────────────
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
</div>
);
}
@@ -3,12 +3,14 @@ import { Route, Routes } from "react-router-dom";
import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
export default function OpenCanvasRoutes() {
return (
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes>
);
@@ -124,7 +124,7 @@ export interface UserInterventionPayload {
export interface AIWritingRequest {
userIntent: string;
articleType: 'news' | 'analysis' | 'feature' | 'report';
articleType: string;
targetAudience: string;
wordCountTarget: number;
modelName?: string;