bug修复
This commit is contained in:
1 parent
1ece2c5a4f
commit
44cb118f95
5 files changed
+333
-244
No files matched your search
@@ -28,7 +28,7 @@ import { uuid } from '@/core/utils/uuid';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||||
import { useWritingForm } from '../../contexts/WritingFormContext';
|
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
|
||||||
import { WritingWelcome } from './WritingWelcome';
|
import { WritingWelcome } from './WritingWelcome';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,13 +42,25 @@ import { WritingWelcome } from './WritingWelcome';
|
|||||||
*
|
*
|
||||||
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
|
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
|
||||||
*/
|
*/
|
||||||
export function WritingSetupChat() {
|
export function WritingSetupChat({
|
||||||
|
onApprove: onApproveProp,
|
||||||
|
title = '写作助手',
|
||||||
|
hint = '描述你想写什么,我来帮你填好右侧配置',
|
||||||
|
}: {
|
||||||
|
/** 审批卡「应用」回调;缺省回填写作表单(需 WritingFormProvider)。传入则脱离表单上下文,
|
||||||
|
* 用于复用到「新建研究模板」等场景,把建议字段写进调用方的表单。 */
|
||||||
|
onApprove?: (payload: SetupWritingApprovalPayload) => void;
|
||||||
|
title?: string;
|
||||||
|
hint?: string;
|
||||||
|
} = {}) {
|
||||||
const threadId = useMemo(() => uuid(), []);
|
const threadId = useMemo(() => uuid(), []);
|
||||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||||
const [chatModel, setChatModel] = useState('');
|
const [chatModel, setChatModel] = useState('');
|
||||||
const { models, isLoading: modelsLoading } = useModels();
|
const { models, isLoading: modelsLoading } = useModels();
|
||||||
const { articleTypes } = useArticleTypes();
|
const { articleTypes } = useArticleTypes();
|
||||||
const { applyProposal } = useWritingForm();
|
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
|
||||||
|
const formCtx = useWritingFormOptional();
|
||||||
|
const applyProposal = onApproveProp ?? formCtx?.applyProposal;
|
||||||
|
|
||||||
// 首个模型可用时默认选中
|
// 首个模型可用时默认选中
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,8 +110,8 @@ export function WritingSetupChat() {
|
|||||||
|
|
||||||
const handleApprove = useCallback(
|
const handleApprove = useCallback(
|
||||||
async (payload: SetupWritingApprovalPayload) => {
|
async (payload: SetupWritingApprovalPayload) => {
|
||||||
applyProposal(payload);
|
applyProposal?.(payload);
|
||||||
toast.success('已应用到左侧表单,可继续编辑或开始写作');
|
toast.success('已应用到表单,可继续编辑');
|
||||||
},
|
},
|
||||||
[applyProposal],
|
[applyProposal],
|
||||||
);
|
);
|
||||||
@@ -119,8 +131,8 @@ export function WritingSetupChat() {
|
|||||||
{!showWelcome && (
|
{!showWelcome && (
|
||||||
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||||
<Sparkles className="size-4 text-primary" />
|
<Sparkles className="size-4 text-primary" />
|
||||||
<h2 className="text-sm font-semibold">写作助手</h2>
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好右侧配置</span>
|
<span className="text-muted-foreground text-xs">{hint}</span>
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function WritingWelcome() {
|
|||||||
<div className="flex flex-1 flex-col justify-center gap-6">
|
<div className="flex flex-1 flex-col justify-center gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
|
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
|
||||||
欢迎使用「妙笔 AI」智力打字协作工坊
|
欢迎使用「ai写作」智力打字协作工坊
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
|
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
|
||||||
这是一个专业的自动化写作系统。你只需在下方对话框中
|
这是一个专业的自动化写作系统。你只需在下方对话框中
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { SetupWritingApprovalPayload } from '@/components/workspace/messages';
|
||||||
|
|
||||||
|
import { useResearchTemplates } from '../hooks/useResearchTemplates';
|
||||||
|
import { useArticleTypes } from '../hooks/useArticleTypes';
|
||||||
|
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
|
||||||
|
import type {
|
||||||
|
CreateResearchTemplateRequest,
|
||||||
|
ResearchTemplate,
|
||||||
|
} from '../api/ai-writing-research-templates';
|
||||||
|
|
||||||
|
const LIST_PATH = '/page/canvas/research-templates';
|
||||||
|
|
||||||
|
const INPUT_CLASS =
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ' +
|
||||||
|
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50';
|
||||||
|
const TEXTAREA_CLASS =
|
||||||
|
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ' +
|
||||||
|
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none';
|
||||||
|
const LABEL_CLASS = 'mb-1 block text-xs font-medium text-muted-foreground';
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
article_type_key: string;
|
||||||
|
outline: string;
|
||||||
|
material_source: 'general' | 'notebook';
|
||||||
|
word_count: string;
|
||||||
|
audience: string;
|
||||||
|
strict_mode: boolean;
|
||||||
|
topic: string;
|
||||||
|
sort_order: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormState = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
article_type_key: '',
|
||||||
|
outline: '',
|
||||||
|
material_source: 'general',
|
||||||
|
word_count: '800',
|
||||||
|
audience: '',
|
||||||
|
strict_mode: false,
|
||||||
|
topic: '',
|
||||||
|
sort_order: '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
function toForm(t: ResearchTemplate): FormState {
|
||||||
|
return {
|
||||||
|
name: t.name,
|
||||||
|
description: t.description ?? '',
|
||||||
|
article_type_key: t.article_type_key ?? '',
|
||||||
|
outline: t.outline ?? '',
|
||||||
|
material_source: t.material_source,
|
||||||
|
word_count: String(t.word_count),
|
||||||
|
audience: t.audience ?? '',
|
||||||
|
strict_mode: t.strict_mode,
|
||||||
|
topic: t.topic ?? '',
|
||||||
|
sort_order: String(t.sort_order),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(form: FormState): CreateResearchTemplateRequest {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
article_type_key: form.article_type_key || null,
|
||||||
|
outline: form.outline.trim() || null,
|
||||||
|
material_source: form.material_source,
|
||||||
|
word_count: Math.max(100, parseInt(form.word_count, 10) || 800),
|
||||||
|
audience: form.audience.trim() || null,
|
||||||
|
strict_mode: form.strict_mode,
|
||||||
|
topic: form.topic.trim() || null,
|
||||||
|
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResearchTemplateEditorPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const editing = !!id;
|
||||||
|
|
||||||
|
const { templates, refresh, create, update } = useResearchTemplates();
|
||||||
|
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
|
// 新建即就绪;编辑需等列表加载后回填一次。
|
||||||
|
const [initialized, setInitialized] = useState(!editing);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
void refreshTypes();
|
||||||
|
}, [refresh, refreshTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing || initialized) return;
|
||||||
|
const t = templates.find((x) => x.id === id);
|
||||||
|
if (t) {
|
||||||
|
setForm(toForm(t));
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [editing, initialized, templates, id]);
|
||||||
|
|
||||||
|
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
// 问答助手「应用」→ 把 setup_writing 建议映射进模板表单字段(与 AI 写作同款机制)。
|
||||||
|
const applyProposalToForm = (p: SetupWritingApprovalPayload) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
name: f.name || (p.user_intent?.trim() ? p.user_intent.trim().slice(0, 20) : f.name),
|
||||||
|
topic: p.user_intent?.trim() || f.topic,
|
||||||
|
article_type_key: p.article_type?.trim() || f.article_type_key,
|
||||||
|
outline: p.outline?.trim() || f.outline,
|
||||||
|
word_count:
|
||||||
|
typeof p.word_count_target === 'number' && p.word_count_target > 0
|
||||||
|
? String(p.word_count_target)
|
||||||
|
: f.word_count,
|
||||||
|
audience: p.target_audience?.trim() || f.audience,
|
||||||
|
strict_mode:
|
||||||
|
p.writing_mode === 'strict' ? true : p.writing_mode === 'loose' ? false : f.strict_mode,
|
||||||
|
material_source: p.material_source
|
||||||
|
? p.material_source === 'notebook'
|
||||||
|
? 'notebook'
|
||||||
|
: 'general'
|
||||||
|
: f.material_source,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
setFormError('请填写模板名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
const payload = toPayload(form);
|
||||||
|
if (editing && id) await update(id, payload);
|
||||||
|
else await create(payload);
|
||||||
|
navigate(LIST_PATH);
|
||||||
|
} catch (e) {
|
||||||
|
setFormError(e instanceof Error ? e.message : '保存失败');
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col bg-background">
|
||||||
|
<header className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon-sm" onClick={() => navigate(LIST_PATH)}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-base font-semibold">
|
||||||
|
{editing ? '编辑研究模板' : '新建研究模板'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
左侧用问答描述需求,自动填好右侧字段;也可手动编辑。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{formError && <span className="text-sm text-destructive">{formError}</span>}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(LIST_PATH)} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* 左:模板助手问答(同 AI 写作 setup 机制)→ 应用到右侧表单。问答区更宽(约 7:3)。 */}
|
||||||
|
<div className="min-w-0 flex-[7] border-r border-border">
|
||||||
|
<WritingSetupChat
|
||||||
|
onApprove={applyProposalToForm}
|
||||||
|
title="模板助手"
|
||||||
|
hint="描述你想要的研究模板,我来帮你填好右侧字段"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右:模板表单 */}
|
||||||
|
<div className="grid min-w-0 flex-[3] grid-cols-2 content-start gap-4 overflow-y-auto p-6">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={LABEL_CLASS}>模板名称 *</label>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set('name', e.target.value)}
|
||||||
|
placeholder="如:深度舆情分析"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={LABEL_CLASS}>描述</label>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => set('description', e.target.value)}
|
||||||
|
placeholder="模板用途简述"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>文章类型</label>
|
||||||
|
<select
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.article_type_key}
|
||||||
|
onChange={(e) => set('article_type_key', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">不设</option>
|
||||||
|
{articleTypes.map((t) => (
|
||||||
|
<option key={t.key} value={t.key}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>搜索来源</label>
|
||||||
|
<select
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.material_source}
|
||||||
|
onChange={(e) => set('material_source', e.target.value as 'general' | 'notebook')}
|
||||||
|
>
|
||||||
|
<option value="general">通用检索</option>
|
||||||
|
<option value="notebook">我的空间(笔记本)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>目标字数</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.word_count}
|
||||||
|
onChange={(e) => set('word_count', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>目标受众</label>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.audience}
|
||||||
|
onChange={(e) => set('audience', e.target.value)}
|
||||||
|
placeholder="如:决策者"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={LABEL_CLASS}>主题 / 意图</label>
|
||||||
|
<textarea
|
||||||
|
className={TEXTAREA_CLASS}
|
||||||
|
rows={2}
|
||||||
|
value={form.topic}
|
||||||
|
onChange={(e) => set('topic', e.target.value)}
|
||||||
|
placeholder="点击模板时预填到写作意图输入框"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={LABEL_CLASS}>默认大纲</label>
|
||||||
|
<textarea
|
||||||
|
className={TEXTAREA_CLASS}
|
||||||
|
rows={6}
|
||||||
|
value={form.outline}
|
||||||
|
onChange={(e) => set('outline', e.target.value)}
|
||||||
|
placeholder={'一、…\n二、…\n三、…'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="rt-strict"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.strict_mode}
|
||||||
|
onChange={(e) => set('strict_mode', e.target.checked)}
|
||||||
|
className="size-4"
|
||||||
|
/>
|
||||||
|
<label htmlFor="rt-strict" className="text-sm">
|
||||||
|
严格写作模式
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>排序(小在前)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.sort_order}
|
||||||
|
onChange={(e) => set('sort_order', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,86 +13,15 @@ import {
|
|||||||
|
|
||||||
import { useResearchTemplates } from '../hooks/useResearchTemplates';
|
import { useResearchTemplates } from '../hooks/useResearchTemplates';
|
||||||
import { useArticleTypes } from '../hooks/useArticleTypes';
|
import { useArticleTypes } from '../hooks/useArticleTypes';
|
||||||
import type {
|
|
||||||
CreateResearchTemplateRequest,
|
|
||||||
ResearchTemplate,
|
|
||||||
} from '../api/ai-writing-research-templates';
|
|
||||||
|
|
||||||
const INPUT_CLASS =
|
const NEW_PATH = '/page/canvas/research-templates/new';
|
||||||
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ' +
|
const editPath = (id: string) => `/page/canvas/research-templates/edit/${id}`;
|
||||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50';
|
|
||||||
const TEXTAREA_CLASS =
|
|
||||||
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ' +
|
|
||||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none';
|
|
||||||
const LABEL_CLASS = 'mb-1 block text-xs font-medium text-muted-foreground';
|
|
||||||
|
|
||||||
interface FormState {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
article_type_key: string;
|
|
||||||
outline: string;
|
|
||||||
material_source: 'general' | 'notebook';
|
|
||||||
word_count: string;
|
|
||||||
audience: string;
|
|
||||||
strict_mode: boolean;
|
|
||||||
topic: string;
|
|
||||||
sort_order: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_FORM: FormState = {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
article_type_key: '',
|
|
||||||
outline: '',
|
|
||||||
material_source: 'general',
|
|
||||||
word_count: '800',
|
|
||||||
audience: '',
|
|
||||||
strict_mode: false,
|
|
||||||
topic: '',
|
|
||||||
sort_order: '0',
|
|
||||||
};
|
|
||||||
|
|
||||||
function toForm(t: ResearchTemplate): FormState {
|
|
||||||
return {
|
|
||||||
name: t.name,
|
|
||||||
description: t.description ?? '',
|
|
||||||
article_type_key: t.article_type_key ?? '',
|
|
||||||
outline: t.outline ?? '',
|
|
||||||
material_source: t.material_source,
|
|
||||||
word_count: String(t.word_count),
|
|
||||||
audience: t.audience ?? '',
|
|
||||||
strict_mode: t.strict_mode,
|
|
||||||
topic: t.topic ?? '',
|
|
||||||
sort_order: String(t.sort_order),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPayload(form: FormState): CreateResearchTemplateRequest {
|
|
||||||
return {
|
|
||||||
name: form.name.trim(),
|
|
||||||
description: form.description.trim() || null,
|
|
||||||
article_type_key: form.article_type_key || null,
|
|
||||||
outline: form.outline.trim() || null,
|
|
||||||
material_source: form.material_source,
|
|
||||||
word_count: Math.max(100, parseInt(form.word_count, 10) || 800),
|
|
||||||
audience: form.audience.trim() || null,
|
|
||||||
strict_mode: form.strict_mode,
|
|
||||||
topic: form.topic.trim() || null,
|
|
||||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ResearchTemplatesConfigPage() {
|
export default function ResearchTemplatesConfigPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { templates, loading, error, refresh, create, update, remove } = useResearchTemplates();
|
const { templates, loading, error, refresh, remove } = useResearchTemplates();
|
||||||
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
|
const { articleTypes, refresh: refreshTypes } = 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 [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
@@ -101,42 +30,6 @@ export default function ResearchTemplatesConfigPage() {
|
|||||||
void refreshTypes();
|
void refreshTypes();
|
||||||
}, [refresh, refreshTypes]);
|
}, [refresh, refreshTypes]);
|
||||||
|
|
||||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
|
||||||
setForm((f) => ({ ...f, [key]: value }));
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setEditingId(null);
|
|
||||||
setForm(EMPTY_FORM);
|
|
||||||
setFormError(null);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (t: ResearchTemplate) => {
|
|
||||||
setEditingId(t.id);
|
|
||||||
setForm(toForm(t));
|
|
||||||
setFormError(null);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!form.name.trim()) {
|
|
||||||
setFormError('请填写模板名称');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
setFormError(null);
|
|
||||||
try {
|
|
||||||
const payload = toPayload(form);
|
|
||||||
if (editingId) await update(editingId, payload);
|
|
||||||
else await create(payload);
|
|
||||||
setDialogOpen(false);
|
|
||||||
} catch (e) {
|
|
||||||
setFormError(e instanceof Error ? e.message : '保存失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTargetId) return;
|
if (!deleteTargetId) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
@@ -165,7 +58,7 @@ export default function ResearchTemplatesConfigPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={openCreate}>
|
<Button size="sm" onClick={() => navigate(NEW_PATH)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
新建模板
|
新建模板
|
||||||
</Button>
|
</Button>
|
||||||
@@ -209,7 +102,12 @@ export default function ResearchTemplatesConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(t)} title="编辑">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => navigate(editPath(t.id))}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -227,130 +125,6 @@ export default function ResearchTemplatesConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 新建 / 编辑 弹窗 */}
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={(v) => !saving && setDialogOpen(v)}>
|
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[640px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editingId ? '编辑研究模板' : '新建研究模板'}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid grid-cols-2 gap-4 py-2">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className={LABEL_CLASS}>模板名称 *</label>
|
|
||||||
<input
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => set('name', e.target.value)}
|
|
||||||
placeholder="如:深度舆情分析"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className={LABEL_CLASS}>描述</label>
|
|
||||||
<input
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.description}
|
|
||||||
onChange={(e) => set('description', e.target.value)}
|
|
||||||
placeholder="模板用途简述"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>文章类型</label>
|
|
||||||
<select
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.article_type_key}
|
|
||||||
onChange={(e) => set('article_type_key', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">不设</option>
|
|
||||||
{articleTypes.map((t) => (
|
|
||||||
<option key={t.key} value={t.key}>
|
|
||||||
{t.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>搜索来源</label>
|
|
||||||
<select
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.material_source}
|
|
||||||
onChange={(e) => set('material_source', e.target.value as 'general' | 'notebook')}
|
|
||||||
>
|
|
||||||
<option value="general">通用检索</option>
|
|
||||||
<option value="notebook">我的空间(笔记本)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>目标字数</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.word_count}
|
|
||||||
onChange={(e) => set('word_count', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>目标受众</label>
|
|
||||||
<input
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.audience}
|
|
||||||
onChange={(e) => set('audience', e.target.value)}
|
|
||||||
placeholder="如:决策者"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className={LABEL_CLASS}>主题 / 意图</label>
|
|
||||||
<textarea
|
|
||||||
className={TEXTAREA_CLASS}
|
|
||||||
rows={2}
|
|
||||||
value={form.topic}
|
|
||||||
onChange={(e) => set('topic', e.target.value)}
|
|
||||||
placeholder="点击模板时预填到写作意图输入框"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className={LABEL_CLASS}>默认大纲</label>
|
|
||||||
<textarea
|
|
||||||
className={TEXTAREA_CLASS}
|
|
||||||
rows={4}
|
|
||||||
value={form.outline}
|
|
||||||
onChange={(e) => set('outline', e.target.value)}
|
|
||||||
placeholder={'一、…\n二、…\n三、…'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
id="rt-strict"
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.strict_mode}
|
|
||||||
onChange={(e) => set('strict_mode', e.target.checked)}
|
|
||||||
className="size-4"
|
|
||||||
/>
|
|
||||||
<label htmlFor="rt-strict" className="text-sm">
|
|
||||||
严格写作模式
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={LABEL_CLASS}>排序(小在前)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={form.sort_order}
|
|
||||||
onChange={(e) => set('sort_order', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void handleSave()} disabled={saving}>
|
|
||||||
{saving && <Loader2 className="size-4 animate-spin" />}
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 删除确认 */}
|
{/* 删除确认 */}
|
||||||
<Dialog open={!!deleteTargetId} onOpenChange={(v) => !deleting && !v && setDeleteTargetId(null)}>
|
<Dialog open={!!deleteTargetId} onOpenChange={(v) => !deleting && !v && setDeleteTargetId(null)}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import CanvasThreadPage from "../pages/CanvasThreadPage";
|
|||||||
import AIWritingPage from "../pages/AIWritingPage";
|
import AIWritingPage from "../pages/AIWritingPage";
|
||||||
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
|
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
|
||||||
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
|
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
|
||||||
|
import ResearchTemplateEditorPage from "../pages/ResearchTemplateEditorPage";
|
||||||
|
|
||||||
export default function OpenCanvasRoutes() {
|
export default function OpenCanvasRoutes() {
|
||||||
return (
|
return (
|
||||||
@@ -13,6 +14,8 @@ export default function OpenCanvasRoutes() {
|
|||||||
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
|
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
|
||||||
<Route path="article-types" element={<ArticleTypesConfigPage />} />
|
<Route path="article-types" element={<ArticleTypesConfigPage />} />
|
||||||
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
|
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
|
||||||
|
<Route path="research-templates/new" element={<ResearchTemplateEditorPage />} />
|
||||||
|
<Route path="research-templates/edit/:id" element={<ResearchTemplateEditorPage />} />
|
||||||
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user