Files
Genarrative/src/components/CharacterChatModal.tsx
高物 c49c64896a
Some checks failed
CI / verify (push) Has been cancelled
初始仓库迁移
2026-04-04 23:57:06 +08:00

204 lines
9.7 KiB
TypeScript
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 { AnimatePresence, motion } from 'motion/react';
import { useEffect, useRef } from 'react';
import type { CharacterChatModalState } from '../hooks/useStoryGeneration';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
interface CharacterChatModalProps {
modal: CharacterChatModalState | null;
onClose: () => void;
onDraftChange: (value: string) => void;
onUseSuggestion: (value: string) => void;
onRefreshSuggestions: () => void;
onSendDraft: () => void;
}
export function CharacterChatModal({
modal,
onClose,
onDraftChange,
onUseSuggestion,
onRefreshSuggestions,
onSendDraft,
}: CharacterChatModalProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!modal || !scrollContainerRef.current) return;
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}, [modal]);
return (
<AnimatePresence>
{modal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[85] flex items-center justify-center bg-black/76 p-3 backdrop-blur-sm sm:p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,56rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-4 sm:px-5">
<div className="min-w-0">
<div className="text-[10px] tracking-[0.22em] text-sky-300/80"></div>
<div className="mt-1 truncate text-sm font-semibold text-white">{modal.target.character.name}</div>
<div className="mt-1 text-[11px] text-zinc-500">
{modal.target.character.title} / {modal.target.roleLabel}
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] sm:overflow-hidden sm:p-5">
<div className="space-y-4 sm:max-h-full sm:overflow-y-auto sm:pr-1">
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-2 text-xs font-bold text-white"></div>
<div className="space-y-2 text-sm text-zinc-300">
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2">
{modal.target.hp} / {modal.target.maxHp}
</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2">
{modal.target.mana} / {modal.target.maxMana}
</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2 text-xs leading-relaxed text-zinc-400">
{modal.target.character.personality}
</div>
</div>
</div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-2 text-xs font-bold text-white"></div>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-3 text-sm leading-relaxed text-zinc-300">
{modal.summary || '你们还没有形成新的私下聊天总结。'}
</div>
</div>
</div>
<div className="flex min-h-0 flex-col">
<div
ref={scrollContainerRef}
className="pixel-nine-slice pixel-panel min-h-[20rem] flex-1 space-y-3 overflow-y-auto pr-1 scrollbar-hide"
style={getNineSliceStyle(UI_CHROME.storyPanel)}
>
{modal.messages.length > 0 ? (
modal.messages.map((message, index) => (
<div
key={`${message.speaker}-${index}-${message.text}`}
className={`flex ${message.speaker === 'player' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[88%] rounded-2xl border px-3 py-2 text-sm leading-relaxed ${
message.speaker === 'player'
? 'rounded-br-none border-sky-400/20 bg-sky-500/10 text-sky-50'
: 'rounded-bl-none border-amber-400/20 bg-amber-500/10 text-amber-50'
}`}
>
<div className="mb-1 text-[10px] tracking-[0.18em] text-zinc-400">
{message.speaker === 'player' ? '你' : modal.target.character.name}
</div>
{message.text || (modal.isSending && message.speaker === 'character' ? '正在回复...' : '...')}
</div>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-white/10 bg-black/18 px-4 py-6 text-sm leading-relaxed text-zinc-500">
</div>
)}
</div>
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold text-white"></div>
<button
type="button"
onClick={onRefreshSuggestions}
disabled={modal.isLoadingSuggestions || modal.isSending}
className={`rounded-full border px-3 py-1 text-[10px] transition-colors ${
modal.isLoadingSuggestions || modal.isSending
? 'border-white/8 bg-black/20 text-zinc-600'
: 'border-white/10 bg-black/20 text-zinc-200 hover:text-white'
}`}
>
{modal.isLoadingSuggestions ? '生成中...' : '换一组'}
</button>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{modal.suggestions.map((suggestion, index) => (
<button
key={`${suggestion}-${index}`}
type="button"
onClick={() => onUseSuggestion(suggestion)}
disabled={modal.isSending}
className={`rounded-xl border px-3 py-2 text-left text-xs leading-relaxed transition ${
modal.isSending
? 'border-white/8 bg-black/20 text-zinc-600'
: 'border-white/8 bg-black/20 text-zinc-200 hover:border-sky-300/30 hover:bg-sky-500/10 hover:text-white'
}`}
>
{suggestion}
</button>
))}
</div>
{modal.error && (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-100">
{modal.error}
</div>
)}
<form
className="space-y-3"
onSubmit={event => {
event.preventDefault();
onSendDraft();
}}
>
<textarea
value={modal.draft}
onChange={event => onDraftChange(event.target.value)}
placeholder={`${modal.target.character.name}说点什么...`}
disabled={modal.isSending}
rows={4}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-100 outline-none transition focus:border-sky-300/35"
/>
<div className="flex justify-end">
<button
type="submit"
disabled={modal.isSending || !modal.draft.trim()}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
modal.isSending || !modal.draft.trim() ? 'text-zinc-600' : 'text-white'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
>
{modal.isSending ? '对话生成中...' : '发送'}
</button>
</div>
</form>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}