Files
CamTalk/frontend/src/components/EditScenarioModal/index.tsx
cfy666 1079e22699 feat: 实现自建情景功能
## 功能概述
- 用户可创建、编辑、删除自定义情景
- 支持自定义情景名称、图标、描述、Prompt、首句引导
- 完整的权限隔离,用户只能管理自己的情景
- 深度集成 Eino 框架,动态加载自建情景 Prompt

## 后端实现
### 数据库
- 新增 user_scenarios 表
- 支持用户配额(最多 20 个)
- 字段验证:description 可选,prompt 最小 10 字符

### API
- GET /api/scenarios - 获取用户情景列表
- POST /api/scenarios - 创建情景
- GET /api/scenarios/:id - 获取详情
- PATCH /api/scenarios/:id - 更新情景
- DELETE /api/scenarios/:id - 删除情景

### Eino 集成
- PipelineState 添加 UserID 字段
- nodes_history 动态加载用户自建情景
- GetScenarioPrompt 支持自建情景优先级

## 前端实现
### 组件
- CreateScenarioModal - 创建情景对话框
- EditScenarioModal - 编辑情景对话框
- ConfigPanel 改造 - 分组显示系统预置和自建情景

### Hook
- useScenarios - 合并系统和自建情景,提供 CRUD 接口

### 国际化
- 中文、英文、日文翻译支持

## 问题修复
- 修复 CORS 问题:使用 Vite 代理
- 统一验证规则:description 可选,prompt 最小 10 字符
- 修复数据库约束:使用 NULLIF 处理空字符串

## 文件变更
新增文件: 13 个
修改文件: 14 个

详见文档: docs/自建情景功能完整文档.md
2026-06-21 15:38:28 +08:00

265 lines
8.7 KiB
TypeScript

