Files
st-react/web-app/src/components/SettingsPanel.tsx
2026-02-28 15:09:53 +08:00

331 lines
12 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 {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"> JPGPNG 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>
)
}