Files
Genarrative/src/components/CharacterChatModal.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

241 lines
10 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/rpg-runtime-story';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PlatformTextField } from './common/PlatformTextField';
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">
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
>
{modal.target.hp} / {modal.target.maxHp}
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
>
{modal.target.mana} / {modal.target.maxMana}
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="text-xs leading-relaxed text-zinc-400"
>
{modal.target.character.personality}
</PlatformSubpanel>
</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>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="md"
className="text-sm leading-relaxed text-zinc-300"
>
{modal.summary || '你们还没有形成新的私下聊天总结。'}
</PlatformSubpanel>
</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>
))
) : (
<PlatformEmptyState
surface="editorDark"
size="inline"
className="py-6 font-normal leading-relaxed text-zinc-500"
>
</PlatformEmptyState>
)}
</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>
<PlatformActionButton
surface="editorDark"
tone="ghost"
size="xxs"
shape="pill"
onClick={onRefreshSuggestions}
disabled={modal.isLoadingSuggestions || modal.isSending}
>
{modal.isLoadingSuggestions ? '生成中...' : '换一组'}
</PlatformActionButton>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{modal.suggestions.map((suggestion, index) => (
<PlatformDarkOptionCard
key={`${suggestion}-${index}`}
onClick={() => onUseSuggestion(suggestion)}
disabled={modal.isSending}
selected={false}
tone="sky"
radius="md"
padding="sm"
className="text-xs leading-relaxed"
>
{suggestion}
</PlatformDarkOptionCard>
))}
</div>
{modal.error && (
<PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="leading-relaxed"
>
{modal.error}
</PlatformStatusMessage>
)}
<form
className="space-y-3"
onSubmit={event => {
event.preventDefault();
onSendDraft();
}}
>
<PlatformTextField
variant="textarea"
value={modal.draft}
onChange={event => onDraftChange(event.target.value)}
placeholder={`${modal.target.character.name}说点什么...`}
disabled={modal.isSending}
rows={4}
surface="editorDark"
tone="sky"
size="md"
density="roomy"
className="rounded-2xl bg-black/25 leading-relaxed text-zinc-100 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>
);
}