443 lines
16 KiB
React
443 lines
16 KiB
React
import { useState, useEffect } from 'react';
|
||
import { UserCog, PenTool, Sparkles, BookOpen, Loader2, Pencil, Trash2 } from 'lucide-react';
|
||
import { GlassCard, GlassButton, GlassInput, GlassSelect } from '../components/ui';
|
||
import Modal from '../components/Modal';
|
||
import useStore from '../store/useStore';
|
||
import { scriptStyles, scriptLengths } from '../utils/constants';
|
||
import { generateEpicScript } from '../services/ai';
|
||
import useTypewriterStream from '../hooks/useTypewriterStream';
|
||
|
||
/**
|
||
* ScriptView 组件
|
||
* 爽文剧本视图,包含角色设定、创作需求和剧本展示
|
||
* @param {Object} props
|
||
* @param {Function} props.onOpenProfile - 打开用户资料模态框回调
|
||
*/
|
||
const ScriptView = ({ onOpenProfile }) => {
|
||
const {
|
||
registrationData,
|
||
lifeEvents,
|
||
scripts,
|
||
selectedScriptId,
|
||
addScript,
|
||
updateScript,
|
||
deleteScript,
|
||
setSelectedScriptId,
|
||
getSelectedScript,
|
||
loadScripts
|
||
} = useStore();
|
||
|
||
// 加载剧本列表
|
||
useEffect(() => {
|
||
loadScripts().catch(() => {
|
||
// 后端不可用时忽略错误
|
||
});
|
||
}, [loadScripts]);
|
||
|
||
// 表单状态
|
||
const [theme, setTheme] = useState('');
|
||
const [style, setStyle] = useState(scriptStyles[0].value);
|
||
const [length, setLength] = useState(scriptLengths[0].value);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [streamContent, setStreamContent] = useState('');
|
||
const scriptWriter = useTypewriterStream({ interval: 30, step: 1 });
|
||
|
||
// 编辑模态框状态
|
||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||
const [editingScript, setEditingScript] = useState(null);
|
||
const [editForm, setEditForm] = useState({
|
||
theme: '',
|
||
style: '',
|
||
length: ''
|
||
});
|
||
|
||
// 删除确认状态
|
||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||
|
||
/**
|
||
* 处理剧本生成
|
||
*/
|
||
const handleGenerate = async () => {
|
||
if (!theme) {
|
||
alert('请输入主题');
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
scriptWriter.reset();
|
||
|
||
try {
|
||
setStreamContent('');
|
||
const content = await generateEpicScript(
|
||
{ theme, style, length, character: registrationData },
|
||
lifeEvents,
|
||
{
|
||
onDelta: (_delta, output) => {
|
||
setStreamContent(output);
|
||
scriptWriter.push(output);
|
||
}
|
||
}
|
||
);
|
||
scriptWriter.finish(content);
|
||
await scriptWriter.waitForDone();
|
||
await addScript({
|
||
theme,
|
||
style,
|
||
length,
|
||
content,
|
||
character: registrationData,
|
||
events: lifeEvents
|
||
});
|
||
|
||
setTheme('');
|
||
setStreamContent('');
|
||
} catch (error) {
|
||
scriptWriter.fail('生成失败,请稍后重试');
|
||
console.error('Failed to generate script:', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 打开编辑模态框
|
||
* @param {Object} script - 要编辑的剧本
|
||
*/
|
||
const openEditModal = (script) => {
|
||
setEditingScript(script);
|
||
setEditForm({
|
||
theme: script.theme || '',
|
||
style: script.style || scriptStyles[0].value,
|
||
length: script.length || scriptLengths[0].value
|
||
});
|
||
setIsEditModalOpen(true);
|
||
};
|
||
|
||
/**
|
||
* 关闭编辑模态框
|
||
*/
|
||
const closeEditModal = () => {
|
||
setIsEditModalOpen(false);
|
||
setEditingScript(null);
|
||
setEditForm({ theme: '', style: '', length: '' });
|
||
};
|
||
|
||
/**
|
||
* 处理编辑提交
|
||
*/
|
||
const handleEditSubmit = async () => {
|
||
if (!editForm.theme) {
|
||
alert('请输入主题');
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
scriptWriter.reset();
|
||
|
||
try {
|
||
setStreamContent('');
|
||
const content = await generateEpicScript(
|
||
{ theme: editForm.theme, style: editForm.style, length: editForm.length, character: registrationData },
|
||
lifeEvents,
|
||
{
|
||
onDelta: (_delta, output) => {
|
||
setStreamContent(output);
|
||
scriptWriter.push(output);
|
||
}
|
||
}
|
||
);
|
||
scriptWriter.finish(content);
|
||
await scriptWriter.waitForDone();
|
||
await updateScript({
|
||
id: editingScript.id,
|
||
theme: editForm.theme,
|
||
style: editForm.style,
|
||
length: editForm.length,
|
||
content,
|
||
character: registrationData,
|
||
events: lifeEvents,
|
||
regenerateContent: false
|
||
});
|
||
closeEditModal();
|
||
setStreamContent('');
|
||
} catch (error) {
|
||
scriptWriter.fail('生成失败,请稍后重试');
|
||
console.error('Failed to update script:', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 处理删除确认
|
||
* @param {string} id - 剧本ID
|
||
*/
|
||
const handleDeleteConfirm = async (id) => {
|
||
try {
|
||
await deleteScript(id);
|
||
setDeleteConfirmId(null);
|
||
} catch (error) {
|
||
console.error('Failed to delete script:', error);
|
||
alert('删除失败,请稍后重试');
|
||
setDeleteConfirmId(null);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 格式化剧本内容,高亮【标题】
|
||
*/
|
||
const formatScriptContent = (content) => {
|
||
if (!content) return '';
|
||
return content.replace(
|
||
/【([^】]+)】/g,
|
||
'<div class="mt-8 mb-4 text-orange-100 font-bold text-lg border-l-2 border-orange-400 pl-4">【$1】</div>'
|
||
);
|
||
};
|
||
|
||
const selectedScript = getSelectedScript();
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||
{/* 左侧面板 */}
|
||
<div className="lg:col-span-4 space-y-6">
|
||
{/* 角色设定卡片 */}
|
||
<GlassCard className="border-white/10 space-y-4">
|
||
<div className="flex items-center gap-2 pb-2 border-b border-white/5">
|
||
<UserCog className="w-4 h-4 text-orange-200" />
|
||
<h4 className="text-sm font-bold tracking-widest text-white/80 uppercase">角色设定</h4>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-x-2 gap-y-4 text-[11px]">
|
||
<div>
|
||
<label className="text-white/20 block">昵称</label>
|
||
<span className="text-white/70">{registrationData.nickname || '-'}</span>
|
||
</div>
|
||
<div>
|
||
<label className="text-white/20 block">星座</label>
|
||
<span className="text-white/70">{registrationData.zodiac || '-'}</span>
|
||
</div>
|
||
<div>
|
||
<label className="text-white/20 block">MBTI</label>
|
||
<span className="text-white/70">{registrationData.mbti || '-'}</span>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className="text-white/20 block">兴趣爱好</label>
|
||
<span className="text-white/70">
|
||
{registrationData.hobbies?.join(', ') || '-'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={onOpenProfile}
|
||
className="w-full py-2 text-[10px] text-orange-200/50 hover:text-orange-200 border border-white/5 rounded-xl transition-all"
|
||
>
|
||
修改人设
|
||
</button>
|
||
</GlassCard>
|
||
|
||
{/* 创作需求表单 */}
|
||
<GlassCard className="border-white/10 space-y-6">
|
||
<div className="flex items-center gap-2 pb-2 border-b border-white/5">
|
||
<PenTool className="w-4 h-4 text-orange-200" />
|
||
<h4 className="text-sm font-bold tracking-widest text-white/80 uppercase">创作需求</h4>
|
||
</div>
|
||
<GlassInput
|
||
label="剧本主题"
|
||
placeholder="例如:我在职场逆袭了"
|
||
value={theme}
|
||
onChange={setTheme}
|
||
/>
|
||
<GlassSelect
|
||
label="叙事风格"
|
||
options={scriptStyles}
|
||
value={style}
|
||
onChange={setStyle}
|
||
/>
|
||
<GlassSelect
|
||
label="剧本篇幅"
|
||
options={scriptLengths}
|
||
value={length}
|
||
onChange={setLength}
|
||
/>
|
||
<GlassButton
|
||
onClick={handleGenerate}
|
||
disabled={isLoading}
|
||
className="w-full py-4 bg-orange-200/5 text-orange-200 font-bold text-sm tracking-widest border-orange-200/20"
|
||
>
|
||
{isLoading ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
编撰中...
|
||
</>
|
||
) : (
|
||
'生成爽文人生'
|
||
)}
|
||
</GlassButton>
|
||
</GlassCard>
|
||
|
||
{/* 历史卷轴列表 */}
|
||
<div className="space-y-4">
|
||
<h5 className="text-[10px] text-white/20 uppercase tracking-widest font-bold px-2">
|
||
历史卷轴
|
||
</h5>
|
||
<div className="space-y-2 max-h-[25vh] overflow-y-auto custom-scrollbar">
|
||
{scripts.length > 0 ? (
|
||
scripts.map((script) => (
|
||
<div
|
||
key={script.id}
|
||
className={`
|
||
p-3 glass-card text-left cursor-pointer hover:bg-white/5 border-white/5 transition-all relative group
|
||
${script.id === selectedScriptId ? 'border-orange-200/30 bg-orange-200/5' : ''}
|
||
`}
|
||
>
|
||
{/* 操作按钮 */}
|
||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); openEditModal(script); }}
|
||
className="p-1.5 rounded-full bg-white/5 hover:bg-orange-200/10 text-white/30 hover:text-orange-200 transition-all"
|
||
title="编辑"
|
||
>
|
||
<Pencil className="w-3 h-3" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setDeleteConfirmId(script.id); }}
|
||
className="p-1.5 rounded-full bg-white/5 hover:bg-red-400/10 text-white/30 hover:text-red-400 transition-all"
|
||
title="删除"
|
||
>
|
||
<Trash2 className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
|
||
<div onClick={() => setSelectedScriptId(script.id)}>
|
||
<div className="text-[11px] text-white/80 truncate pr-12">{script.theme}</div>
|
||
<div className="text-[9px] text-white/30 flex justify-between mt-1">
|
||
<span>{script.style} | {script.length}</span>
|
||
<span>{script.date}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-center text-xs text-white/10 py-4 italic">暂无卷轴</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧剧本展示区 */}
|
||
<div className="lg:col-span-8">
|
||
<div className="h-full">
|
||
{isLoading ? (
|
||
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in" padding="lg">
|
||
<div className="prose prose-invert max-w-none">
|
||
<div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5">
|
||
<div>
|
||
<h4 className="text-2xl font-serif text-orange-200">{theme || editForm.theme}</h4>
|
||
<p className="text-[10px] text-white/40 mt-1 tracking-widest">
|
||
{scriptWriter.isWaiting ? '正在理解你的创作目标' : scriptWriter.isDraining ? '正在收束最后一句' : '正在逐字生成剧本'}
|
||
</p>
|
||
</div>
|
||
<Loader2 className="text-orange-200/60 animate-spin" />
|
||
</div>
|
||
<div className="text-white/70 leading-loose whitespace-pre-wrap space-y-6 text-sm">
|
||
{scriptWriter.visibleText || '故事正在生成,请稍候...'}
|
||
{(scriptWriter.isStreaming || scriptWriter.isDraining) && <span className="text-orange-200 animate-pulse">|</span>}
|
||
</div>
|
||
</div>
|
||
</GlassCard>
|
||
) : selectedScript ? (
|
||
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in relative group" padding="lg">
|
||
{/* 编辑按钮 */}
|
||
<button
|
||
onClick={() => openEditModal(selectedScript)}
|
||
className="absolute top-4 right-4 p-2 rounded-full bg-white/5 hover:bg-orange-200/10 text-white/30 hover:text-orange-200 transition-all opacity-0 group-hover:opacity-100"
|
||
title="修改剧本"
|
||
>
|
||
<Pencil className="w-4 h-4" />
|
||
</button>
|
||
|
||
<div className="prose prose-invert max-w-none">
|
||
<div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5 pr-10">
|
||
<div>
|
||
<h4 className="text-2xl font-serif text-orange-200">{selectedScript.theme}</h4>
|
||
<p className="text-[10px] text-white/30 mt-1 uppercase tracking-widest">
|
||
{selectedScript.style}篇 · {selectedScript.length}卷
|
||
</p>
|
||
</div>
|
||
<BookOpen className="text-white/20" />
|
||
</div>
|
||
<div
|
||
className="text-white/70 leading-loose whitespace-pre-wrap space-y-6 text-sm"
|
||
dangerouslySetInnerHTML={{ __html: formatScriptContent(selectedScript.content) }}
|
||
/>
|
||
</div>
|
||
</GlassCard>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center h-full text-center opacity-20 py-32">
|
||
<Sparkles className="w-20 h-20 mb-6" />
|
||
<p className="text-xl font-serif">请在左侧设定需求,开启你的爽文人生</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 编辑剧本模态框 */}
|
||
<Modal isOpen={isEditModalOpen} onClose={closeEditModal} title="修改剧本">
|
||
<div className="space-y-6">
|
||
<GlassInput
|
||
label="剧本主题"
|
||
placeholder="例如:我在职场逆袭了"
|
||
value={editForm.theme}
|
||
onChange={(v) => setEditForm(prev => ({ ...prev, theme: v }))}
|
||
/>
|
||
<GlassSelect
|
||
label="叙事风格"
|
||
options={scriptStyles}
|
||
value={editForm.style}
|
||
onChange={(v) => setEditForm(prev => ({ ...prev, style: v }))}
|
||
/>
|
||
<GlassSelect
|
||
label="剧本篇幅"
|
||
options={scriptLengths}
|
||
value={editForm.length}
|
||
onChange={(v) => setEditForm(prev => ({ ...prev, length: v }))}
|
||
/>
|
||
<GlassButton
|
||
variant="primary"
|
||
onClick={handleEditSubmit}
|
||
loading={isLoading}
|
||
className="w-full"
|
||
>
|
||
{isLoading ? '正在重新编撰...' : '重新生成剧本'}
|
||
</GlassButton>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 删除确认模态框 */}
|
||
<Modal isOpen={!!deleteConfirmId} onClose={() => setDeleteConfirmId(null)} title="确认删除" maxWidth="sm">
|
||
<div className="space-y-6">
|
||
<p className="text-white/70 text-sm">
|
||
确定要删除这个剧本吗?此操作不可恢复,关联的实现路径也将被删除。
|
||
</p>
|
||
<div className="flex gap-4">
|
||
<GlassButton
|
||
onClick={() => setDeleteConfirmId(null)}
|
||
className="flex-1"
|
||
>
|
||
取消
|
||
</GlassButton>
|
||
<GlassButton
|
||
variant="primary"
|
||
onClick={() => handleDeleteConfirm(deleteConfirmId)}
|
||
className="flex-1 bg-red-500/10 text-red-400 border-red-400/20 hover:bg-red-500/20"
|
||
>
|
||
确认删除
|
||
</GlassButton>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ScriptView;
|