🎨 新增世界书模块,将原有的世界书分离

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-02-27 23:15:30 +08:00
parent 689e8af3df
commit 032d0ccdf0
18 changed files with 1880 additions and 8 deletions

View File

@@ -10,6 +10,7 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ProfilePage from './pages/ProfilePage'
import CharacterManagePage from './pages/CharacterManagePage'
import PresetManagePage from './pages/PresetManagePage'
import WorldbookManagePage from './pages/WorldbookManagePage'
import AdminPage from './pages/AdminPage'
function App() {
@@ -27,6 +28,7 @@ function App() {
<Route path="/profile" element={<ProfilePage />} />
<Route path="/characters" element={<CharacterManagePage />} />
<Route path="/presets" element={<PresetManagePage />} />
<Route path="/worldbooks" element={<WorldbookManagePage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</BrowserRouter>

View File

@@ -0,0 +1,164 @@
import client from './client';
export interface Worldbook {
id: number;
userId: number;
name: string;
description: string;
isPublic: boolean;
entryCount: number;
createdAt: string;
updatedAt: string;
}
export interface WorldbookEntry {
id: number;
worldbookId: number;
comment: string;
content: string;
keys: string[];
secondaryKeys: string[];
constant: boolean;
enabled: boolean;
useRegex: boolean;
caseSensitive: boolean;
matchWholeWords: boolean;
selective: boolean;
selectiveLogic: number;
position: number;
depth: number;
order: number;
probability: number;
scanDepth: number;
groupId: string;
extensions?: Record<string, any>;
createdAt: string;
updatedAt: string;
}
export interface CreateWorldbookRequest {
name: string;
description?: string;
isPublic?: boolean;
}
export interface UpdateWorldbookRequest {
name?: string;
description?: string;
isPublic?: boolean;
}
export interface CreateEntryRequest {
comment?: string;
content: string;
keys?: string[];
secondaryKeys?: string[];
constant?: boolean;
enabled?: boolean;
useRegex?: boolean;
caseSensitive?: boolean;
matchWholeWords?: boolean;
selective?: boolean;
selectiveLogic?: number;
position?: number;
depth?: number;
order?: number;
probability?: number;
scanDepth?: number;
groupId?: string;
}
export interface UpdateEntryRequest {
comment?: string;
content?: string;
keys?: string[];
secondaryKeys?: string[];
constant?: boolean;
enabled?: boolean;
useRegex?: boolean;
caseSensitive?: boolean;
matchWholeWords?: boolean;
selective?: boolean;
selectiveLogic?: number;
position?: number;
depth?: number;
order?: number;
probability?: number;
scanDepth?: number;
groupId?: string;
}
// 创建世界书
export const createWorldbook = (data: CreateWorldbookRequest) => {
return client.post<Worldbook>('/app/worldbook', data);
};
// 获取世界书列表
export const getWorldbookList = (params: {
page?: number;
pageSize?: number;
keyword?: string;
}) => {
return client.get<{
list: Worldbook[];
total: number;
page: number;
pageSize: number;
}>('/app/worldbook', { params });
};
// 获取世界书详情
export const getWorldbookById = (id: number) => {
return client.get<Worldbook>(`/app/worldbook/${id}`);
};
// 更新世界书
export const updateWorldbook = (id: number, data: UpdateWorldbookRequest) => {
return client.put(`/app/worldbook/${id}`, data);
};
// 删除世界书
export const deleteWorldbook = (id: number) => {
return client.delete(`/app/worldbook/${id}`);
};
// 导入世界书
export const importWorldbook = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return client.post<Worldbook>('/app/worldbook/import', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};
// 导出世界书
export const exportWorldbook = (id: number) => {
return client.get(`/app/worldbook/${id}/export`, {
responseType: 'blob',
});
};
// 创建条目
export const createEntry = (worldbookId: number, data: CreateEntryRequest) => {
return client.post<WorldbookEntry>(`/app/worldbook/${worldbookId}/entry`, data);
};
// 获取条目列表
export const getEntryList = (worldbookId: number) => {
return client.get<{
list: WorldbookEntry[];
total: number;
}>(`/app/worldbook/${worldbookId}/entries`);
};
// 更新条目
export const updateEntry = (worldbookId: number, entryId: number, data: UpdateEntryRequest) => {
return client.put(`/app/worldbook/${worldbookId}/entry/${entryId}`, data);
};
// 删除条目
export const deleteEntry = (worldbookId: number, entryId: number) => {
return client.delete(`/app/worldbook/${worldbookId}/entry/${entryId}`);
};

View File

@@ -0,0 +1,613 @@
import React, { useState, useEffect } from 'react';
import {
getWorldbookList,
createWorldbook,
updateWorldbook,
deleteWorldbook,
importWorldbook,
exportWorldbook,
getEntryList,
createEntry,
updateEntry,
deleteEntry,
type Worldbook,
type WorldbookEntry,
type CreateWorldbookRequest,
type CreateEntryRequest,
} from '../api/worldbook';
const WorldbookManagePage: React.FC = () => {
const [worldbooks, setWorldbooks] = useState<Worldbook[]>([]);
const [selectedWorldbook, setSelectedWorldbook] = useState<Worldbook | null>(null);
const [entries, setEntries] = useState<WorldbookEntry[]>([]);
const [selectedEntry, setSelectedEntry] = useState<WorldbookEntry | null>(null);
const [loading, setLoading] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEntryModal, setShowEntryModal] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
// 表单状态
const [worldbookForm, setWorldbookForm] = useState<CreateWorldbookRequest>({
name: '',
description: '',
isPublic: false,
});
const [entryForm, setEntryForm] = useState<CreateEntryRequest>({
comment: '',
content: '',
keys: [],
secondaryKeys: [],
constant: false,
enabled: true,
useRegex: false,
caseSensitive: false,
matchWholeWords: false,
selective: false,
selectiveLogic: 0,
position: 1,
depth: 4,
order: 100,
probability: 100,
scanDepth: 2,
groupId: '',
});
const [keyInput, setKeyInput] = useState('');
const [secondaryKeyInput, setSecondaryKeyInput] = useState('');
useEffect(() => {
loadWorldbooks();
}, [searchKeyword]);
useEffect(() => {
if (selectedWorldbook) {
loadEntries(selectedWorldbook.id);
}
}, [selectedWorldbook]);
const loadWorldbooks = async () => {
try {
setLoading(true);
const response = await getWorldbookList({
page: 1,
pageSize: 100,
keyword: searchKeyword,
});
setWorldbooks(response.data.list || []);
} catch (error: any) {
alert('加载世界书列表失败: ' + (error.response?.data?.msg || error.message));
} finally {
setLoading(false);
}
};
const loadEntries = async (worldbookId: number) => {
try {
const response = await getEntryList(worldbookId);
setEntries(response.data.list || []);
} catch (error: any) {
alert('加载条目列表失败: ' + (error.response?.data?.msg || error.message));
}
};
const handleCreateWorldbook = async () => {
if (!worldbookForm.name.trim()) {
alert('请输入世界书名称');
return;
}
try {
await createWorldbook(worldbookForm);
alert('创建成功');
setShowCreateModal(false);
setWorldbookForm({ name: '', description: '', isPublic: false });
loadWorldbooks();
} catch (error: any) {
alert('创建失败: ' + (error.response?.data?.msg || error.message));
}
};
const handleDeleteWorldbook = async (id: number) => {
if (!confirm('确定要删除这个世界书吗?所有条目也会被删除。')) return;
try {
await deleteWorldbook(id);
alert('删除成功');
if (selectedWorldbook?.id === id) {
setSelectedWorldbook(null);
setEntries([]);
}
loadWorldbooks();
} catch (error: any) {
alert('删除失败: ' + (error.response?.data?.msg || error.message));
}
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setLoading(true);
await importWorldbook(file);
alert('导入成功');
loadWorldbooks();
} catch (error: any) {
alert('导入失败: ' + (error.response?.data?.msg || error.message));
} finally {
setLoading(false);
e.target.value = '';
}
};
const handleExport = async (id: number) => {
try {
const response = await exportWorldbook(id);
const blob = new Blob([response.data], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `worldbook_${id}.json`;
a.click();
window.URL.revokeObjectURL(url);
} catch (error: any) {
alert('导出失败: ' + (error.response?.data?.msg || error.message));
}
};
const handleSaveEntry = async () => {
if (!selectedWorldbook) return;
if (!entryForm.content.trim()) {
alert('请输入条目内容');
return;
}
try {
if (selectedEntry) {
await updateEntry(selectedWorldbook.id, selectedEntry.id, entryForm);
alert('更新成功');
} else {
await createEntry(selectedWorldbook.id, entryForm);
alert('创建成功');
}
setShowEntryModal(false);
resetEntryForm();
loadEntries(selectedWorldbook.id);
} catch (error: any) {
alert('保存失败: ' + (error.response?.data?.msg || error.message));
}
};
const handleDeleteEntry = async (entryId: number) => {
if (!selectedWorldbook) return;
if (!confirm('确定要删除这个条目吗?')) return;
try {
await deleteEntry(selectedWorldbook.id, entryId);
alert('删除成功');
loadEntries(selectedWorldbook.id);
} catch (error: any) {
alert('删除失败: ' + (error.response?.data?.msg || error.message));
}
};
const openEntryModal = (entry?: WorldbookEntry) => {
if (entry) {
setSelectedEntry(entry);
setEntryForm({
comment: entry.comment,
content: entry.content,
keys: entry.keys,
secondaryKeys: entry.secondaryKeys,
constant: entry.constant,
enabled: entry.enabled,
useRegex: entry.useRegex,
caseSensitive: entry.caseSensitive,
matchWholeWords: entry.matchWholeWords,
selective: entry.selective,
selectiveLogic: entry.selectiveLogic,
position: entry.position,
depth: entry.depth,
order: entry.order,
probability: entry.probability,
scanDepth: entry.scanDepth,
groupId: entry.groupId,
});
} else {
resetEntryForm();
}
setShowEntryModal(true);
};
const resetEntryForm = () => {
setSelectedEntry(null);
setEntryForm({
comment: '',
content: '',
keys: [],
secondaryKeys: [],
constant: false,
enabled: true,
useRegex: false,
caseSensitive: false,
matchWholeWords: false,
selective: false,
selectiveLogic: 0,
position: 1,
depth: 4,
order: 100,
probability: 100,
scanDepth: 2,
groupId: '',
});
setKeyInput('');
setSecondaryKeyInput('');
};
const addKey = () => {
if (keyInput.trim()) {
setEntryForm({
...entryForm,
keys: [...(entryForm.keys || []), keyInput.trim()],
});
setKeyInput('');
}
};
const removeKey = (index: number) => {
setEntryForm({
...entryForm,
keys: entryForm.keys?.filter((_, i) => i !== index),
});
};
const addSecondaryKey = () => {
if (secondaryKeyInput.trim()) {
setEntryForm({
...entryForm,
secondaryKeys: [...(entryForm.secondaryKeys || []), secondaryKeyInput.trim()],
});
setSecondaryKeyInput('');
}
};
const removeSecondaryKey = (index: number) => {
setEntryForm({
...entryForm,
secondaryKeys: entryForm.secondaryKeys?.filter((_, i) => i !== index),
});
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-white"></h1>
<div className="flex gap-3">
<label className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg cursor-pointer transition-colors">
<input type="file" accept=".json" onChange={handleImport} className="hidden" />
</label>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 世界书列表 */}
<div className="lg:col-span-1 bg-white/10 backdrop-blur-md rounded-xl p-4">
<input
type="text"
placeholder="搜索世界书..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 mb-4"
/>
<div className="space-y-2 max-h-[calc(100vh-250px)] overflow-y-auto">
{worldbooks.map((wb) => (
<div
key={wb.id}
onClick={() => setSelectedWorldbook(wb)}
className={`p-3 rounded-lg cursor-pointer transition-colors ${
selectedWorldbook?.id === wb.id
? 'bg-purple-600 text-white'
: 'bg-white/5 hover:bg-white/10 text-white/80'
}`}
>
<div className="font-medium">{wb.name}</div>
<div className="text-sm opacity-70">{wb.entryCount} </div>
<div className="flex gap-2 mt-2">
<button
onClick={(e) => {
e.stopPropagation();
handleExport(wb.id);
}}
className="text-xs px-2 py-1 bg-blue-500 hover:bg-blue-600 rounded"
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteWorldbook(wb.id);
}}
className="text-xs px-2 py-1 bg-red-500 hover:bg-red-600 rounded"
>
</button>
</div>
</div>
))}
</div>
</div>
{/* 条目列表 */}
<div className="lg:col-span-2 bg-white/10 backdrop-blur-md rounded-xl p-4">
{selectedWorldbook ? (
<>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-white">{selectedWorldbook.name}</h2>
<button
onClick={() => openEntryModal()}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
</button>
</div>
<div className="space-y-3 max-h-[calc(100vh-250px)] overflow-y-auto">
{entries.map((entry) => (
<div key={entry.id} className="bg-white/5 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="font-medium text-white">
{entry.comment || '未命名条目'}
</div>
<div className="text-sm text-white/60 mt-1">
: {entry.keys.join(', ') || '无'}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => openEntryModal(entry)}
className="text-xs px-3 py-1 bg-blue-500 hover:bg-blue-600 text-white rounded"
>
</button>
<button
onClick={() => handleDeleteEntry(entry.id)}
className="text-xs px-3 py-1 bg-red-500 hover:bg-red-600 text-white rounded"
>
</button>
</div>
</div>
<div className="text-sm text-white/70 line-clamp-2">{entry.content}</div>
<div className="flex gap-2 mt-2 text-xs text-white/50">
{entry.constant && <span className="px-2 py-1 bg-yellow-500/20 rounded"></span>}
{!entry.enabled && <span className="px-2 py-1 bg-red-500/20 rounded"></span>}
{entry.useRegex && <span className="px-2 py-1 bg-blue-500/20 rounded"></span>}
</div>
</div>
))}
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-white/50">
</div>
)}
</div>
</div>
</div>
{/* 创建世界书模态框 */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-slate-800 rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-white mb-4"></h2>
<div className="space-y-4">
<input
type="text"
placeholder="世界书名称"
value={worldbookForm.name}
onChange={(e) => setWorldbookForm({ ...worldbookForm, name: e.target.value })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
<textarea
placeholder="描述(可选)"
value={worldbookForm.description}
onChange={(e) => setWorldbookForm({ ...worldbookForm, description: e.target.value })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white h-24"
/>
<label className="flex items-center text-white">
<input
type="checkbox"
checked={worldbookForm.isPublic}
onChange={(e) => setWorldbookForm({ ...worldbookForm, isPublic: e.target.checked })}
className="mr-2"
/>
</label>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={handleCreateWorldbook}
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg"
>
</button>
<button
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg"
>
</button>
</div>
</div>
</div>
)}
{/* 条目编辑模态框 */}
{showEntryModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-slate-800 rounded-xl p-6 w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-white mb-4">
{selectedEntry ? '编辑条目' : '创建条目'}
</h2>
<div className="space-y-4">
<input
type="text"
placeholder="条目标题/备注"
value={entryForm.comment}
onChange={(e) => setEntryForm({ ...entryForm, comment: e.target.value })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
<textarea
placeholder="条目内容 *"
value={entryForm.content}
onChange={(e) => setEntryForm({ ...entryForm, content: e.target.value })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white h-32"
/>
{/* 关键词 */}
<div>
<label className="text-white text-sm mb-2 block"></label>
<div className="flex gap-2 mb-2">
<input
type="text"
placeholder="输入关键词后按回车"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addKey()}
className="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
<button onClick={addKey} className="px-4 py-2 bg-blue-600 rounded-lg text-white">
</button>
</div>
<div className="flex flex-wrap gap-2">
{entryForm.keys?.map((key, index) => (
<span key={index} className="px-3 py-1 bg-blue-500/20 text-white rounded-full text-sm flex items-center gap-2">
{key}
<button onClick={() => removeKey(index)} className="text-red-400 hover:text-red-300">×</button>
</span>
))}
</div>
</div>
{/* 次要关键词 */}
<div>
<label className="text-white text-sm mb-2 block"></label>
<div className="flex gap-2 mb-2">
<input
type="text"
placeholder="输入次要关键词后按回车"
value={secondaryKeyInput}
onChange={(e) => setSecondaryKeyInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addSecondaryKey()}
className="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
<button onClick={addSecondaryKey} className="px-4 py-2 bg-blue-600 rounded-lg text-white">
</button>
</div>
<div className="flex flex-wrap gap-2">
{entryForm.secondaryKeys?.map((key, index) => (
<span key={index} className="px-3 py-1 bg-purple-500/20 text-white rounded-full text-sm flex items-center gap-2">
{key}
<button onClick={() => removeSecondaryKey(index)} className="text-red-400 hover:text-red-300">×</button>
</span>
))}
</div>
</div>
{/* 选项 */}
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center text-white">
<input
type="checkbox"
checked={entryForm.constant}
onChange={(e) => setEntryForm({ ...entryForm, constant: e.target.checked })}
className="mr-2"
/>
</label>
<label className="flex items-center text-white">
<input
type="checkbox"
checked={entryForm.enabled}
onChange={(e) => setEntryForm({ ...entryForm, enabled: e.target.checked })}
className="mr-2"
/>
</label>
<label className="flex items-center text-white">
<input
type="checkbox"
checked={entryForm.useRegex}
onChange={(e) => setEntryForm({ ...entryForm, useRegex: e.target.checked })}
className="mr-2"
/>
使
</label>
<label className="flex items-center text-white">
<input
type="checkbox"
checked={entryForm.caseSensitive}
onChange={(e) => setEntryForm({ ...entryForm, caseSensitive: e.target.checked })}
className="mr-2"
/>
</label>
</div>
{/* 高级选项 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-white text-sm mb-1 block"> (%)</label>
<input
type="number"
min="0"
max="100"
value={entryForm.probability}
onChange={(e) => setEntryForm({ ...entryForm, probability: parseInt(e.target.value) })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
<div>
<label className="text-white text-sm mb-1 block"></label>
<input
type="number"
min="0"
value={entryForm.scanDepth}
onChange={(e) => setEntryForm({ ...entryForm, scanDepth: parseInt(e.target.value) })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={handleSaveEntry}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg"
>
</button>
<button
onClick={() => {
setShowEntryModal(false);
resetEntryForm();
}}
className="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg"
>
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default WorldbookManagePage;