331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
import {Image, Palette, RotateCcw, Save, Upload, X, BookOpen} from 'lucide-react'
|
||
import {useEffect, useRef, useState} from 'react'
|
||
import {type Conversation, conversationApi} from '../api/conversation'
|
||
import {type Worldbook, getWorldbookList} from '../api/worldbook'
|
||
|
||
interface SettingsPanelProps {
|
||
conversation: Conversation
|
||
onClose: () => void
|
||
onUpdate?: (settings: ConversationSettings) => void
|
||
}
|
||
|
||
interface ConversationSettings {
|
||
themeColor: string
|
||
backgroundImage?: string
|
||
worldbookId?: number
|
||
worldbookEnabled: boolean
|
||
}
|
||
|
||
const THEME_COLORS = [
|
||
{ value: '#7C3AED', label: '紫色' },
|
||
{ value: '#F97316', label: '橙色' },
|
||
{ value: '#10B981', label: '绿色' },
|
||
{ value: '#3B82F6', label: '蓝色' },
|
||
{ value: '#EC4899', label: '粉色' },
|
||
{ value: '#F59E0B', label: '黄色' },
|
||
]
|
||
|
||
export default function SettingsPanel({ conversation, onClose, onUpdate }: SettingsPanelProps) {
|
||
const [settings, setSettings] = useState<ConversationSettings>({
|
||
themeColor: '#7C3AED',
|
||
worldbookEnabled: false,
|
||
})
|
||
const [hasChanges, setHasChanges] = useState(false)
|
||
const [backgroundPreview, setBackgroundPreview] = useState<string>()
|
||
const [worldbooks, setWorldbooks] = useState<Worldbook[]>([])
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
useEffect(() => {
|
||
loadSettings()
|
||
loadWorldbooks()
|
||
}, [])
|
||
|
||
const loadSettings = () => {
|
||
if (conversation.settings) {
|
||
try {
|
||
const parsed = typeof conversation.settings === 'string'
|
||
? JSON.parse(conversation.settings)
|
||
: conversation.settings
|
||
|
||
// 合并默认设置和保存的设置
|
||
const mergedSettings = {
|
||
themeColor: '#7C3AED',
|
||
worldbookEnabled: conversation.worldbookEnabled || false,
|
||
worldbookId: conversation.worldbookId,
|
||
...parsed
|
||
}
|
||
|
||
setSettings(mergedSettings)
|
||
|
||
if (parsed.backgroundImage) {
|
||
setBackgroundPreview(parsed.backgroundImage)
|
||
}
|
||
|
||
// 应用主题色
|
||
if (parsed.themeColor) {
|
||
document.documentElement.style.setProperty('--color-primary', parsed.themeColor)
|
||
}
|
||
} catch (e) {
|
||
console.error('解析设置失败:', e)
|
||
}
|
||
} else {
|
||
// 如果没有 settings,从 conversation 直接读取世界书配置
|
||
setSettings(prev => ({
|
||
...prev,
|
||
worldbookEnabled: conversation.worldbookEnabled || false,
|
||
worldbookId: conversation.worldbookId,
|
||
}))
|
||
}
|
||
}
|
||
|
||
const loadWorldbooks = async () => {
|
||
try {
|
||
const response = await getWorldbookList({ page: 1, pageSize: 100 })
|
||
setWorldbooks(response.data.list || [])
|
||
} catch (error) {
|
||
console.error('加载世界书列表失败:', error)
|
||
}
|
||
}
|
||
|
||
const handleChange = (key: keyof ConversationSettings, value: any) => {
|
||
setSettings(prev => ({ ...prev, [key]: value }))
|
||
setHasChanges(true)
|
||
}
|
||
|
||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
|
||
if (!file.type.startsWith('image/')) {
|
||
alert('请选择图片文件')
|
||
return
|
||
}
|
||
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
alert('图片大小不能超过 5MB')
|
||
return
|
||
}
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = (event) => {
|
||
const dataUrl = event.target?.result as string
|
||
setBackgroundPreview(dataUrl)
|
||
handleChange('backgroundImage', dataUrl)
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
const handleRemoveBackground = () => {
|
||
setBackgroundPreview(undefined)
|
||
handleChange('backgroundImage', undefined)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
// 保存到后端
|
||
await conversationApi.updateConversationSettings(conversation.id, {
|
||
settings,
|
||
worldbookId: settings.worldbookId,
|
||
worldbookEnabled: settings.worldbookEnabled,
|
||
})
|
||
|
||
// 应用主题色到根元素
|
||
if (settings.themeColor) {
|
||
document.documentElement.style.setProperty('--color-primary', settings.themeColor)
|
||
}
|
||
|
||
// 通知父组件更新
|
||
onUpdate?.(settings)
|
||
setHasChanges(false)
|
||
alert('设置已保存')
|
||
} catch (err) {
|
||
console.error('保存设置失败:', err)
|
||
alert('保存失败,请重试')
|
||
}
|
||
}
|
||
|
||
const handleReset = () => {
|
||
setSettings({
|
||
themeColor: '#7C3AED',
|
||
worldbookEnabled: false,
|
||
})
|
||
setBackgroundPreview(undefined)
|
||
setHasChanges(false)
|
||
}
|
||
|
||
return (
|
||
<div className="flex-1 flex flex-col">
|
||
<div className="p-4 glass border-b border-white/10 flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">对话设置</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="max-w-2xl mx-auto space-y-6">
|
||
<div className="glass-hover rounded-2xl p-6">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<div className="p-2 bg-primary/20 rounded-lg">
|
||
<Palette className="w-5 h-5 text-primary" />
|
||
</div>
|
||
<h3 className="font-semibold">外观设置</h3>
|
||
</div>
|
||
|
||
<div className="space-y-5">
|
||
<div>
|
||
<label className="text-sm text-white/80 mb-3 block">主题颜色</label>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{THEME_COLORS.map((color) => (
|
||
<button
|
||
key={color.value}
|
||
onClick={() => handleChange('themeColor', color.value)}
|
||
className={`p-4 rounded-xl cursor-pointer transition-all ${
|
||
settings.themeColor === color.value
|
||
? 'ring-2 ring-white/40 scale-105'
|
||
: 'ring-1 ring-white/20 hover:ring-white/30'
|
||
}`}
|
||
style={{ backgroundColor: color.value }}
|
||
>
|
||
<div className="text-xs font-medium text-white">{color.label}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-sm text-white/80 mb-3 block">对话背景图</label>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleImageUpload}
|
||
className="hidden"
|
||
/>
|
||
|
||
{backgroundPreview ? (
|
||
<div className="relative glass rounded-xl p-4">
|
||
<img
|
||
src={backgroundPreview}
|
||
alt="背景预览"
|
||
className="w-full h-32 object-cover rounded-lg"
|
||
/>
|
||
<div className="flex gap-2 mt-3">
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
|
||
>
|
||
<Upload className="w-4 h-4" />
|
||
更换图片
|
||
</button>
|
||
<button
|
||
onClick={handleRemoveBackground}
|
||
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer text-red-400"
|
||
>
|
||
移除背景
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="w-full p-8 glass-hover rounded-xl cursor-pointer border-2 border-dashed border-white/20 hover:border-primary/50 transition-all"
|
||
>
|
||
<Image className="w-8 h-8 mx-auto mb-2 text-white/40" />
|
||
<p className="text-sm text-white/60">点击上传背景图片</p>
|
||
<p className="text-xs text-white/40 mt-1">支持 JPG、PNG,最大 5MB</p>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="glass-hover rounded-2xl p-6">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<div className="p-2 bg-primary/20 rounded-lg">
|
||
<BookOpen className="w-5 h-5 text-primary" />
|
||
</div>
|
||
<h3 className="font-semibold">世界书设置</h3>
|
||
</div>
|
||
|
||
<div className="space-y-5">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<label className="text-sm font-medium">启用世界书</label>
|
||
<p className="text-xs text-white/60 mt-1">根据关键词自动注入背景信息</p>
|
||
</div>
|
||
<button
|
||
onClick={() => handleChange('worldbookEnabled', !settings.worldbookEnabled)}
|
||
className={`relative w-12 h-6 rounded-full transition-colors cursor-pointer ${
|
||
settings.worldbookEnabled ? 'bg-primary' : 'bg-white/20'
|
||
}`}
|
||
>
|
||
<div
|
||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||
settings.worldbookEnabled ? 'translate-x-6' : ''
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
{settings.worldbookEnabled && (
|
||
<div>
|
||
<label className="text-sm text-white/80 mb-3 block">选择世界书</label>
|
||
<select
|
||
value={settings.worldbookId || ''}
|
||
onChange={(e) => handleChange('worldbookId', e.target.value ? Number(e.target.value) : undefined)}
|
||
className="w-full px-4 py-3 glass rounded-xl text-sm bg-white/5 border border-white/10 focus:border-primary/50 focus:outline-none cursor-pointer"
|
||
>
|
||
<option value="">未选择</option>
|
||
{worldbooks.map((wb) => (
|
||
<option key={wb.id} value={wb.id}>
|
||
{wb.name} ({wb.entryCount} 条目)
|
||
</option>
|
||
))}
|
||
</select>
|
||
{worldbooks.length === 0 && (
|
||
<p className="text-xs text-white/40 mt-2">
|
||
还没有世界书,请先在世界书管理页面创建
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="glass-hover rounded-2xl p-4">
|
||
<div className="text-xs text-white/60 space-y-2">
|
||
<p>💡 主题颜色会应用到整个界面</p>
|
||
<p>💡 背景图片仅在对话页面显示</p>
|
||
<p>💡 世界书会根据对话内容自动触发相关条目</p>
|
||
<p>💡 AI 模型可在对话界面顶部切换</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 glass border-t border-white/10 flex gap-3">
|
||
<button
|
||
onClick={handleReset}
|
||
disabled={!hasChanges}
|
||
className="flex-1 px-4 py-3 glass-hover rounded-xl text-sm font-medium cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||
>
|
||
<RotateCcw className="w-4 h-4" />
|
||
重置
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={!hasChanges}
|
||
className="flex-1 px-4 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl text-sm font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||
>
|
||
<Save className="w-4 h-4" />
|
||
保存设置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|