xinyu-pro 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.env.example +21 -0
  2. package/README.md +36 -0
  3. package/app/api/chat/route.ts +84 -0
  4. package/app/api/generate-svg/route.ts +171 -0
  5. package/app/api/generate-theme/route.ts +137 -0
  6. package/app/api/plugins/bindings/route.ts +173 -0
  7. package/app/api/plugins/export/route.ts +122 -0
  8. package/app/api/plugins/export-xye/route.ts +156 -0
  9. package/app/api/plugins/files/route.ts +146 -0
  10. package/app/api/plugins/files-list/route.ts +168 -0
  11. package/app/api/plugins/files-upload/route.ts +101 -0
  12. package/app/api/plugins/files-write/route.ts +272 -0
  13. package/app/api/plugins/import/route.ts +140 -0
  14. package/app/api/plugins/import-package/route.ts +231 -0
  15. package/app/api/plugins/resources/route.ts +109 -0
  16. package/app/api/plugins/route.ts +308 -0
  17. package/app/api/plugins/scan/route.ts +280 -0
  18. package/app/api/plugins/storage/route.ts +146 -0
  19. package/app/api/sessions/route.ts +165 -0
  20. package/app/api/settings/route.ts +40 -0
  21. package/app/api/suggest-fields/route.ts +129 -0
  22. package/app/api/templates/route.ts +159 -0
  23. package/app/api/test-api/route.ts +63 -0
  24. package/app/editor/page.tsx +1466 -0
  25. package/app/extensions/create/page.tsx +1422 -0
  26. package/app/extensions/edit/[id]/page.tsx +2342 -0
  27. package/app/extensions/page.tsx +1572 -0
  28. package/app/extensions/tutorial/page.tsx +4258 -0
  29. package/app/favicon.ico +0 -0
  30. package/app/fonts/GeistMonoVF.woff +0 -0
  31. package/app/fonts/GeistVF.woff +0 -0
  32. package/app/game/[id]/page.tsx +996 -0
  33. package/app/globals.css +3 -0
  34. package/app/layout.tsx +26 -0
  35. package/app/loading.tsx +26 -0
  36. package/app/page.tsx +345 -0
  37. package/app/settings/page.tsx +1490 -0
  38. package/bin/cli.js +262 -0
  39. package/components/ChatInput.tsx +106 -0
  40. package/components/ChatWindow.tsx +52 -0
  41. package/components/FullPageLoader.tsx +107 -0
  42. package/components/LoadingDots.tsx +20 -0
  43. package/components/MathCurveLoader.tsx +173 -0
  44. package/components/MessageBubble.tsx +147 -0
  45. package/components/WorldCardPreview.tsx +98 -0
  46. package/components/WorldCardUploader.tsx +58 -0
  47. package/components/ui/ConfirmDialog.tsx +135 -0
  48. package/components/ui/PageHeader.tsx +99 -0
  49. package/components/ui/PermissionConflictDialog.tsx +206 -0
  50. package/components/ui/PluginConfigForm.tsx +192 -0
  51. package/components/ui/PluginFloatingLayer.tsx +52 -0
  52. package/components/ui/PluginIcon.tsx +53 -0
  53. package/components/ui/PluginModalRenderer.tsx +185 -0
  54. package/components/ui/PluginProvider.tsx +1038 -0
  55. package/components/ui/PluginSlotRenderer.tsx +76 -0
  56. package/components/ui/ThemeCustomizer.tsx +174 -0
  57. package/components/ui/ThemeProvider.tsx +125 -0
  58. package/components/ui/ThemeSwitcher.tsx +140 -0
  59. package/components/ui/ToastProvider.tsx +141 -0
  60. package/lib/builtin-plugins.ts +11 -0
  61. package/lib/db-init.ts +35 -0
  62. package/lib/db.ts +244 -0
  63. package/lib/manifest-parser.ts +185 -0
  64. package/lib/parseWorldCard.ts +110 -0
  65. package/lib/plugin-dom-sandbox.ts +327 -0
  66. package/lib/plugin-events.ts +88 -0
  67. package/lib/plugin-files.ts +186 -0
  68. package/lib/plugin-html-sanitizer.ts +79 -0
  69. package/lib/plugin-resource-tracker.ts +175 -0
  70. package/lib/plugin-runtime.ts +2287 -0
  71. package/lib/plugin-security.ts +151 -0
  72. package/lib/plugin-types.ts +416 -0
  73. package/lib/prompt-builder.ts +55 -0
  74. package/lib/router-history.ts +119 -0
  75. package/lib/storage.ts +381 -0
  76. package/lib/themes.ts +129 -0
  77. package/lib/types.ts +117 -0
  78. package/lib/version.ts +55 -0
  79. package/next.config.mjs +43 -0
  80. package/package.json +56 -0
  81. package/plugins/xinyu.bag-system.xye +0 -0
  82. package/plugins/xinyu.cache-optimizer.xye +0 -0
  83. package/plugins/xinyu.dice-arbiter.xye +0 -0
  84. package/plugins/xinyu.game-auto-start-choices.xye +0 -0
  85. package/plugins/xinyu.markdown-render.xye +0 -0
  86. package/plugins/xinyu.slot-ui-beautify.xye +0 -0
  87. package/plugins/xinyu.world-info.xye +0 -0
  88. package/postcss.config.mjs +8 -0
  89. package/public/templates/atlantis.svg +63 -0
  90. package/public/templates/cyber-city.svg +68 -0
  91. package/public/templates/jianghu.svg +69 -0
  92. package/public/templates//351/255/224/346/263/225/344/270/226/347/225/214.svg +137 -0
  93. package/styles/themes.css +111 -0
  94. package/tailwind.config.ts +18 -0
  95. package/tsconfig.json +26 -0
  96. package/version.json +6 -0
