202 lines
9.6 KiB
TypeScript
202 lines
9.6 KiB
TypeScript
import { AnimatePresence, motion } from 'motion/react';
|
||
import { useEffect, useRef } from 'react';
|
||
|
||
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
|
||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||
import { PixelCloseButton } from './PixelCloseButton';
|
||
|
||
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>
|
||
<PixelCloseButton
|
||
onClick={onClose}
|
||
label="关闭角色聊天"
|
||
placement="inline"
|
||
/>
|
||
</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>
|
||
);
|
||
}
|