Files
happy-life-star/life-script/src/views/ScriptView.jsx
T

443 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;