@@ -0,0 +1,1490 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { useRouterHistory } from '@/lib/router-history';
5
+ import { AppSettings, Theme } from '@/lib/types';
6
+ import { APP_VERSION, APP_NAME, APP_DESCRIPTION, formatVersion } from '@/lib/version';
7
+ import {
8
+ getDefaultAppSettings,
9
+ getGameSessions,
10
+ deleteGameSession,
11
+ clearAllGameSessions,
12
+ getPlugins,
13
+ getPluginBindings,
14
+ upsertPluginBinding,
15
+ deletePlugin, getPlugin,
16
+ } from '@/lib/storage';
17
+ import { PluginManifest, PluginBinding, PLUGIN_TYPE_LABELS } from '@/lib/plugin-types';
18
+ import { useTheme } from '@/components/ui/ThemeProvider';
19
+ import { usePluginContext } from '@/components/ui/PluginProvider';
20
+ import ConfirmDialog from '@/components/ui/ConfirmDialog';
21
+ import { broadcastPluginBindingChange } from '@/lib/plugin-events';
22
+ import { useToast } from '@/components/ui/ToastProvider';
23
+ import PageHeader from '@/components/ui/PageHeader';
24
+ import { PluginIcon } from '@/components/ui/PluginIcon';
25
+
26
+ // ==================== 通用组件 ====================
27
+
28
+ function SettingRow({ label, description, children }: {
29
+ label: string;
30
+ description?: string;
31
+ children: React.ReactNode;
32
+ }) {
33
+ return (
34
+ <div className="flex items-start gap-3 py-2.5" style={{ borderBottom: '1px solid var(--color-border)' }}>
35
+ <div className="flex-1 min-w-0">
36
+ <div className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>{label}</div>
37
+ {description && (
38
+ <div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>{description}</div>
39
+ )}
40
+ </div>
41
+ <div className="shrink-0">{children}</div>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ function SettingCard({ title, button, children }: { title: string; button?: React.ReactNode; children: React.ReactNode }) {
47
+ return (
48
+ <div className="rounded-xl border overflow-hidden" style={{
49
+ backgroundColor: 'var(--color-bg-secondary)',
50
+ borderColor: 'var(--color-border)',
51
+ }}>
52
+ <div className="flex px-4 py-2.5 font-bold text-sm justify-between items-center" style={{
53
+ color: 'var(--color-text-primary)',
54
+ borderBottom: '1px solid var(--color-border)',
55
+ }}>
56
+ <span>{title}</span>
57
+ {button}
58
+ </div>
59
+ <div className="px-4 py-3">{children}</div>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ /** 分列布局:每列最多 maxPerCol 个子元素,列宽固定,支持横向滚动 */
65
+ function ColumnLayout({ children, maxPerCol = 3 }: { children: React.ReactNode; maxPerCol?: number }) {
66
+ const items = React.Children.toArray(children);
67
+ const columns: React.ReactNode[][] = [];
68
+ for (let i = 0; i < items.length; i += maxPerCol) {
69
+ columns.push(items.slice(i, i + maxPerCol));
70
+ }
71
+ return (
72
+ <div
73
+ className="flex gap-4 overflow-x-auto overflow-y-auto pb-2"
74
+ style={{ minHeight: 0 }}
75
+ >
76
+ {columns.map((col, i) => (
77
+ <div
78
+ key={i}
79
+ className="flex flex-col gap-4 shrink-0"
80
+ style={{ width: '425px' }}
81
+ >
82
+ {col}
83
+ </div>
84
+ ))}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ function TextField({ value, onChange, placeholder, mono = false, type = 'text' }: {
90
+ value: string;
91
+ onChange: (v: string) => void;
92
+ placeholder?: string;
93
+ mono?: boolean;
94
+ type?: string;
95
+ }) {
96
+ return (
97
+ <input
98
+ type={type}
99
+ value={value}
100
+ onChange={(e) => onChange(e.target.value)}
101
+ placeholder={placeholder}
102
+ className="w-full px-3 py-1.5 rounded-lg text-sm border outline-none transition-colors"
103
+ style={{
104
+ backgroundColor: 'var(--color-bg-tertiary)',
105
+ borderColor: 'var(--color-border)',
106
+ color: 'var(--color-text-primary)',
107
+ fontFamily: mono ? 'monospace' : 'inherit',
108
+ }}
109
+ onFocus={(e) => (e.target.style.borderColor = 'var(--color-accent)')}
110
+ onBlur={(e) => (e.target.style.borderColor = 'var(--color-border)')}
111
+ />
112
+ );
113
+ }
114
+
115
+ // ==================== API 预设 ====================
116
+
117
+ const API_PRESETS = [
118
+ { name: 'OpenAI', base: 'https://api.openai.com/v1', model: 'gpt-4o' },
119
+ { name: 'DeepSeek', base: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
120
+ { name: '通义千问', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-turbo' },
121
+ { name: 'Claude', base: 'https://api.anthropic.com/v1', model: 'claude-3-5-sonnet-20241022' },
122
+ ];
123
+
124
+ // ==================== 分类定义 ====================
125
+
126
+ const CATEGORIES = [
127
+ { id: 'api', label: 'AI 模型', icon: '🤖' },
128
+ { id: 'chat', label: '对话偏好', icon: '💬' },
129
+ { id: 'theme', label: '主题外观', icon: '🎨' },
130
+ { id: 'extensions', label: '插件', icon: '🔌' },
131
+ { id: 'data', label: '数据管理', icon: '📦' },
132
+ { id: 'about', label: '关于', icon: 'ℹ️' },
133
+ ] as const;
134
+
135
+ type CategoryId = (typeof CATEGORIES)[number]['id'];
136
+
137
+ // ==================== 页面 ====================
138
+
139
+ export default function SettingsPage() {
140
+ const { toast } = useToast();
141
+ const { navigate, back } = useRouterHistory();
142
+ const { config, activeTheme, setActiveThemeId, addImportedTheme, removeImportedTheme } = useTheme();
143
+ const { reloadPlugins, updateToastSettings } = usePluginContext();
144
+ const [settings, setSettings] = useState<AppSettings>(getDefaultAppSettings());
145
+ const [showKey, setShowKey] = useState(false);
146
+ const [saved, setSaved] = useState(false);
147
+ const [saving, setSaving] = useState(false);
148
+ const [initialSettings, setInitialSettings] = useState<AppSettings | null>(null);
149
+ const [activeCategory, setActiveCategory] = useState<CategoryId>('api');
150
+ const [sessions, setSessions] = useState<Array<{ id: string; title: string; era: string; count: number }>>([]);
151
+
152
+ // 自定义主题编辑状态
153
+ const [isEditingCustom, setIsEditingCustom] = useState(false);
154
+ const [editingThemeId, setEditingThemeId] = useState<string | null>(null);
155
+ const [customVars, setCustomVars] = useState<Record<string, string>>({});
156
+ const [customIsDark, setCustomIsDark] = useState<boolean>(true);
157
+ const importThemeRef = useRef<HTMLInputElement>(null);
158
+ // API 测试连接
159
+ const [testingApi, setTestingApi] = useState(false);
160
+ const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
161
+ // AI 生成主题
162
+ const [aiThemePrompt, setAiThemePrompt] = useState('');
163
+ const [aiThemeFormat, setAiThemeFormat] = useState<'svg' | 'json'>('json');
164
+ const [generatingTheme, setGeneratingTheme] = useState(false);
165
+ // 退出确认弹窗
166
+ const [confirmOpen, setConfirmOpen] = useState(false);
167
+ const [confirmConfig, setConfirmConfig] = useState<{ title: string; message: string; danger?: boolean; onConfirm: () => void }>({ title: '', message: '', onConfirm: () => {} });
168
+
169
+ // 插件管理状态
170
+ const [plugins, setPlugins] = useState<PluginManifest[]>([]);
171
+ const [pluginBindings, setPluginBindings] = useState<PluginBinding[]>([]);
172
+
173
+ const refreshSessions = async () => {
174
+ const all = await getGameSessions();
175
+ setSessions(all.map((s) => ({
176
+ id: s.id,
177
+ title: s.worldSetting.title,
178
+ era: s.worldSetting.era,
179
+ count: s.messages.length,
180
+ })));
181
+ };
182
+
183
+ const refreshPlugins = useCallback(async () => {
184
+ const [allPlugins, allBindings] = await Promise.all([
185
+ getPlugins(),
186
+ getPluginBindings('global'),
187
+ ]);
188
+ setPlugins(allPlugins);
189
+ setPluginBindings(allBindings);
190
+ }, []);
191
+
192
+ const handleTogglePlugin = useCallback(async (pluginId: string, enabled: boolean) => {
193
+ const existing = pluginBindings.find(b => b.extensionId === pluginId);
194
+ if (existing) {
195
+ await upsertPluginBinding({ id: existing.id, extensionId: pluginId, scope: 'global', enabled, config: existing.config, sortOrder: existing.sortOrder });
196
+ } else {
197
+ await upsertPluginBinding({ extensionId: pluginId, scope: 'global', enabled, config: {}, sortOrder: 0 });
198
+ }
199
+ // 全局禁用时:同步禁用所有世界级绑定,防止游戏内残留启用状态
200
+ if (!enabled) {
201
+ const allWorldBindings = await getPluginBindings('world');
202
+ const worldBindingsForPlugin = allWorldBindings.filter(b => b.extensionId === pluginId);
203
+ for (const wb of worldBindingsForPlugin) {
204
+ await upsertPluginBinding({
205
+ extensionId: pluginId,
206
+ scope: 'world',
207
+ worldId: wb.worldId,
208
+ enabled: false,
209
+ config: wb.config || {},
210
+ sortOrder: wb.sortOrder || 0,
211
+ });
212
+ }
213
+ }
214
+ await refreshPlugins();
215
+ const plugin = await getPlugin(pluginId);
216
+ toast(enabled ? `已启用「${plugin?.name}」` : `已禁用「${plugin?.name}」`, 'success')
217
+ // 广播变更事件,通知游戏页实时响应
218
+ broadcastPluginBindingChange({ pluginId, enabled, source: 'global' });
219
+ }, [pluginBindings, refreshPlugins]);
220
+
221
+ // 一键禁用所有插件
222
+ const handleDisableAllPlugins = useCallback(async () => {
223
+ const enabledBindings = pluginBindings.filter(b => b.enabled);
224
+ if (enabledBindings.length === 0) return;
225
+ for (const binding of enabledBindings) {
226
+ await upsertPluginBinding({
227
+ id: binding.id,
228
+ extensionId: binding.extensionId,
229
+ scope: 'global',
230
+ enabled: false,
231
+ config: binding.config,
232
+ sortOrder: binding.sortOrder,
233
+ });
234
+ // 同步禁用所有世界级绑定,防止游戏内残留启用状态
235
+ const allWorldBindings = await getPluginBindings('world');
236
+ const worldBindingsForPlugin = allWorldBindings.filter(b => b.extensionId === binding.extensionId);
237
+ for (const wb of worldBindingsForPlugin) {
238
+ await upsertPluginBinding({
239
+ extensionId: binding.extensionId,
240
+ scope: 'world',
241
+ worldId: wb.worldId,
242
+ enabled: false,
243
+ config: wb.config || {},
244
+ sortOrder: wb.sortOrder || 0,
245
+ });
246
+ }
247
+ broadcastPluginBindingChange({ pluginId: binding.extensionId, enabled: false, source: 'global' });
248
+ }
249
+ await refreshPlugins();
250
+ toast(`已禁用全部 ${enabledBindings.length} 个插件`, 'success');
251
+ }, [pluginBindings, refreshPlugins]);
252
+
253
+ const handleRefreshAllPlugins = useCallback(async () => {
254
+ await reloadPlugins();
255
+ await refreshPlugins();
256
+ toast('已刷新全部插件', 'success');
257
+ }, [reloadPlugins, refreshPlugins]);
258
+
259
+ const handleDeletePlugin = useCallback((plugin: PluginManifest) => {
260
+ setConfirmConfig({
261
+ title: '删除插件',
262
+ message: `确定要删除插件「${plugin.name}」吗?此操作不可撤销。`,
263
+ danger: true,
264
+ onConfirm: async () => {
265
+ await deletePlugin(plugin.id);
266
+ await refreshPlugins();
267
+ setConfirmOpen(false);
268
+ },
269
+ });
270
+ setConfirmOpen(true);
271
+ }, [refreshPlugins]);
272
+
273
+ useEffect(() => {
274
+ // 从后端加载设置,失败时使用默认值
275
+ fetch('/api/settings')
276
+ .then(r => r.json())
277
+ .then(data => {
278
+ if (data && !data.error) {
279
+ const merged = { ...getDefaultAppSettings(), ...data };
280
+ setSettings(merged);
281
+ setInitialSettings(merged);
282
+ return;
283
+ }
284
+ throw new Error('API error');
285
+ })
286
+ .catch(() => {
287
+ const defaults = getDefaultAppSettings();
288
+ setSettings(defaults);
289
+ setInitialSettings(defaults);
290
+ });
291
+ refreshSessions().then();
292
+ refreshPlugins().then();
293
+ }, [refreshPlugins]);
294
+
295
+ const updateSetting = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
296
+ setSettings((prev) => ({ ...prev, [key]: value }));
297
+ setSaved(false);
298
+ }, []);
299
+
300
+ const handleSave = useCallback(async () => {
301
+ setSaving(true);
302
+ try {
303
+ const res = await fetch('/api/settings', {
304
+ method: 'PUT',
305
+ headers: { 'Content-Type': 'application/json' },
306
+ body: JSON.stringify(settings),
307
+ });
308
+ if (!res.ok) throw new Error();
309
+ setInitialSettings({ ...settings });
310
+ setSaved(true);
311
+ setTimeout(() => setSaved(false), 2000);
312
+ // 同步 toast 设置到 PluginProvider
313
+ updateToastSettings(settings.pluginToastEnabled, settings.pluginToastLevels);
314
+ } catch {
315
+ toast('保存失败', 'error');
316
+ } finally {
317
+ setSaving(false);
318
+ }
319
+ }, [settings, updateToastSettings]);
320
+
321
+ const handleReset = useCallback(async () => {
322
+ const defaults = getDefaultAppSettings();
323
+ setSettings(defaults);
324
+ setSaving(true);
325
+ try {
326
+ await fetch('/api/settings', {
327
+ method: 'PUT',
328
+ headers: { 'Content-Type': 'application/json' },
329
+ body: JSON.stringify(defaults),
330
+ });
331
+ } catch {
332
+ // ignore
333
+ }
334
+ setInitialSettings(defaults);
335
+ setSaving(false);
336
+ setSaved(true);
337
+ setTimeout(() => setSaved(false), 2000);
338
+ }, []);
339
+
340
+ const handleClearSessions = useCallback(() => {
341
+ setConfirmConfig({
342
+ title: '清除全部游戏记录',
343
+ message: '确定要删除所有游戏记录吗?此操作不可撤销。',
344
+ danger: true,
345
+ onConfirm: async () => {
346
+ await clearAllGameSessions();
347
+ await refreshSessions();
348
+ setConfirmOpen(false);
349
+ },
350
+ });
351
+ setConfirmOpen(true);
352
+ }, []);
353
+
354
+ const handleDeleteSession = useCallback((id: string) => {
355
+ setConfirmConfig({
356
+ title: '删除游戏记录',
357
+ message: '确定要删除这条游戏记录吗?',
358
+ danger: true,
359
+ onConfirm: async () => {
360
+ await deleteGameSession(id);
361
+ await refreshSessions();
362
+ setConfirmOpen(false);
363
+ },
364
+ });
365
+ setConfirmOpen(true);
366
+ }, []);
367
+
368
+ const handlePresetSelect = useCallback((preset: (typeof API_PRESETS)[number]) => {
369
+ setSettings((prev) => ({ ...prev, aiApiBase: preset.base, aiModel: preset.model }));
370
+ setSaved(false);
371
+ }, []);
372
+
373
+ const handleStartCustomTheme = useCallback(() => {
374
+ setCustomVars({ ...activeTheme.variables });
375
+ setCustomIsDark(activeTheme.isDark);
376
+ setEditingThemeId(null);
377
+ setIsEditingCustom(true);
378
+ }, [activeTheme]);
379
+
380
+ const handleEditCustomTheme = useCallback((theme: Theme) => {
381
+ setCustomVars({ ...theme.variables });
382
+ setCustomIsDark(theme.isDark);
383
+ setEditingThemeId(theme.id);
384
+ setIsEditingCustom(true);
385
+ }, []);
386
+
387
+ const handleApplyCustomTheme = useCallback(() => {
388
+ const theme: Theme = {
389
+ id: editingThemeId || `custom-${Date.now()}`,
390
+ name: editingThemeId
391
+ ? (config.importedThemes || []).find(t => t.id === editingThemeId)?.name || '自定义主题'
392
+ : '自定义主题',
393
+ description: '用户自定义主题',
394
+ isDark: customIsDark,
395
+ variables: customVars,
396
+ };
397
+ addImportedTheme(theme);
398
+ setIsEditingCustom(false);
399
+ setEditingThemeId(null);
400
+ }, [editingThemeId, config.importedThemes, activeTheme, customVars, addImportedTheme]);
401
+
402
+ const handleDeleteCustomTheme = useCallback((id: string) => {
403
+ removeImportedTheme(id);
404
+ if (editingThemeId === id) {
405
+ setIsEditingCustom(false);
406
+ setEditingThemeId(null);
407
+ }
408
+ }, [removeImportedTheme, editingThemeId]);
409
+
410
+ // ---- API 测试连接 ----
411
+ const handleTestApi = useCallback(async () => {
412
+ setTestingApi(true);
413
+ setTestResult(null);
414
+ try {
415
+ const res = await fetch('/api/test-api', {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/json' },
418
+ body: JSON.stringify({
419
+ apiKey: settings.aiApiKey,
420
+ apiBase: settings.aiApiBase,
421
+ model: settings.aiModel,
422
+ }),
423
+ });
424
+ const data = await res.json();
425
+ if (data.success) {
426
+ setTestResult({ success: true, message: data.message });
427
+ } else {
428
+ setTestResult({ success: false, message: data.error + (data.detail ? `: ${data.detail}` : '') });
429
+ }
430
+ } catch {
431
+ setTestResult({ success: false, message: '网络请求失败' });
432
+ } finally {
433
+ setTestingApi(false);
434
+ setTimeout(() => setTestResult(null), 5000);
435
+ }
436
+ }, [settings]);
437
+
438
+ // ---- AI 生成主题 ----
439
+ const handleGenerateTheme = useCallback(async () => {
440
+ if (!aiThemePrompt.trim()) return;
441
+ setGeneratingTheme(true);
442
+ try {
443
+ const res = await fetch('/api/generate-theme', {
444
+ method: 'POST',
445
+ headers: { 'Content-Type': 'application/json' },
446
+ body: JSON.stringify({
447
+ prompt: aiThemePrompt,
448
+ format: aiThemeFormat,
449
+ apiConfig: {
450
+ apiKey: settings.aiApiKey,
451
+ apiBase: settings.aiApiBase,
452
+ model: settings.aiModel,
453
+ },
454
+ }),
455
+ });
456
+ const data = await res.json();
457
+ if (data.error) { alert(data.error); return; }
458
+
459
+ if (data.type === 'json' && data.content) {
460
+ const d = data.content;
461
+ const theme: Theme = {
462
+ id: `ai-${Date.now()}`,
463
+ name: d.name || aiThemePrompt.slice(0, 20),
464
+ description: d.description || `AI 生成: ${aiThemePrompt.slice(0, 30)}`,
465
+ isDark: d.isDark ?? true,
466
+ variables: d.variables || {},
467
+ preview: { type: 'json', content: JSON.stringify(d, null, 2) },
468
+ };
469
+ addImportedTheme(theme);
470
+ } else if (data.type === 'svg' && data.content) {
471
+ const svgText = data.content;
472
+ const colorMatches = svgText.match(/#[0-9a-fA-F]{3,8}/g) || [];
473
+ const bg = colorMatches[0] || '#1a1a2e';
474
+ const accent = colorMatches.find((c: string) => c !== bg) || '#d4a843';
475
+ const theme: Theme = {
476
+ id: `ai-${Date.now()}`,
477
+ name: aiThemePrompt.slice(0, 20),
478
+ description: `AI 生成 SVG: ${aiThemePrompt.slice(0, 30)}`,
479
+ isDark: true,
480
+ variables: {
481
+ '--color-bg-primary': bg,
482
+ '--color-bg-secondary': bg,
483
+ '--color-bg-tertiary': accent + '33',
484
+ '--color-text-primary': '#e8e0f0',
485
+ '--color-text-secondary': '#a89bc2',
486
+ '--color-text-muted': '#6b5f85',
487
+ '--color-accent': accent,
488
+ '--color-accent-hover': accent,
489
+ '--color-border': accent + '44',
490
+ '--color-shadow': 'rgba(0,0,0,0.5)',
491
+ '--color-user-bubble': accent + '33',
492
+ '--color-ai-bubble': bg,
493
+ '--font-body': "'Noto Sans SC', sans-serif",
494
+ '--font-heading': "'Noto Sans SC', serif",
495
+ '--border-radius': '12px',
496
+ },
497
+ preview: { type: 'svg', content: svgText },
498
+ };
499
+ addImportedTheme(theme);
500
+ }
501
+ } catch {
502
+ alert('生成主题失败');
503
+ } finally {
504
+ setGeneratingTheme(false);
505
+ }
506
+ }, [aiThemePrompt, aiThemeFormat, settings, addImportedTheme]);
507
+
508
+ // ---- 导出自定义主题 ----
509
+ const customThemes = (config.importedThemes || []).filter(t => t.id.startsWith('custom-'));
510
+
511
+ const handleExportThemeById = useCallback((theme: Theme, format: 'svg' | 'json') => {
512
+ const vars = theme.variables;
513
+ const name = theme.name;
514
+ if (format === 'json') {
515
+ const data = {
516
+ name,
517
+ description: `${name} 配色方案`,
518
+ isDark: theme.isDark,
519
+ variables: vars,
520
+ };
521
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
522
+ const url = URL.createObjectURL(blob);
523
+ const a = document.createElement('a');
524
+ a.href = url; a.download = `${name}.json`; a.click();
525
+ URL.revokeObjectURL(url);
526
+ } else {
527
+ // 生成 SVG 色卡
528
+ const bg = vars['--color-bg-primary'] || '#1a1a2e';
529
+ const bgSec = vars['--color-bg-secondary'] || bg;
530
+ const bgTer = vars['--color-bg-tertiary'] || '#2a2a4e';
531
+ const textPri = vars['--color-text-primary'] || '#e8e0f0';
532
+ const textSec = vars['--color-text-secondary'] || '#a89bc2';
533
+ const textMut = vars['--color-text-muted'] || '#6b5f85';
534
+ const accent = vars['--color-accent'] || '#d4a843';
535
+ const border = vars['--color-border'] || '#3a3a5e';
536
+ const userBub = vars['--color-user-bubble'] || '#d4a84333';
537
+ const aiBub = vars['--color-ai-bubble'] || '#2a2a4e';
538
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 560">
539
+ <metadata><isDark>${theme.isDark}</isDark></metadata>
540
+ <defs>
541
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
542
+ <stop offset="0%" stop-color="${bg}"/>
543
+ <stop offset="100%" stop-color="${bgSec}"/>
544
+ </linearGradient>
545
+ </defs>
546
+ <rect width="400" height="560" fill="url(#bg)" rx="16"/>
547
+ <text x="200" y="50" text-anchor="middle" fill="${accent}" font-size="22" font-weight="bold" font-family="sans-serif">${name}</text>
548
+ <text x="200" y="75" text-anchor="middle" fill="${textMut}" font-size="12" font-family="sans-serif">Color Palette</text>
549
+ ${[
550
+ ['背景色', bg], ['次背景', bgSec], ['三级背景', bgTer],
551
+ ['主文字', textPri], ['次文字', textSec], ['弱化文字', textMut],
552
+ ['强调色', accent], ['边框色', border], ['用户气泡', userBub], ['AI气泡', aiBub],
553
+ ].map(([label, color], i) => {
554
+ const y = 105 + i * 42;
555
+ return `<rect x="40" y="${y}" width="36" height="28" rx="6" fill="${color}" stroke="${border}" stroke-width="0.5"/>
556
+ <text x="88" y="${y + 18}" fill="${textPri}" font-size="13" font-family="sans-serif">${label}</text>
557
+ <text x="360" y="${y + 18}" text-anchor="end" fill="${textSec}" font-size="11" font-family="monospace">${color}</text>`;
558
+ }).join('\n ')}
559
+ </svg>`;
560
+ const blob = new Blob([svg], { type: 'image/svg+xml' });
561
+ const url = URL.createObjectURL(blob);
562
+ const a = document.createElement('a');
563
+ a.href = url; a.download = `${name}.svg`; a.click();
564
+ URL.revokeObjectURL(url);
565
+ }
566
+ }, []);
567
+
568
+ const hasChanges = initialSettings ? JSON.stringify(settings) !== JSON.stringify(initialSettings) : false;
569
+
570
+ // ---- 导入主题 ----
571
+ const handleImportTheme = useCallback(async (file: File) => {
572
+ try {
573
+ const text = await file.text();
574
+ const ext = file.name.split('.').pop()?.toLowerCase();
575
+ let theme: Theme | null = null;
576
+
577
+ if (ext === 'svg') {
578
+ // SVG 主题:提取 script 标签中的 JSON,如果没有则用默认变量
579
+ const dataMatch = text.match(/<script[^>]*id="world-data"[^>]*>([\s\S]*?)<\/script>/i);
580
+ let themeData: Record<string, unknown> = {};
581
+ if (dataMatch) {
582
+ try { themeData = JSON.parse(dataMatch[1]); } catch { /* ignore */ }
583
+ }
584
+ // 从 SVG 中提取 isDark
585
+ const isDarkMatch = text.match(/<isDark>(true|false)<\/isDark>/i);
586
+ const svgIsDark = isDarkMatch ? isDarkMatch[1].toLowerCase() === 'true' : null;
587
+ // 从 SVG 中提取颜色作为主题变量
588
+ const colorMatches = text.match(/#[0-9a-fA-F]{3,8}/g) || [];
589
+ const bg = colorMatches[0] || '#1a1a2e';
590
+ const accent = colorMatches.find((c: string) => c !== bg) || '#d4a843';
591
+ theme = {
592
+ id: `imported-${Date.now()}`,
593
+ name: file.name.replace(/\.svg$/i, ''),
594
+ description: '从 SVG 导入的主题',
595
+ isDark: svgIsDark ?? true,
596
+ variables: {
597
+ '--color-bg-primary': bg,
598
+ '--color-bg-secondary': bg,
599
+ '--color-bg-tertiary': accent + '33',
600
+ '--color-text-primary': '#e8e0f0',
601
+ '--color-text-secondary': '#a89bc2',
602
+ '--color-text-muted': '#6b5f85',
603
+ '--color-accent': accent,
604
+ '--color-accent-hover': accent,
605
+ '--color-border': accent + '44',
606
+ '--color-shadow': 'rgba(0,0,0,0.5)',
607
+ '--color-user-bubble': accent + '33',
608
+ '--color-ai-bubble': bg,
609
+ '--font-body': "'Noto Sans SC', sans-serif",
610
+ '--font-heading': "'Noto Sans SC', serif",
611
+ '--border-radius': '12px',
612
+ },
613
+ preview: { type: 'svg', content: text },
614
+ };
615
+ // 如果 SVG 中有 _labels 数据,用 title 命名
616
+ if (themeData.title) theme.name = String(themeData.title);
617
+ } else if (ext === 'json') {
618
+ // JSON 主题:解析为主题变量
619
+ const data = JSON.parse(text);
620
+ if (data.variables && typeof data.variables === 'object') {
621
+ theme = {
622
+ id: `imported-${Date.now()}`,
623
+ name: data.name || file.name.replace(/\.json$/i, ''),
624
+ description: data.description || '从 JSON 导入的主题',
625
+ isDark: data.isDark ?? true,
626
+ variables: data.variables,
627
+ preview: { type: 'json', content: JSON.stringify(data, null, 2) },
628
+ };
629
+ } else {
630
+ // 尝试从 JSON 中提取颜色
631
+ const allColors = JSON.stringify(data).match(/#[0-9a-fA-F]{3,8}/g) || [];
632
+ if (allColors.length >= 2) {
633
+ theme = {
634
+ id: `imported-${Date.now()}`,
635
+ name: data.title || data.name || file.name.replace(/\.json$/i, ''),
636
+ description: '从 JSON 导入的主题',
637
+ isDark: true,
638
+ variables: {
639
+ '--color-bg-primary': allColors[0] || '#1a1a2e',
640
+ '--color-bg-secondary': allColors[0] || '#1a1a2e',
641
+ '--color-bg-tertiary': (allColors[1] || '#d4a843') + '33',
642
+ '--color-text-primary': '#e8e0f0',
643
+ '--color-text-secondary': '#a89bc2',
644
+ '--color-text-muted': '#6b5f85',
645
+ '--color-accent': allColors[1] || '#d4a843',
646
+ '--color-accent-hover': allColors[1] || '#d4a843',
647
+ '--color-border': (allColors[1] || '#d4a843') + '44',
648
+ '--color-shadow': 'rgba(0,0,0,0.5)',
649
+ '--color-user-bubble': (allColors[1] || '#d4a843') + '33',
650
+ '--color-ai-bubble': allColors[0] || '#1a1a2e',
651
+ '--font-body': "'Noto Sans SC', sans-serif",
652
+ '--font-heading': "'Noto Sans SC', serif",
653
+ '--border-radius': '12px',
654
+ },
655
+ preview: { type: 'json', content: JSON.stringify(data, null, 2) },
656
+ };
657
+ }
658
+ }
659
+ }
660
+
661
+ if (theme) {
662
+ addImportedTheme(theme);
663
+ } else {
664
+ alert('无法识别的主题文件格式');
665
+ }
666
+ } catch {
667
+ alert('导入主题失败:文件格式不正确');
668
+ }
669
+ }, [addImportedTheme]);
670
+
671
+ // ==================== 颜色编辑器变量 ====================
672
+ const colorVars = [
673
+ { key: '--color-bg-primary', label: '主背景色' },
674
+ { key: '--color-bg-secondary', label: '次级背景色' },
675
+ { key: '--color-text-primary', label: '主文字色' },
676
+ { key: '--color-accent', label: '强调色' },
677
+ { key: '--color-accent-hover', label: '强调色悬停' },
678
+ { key: '--color-user-bubble', label: '用户气泡色' },
679
+ { key: '--color-ai-bubble', label: 'AI 气泡色' },
680
+ { key: '--color-border', label: '边框色' },
681
+ ];
682
+
683
+ // ==================== 渲染右侧内容 ====================
684
+
685
+ const renderContent = () => {
686
+ switch (activeCategory) {
687
+ case 'api':
688
+ return (
689
+ <ColumnLayout>
690
+ <SettingCard title="API 供应商预设">
691
+ <div className="py-3 flex flex-wrap gap-2">
692
+ {API_PRESETS.map((p) => (
693
+ <button key={p.name} onClick={() => handlePresetSelect(p)}
694
+ className="px-3 py-1.5 rounded-lg text-xs border transition-colors"
695
+ style={{
696
+ borderColor: settings.aiApiBase === p.base ? 'var(--color-accent)' : 'var(--color-border)',
697
+ backgroundColor: settings.aiApiBase === p.base ? 'var(--color-bg-tertiary)' : 'transparent',
698
+ color: settings.aiApiBase === p.base ? 'var(--color-accent)' : 'var(--color-text-secondary)',
699
+ }}
700
+ >{p.name}</button>
701
+ ))}
702
+ </div>
703
+ </SettingCard>
704
+
705
+ <SettingCard title="连接配置">
706
+ <SettingRow label="API Key" description="仅存储在本地浏览器中,不会上传">
707
+ <div className="relative">
708
+ <input
709
+ type={showKey ? 'text' : 'password'}
710
+ value={settings.aiApiKey}
711
+ onChange={(e) => updateSetting('aiApiKey', e.target.value)}
712
+ placeholder="sk-..."
713
+ className="w-52 px-3 py-1.5 rounded-lg text-sm border outline-none transition-colors pr-9"
714
+ style={{
715
+ backgroundColor: 'var(--color-bg-tertiary)',
716
+ borderColor: 'var(--color-border)',
717
+ color: 'var(--color-text-primary)',
718
+ fontFamily: 'monospace',
719
+ }}
720
+ onFocus={(e) => (e.target.style.borderColor = 'var(--color-accent)')}
721
+ onBlur={(e) => (e.target.style.borderColor = 'var(--color-border)')}
722
+ />
723
+ <button type="button" onClick={() => setShowKey(!showKey)}
724
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5"
725
+ style={{ color: 'var(--color-text-muted)' }}
726
+ >
727
+ {showKey ? (
728
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
729
+ <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
730
+ <line x1="1" y1="1" x2="23" y2="23" />
731
+ </svg>
732
+ ) : (
733
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
734
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
735
+ <circle cx="12" cy="12" r="3" />
736
+ </svg>
737
+ )}
738
+ </button>
739
+ </div>
740
+ </SettingRow>
741
+ <SettingRow label="API 地址" description="兼容 OpenAI 格式的端点">
742
+ <TextField value={settings.aiApiBase} onChange={(v) => updateSetting('aiApiBase', v)} placeholder="https://api.openai.com/v1" mono />
743
+ </SettingRow>
744
+ <SettingRow label="模型名称">
745
+ <TextField value={settings.aiModel} onChange={(v) => updateSetting('aiModel', v)} placeholder="gpt-4o" mono />
746
+ </SettingRow>
747
+ {/* 测试连接 */}
748
+ <div>
749
+ {testResult && (
750
+ <div className="text-xs pt-2" style={{ color: testResult.success ? '#22c55e' : '#ef4444' }}>
751
+ <i className={`fa-solid ${testResult.success ? 'fa-circle-check' : 'fa-circle-xmark'} mr-1`} />
752
+ {testResult.message}
753
+ </div>
754
+ )}
755
+ <div className="flex justify-end pt-2">
756
+ <button onClick={handleTestApi} disabled={testingApi || !settings.aiApiKey}
757
+ className="px-4 py-2 rounded-lg text-sm font-medium transition-colors"
758
+ style={{ backgroundColor: 'var(--color-accent)', color: activeTheme.isDark ? '#000' : '#fff', opacity: (testingApi || !settings.aiApiKey) ? 0.5 : 1 }}>
759
+ {testingApi ? '测试中...' : '测试连接'}
760
+ </button>
761
+ </div>
762
+ </div>
763
+ </SettingCard>
764
+
765
+ <SettingCard title="生成参数">
766
+ <SettingRow label="Temperature" description="越高回复越有创意,越低越稳定">
767
+ <div className="flex items-center gap-3 w-52">
768
+ <input type="range" min={0} max={2} step={0.1} value={settings.aiTemperature}
769
+ onChange={(e) => updateSetting('aiTemperature', parseFloat(e.target.value))}
770
+ className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
771
+ style={{ backgroundColor: 'var(--color-bg-tertiary)', accentColor: 'var(--color-accent)' }}
772
+ />
773
+ <span className="text-xs font-mono w-8 text-right" style={{ color: 'var(--color-text-secondary)' }}>
774
+ {settings.aiTemperature.toFixed(1)}
775
+ </span>
776
+ </div>
777
+ </SettingRow>
778
+ <SettingRow label="最大 Token 数" description="单次回复的最大长度">
779
+ <div className="flex items-center gap-3 w-52">
780
+ <input type="range" min={128} max={4096} step={128} value={settings.aiMaxTokens}
781
+ onChange={(e) => updateSetting('aiMaxTokens', parseInt(e.target.value))}
782
+ className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
783
+ style={{ backgroundColor: 'var(--color-bg-tertiary)', accentColor: 'var(--color-accent)' }}
784
+ />
785
+ <span className="text-xs font-mono w-10 text-right" style={{ color: 'var(--color-text-secondary)' }}>
786
+ {settings.aiMaxTokens}
787
+ </span>
788
+ </div>
789
+ </SettingRow>
790
+ </SettingCard>
791
+ </ColumnLayout>
792
+ );
793
+
794
+ case 'chat':
795
+ return (
796
+ <ColumnLayout>
797
+ <SettingCard title="全局追加指令">
798
+ <div className="py-3">
799
+ <p className="text-xs mb-2" style={{ color: 'var(--color-text-muted)' }}>
800
+ 追加到每次 AI 对话的 system prompt 末尾,适用于所有游戏。可用于控制回复风格、长度等。
801
+ </p>
802
+ <textarea
803
+ value={settings.systemPromptExtra}
804
+ onChange={(e) => updateSetting('systemPromptExtra', e.target.value)}
805
+ placeholder="例如:请用更简洁的语言回复,每次不超过200字"
806
+ rows={5}
807
+ className="w-full px-3 py-2 rounded-lg text-sm border outline-none resize-none transition-colors"
808
+ style={{
809
+ backgroundColor: 'var(--color-bg-tertiary)',
810
+ borderColor: 'var(--color-border)',
811
+ color: 'var(--color-text-primary)',
812
+ }}
813
+ onFocus={(e) => (e.target.style.borderColor = 'var(--color-accent)')}
814
+ onBlur={(e) => (e.target.style.borderColor = 'var(--color-border)')}
815
+ />
816
+ </div>
817
+ </SettingCard>
818
+ </ColumnLayout>
819
+ );
820
+
821
+ case 'theme':
822
+ return (
823
+ <ColumnLayout>
824
+ <SettingCard title="预设主题">
825
+ <div className="py-3 grid grid-cols-2 gap-2">
826
+ {config.presets.map((theme) => (
827
+ <button key={theme.id} onClick={() => { setActiveThemeId(theme.id); setIsEditingCustom(false); }}
828
+ className="flex items-center gap-3 px-3 py-2.5 rounded-lg border text-left transition-colors"
829
+ style={{
830
+ borderColor: activeTheme.id === theme.id ? 'var(--color-accent)' : 'var(--color-border)',
831
+ backgroundColor: activeTheme.id === theme.id ? 'var(--color-bg-tertiary)' : 'transparent',
832
+ }}
833
+ >
834
+ <div className="flex gap-1 shrink-0">
835
+ <span className="w-5 h-5 rounded-full border" style={{
836
+ backgroundColor: theme.variables['--color-bg-primary'],
837
+ borderColor: theme.variables['--color-border'],
838
+ }} />
839
+ <span className="w-5 h-5 rounded-full" style={{
840
+ backgroundColor: theme.variables['--color-accent'],
841
+ }} />
842
+ </div>
843
+ <div className="min-w-0">
844
+ <div className="text-sm font-medium truncate" style={{ color: 'var(--color-text-primary)' }}>
845
+ {theme.name}
846
+ {activeTheme.id === theme.id && (
847
+ <span className="ml-1" style={{ color: 'var(--color-accent)' }}>✓</span>
848
+ )}
849
+ </div>
850
+ <div className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
851
+ {theme.description}
852
+ </div>
853
+ </div>
854
+ </button>
855
+ ))}
856
+ </div>
857
+ </SettingCard>
858
+
859
+ <SettingCard title="导入主题">
860
+ <div className="py-3">
861
+ <input ref={importThemeRef} type="file" accept=".svg,.json" className="hidden"
862
+ onChange={(e) => { const f = e.target.files?.[0]; if (f) handleImportTheme(f); e.target.value = ''; }} />
863
+ <button onClick={() => importThemeRef.current?.click()}
864
+ className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors"
865
+ style={{ borderColor: 'var(--color-accent)', color: 'var(--color-accent)', backgroundColor: 'transparent' }}>
866
+ <i className="fa-solid fa-file-import" style={{ fontSize: '12px' }} />
867
+ 导入 SVG 或 JSON 主题文件
868
+ </button>
869
+ <p className="text-xs mt-2" style={{ color: 'var(--color-text-muted)' }}>
870
+ SVG 文件会自动提取配色方案,JSON 文件需包含 variables 字段
871
+ </p>
872
+ </div>
873
+ </SettingCard>
874
+
875
+ <SettingCard title="自定义主题">
876
+ <div className="py-3">
877
+ {isEditingCustom ? (
878
+ <div className="space-y-3">
879
+ <div className="text-xs font-medium" style={{ color: 'var(--color-text-muted)' }}>
880
+ {editingThemeId ? '编辑自定义主题' : '创建自定义主题'}
881
+ </div>
882
+ <div className="flex items-center gap-3 mb-2">
883
+ <span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>主题类型</span>
884
+ <button
885
+ onClick={() => setCustomIsDark(false)}
886
+ className="px-3 py-1 rounded-lg text-xs border transition-colors"
887
+ style={{
888
+ borderColor: !customIsDark ? 'var(--color-accent)' : 'var(--color-border)',
889
+ backgroundColor: !customIsDark ? 'var(--color-accent)' : 'transparent',
890
+ color: !customIsDark ? (activeTheme.isDark ? '#000' : '#fff') : 'var(--color-text-secondary)',
891
+ }}
892
+ >☀️ 浅色</button>
893
+ <button
894
+ onClick={() => setCustomIsDark(true)}
895
+ className="px-3 py-1 rounded-lg text-xs border transition-colors"
896
+ style={{
897
+ borderColor: customIsDark ? 'var(--color-accent)' : 'var(--color-border)',
898
+ backgroundColor: customIsDark ? 'var(--color-accent)' : 'transparent',
899
+ color: customIsDark ? (activeTheme.isDark ? '#000' : '#fff') : 'var(--color-text-secondary)',
900
+ }}
901
+ >🌙 深色</button>
902
+ </div>
903
+ <div className="grid grid-cols-2 gap-x-4 gap-y-2.5">
904
+ {colorVars.map(({ key, label }) => (
905
+ <div key={key} className="flex items-center gap-2">
906
+ <span className="text-xs w-20 shrink-0" style={{ color: 'var(--color-text-secondary)' }}>
907
+ {label}
908
+ </span>
909
+ <input type="color" value={customVars[key] || '#000000'}
910
+ onChange={(e) => setCustomVars((p) => ({ ...p, [key]: e.target.value }))}
911
+ className="w-7 h-7 rounded cursor-pointer border-0 p-0 shrink-0"
912
+ />
913
+ <input type="text" value={customVars[key] || ''}
914
+ onChange={(e) => setCustomVars((p) => ({ ...p, [key]: e.target.value }))}
915
+ className="flex-1 min-w-0 px-2 py-1 rounded text-xs border"
916
+ style={{
917
+ backgroundColor: 'var(--color-bg-tertiary)',
918
+ borderColor: 'var(--color-border)',
919
+ color: 'var(--color-text-primary)',
920
+ fontFamily: 'monospace',
921
+ }}
922
+ />
923
+ </div>
924
+ ))}
925
+ </div>
926
+ <div className="flex gap-2 pt-1">
927
+ <button onClick={handleApplyCustomTheme}
928
+ className="flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
929
+ style={{ backgroundColor: 'var(--color-accent)', color: activeTheme.isDark ? '#000' : '#fff' }}
930
+ >{editingThemeId ? '保存修改' : '添加主题'}</button>
931
+ <button onClick={() => { setIsEditingCustom(false); setEditingThemeId(null); }}
932
+ className="px-4 py-2 rounded-lg text-sm border transition-colors"
933
+ style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
934
+ >取消</button>
935
+ </div>
936
+ </div>
937
+ ) : (
938
+ <>
939
+ <button onClick={handleStartCustomTheme}
940
+ className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors mb-3"
941
+ style={{
942
+ borderColor: 'var(--color-accent)',
943
+ color: 'var(--color-accent)',
944
+ backgroundColor: 'transparent',
945
+ }}
946
+ >
947
+ <i className="fa-solid fa-plus mr-1" style={{ fontSize: '11px' }} />
948
+ 创建自定义主题
949
+ </button>
950
+ {customThemes.length > 0 ? (
951
+ <div className="space-y-2">
952
+ {customThemes.map((t) => (
953
+ <div key={t.id} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border group"
954
+ style={{
955
+ borderColor: activeTheme.id === t.id ? 'var(--color-accent)' : 'var(--color-border)',
956
+ backgroundColor: activeTheme.id === t.id ? 'var(--color-bg-tertiary)' : 'transparent',
957
+ }}>
958
+ <div className="w-10 h-14 rounded flex flex-col items-center justify-center gap-0.5 shrink-0"
959
+ style={{ backgroundColor: t.variables['--color-bg-primary'], border: `1px solid ${t.variables['--color-border']}` }}>
960
+ <span className="w-4 h-4 rounded-full" style={{ backgroundColor: t.variables['--color-accent'] }} />
961
+ <span className="w-4 h-4 rounded-full" style={{ backgroundColor: t.variables['--color-text-primary'] }} />
962
+ </div>
963
+ <div className="flex-1 min-w-0 cursor-pointer" onClick={() => setActiveThemeId(t.id)}>
964
+ <div className="text-sm font-medium truncate" style={{ color: 'var(--color-text-primary)' }}>
965
+ {t.name}
966
+ {activeTheme.id === t.id && <span className="ml-1" style={{ color: 'var(--color-accent)' }}>✓</span>}
967
+ </div>
968
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
969
+ {t.isDark ? '深色' : '浅色'} · 自定义
970
+ </div>
971
+ </div>
972
+ <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
973
+ <button onClick={() => handleEditCustomTheme(t)} title="编辑"
974
+ className="p-1.5 rounded transition-colors"
975
+ style={{ color: 'var(--color-text-muted)' }}>
976
+ <i className="fa-solid fa-pen" style={{ fontSize: '10px' }} />
977
+ </button>
978
+ <button onClick={() => handleExportThemeById(t, 'json')} title="导出 JSON"
979
+ className="p-1.5 rounded transition-colors"
980
+ style={{ color: 'var(--color-text-muted)' }}>
981
+ <i className="fa-solid fa-file-arrow-down" style={{ fontSize: '10px' }} />
982
+ </button>
983
+ <button onClick={() => handleExportThemeById(t, 'svg')} title="导出 SVG"
984
+ className="p-1.5 rounded transition-colors"
985
+ style={{ color: 'var(--color-text-muted)' }}>
986
+ <i className="fa-solid fa-palette" style={{ fontSize: '10px' }} />
987
+ </button>
988
+ <button onClick={() => handleDeleteCustomTheme(t.id)} title="删除"
989
+ className="p-1.5 rounded transition-colors"
990
+ style={{ color: '#ef4444' }}>
991
+ <i className="fa-solid fa-trash-can" style={{ fontSize: '10px' }} />
992
+ </button>
993
+ </div>
994
+ </div>
995
+ ))}
996
+ </div>
997
+ ) : (
998
+ <div className="flex flex-col items-center justify-center py-8 gap-2 opacity-40">
999
+ <i className="fa-regular fa-palette" style={{ color: 'var(--color-text-muted)', fontSize: '28px' }} />
1000
+ <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>暂无自定义主题</p>
1001
+ </div>
1002
+ )}
1003
+ </>
1004
+ )}
1005
+ </div>
1006
+ </SettingCard>
1007
+
1008
+ <SettingCard title="AI 生成配色方案">
1009
+ <div className="py-3 space-y-3">
1010
+ <textarea
1011
+ value={aiThemePrompt}
1012
+ onChange={(e) => setAiThemePrompt(e.target.value)}
1013
+ placeholder="描述你想要的配色风格,例如:深海蓝绿色调、温暖日落橙、赛博朋克霓虹..."
1014
+ className="w-full px-3 py-2 rounded-lg text-sm border outline-none resize-none"
1015
+ style={{
1016
+ backgroundColor: 'var(--color-bg-tertiary)',
1017
+ borderColor: 'var(--color-border)',
1018
+ color: 'var(--color-text-primary)',
1019
+ minHeight: '60px',
1020
+ }}
1021
+ rows={2}
1022
+ />
1023
+ <div className="flex items-center gap-3">
1024
+ <div className="flex rounded-lg border overflow-hidden" style={{ borderColor: 'var(--color-border)' }}>
1025
+ <button onClick={() => setAiThemeFormat('json')}
1026
+ className="px-3 py-1.5 text-xs transition-colors"
1027
+ style={{
1028
+ backgroundColor: aiThemeFormat === 'json' ? 'var(--color-accent)' : 'transparent',
1029
+ color: aiThemeFormat === 'json' ? (activeTheme.isDark ? '#000' : '#fff') : 'var(--color-text-secondary)',
1030
+ }}>JSON</button>
1031
+ <button onClick={() => setAiThemeFormat('svg')}
1032
+ className="px-3 py-1.5 text-xs transition-colors"
1033
+ style={{
1034
+ backgroundColor: aiThemeFormat === 'svg' ? 'var(--color-accent)' : 'transparent',
1035
+ color: aiThemeFormat === 'svg' ? (activeTheme.isDark ? '#000' : '#fff') : 'var(--color-text-secondary)',
1036
+ }}>SVG 色卡</button>
1037
+ </div>
1038
+ <button onClick={handleGenerateTheme} disabled={generatingTheme || !aiThemePrompt.trim()}
1039
+ className="flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
1040
+ style={{ backgroundColor: 'var(--color-accent)', color: activeTheme.isDark ? '#000' : '#fff', opacity: (generatingTheme || !aiThemePrompt.trim()) ? 0.5 : 1 }}>
1041
+ {generatingTheme ? '生成中...' : 'AI 生成'}
1042
+ </button>
1043
+ </div>
1044
+ </div>
1045
+ </SettingCard>
1046
+
1047
+ {(config.importedThemes || []).length > 0 && (
1048
+ <SettingCard title="已导入主题">
1049
+ <div className="py-3 space-y-2">
1050
+ {(config.importedThemes || []).map((t) => (
1051
+ <div key={t.id} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border group"
1052
+ style={{
1053
+ borderColor: activeTheme.id === t.id ? 'var(--color-accent)' : 'var(--color-border)',
1054
+ backgroundColor: activeTheme.id === t.id ? 'var(--color-bg-tertiary)' : 'transparent',
1055
+ cursor: 'pointer',
1056
+ }}
1057
+ onClick={() => setActiveThemeId(t.id)}>
1058
+ {t.preview?.type === 'svg' ? (
1059
+ <div className="w-10 h-14 rounded overflow-hidden shrink-0" style={{ backgroundColor: 'var(--color-bg-tertiary)' }}>
1060
+ <iframe srcDoc={t.preview.content} className="w-full h-full pointer-events-none"
1061
+ style={{ border: 'none', display: 'block', transform: 'scale(1.5)', transformOrigin: 'top left' }}
1062
+ title={t.name} />
1063
+ </div>
1064
+ ) : (
1065
+ <div className="w-10 h-14 rounded flex flex-col items-center justify-center gap-0.5 shrink-0"
1066
+ style={{ backgroundColor: t.variables['--color-bg-primary'], border: `1px solid ${t.variables['--color-border']}` }}>
1067
+ <span className="w-4 h-4 rounded-full" style={{ backgroundColor: t.variables['--color-accent'] }} />
1068
+ <span className="w-4 h-4 rounded-full" style={{ backgroundColor: t.variables['--color-text-primary'] }} />
1069
+ </div>
1070
+ )}
1071
+ <div className="flex-1 min-w-0">
1072
+ <div className="text-sm font-medium truncate" style={{ color: 'var(--color-text-primary)' }}>
1073
+ {t.name}
1074
+ {activeTheme.id === t.id && <span className="ml-1" style={{ color: 'var(--color-accent)' }}>✓</span>}
1075
+ </div>
1076
+ <div className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
1077
+ {t.description} · {t.preview?.type === 'svg' ? 'SVG' : 'JSON'}
1078
+ </div>
1079
+ </div>
1080
+ <button onClick={(e) => { e.stopPropagation(); removeImportedTheme(t.id); }}
1081
+ className="opacity-0 group-hover:opacity-100 p-1 rounded transition-opacity shrink-0"
1082
+ style={{ color: 'var(--color-text-muted)' }} title="删除">
1083
+ <i className="fa-solid fa-xmark" style={{ fontSize: '11px' }} />
1084
+ </button>
1085
+ </div>
1086
+ ))}
1087
+ </div>
1088
+ </SettingCard>
1089
+ )}
1090
+ </ColumnLayout>
1091
+ );
1092
+
1093
+ case 'extensions':
1094
+ return (
1095
+ <ColumnLayout>
1096
+ {/* 插件消息提示设置 */}
1097
+ <SettingCard title="插件消息提示">
1098
+ <div className="py-3 space-y-4">
1099
+ {/* 总开关 */}
1100
+ <div className="flex items-center justify-between">
1101
+ <div>
1102
+ <p className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
1103
+ 显示插件提示消息
1104
+ </p>
1105
+ <p className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
1106
+ 控制是否显示插件通过 xinyu.ui.toast() 发送的消息
1107
+ </p>
1108
+ </div>
1109
+ <button
1110
+ onClick={() => {
1111
+ setSettings(prev => ({ ...prev, pluginToastEnabled: !prev.pluginToastEnabled }));
1112
+ setSaved(false);
1113
+ }}
1114
+ className="relative w-9 h-5 rounded-full transition-colors shrink-0"
1115
+ style={{
1116
+ backgroundColor: settings.pluginToastEnabled ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
1117
+ border: `1px solid ${settings.pluginToastEnabled ? 'var(--color-accent)' : 'var(--color-border)'}`,
1118
+ }}
1119
+ >
1120
+ <span className="absolute top-0.5 w-3.5 h-3.5 rounded-full transition-all"
1121
+ style={{
1122
+ backgroundColor: settings.pluginToastEnabled ? (activeTheme.isDark ? '#000' : '#fff') : 'var(--color-text-muted)',
1123
+ left: settings.pluginToastEnabled ? 'calc(100% - 16px)' : '2px',
1124
+ }}
1125
+ />
1126
+ </button>
1127
+ </div>
1128
+
1129
+ {/* 级别过滤 */}
1130
+ {settings.pluginToastEnabled && (
1131
+ <div>
1132
+ <p className="text-xs mb-2" style={{ color: 'var(--color-text-muted)' }}>
1133
+ 允许显示的消息级别
1134
+ </p>
1135
+ <div className="flex flex-wrap gap-2">
1136
+ {([
1137
+ { key: 'info' as const, label: '信息', color: '#3b82f6', icon: 'fa-circle-info' },
1138
+ { key: 'success' as const, label: '成功', color: '#22c55e', icon: 'fa-circle-check' },
1139
+ { key: 'warning' as const, label: '警告', color: '#f59e0b', icon: 'fa-triangle-exclamation' },
1140
+ { key: 'error' as const, label: '错误', color: '#ef4444', icon: 'fa-circle-xmark' },
1141
+ ]).map(level => {
1142
+ const active = settings.pluginToastLevels.includes(level.key);
1143
+ return (
1144
+ <button
1145
+ key={level.key}
1146
+ onClick={() => {
1147
+ setSettings(prev => ({
1148
+ ...prev,
1149
+ pluginToastLevels: active
1150
+ ? prev.pluginToastLevels.filter(l => l !== level.key)
1151
+ : [...prev.pluginToastLevels, level.key],
1152
+ }));
1153
+ setSaved(false);
1154
+ }}
1155
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs border transition-colors"
1156
+ style={{
1157
+ borderColor: active ? level.color : 'var(--color-border)',
1158
+ backgroundColor: active ? `${level.color}15` : 'transparent',
1159
+ color: active ? level.color : 'var(--color-text-muted)',
1160
+ }}
1161
+ >
1162
+ <i className={`fa-solid ${level.icon}`} style={{ fontSize: '10px' }} />
1163
+ {level.label}
1164
+ </button>
1165
+ );
1166
+ })}
1167
+ </div>
1168
+ </div>
1169
+ )}
1170
+ </div>
1171
+ </SettingCard>
1172
+
1173
+ {/* 插件管理入口 */}
1174
+ <SettingCard title="插件管理">
1175
+ <div className="py-3 space-y-3">
1176
+ <p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
1177
+ 前往插件管理页面,可以创建新插件、导入导出插件,以及进行更详细的配置。
1178
+ </p>
1179
+ <button
1180
+ onClick={() => navigate('/extensions')}
1181
+ className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors"
1182
+ style={{
1183
+ borderColor: 'var(--color-border)',
1184
+ color: 'var(--color-text-secondary)',
1185
+ }}
1186
+ >
1187
+ <i className="fa-solid fa-puzzle-piece mr-1.5" style={{ fontSize: '12px' }} />
1188
+ 打开插件管理页面
1189
+ </button>
1190
+ </div>
1191
+ </SettingCard>
1192
+
1193
+ {/* 开发教程入口 */}
1194
+ <SettingCard title="开发教程">
1195
+ <div className="py-3 space-y-3">
1196
+ <p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
1197
+ 查看插件开发教程,了解插件系统的设计理念和 API 使用方法,快速上手插件开发。
1198
+ </p>
1199
+ <button
1200
+ onClick={() => navigate('/extensions/tutorial')}
1201
+ className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors"
1202
+ style={{
1203
+ borderColor: 'var(--color-border)',
1204
+ color: 'var(--color-text-secondary)',
1205
+ }}
1206
+ >
1207
+ <i className="fa-solid fa-book mr-1.5" style={{ fontSize: '12px' }} />
1208
+ 查看开发教程
1209
+ </button>
1210
+ </div>
1211
+ </SettingCard>
1212
+
1213
+ {/* 插件列表 */}
1214
+ <SettingCard
1215
+ title={`已安装插件 (${plugins.length})`}
1216
+ button={
1217
+ pluginBindings.some(b => b.enabled) && (
1218
+ <div className='flex gap-1.5'>
1219
+ <button
1220
+ onClick={handleDisableAllPlugins}
1221
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5"
1222
+ style={{
1223
+ borderColor: '#ef4444',
1224
+ color: '#ef4444',
1225
+ backgroundColor: 'transparent',
1226
+ }}
1227
+ title="禁用所有已启用的插件"
1228
+ >
1229
+ <i className="fa-solid fa-power-off" style={{ fontSize: '11px' }} />
1230
+ 全部禁用
1231
+ </button>
1232
+ <button
1233
+ onClick={handleRefreshAllPlugins}
1234
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5"
1235
+ style={{
1236
+ borderColor: 'var(--color-border)',
1237
+ color: 'var(--color-text-secondary)',
1238
+ backgroundColor: 'transparent',
1239
+ }}
1240
+ title="重新加载所有插件"
1241
+ >
1242
+ <i className="fa-solid fa-arrows-rotate" style={{ fontSize: '11px' }} />
1243
+ 刷新全部
1244
+ </button>
1245
+ </div>)
1246
+ }>
1247
+ <div className="py-3">
1248
+ {plugins.length === 0 ? (
1249
+ <div className="flex flex-col items-center justify-center py-8 gap-2 opacity-40">
1250
+ <i className="fa-solid fa-puzzle-piece" style={{ color: 'var(--color-text-muted)', fontSize: '28px' }} />
1251
+ <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>暂无已安装的插件</p>
1252
+ </div>
1253
+ ) : (
1254
+ <div className="space-y-2 max-h-96 overflow-y-auto">
1255
+ {plugins.map((plugin) => {
1256
+ const binding = pluginBindings.find(b => b.extensionId === plugin.id);
1257
+ const isEnabled = binding ? binding.enabled : false;
1258
+ const typeInfo = PLUGIN_TYPE_LABELS[plugin.type] || { label: plugin.type, icon: '📦' };
1259
+ return (
1260
+ <div key={plugin.id} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border group select-none"
1261
+ style={{
1262
+ borderColor: 'var(--color-border)',
1263
+ backgroundColor: 'transparent',
1264
+ }}
1265
+ >
1266
+ {/* 插件图标 */}
1267
+ <div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 text-lg"
1268
+ style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
1269
+ >
1270
+ <PluginIcon icon={plugin.icon} pluginId={plugin.id} fallback={typeInfo.icon} size={20} />
1271
+ </div>
1272
+ {/* 插件信息 */}
1273
+ <div className="flex-1 min-w-0">
1274
+ <div className="flex items-center gap-2">
1275
+ <span className="text-sm font-medium truncate" style={{ color: 'var(--color-text-primary)' }}>
1276
+ {plugin.name}
1277
+ </span>
1278
+ <span className="text-xs px-1.5 py-0.5 rounded" style={{
1279
+ backgroundColor: 'var(--color-bg-tertiary)',
1280
+ color: 'var(--color-text-muted)',
1281
+ }}>
1282
+ {typeInfo.label}
1283
+ </span>
1284
+ </div>
1285
+ <div className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
1286
+ v{plugin.version}{plugin.author ? ` · ${plugin.author}` : ''}
1287
+ </div>
1288
+ </div>
1289
+ {/* 操作按钮 */}
1290
+ <div className="flex items-center gap-1 shrink-0">
1291
+ {/* 编辑按钮 */}
1292
+ <button
1293
+ onClick={() => navigate(`/extensions/edit/${plugin.id}`)}
1294
+ className="opacity-0 group-hover:opacity-100 p-1.5 rounded transition-opacity"
1295
+ style={{ color: 'var(--color-text-muted)' }}
1296
+ title="编辑插件"
1297
+ >
1298
+ <i className="fa-solid fa-pen" style={{ fontSize: '10px' }} />
1299
+ </button>
1300
+ {/* 删除按钮 */}
1301
+ <button
1302
+ onClick={() => handleDeletePlugin(plugin)}
1303
+ className="opacity-0 group-hover:opacity-100 p-1.5 rounded transition-opacity"
1304
+ style={{ color: '#ef4444' }}
1305
+ title="删除插件"
1306
+ >
1307
+ <i className="fa-solid fa-trash-can" style={{ fontSize: '10px' }} />
1308
+ </button>
1309
+ {/* 启用/禁用开关 */}
1310
+ <button
1311
+ onClick={() => handleTogglePlugin(plugin.id, !isEnabled)}
1312
+ className="relative w-9 h-5 rounded-full transition-colors shrink-0"
1313
+ style={{
1314
+ backgroundColor: isEnabled ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
1315
+ border: `1px solid ${isEnabled ? 'var(--color-accent)' : 'var(--color-border)'}`,
1316
+ }}
1317
+ title={isEnabled ? '禁用插件' : '启用插件'}
1318
+ >
1319
+ <span className="absolute top-0.5 w-3.5 h-3.5 rounded-full transition-all"
1320
+ style={{
1321
+ backgroundColor: isEnabled ? (activeTheme.isDark ? '#000' : '#fff') : 'var(--color-text-muted)',
1322
+ left: isEnabled ? 'calc(100% - 16px)' : '2px',
1323
+ }}
1324
+ />
1325
+ </button>
1326
+ </div>
1327
+ </div>
1328
+ );
1329
+ })}
1330
+ </div>
1331
+ )}
1332
+ </div>
1333
+ </SettingCard>
1334
+ </ColumnLayout>
1335
+ );
1336
+ case 'data':
1337
+ return (
1338
+ <ColumnLayout>
1339
+ <SettingCard title="游戏记录">
1340
+ <div className="py-3">
1341
+ <div className="flex items-center justify-between mb-3">
1342
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
1343
+ 共 {sessions.length} 个游戏会话
1344
+ </span>
1345
+ {sessions.length > 0 && (
1346
+ <button onClick={handleClearSessions}
1347
+ className="px-3 py-1 rounded-lg text-xs border transition-colors"
1348
+ style={{
1349
+ borderColor: 'rgba(220, 38, 38, 0.3)',
1350
+ color: '#ef4444',
1351
+ backgroundColor: 'rgba(220, 38, 38, 0.05)',
1352
+ }}
1353
+ >清除全部</button>
1354
+ )}
1355
+ </div>
1356
+ {sessions.length === 0 ? (
1357
+ <div className="text-center py-6">
1358
+ <div className="text-2xl mb-1">📭</div>
1359
+ <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>暂无游戏记录</p>
1360
+ </div>
1361
+ ) : (
1362
+ <div className="space-y-1.5 max-h-64 overflow-y-auto">
1363
+ {sessions.map((s) => (
1364
+ <div key={s.id} className="flex items-center justify-between px-3 py-2 rounded-lg group"
1365
+ style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
1366
+ >
1367
+ <div className="min-w-0 flex-1">
1368
+ <div className="text-sm font-medium truncate" style={{ color: 'var(--color-text-primary)' }}>
1369
+ {s.title}
1370
+ </div>
1371
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
1372
+ {s.era} · {s.count} 条消息
1373
+ </div>
1374
+ </div>
1375
+ <button onClick={() => handleDeleteSession(s.id)}
1376
+ className="opacity-0 group-hover:opacity-100 p-1 rounded transition-opacity shrink-0 ml-2"
1377
+ style={{ color: 'var(--color-text-muted)' }}
1378
+ title="删除"
1379
+ >
1380
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1381
+ <polyline points="3 6 5 6 21 6" />
1382
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
1383
+ </svg>
1384
+ </button>
1385
+ </div>
1386
+ ))}
1387
+ </div>
1388
+ )}
1389
+ </div>
1390
+ </SettingCard>
1391
+ </ColumnLayout>
1392
+ );
1393
+
1394
+ case 'about':
1395
+ return (
1396
+ <ColumnLayout>
1397
+ <SettingCard title="关于星语">
1398
+ <div className="py-3 space-y-2">
1399
+ {[
1400
+ ['应用名称', APP_NAME],
1401
+ ['版本', formatVersion(APP_VERSION)],
1402
+ ['描述', APP_DESCRIPTION],
1403
+ ['技术栈', 'Next.js 14 + TypeScript + Tailwind CSS'],
1404
+ ].map(([k, v]) => (
1405
+ <div key={k} className="flex justify-between text-sm">
1406
+ <span style={{ color: 'var(--color-text-secondary)' }}>{k}</span>
1407
+ <span style={{ color: 'var(--color-text-primary)' }}>{v}</span>
1408
+ </div>
1409
+ ))}
1410
+ </div>
1411
+ </SettingCard>
1412
+ </ColumnLayout>
1413
+ );
1414
+ }
1415
+ };
1416
+
1417
+ return (
1418
+ <>
1419
+ <div className="h-screen flex flex-col overflow-hidden" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
1420
+ {/* 顶部导航 */}
1421
+ <PageHeader
1422
+ title="⚙ 设置"
1423
+ showBack={true}
1424
+ onBeforeBack={() => {
1425
+ if (hasChanges) {
1426
+ setConfirmConfig({
1427
+ title: '设置未保存',
1428
+ message: '你有未保存的设置更改,确定要离开吗?',
1429
+ onConfirm: () => { setConfirmOpen(false); back(); },
1430
+ });
1431
+ setConfirmOpen(true);
1432
+ return false;
1433
+ }
1434
+ return true;
1435
+ }}
1436
+ sticky
1437
+ actions={
1438
+ <div className="flex items-center gap-3">
1439
+ {saved && <span className="text-xs" style={{ color: 'var(--color-accent)' }}>✓ 已保存</span>}
1440
+ {hasChanges && !saved && <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>有未保存的更改</span>}
1441
+ <button onClick={handleReset} className="px-3 py-1.5 rounded-lg text-xs border transition-colors"
1442
+ style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
1443
+ >恢复默认</button>
1444
+ <button onClick={handleSave} disabled={saving} className="px-4 py-1.5 rounded-lg text-xs font-medium transition-colors"
1445
+ style={{ backgroundColor: 'var(--color-accent)', color: activeTheme.isDark ? '#000' : '#fff', opacity: saving ? 0.6 : 1 }}
1446
+ >{saving ? '保存中...' : '保存设置'}</button>
1447
+ </div>
1448
+ }
1449
+ />
1450
+
1451
+ {/* 主体:左侧标签 + 右侧内容 */}
1452
+ <div className="flex flex-1 overflow-hidden">
1453
+ {/* 左侧分类标签 */}
1454
+ <nav
1455
+ className="w-40 sm:w-48 shrink-0 border-r overflow-y-auto py-3 px-2 space-y-1"
1456
+ style={{ borderColor: 'var(--color-border)', backgroundColor: 'var(--color-bg-secondary)' }}
1457
+ >
1458
+ {CATEGORIES.map((cat) => (
1459
+ <button key={cat.id} onClick={() => setActiveCategory(cat.id)}
1460
+ className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
1461
+ style={{
1462
+ backgroundColor: activeCategory === cat.id ? 'var(--color-bg-tertiary)' : 'transparent',
1463
+ color: activeCategory === cat.id ? 'var(--color-accent)' : 'var(--color-text-secondary)',
1464
+ fontWeight: activeCategory === cat.id ? 600 : 400,
1465
+ }}
1466
+ >
1467
+ <span className="text-base">{cat.icon}</span>
1468
+ {cat.label}
1469
+ </button>
1470
+ ))}
1471
+ </nav>
1472
+
1473
+ {/* 右侧配置内容 */}
1474
+ <main className="flex-1 overflow-y-auto px-4 sm:px-6 py-5">
1475
+ <div>{renderContent()}</div>
1476
+ </main>
1477
+ </div>
1478
+ </div>
1479
+
1480
+ <ConfirmDialog
1481
+ open={confirmOpen}
1482
+ title={confirmConfig.title}
1483
+ message={confirmConfig.message}
1484
+ danger={confirmConfig.danger}
1485
+ onConfirm={confirmConfig.onConfirm}
1486
+ onCancel={() => setConfirmOpen(false)}
1487
+ />
1488
+ </>
1489
+ );
1490
+ }