// ============================================================
// EditScenarioModal — 编辑自建情景对话框
// 职责:提供表单让用户编辑现有的自定义情景
// ============================================================
import { useCallback, useEffect, useRef, useState } from "react";
import { useI18n } from "../../lib/i18n";
import type { UpdateScenarioRequest, UserScenario } from "../../lib/api/scenarios";
interface EditScenarioModalProps {
scenario: UserScenario;
onClose: () => void;
onSubmit: (id: string, data: UpdateScenarioRequest) => Promise<void>;
}
// 预设常用图标
const PRESET_ICONS = [
"✨", "🎭", "🎨", "🎯", "🎪", "🎬",
"📖", "📚", "📝", "📋", "📌", "📍",
"🔬", "🔭", "🔮", "💡", "💼", "💻",
"🎓", "🎤", "🎵", "🎸", "🎹", "🎺",
];
export function EditScenarioModal({ scenario, onClose, onSubmit }: EditScenarioModalProps) {
const { t } = useI18n();
const [name, setName] = useState(scenario.name);
const [icon, setIcon] = useState(scenario.icon);
const [description, setDescription] = useState(scenario.description);
const [prompt, setPrompt] = useState(scenario.prompt);
const [greeting, setGreeting] = useState(scenario.greeting);
const [language, setLanguage] = useState(scenario.language);
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [showGuide, setShowGuide] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
// 点击遮罩关闭
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose();
},
[onClose]
);
// ESC 关闭
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// 阻止 body 滚动
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError("");
// 表单验证
if (name.length < 2 || name.length > 50) {
setError(t("scenario.error.nameLength"));
return;
}
if (prompt.length < 10 || prompt.length > 2000) {
setError(t("scenario.error.promptLength"));
return;
}
if (greeting && greeting.length > 500) {
setError(t("scenario.error.greetingLength"));
return;
}
setIsSubmitting(true);
try {
await onSubmit(scenario.id, {
name,
icon,
description: description || undefined,
prompt,
greeting: greeting || undefined,
language,
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setIsSubmitting(false);
}
},
[scenario.id, name, icon, description, prompt, greeting, language, onSubmit, onClose, t]
);
return (
<div
ref={overlayRef}
className="modal-overlay"
onClick={handleOverlayClick}
>
<div className="modal modal--large" onClick={(e) => e.stopPropagation()}>
<div className="modal__header">
<span className="modal__title">{t("scenario.edit.title")}</span>
<button className="modal__close" onClick={onClose}>
</button>
</div>
<form className="modal__body" onSubmit={handleSubmit}>
{/* 名称 */}
<div className="form-group">
<label className="form-label">
{t("scenario.create.name")} <span className="form-required">*</span>
</label>
<input
type="text"
className="form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("scenario.create.namePlaceholder")}
maxLength={50}
required
/>
<span className="form-hint">{name.length}/50</span>
</div>
{/* 图标选择 */}
<div className="form-group">
<label className="form-label">{t("scenario.create.icon")}</label>
<div className="icon-picker">
{PRESET_ICONS.map((ic) => (
<button
key={ic}
type="button"
className={`icon-picker__item ${icon === ic ? "icon-picker__item--active" : ""}`}
onClick={() => setIcon(ic)}
>
{ic}
</button>
))}
</div>
</div>
{/* 描述 */}
<div className="form-group">
<label className="form-label">{t("scenario.create.description")}</label>
<input
type="text"
className="form-input"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("scenario.create.descriptionPlaceholder")}
maxLength={100}
/>
<span className="form-hint">{description.length}/100</span>
</div>
{/* Prompt */}
<div className="form-group">
<label className="form-label">
{t("scenario.create.prompt")} <span className="form-required">*</span>
<button
type="button"
className="form-guide-btn"
onClick={() => setShowGuide(!showGuide)}
>
{showGuide ? "▼" : "▶"} {t("scenario.create.promptGuide")}
</button>
</label>
{showGuide && (
<div className="form-guide">
<p><strong>{t("scenario.create.promptGuide.tips")}</strong></p>
<ul>
<li>{t("scenario.create.promptGuide.tip1")}</li>
<li>{t("scenario.create.promptGuide.tip2")}</li>
<li>{t("scenario.create.promptGuide.tip3")}</li>
</ul>
<p><strong>{t("scenario.create.promptGuide.example")}</strong></p>
<pre className="form-guide__code">
{`你是一位创意写作导师。
帮助用户构思故事情节、人物设定和写作技巧。
【角色定位】
- 你是导师,不是代笔人
- 激发用户创意,不直接给答案
【交互规则】
1. 提出启发性问题
2. 给出具体、可操作的建议
3. 回答控制在3-5句话`}
</pre>
</div>
)}
<textarea
className="form-textarea"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={t("scenario.create.promptPlaceholder")}
rows={8}
maxLength={2000}
required
/>
<span className="form-hint">{prompt.length}/2000</span>
</div>
{/* 首句引导 */}
<div className="form-group">
<label className="form-label">{t("scenario.create.greeting")}</label>
<textarea
className="form-textarea"
value={greeting}
onChange={(e) => setGreeting(e.target.value)}
placeholder={t("scenario.create.greetingPlaceholder")}
rows={3}
maxLength={500}
/>
<span className="form-hint">{greeting.length}/500</span>
</div>
{/* 语言 */}
<div className="form-group">
<label className="form-label">{t("scenario.create.language")}</label>
<select
className="form-select"
value={language}
onChange={(e) => setLanguage(e.target.value)}
>
<option value="zh-CN"></option>
<option value="en-US">English</option>
<option value="ja-JP"></option>
</select>
</div>
{error && <div className="form-error">{error}</div>}
<div className="modal__footer">
<button
type="button"
className="btn btn--secondary"
onClick={onClose}
disabled={isSubmitting}
>
{t("common.cancel")}
</button>
<button
type="submit"
className="btn btn--primary"
disabled={isSubmitting}
>
{isSubmitting ? t("common.saving") : t("common.save")}
</button>
</div>
</form>
</div>
</div>
);
}