文章类型添加
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user