This commit is contained in:
董宏凯 committed 2026-06-16 21:06:22 +08:00
commit 56e17f826c
6 files changed
+333 -251

No files matched your search

@@ -249,13 +249,6 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/positions",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
@@ -28,7 +28,7 @@ import { uuid } from '@/core/utils/uuid';
import { cn } from '@/lib/utils';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext';
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
import { WritingWelcome } from './WritingWelcome';
/**
@@ -42,13 +42,25 @@ import { WritingWelcome } from './WritingWelcome';
*
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 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 [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState('');
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes } = useArticleTypes();
const { applyProposal } = useWritingForm();
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
const formCtx = useWritingFormOptional();
const applyProposal = onApproveProp ?? formCtx?.applyProposal;
// 首个模型可用时默认选中
useEffect(() => {
@@ -98,8 +110,8 @@ export function WritingSetupChat() {
const handleApprove = useCallback(
async (payload: SetupWritingApprovalPayload) => {
applyProposal(payload);
toast.success('已应用到左侧表单,可继续编辑或开始写作');
applyProposal?.(payload);
toast.success('已应用到表单,可继续编辑');
},
[applyProposal],
);
@@ -119,8 +131,8 @@ export function WritingSetupChat() {
{!showWelcome && (
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">写作助手</h2>
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好右侧配置</span>
<h2 className="text-sm font-semibold">{title}</h2>
<span className="text-muted-foreground text-xs">{hint}</span>
</header>
)}
@@ -39,7 +39,7 @@ export function WritingWelcome() {
<div className="flex flex-1 flex-col justify-center gap-6">
<div>
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
欢迎使用「妙笔 AI」智力打字协作工坊
欢迎使用「ai写作」智力打字协作工坊
</h2>
<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 { useArticleTypes } from '../hooks/useArticleTypes';
import type {
CreateResearchTemplateRequest,
ResearchTemplate,
} from '../api/ai-writing-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,
};
}
const NEW_PATH = '/page/canvas/research-templates/new';
const editPath = (id: string) => `/page/canvas/research-templates/edit/${id}`;
export default function ResearchTemplatesConfigPage() {
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 [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);
@@ -101,42 +30,6 @@ export default function ResearchTemplatesConfigPage() {
void 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 () => {
if (!deleteTargetId) return;
setDeleting(true);
@@ -165,7 +58,7 @@ export default function ResearchTemplatesConfigPage() {
</p>
</div>
</div>
<Button size="sm" onClick={openCreate}>
<Button size="sm" onClick={() => navigate(NEW_PATH)}>
<Plus className="size-4" />
新建模板
</Button>
@@ -209,7 +102,12 @@ export default function ResearchTemplatesConfigPage() {
</div>
</div>
<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" />
</Button>
<Button
@@ -227,130 +125,6 @@ export default function ResearchTemplatesConfigPage() {
</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)}>
<DialogContent className="sm:max-w-sm">
@@ -5,6 +5,7 @@ import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
import ResearchTemplateEditorPage from "../pages/ResearchTemplateEditorPage";
export default function OpenCanvasRoutes() {
return (
@@ -13,6 +14,8 @@ export default function OpenCanvasRoutes() {
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<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 />} />
</Routes>
);