Files
author/app/components/SettingsItemEditor.js

618 lines
31 KiB
JavaScript
Raw Permalink 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.
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useI18n } from '../lib/useI18n';
// ==================== 分类配色 ====================
const CATEGORY_COLORS = {
character: { color: 'var(--cat-character)', bg: 'var(--cat-character-bg)' },
location: { color: 'var(--cat-location)', bg: 'var(--cat-location-bg)' },
world: { color: 'var(--cat-world)', bg: 'var(--cat-world-bg)' },
object: { color: 'var(--cat-object)', bg: 'var(--cat-object-bg)' },
plot: { color: 'var(--cat-plot)', bg: 'var(--cat-plot-bg)' },
rules: { color: 'var(--cat-rules)', bg: 'var(--cat-rules-bg)' },
custom: { color: 'var(--cat-custom)', bg: 'var(--cat-custom-bg)' },
};
// ==================== 通用字段组件 ====================
function TextField({ label, value, onChange, placeholder, multiline = false, rows = 3, aiBtn = false }) {
const { t } = useI18n();
const [localValue, setLocalValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const isComposingRef = useRef(false);
const timerRef = useRef(null);
const onChangeRef = useRef(onChange);
const localValueRef = useRef(localValue);
onChangeRef.current = onChange;
// 同步外部 prop 变化(切换节点时)—— 仅在外部值真正不同时才更新
// 避免 debounce flush 后父组件回传相同值导致光标跳转
// 并且在 IME 输入法组字期间不同步,防止打断组字
useEffect(() => {
if (!isComposingRef.current && (value || '') !== localValueRef.current) {
setLocalValue(value || '');
localValueRef.current = value || '';
}
}, [value]);
// 组件卸载时 flush 未保存的更改
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
onChangeRef.current(localValueRef.current);
}
};
}, []);
// 防抖刷新:始终使用 localValueRef.current(最新值),而非捕获时的旧值
// 如果正在 IME 组字中,跳过本次 flush,compositionEnd 会重新触发
const scheduleFlush = useCallback(() => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (isComposingRef.current) return; // IME 组字中不 flush
timerRef.current = null;
onChangeRef.current(localValueRef.current);
}, 500);
}, []);
const handleChange = useCallback((e) => {
const newVal = e.target.value;
setLocalValue(newVal);
localValueRef.current = newVal;
if (!isComposingRef.current) {
scheduleFlush();
}
}, [scheduleFlush]);
const handleCompositionStart = useCallback(() => {
isComposingRef.current = true;
}, []);
const handleCompositionEnd = useCallback((e) => {
isComposingRef.current = false;
// compositionend 之后用最新值触发防抖
const newVal = e.target.value;
setLocalValue(newVal);
localValueRef.current = newVal;
scheduleFlush();
}, [scheduleFlush]);
const handleBlur = useCallback((e) => {
e.target.style.borderColor = 'var(--border-light)';
// 失焦时立即 flush,防止切换节点丢数据
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
onChangeRef.current(localValueRef.current);
}
}, []);
// 关闭展开模态框时立即 flush
const handleCloseExpand = useCallback(() => {
setIsExpanded(false);
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
onChangeRef.current(localValueRef.current);
}, []);
const inputProps = {
value: localValue,
onChange: handleChange,
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
onFocus: e => e.target.style.borderColor = 'var(--accent)',
onBlur: handleBlur,
placeholder,
};
return (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)' }}>{label}</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{multiline && (
<button
className="field-expand-btn"
title="展开编辑"
onClick={() => setIsExpanded(true)}
style={{
border: 'none', background: 'transparent', cursor: 'pointer',
color: 'var(--text-muted)', fontSize: 13, padding: '2px 4px',
borderRadius: 4, transition: 'all 0.15s', display: 'flex',
alignItems: 'center', justifyContent: 'center',
}}
onMouseEnter={e => { e.target.style.color = 'var(--accent)'; e.target.style.background = 'var(--bg-hover)'; }}
onMouseLeave={e => { e.target.style.color = 'var(--text-muted)'; e.target.style.background = 'transparent'; }}
>⛶</button>
)}
{aiBtn && (
<button className="field-ai-btn" title={t('settingsEditor.aiFill')}>✦</button>
)}
</div>
</div>
{multiline ? (
<textarea
{...inputProps}
rows={rows}
style={{
width: '100%', padding: '8px 12px', border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)', background: 'var(--bg-primary)', color: 'var(--text-primary)',
fontSize: 13, fontFamily: 'var(--font-ui)', resize: 'vertical', outline: 'none',
lineHeight: 1.6, transition: 'border-color 0.15s',
}}
/>
) : (
<input
type="text"
{...inputProps}
style={{
width: '100%', padding: '8px 12px', border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)', background: 'var(--bg-primary)', color: 'var(--text-primary)',
fontSize: 13, fontFamily: 'var(--font-ui)', outline: 'none', transition: 'border-color 0.15s',
}}
/>
)}
{/* 展开编辑浮窗 */}
{isExpanded && (
<div
className="field-expand-overlay"
onClick={handleCloseExpand}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn 0.2s ease',
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 24px 80px rgba(0,0,0,0.25), 0 0 0 1px rgba(255,255,255,0.06)',
width: '80%', maxWidth: 900, maxHeight: '85vh',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
animation: 'settingsSlideUp 0.3s cubic-bezier(0.16,1,0.3,1)',
}}
>
<div style={{
padding: '16px 20px', borderBottom: '1px solid var(--border-light)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--bg-secondary)',
}}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
✏️ {label}
</span>
<button
onClick={handleCloseExpand}
className="btn btn-ghost btn-icon"
>✕</button>
</div>
<div style={{ flex: 1, padding: 16, overflow: 'hidden', display: 'flex' }}>
<textarea
value={localValue}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
placeholder={placeholder}
autoFocus
style={{
width: '100%', height: '100%', minHeight: '60vh',
padding: '12px 16px', border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-md)', background: 'var(--bg-primary)',
color: 'var(--text-primary)', fontSize: 14, fontFamily: 'var(--font-ui)',
resize: 'none', outline: 'none', lineHeight: 1.8,
transition: 'border-color 0.15s',
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-light)'}
/>
</div>
</div>
</div>
)}
</div>
);
}
function ButtonGroup({ label, value, options, onChange }) {
return (
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>{label}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{options.map(opt => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
style={{
padding: '5px 12px', borderRadius: 16, fontSize: 12, border: '1px solid var(--border-light)',
background: value === opt.value ? 'var(--accent)' : 'transparent',
color: value === opt.value ? 'var(--text-inverse)' : 'var(--text-secondary)',
cursor: 'pointer', transition: 'all 0.15s', fontFamily: 'var(--font-ui)',
}}
>
{opt.label}
</button>
))}
</div>
</div>
);
}
// ==================== 字段分组折叠 ====================
function FieldGroup({ title, icon, children, defaultCollapsed = false }) {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
return (
<div className={`field-group ${collapsed ? 'collapsed' : ''}`}>
<div className="field-group-header" onClick={() => setCollapsed(!collapsed)}>
<h4>{icon && <span>{icon}</span>}{title}</h4>
<span className="field-group-chevron">▼</span>
</div>
<div className="field-group-content">
{children}
</div>
</div>
);
}
// ==================== AI 生成的额外字段 ====================
function ExtraFieldsSection({ content, knownFields, onUpdate }) {
const { t } = useI18n();
const extraKeys = Object.keys(content || {}).filter(k => !knownFields.includes(k) && content[k]);
if (extraKeys.length === 0) return null;
return (
<FieldGroup title={t('settingsEditor.aiExtraFields')} icon="✨" defaultCollapsed>
{extraKeys.map(k => (
<TextField
key={k}
label={k}
value={content[k]}
onChange={v => onUpdate(k, v)}
placeholder=""
multiline
/>
))}
</FieldGroup>
);
}
// ==================== 角色卡片预览 ====================
function CharacterCardPreview({ name, content }) {
const { t } = useI18n();
const c = content || {};
const catColor = CATEGORY_COLORS.character;
const roleLabels = {
protagonist: t('settingsEditor.roles.protagonist'),
antagonist: t('settingsEditor.roles.antagonist'),
supporting: t('settingsEditor.roles.supporting'),
minor: t('settingsEditor.roles.minor')
};
const roleLabel = roleLabels[c.role] || c.role || t('settingsEditor.charRole');
// 头像文字:取名字第一个字
const avatarChar = (name || t('settingsEditor.unnamedChar'))[0];
return (
<div className="character-card-preview" style={{ background: catColor.bg, color: catColor.color, border: `1px solid ${catColor.color}20` }}>
<div className="character-card-header">
<div className="character-card-avatar" style={{ background: `linear-gradient(135deg, ${catColor.color}, ${catColor.color}cc)` }}>
{avatarChar}
</div>
<div className="character-card-info">
<div className="character-card-name">{name || t('settingsEditor.unnamedChar')}</div>
<span className="character-card-role" style={{ background: `${catColor.color}18`, color: catColor.color }}>
{roleLabel}
</span>
</div>
</div>
<div className="character-card-quickinfo">
{c.gender && <span className="info-item"><span className="info-label">{t('settingsEditor.infoGender')}</span>{c.gender}</span>}
{c.age && <span className="info-item"><span className="info-label">{t('settingsEditor.infoAge')}</span>{c.age}</span>}
{c.personality && <span className="info-item"><span className="info-label">{t('settingsEditor.infoPersonality')}</span>{c.personality.length > 20 ? c.personality.slice(0, 20) + '…' : c.personality}</span>}
</div>
</div>
);
}
// ==================== 各分类编辑器 ====================
function CharacterEditor({ node, onUpdate }) {
const { t } = useI18n();
const content = node.content || {};
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
return (
<div>
<CharacterCardPreview name={node.name} content={content} />
<FieldGroup title={t('settingsEditor.tabBasic')} icon="📋">
<ButtonGroup label={t('settingsEditor.charRole')} value={content.role} onChange={v => update('role', v)}
options={[
{ value: 'protagonist', label: t('settingsEditor.roles.proLabel') },
{ value: 'antagonist', label: t('settingsEditor.roles.antLabel') },
{ value: 'supporting', label: t('settingsEditor.roles.supLabel') },
{ value: 'minor', label: t('settingsEditor.roles.minLabel') },
]}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<TextField label={t('settingsEditor.infoGender')} value={content.gender} onChange={v => update('gender', v)} placeholder={t('settingsEditor.charGenderPlaceholder')} />
<TextField label={t('settingsEditor.infoAge')} value={content.age} onChange={v => update('age', v)} placeholder={t('settingsEditor.charAgePlaceholder')} />
</div>
</FieldGroup>
<FieldGroup title={t('settingsEditor.tabAppearance')} icon="✨">
<TextField label={t('settingsEditor.charAppearance')} value={content.appearance} onChange={v => update('appearance', v)} placeholder={t('settingsEditor.charAppearancePlaceholder')} multiline aiBtn />
<TextField label={t('settingsEditor.charPersonality')} value={content.personality} onChange={v => update('personality', v)} placeholder={t('settingsEditor.charPersonalityPlaceholder')} multiline aiBtn />
<TextField label={t('settingsEditor.charSpeechStyle')} value={content.speechStyle} onChange={v => update('speechStyle', v)} placeholder={t('settingsEditor.charSpeechStylePlaceholder')} multiline aiBtn />
</FieldGroup>
<FieldGroup title={t('settingsEditor.tabBackground')} icon="📖" defaultCollapsed>
<TextField label={t('settingsEditor.charBackground')} value={content.background} onChange={v => update('background', v)} placeholder={t('settingsEditor.charBackgroundPlaceholder')} multiline rows={4} aiBtn />
<TextField label={t('settingsEditor.charMotivation')} value={content.motivation} onChange={v => update('motivation', v)} placeholder={t('settingsEditor.charMotivationPlaceholder')} multiline aiBtn />
<TextField label={t('settingsEditor.charArc')} value={content.arc} onChange={v => update('arc', v)} placeholder={t('settingsEditor.charArcPlaceholder')} multiline aiBtn />
</FieldGroup>
<FieldGroup title={t('settingsEditor.tabSkills')} icon="⚔️" defaultCollapsed>
<TextField label={t('settingsEditor.charSkills')} value={content.skills} onChange={v => update('skills', v)} placeholder={t('settingsEditor.charSkillsPlaceholder')} multiline aiBtn />
<TextField label={t('settingsEditor.charRelationships')} value={content.relationships} onChange={v => update('relationships', v)} placeholder={t('settingsEditor.charRelationshipsPlaceholder')} multiline aiBtn />
</FieldGroup>
<FieldGroup title={t('settingsEditor.tabNotes')} icon="📝" defaultCollapsed>
<TextField label={t('settingsEditor.charNotes')} value={content.notes} onChange={v => update('notes', v)} placeholder={t('settingsEditor.charNotesPlaceholder')} multiline />
</FieldGroup>
<ExtraFieldsSection content={content} knownFields={['role', 'age', 'gender', 'appearance', 'personality', 'speechStyle', 'background', 'motivation', 'arc', 'skills', 'relationships', 'notes']} onUpdate={update} />
</div>
);
}
function LocationEditor({ node, onUpdate }) {
const { t } = useI18n();
const content = node.content || {};
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
return (
<div>
<FieldGroup title={t('settingsEditor.tabBasic')} icon="📋">
<TextField label={t('settingsEditor.locDescription')} value={content.description} onChange={v => update('description', v)} placeholder={t('settingsEditor.locDescriptionPlaceholder')} multiline rows={4} aiBtn />
<TextField label={t('settingsEditor.locSlugline')} value={content.slugline} onChange={v => update('slugline', v)} placeholder={t('settingsEditor.locSluglinePlaceholder')} />
</FieldGroup>
<FieldGroup title={t('settingsEditor.tabSensory')} icon="👁">
<TextField label={t('settingsEditor.locVisual')} value={content.sensoryVisual} onChange={v => update('sensoryVisual', v)} placeholder={t('settingsEditor.locVisualPlaceholder')} multiline aiBtn />
<TextField label={t('settingsEditor.locAudio')} value={content.sensoryAudio} onChange={v => update('sensoryAudio', v)} placeholder={t('settingsEditor.locAudioPlaceholder')} multiline aiBtn />
<TextField label={t('settingsEditor.locSmell')} value={content.sensorySmell} onChange={v => update('sensorySmell', v)} placeholder={t('settingsEditor.locSmellPlaceholder')} multiline aiBtn />
</FieldGroup>
<FieldGroup title={t('settingsEditor.tabMood')} icon="🌙" defaultCollapsed>
<TextField label={t('settingsEditor.locMood')} value={content.mood} onChange={v => update('mood', v)} placeholder={t('settingsEditor.locMoodPlaceholder')} />
<ButtonGroup label={t('settingsEditor.locDangerLevel')} value={content.dangerLevel} onChange={v => update('dangerLevel', v)}
options={[
{ value: 'safe', label: t('settingsEditor.dangerSafe') },
{ value: 'caution', label: t('settingsEditor.dangerCaution') },
{ value: 'danger', label: t('settingsEditor.dangerHigh') },
]}
/>
</FieldGroup>
<ExtraFieldsSection content={content} knownFields={['description', 'slugline', 'sensoryVisual', 'sensoryAudio', 'sensorySmell', 'mood', 'dangerLevel']} onUpdate={update} />
</div>
);
}
function ObjectEditor({ node, onUpdate }) {
const { t } = useI18n();
const content = node.content || {};
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
return (
<div>
<FieldGroup title={t('settingsEditor.tabBasic')} icon="📋">
<TextField label={t('settingsEditor.objDescription')} value={content.description} onChange={v => update('description', v)} placeholder={t('settingsEditor.objDescriptionPlaceholder')} multiline rows={4} aiBtn />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<TextField label={t('settingsEditor.objType')} value={content.objectType} onChange={v => update('objectType', v)} placeholder={t('settingsEditor.objTypePlaceholder')} />
<TextField label={t('settingsEditor.objRank')} value={content.rank} onChange={v => update('rank', v)} placeholder={t('settingsEditor.objRankPlaceholder')} />
</div>
</FieldGroup>
<FieldGroup title={t('settingsEditor.tabStats')} icon="📊" defaultCollapsed>
<TextField label={t('settingsEditor.objHolder')} value={content.currentHolder} onChange={v => update('currentHolder', v)} placeholder={t('settingsEditor.objHolderPlaceholder')} />
<TextField label={t('settingsEditor.objStats')} value={content.numericStats} onChange={v => update('numericStats', v)} placeholder={t('settingsEditor.objStatsPlaceholder')} multiline />
<TextField label={t('settingsEditor.objSymbolism')} value={content.symbolism} onChange={v => update('symbolism', v)} placeholder={t('settingsEditor.objSymbolismPlaceholder')} multiline aiBtn />
</FieldGroup>
<ExtraFieldsSection content={content} knownFields={['description', 'objectType', 'rank', 'currentHolder', 'numericStats', 'symbolism']} onUpdate={update} />
</div>
);
}
function WorldEditor({ node, onUpdate }) {
const { t } = useI18n();
const content = node.content || {};
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
return (
<div>
<TextField label={t('settingsEditor.worldDescription')} value={content.description} onChange={v => update('description', v)} placeholder={t('settingsEditor.worldDescriptionPlaceholder')} multiline rows={6} aiBtn />
<TextField label={t('settingsEditor.worldNotes')} value={content.notes} onChange={v => update('notes', v)} placeholder={t('settingsEditor.worldNotesPlaceholder')} multiline />
<ExtraFieldsSection content={content} knownFields={['description', 'notes']} onUpdate={update} />
</div>
);
}
function PlotEditor({ node, onUpdate }) {
const { t } = useI18n();
const content = node.content || {};
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
return (
<div>
<ButtonGroup label={t('settingsEditor.plotStatus')} value={content.status} onChange={v => update('status', v)}
options={[
{ value: 'planned', label: t('settingsEditor.statusPlanned') },
{ value: 'writing', label: t('settingsEditor.statusWriting') },
{ value: 'done', label: t('settingsEditor.statusDone') },
]}
/>
<TextField label={t('settingsEditor.plotDescription')} value={content.description} onChange={v => update('description', v)} placeholder={t('settingsEditor.plotDescriptionPlaceholder')} multiline rows={6} aiBtn />
<TextField label={t('settingsEditor.plotNotes')} value={content.notes} onChange={v => update('notes', v)} placeholder={t('settingsEditor.plotNotesPlaceholder')} multiline />
<ExtraFieldsSection content={content} knownFields={['status', 'description', 'notes']} onUpdate={update} />
</div>
);
}
function RulesEditor({ node, onUpdate }) {
const { t } = useI18n();
const content = node.content || {};
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
return (
<div>
<TextField label={t('settingsEditor.rulesDescription')} value={content.description} onChange={v => update('description', v)}
placeholder={t('settingsEditor.rulesDescriptionPlaceholder')} multiline rows={6} />
<ExtraFieldsSection content={content} knownFields={['description']} onUpdate={update} />
</div>
);
}
function GenericEditor({ node, onUpdate }) {
const { t } = useI18n();
const content = node.content || {};
const update = (field, value) => onUpdate(node.id, { content: { ...content, [field]: value } });
return (
<div>
<TextField label={t('settingsEditor.genericDescription')} value={content.description} onChange={v => update('description', v)} placeholder={t('settingsEditor.genericDescriptionPlaceholder')} multiline rows={6} />
<TextField label={t('settingsEditor.genericNotes')} value={content.notes} onChange={v => update('notes', v)} placeholder={t('settingsEditor.genericNotesPlaceholder')} multiline />
<ExtraFieldsSection content={content} knownFields={['description', 'notes']} onUpdate={update} />
</div>
);
}
// ==================== 面包屑导航 ====================
function Breadcrumb({ node, allNodes, onSelect }) {
const path = [];
let current = node;
while (current) {
path.unshift(current);
current = current.parentId ? allNodes.find(n => n.id === current.parentId) : null;
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, flexWrap: 'wrap' }}>
{path.map((p, i) => (
<span key={p.id} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{i > 0 && <span style={{ opacity: 0.5 }}>/</span>}
<span
onClick={() => onSelect(p.id)}
style={{ cursor: 'pointer', color: i === path.length - 1 ? 'var(--text-primary)' : 'var(--text-muted)', fontWeight: i === path.length - 1 ? 600 : 400, transition: 'color 0.15s' }}
onMouseEnter={e => e.target.style.color = 'var(--accent)'}
onMouseLeave={e => e.target.style.color = i === path.length - 1 ? 'var(--text-primary)' : 'var(--text-muted)'}
>
{p.icon} {p.name}
</span>
</span>
))}
</div>
);
}
// ==================== 文件夹信息 ====================
function FolderInfo({ node, nodes, onAdd }) {
const { t } = useI18n();
const catColor = CATEGORY_COLORS[node.category] || CATEGORY_COLORS.custom;
const children = nodes.filter(n => n.parentId === node.id);
const folders = children.filter(n => n.type === 'folder');
const items = children.filter(n => n.type === 'item');
return (
<div>
<div style={{
padding: 24, borderRadius: 'var(--radius-md)', background: catColor.bg,
border: `1px solid ${catColor.color}20`, marginBottom: 20, textAlign: 'center',
}}>
<div style={{ fontSize: 36, marginBottom: 8 }}>{node.icon || '📁'}</div>
<h3 style={{ fontSize: 18, fontWeight: 700, marginBottom: 4, color: 'var(--text-primary)' }}>{node.name}</h3>
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>
{folders.length > 0 && `${folders.length} 个子文件夹 · `}
{items.length} 个设定项
</p>
</div>
{children.length === 0 && (
<div className="settings-empty-state">
<div className="empty-icon">📝</div>
<h3>{t('settingsEditor.emptyTitle')}</h3>
<p>{t('settingsEditor.emptyDesc')}</p>
</div>
)}
<button
className="tree-ai-generate-btn"
style={{ width: '100%', justifyContent: 'center', marginTop: 8 }}
onClick={() => onAdd(node.id, node.category)}
>
{t('settingsEditor.addBtn')}
</button>
</div>
);
}
// ==================== 空状态 ====================
function EmptyState() {
const { t } = useI18n();
return (
<div className="settings-empty-state">
<div className="empty-icon">🎯</div>
<h3>{t('settingsEditor.selectTitle')}</h3>
<p>{t('settingsEditor.selectDesc')}</p>
</div>
);
}
// ==================== 主组件 ====================
export default function SettingsItemEditor({ selectedNode, allNodes, onUpdate, onSelect, onAdd }) {
if (!selectedNode) return <EmptyState />;
// 文件夹 → 显示文件夹信息
if (selectedNode.type === 'folder' || selectedNode.type === 'special') {
return (
<div style={{ padding: 20 }}>
<Breadcrumb node={selectedNode} allNodes={allNodes} onSelect={onSelect} />
<FolderInfo node={selectedNode} nodes={allNodes} onAdd={onAdd} />
</div>
);
}
// item → 显示对应编辑器
const editorMap = {
character: CharacterEditor,
location: LocationEditor,
object: ObjectEditor,
world: WorldEditor,
plot: PlotEditor,
rules: RulesEditor,
custom: GenericEditor,
};
const EditorComponent = editorMap[selectedNode.category] || GenericEditor;
return (
<div style={{ padding: 20 }}>
<Breadcrumb node={selectedNode} allNodes={allNodes} onSelect={onSelect} />
<EditorComponent node={selectedNode} onUpdate={onUpdate} />
</div>
);
}