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,1572 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { useRouterHistory } from '@/lib/router-history';
5
+ import {
6
+ getPlugins,
7
+ deletePlugin,
8
+ getPluginBindings,
9
+ upsertPluginBinding,
10
+ updatePlugin,
11
+ exportPlugins,
12
+ importPlugins,
13
+ } from '@/lib/storage';
14
+ import { broadcastPluginBindingChange } from '@/lib/plugin-events';
15
+ import {
16
+ PluginType,
17
+ PluginManifest,
18
+ PluginBinding,
19
+ PLUGIN_TYPE_LABELS,
20
+ UIPermission,
21
+ UI_PERMISSION_LABELS,
22
+ } from '@/lib/plugin-types';
23
+ import { usePluginContext } from '@/components/ui/PluginProvider';
24
+ import { useToast } from '@/components/ui/ToastProvider';
25
+ import MathCurveLoader from '@/components/MathCurveLoader';
26
+ import PageHeader from '@/components/ui/PageHeader';
27
+ import { PluginIcon } from '@/components/ui/PluginIcon';
28
+
29
+ // ==================== 常量 ====================
30
+
31
+ const ALL_TYPES: { type: PluginType | 'all'; label: string; icon: string }[] = [
32
+ { type: 'all', label: '全部', icon: '\u{1F4E6}' },
33
+ { type: 'game-mechanics', label: '游戏机制', icon: '\u{1F3AE}' },
34
+ { type: 'message-render', label: '消息渲染', icon: '\u{1F3A8}' },
35
+ { type: 'ai-prompt', label: 'AI Prompt', icon: '\u{1F916}' },
36
+ { type: 'input-enhance', label: '输入增强', icon: '\u2328\uFE0F' },
37
+ ];
38
+
39
+ // ==================== 通用样式辅助 ====================
40
+
41
+ const s = {
42
+ bgPrimary: 'var(--color-bg-primary)',
43
+ bgSecondary: 'var(--color-bg-secondary)',
44
+ bgTertiary: 'var(--color-bg-tertiary)',
45
+ textPrimary: 'var(--color-text-primary)',
46
+ textSecondary: 'var(--color-text-secondary)',
47
+ textMuted: 'var(--color-text-muted)',
48
+ accent: 'var(--color-accent)',
49
+ accentHover: 'var(--color-accent-hover)',
50
+ border: 'var(--color-border)',
51
+ shadow: 'var(--color-shadow)',
52
+ };
53
+
54
+ // ==================== 子组件 ====================
55
+
56
+ /** 可排序的插件列表(同分类内拖拽排序) */
57
+ function SortablePluginList({
58
+ plugins,
59
+ s,
60
+ onToggle,
61
+ onReorder,
62
+ onSelect,
63
+ }: {
64
+ plugins: { plugin: PluginManifest; binding: PluginBinding | undefined }[];
65
+ s: Record<string, string>;
66
+ onToggle: (plugin: PluginManifest, enabled: boolean) => void;
67
+ onReorder: (pluginId: string, newSortOrder: number) => void;
68
+ onSelect: (plugin: PluginManifest) => void;
69
+ }) {
70
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
71
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
72
+
73
+ const handleDragStart = (index: number) => {
74
+ setDragIndex(index);
75
+ };
76
+
77
+ const handleDragOver = (e: React.DragEvent, index: number) => {
78
+ e.preventDefault();
79
+ setDropIndex(index);
80
+ };
81
+
82
+ const handleDrop = (index: number) => {
83
+ if (dragIndex !== null && dragIndex !== index) {
84
+ onReorder(plugins[dragIndex].plugin.id, index);
85
+ }
86
+ setDragIndex(null);
87
+ setDropIndex(null);
88
+ };
89
+
90
+ const handleDragEnd = () => {
91
+ setDragIndex(null);
92
+ setDropIndex(null);
93
+ };
94
+
95
+ return (
96
+ <div className="space-y-1.5">
97
+ {plugins.map(({ plugin, binding }, index) => {
98
+ const typeInfo = PLUGIN_TYPE_LABELS[plugin.type];
99
+ const enabled = binding?.enabled ?? false;
100
+ const isDragging = dragIndex === index;
101
+ const isDropTarget = dropIndex === index;
102
+
103
+ return (
104
+ <div
105
+ key={plugin.id}
106
+ draggable
107
+ onDragStart={() => handleDragStart(index)}
108
+ onDragOver={(e) => handleDragOver(e, index)}
109
+ onDrop={() => handleDrop(index)}
110
+ onDragEnd={handleDragEnd}
111
+ className="rounded-xl border p-4 cursor-pointer transition-all"
112
+ style={{
113
+ backgroundColor: s.bgSecondary,
114
+ borderColor: isDropTarget ? s.accent : s.border,
115
+ opacity: isDragging ? 0.5 : 1,
116
+ transform: isDropTarget && dragIndex !== null && dragIndex < index ? 'translateY(4px)' : undefined,
117
+ }}
118
+ onClick={() => onSelect(plugin)}
119
+ >
120
+ <div className="flex items-center justify-between gap-3">
121
+ <div className="flex items-center gap-3 min-w-0 flex-1">
122
+ {/* 拖拽手柄 */}
123
+ <div
124
+ className="shrink-0 mt-1 cursor-grab active:cursor-grabbing"
125
+ style={{ color: s.textMuted }}
126
+ onClick={(e) => e.stopPropagation()}
127
+ title="拖拽调整顺序"
128
+ >
129
+ <i className="fa-solid fa-grip-vertical" style={{ fontSize: '12px' }} />
130
+ </div>
131
+ <span className="text-2xl shrink-0 mt-0.5">
132
+ <PluginIcon icon={plugin.icon} pluginId={plugin.id} fallback={typeInfo.icon} size={24} />
133
+ </span>
134
+ <div className="min-w-0 flex-1">
135
+ <div className="flex items-center gap-2 flex-wrap">
136
+ <span className="text-sm font-bold" style={{ color: s.textPrimary }}>
137
+ {plugin.name}
138
+ </span>
139
+ <span
140
+ className="text-xs px-1.5 py-0.5 rounded"
141
+ style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}
142
+ >
143
+ v{plugin.version}
144
+ </span>
145
+ <span
146
+ className="text-xs px-1.5 py-0.5 rounded"
147
+ style={{
148
+ backgroundColor: enabled
149
+ ? 'rgba(34,197,94,0.15)'
150
+ : 'rgba(107,95,133,0.15)',
151
+ color: enabled ? '#22c55e' : s.textMuted,
152
+ }}
153
+ >
154
+ {typeInfo.icon} {typeInfo.label}
155
+ </span>
156
+ </div>
157
+ <p className="text-xs mt-0.5" style={{ color: s.textMuted }}>
158
+ by {plugin.author}
159
+ </p>
160
+ <p
161
+ className="text-xs mt-1.5 line-clamp-2"
162
+ style={{ color: s.textSecondary }}
163
+ >
164
+ {plugin.description || '暂无描述'}
165
+ </p>
166
+ </div>
167
+ </div>
168
+ <div className="shrink-0" onClick={(e) => e.stopPropagation()}>
169
+ <ToggleSwitch
170
+ enabled={enabled}
171
+ onChange={(v) => onToggle(plugin, v)}
172
+ />
173
+ </div>
174
+ </div>
175
+ </div>
176
+ );
177
+ })}
178
+ </div>
179
+ );
180
+ }
181
+
182
+ /** 启用/禁用开关 */
183
+ function ToggleSwitch({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) {
184
+ return (
185
+ <button
186
+ onClick={() => onChange(!enabled)}
187
+ className="relative w-10 h-5 rounded-full transition-colors duration-200 shrink-0"
188
+ style={{
189
+ backgroundColor: enabled ? s.accent : s.bgTertiary,
190
+ border: `1px solid ${enabled ? s.accent : s.border}`,
191
+ }}
192
+ >
193
+ <span
194
+ className="absolute top-0.5 w-3.5 h-3.5 rounded-full transition-transform duration-200"
195
+ style={{
196
+ backgroundColor: enabled ? s.bgPrimary : s.textMuted,
197
+ left: enabled ? '22px' : '2px',
198
+ }}
199
+ />
200
+ </button>
201
+ );
202
+ }
203
+
204
+ /** 文本输入框 */
205
+ function TextField({
206
+ value,
207
+ onChange,
208
+ placeholder,
209
+ mono = false,
210
+ type = 'text',
211
+ className = '',
212
+ }: {
213
+ value: string;
214
+ onChange: (v: string) => void;
215
+ placeholder?: string;
216
+ mono?: boolean;
217
+ type?: string;
218
+ className?: string;
219
+ }) {
220
+ return (
221
+ <input
222
+ type={type}
223
+ value={value}
224
+ onChange={(e) => onChange(e.target.value)}
225
+ placeholder={placeholder}
226
+ className={`w-full px-3 py-1.5 rounded-lg text-sm border outline-none transition-colors ${className}`}
227
+ style={{
228
+ backgroundColor: s.bgTertiary,
229
+ borderColor: s.border,
230
+ color: s.textPrimary,
231
+ fontFamily: mono ? 'monospace' : 'inherit',
232
+ }}
233
+ onFocus={(e) => (e.target.style.borderColor = s.accent)}
234
+ onBlur={(e) => (e.target.style.borderColor = s.border)}
235
+ />
236
+ );
237
+ }
238
+
239
+ /** 文本域 */
240
+ function TextAreaField({
241
+ value,
242
+ onChange,
243
+ placeholder,
244
+ rows = 4,
245
+ mono = false,
246
+ }: {
247
+ value: string;
248
+ onChange: (v: string) => void;
249
+ placeholder?: string;
250
+ rows?: number;
251
+ mono?: boolean;
252
+ }) {
253
+ return (
254
+ <textarea
255
+ value={value}
256
+ onChange={(e) => onChange(e.target.value)}
257
+ placeholder={placeholder}
258
+ rows={rows}
259
+ className="w-full px-3 py-2 rounded-lg text-sm border outline-none transition-colors resize-y"
260
+ style={{
261
+ backgroundColor: s.bgTertiary,
262
+ borderColor: s.border,
263
+ color: s.textPrimary,
264
+ fontFamily: mono ? 'monospace' : 'inherit',
265
+ }}
266
+ onFocus={(e) => (e.target.style.borderColor = s.accent)}
267
+ onBlur={(e) => (e.target.style.borderColor = s.border)}
268
+ />
269
+ );
270
+ }
271
+
272
+ /** 下拉选择框 */
273
+ function SelectField({
274
+ value,
275
+ onChange,
276
+ options,
277
+ }: {
278
+ value: string;
279
+ onChange: (v: string) => void;
280
+ options: { label: string; value: string }[];
281
+ }) {
282
+ return (
283
+ <select
284
+ value={value}
285
+ onChange={(e) => onChange(e.target.value)}
286
+ className="w-full px-3 py-1.5 rounded-lg text-sm border outline-none transition-colors cursor-pointer"
287
+ style={{
288
+ backgroundColor: s.bgTertiary,
289
+ borderColor: s.border,
290
+ color: s.textPrimary,
291
+ }}
292
+ onFocus={(e) => (e.target.style.borderColor = s.accent)}
293
+ onBlur={(e) => (e.target.style.borderColor = s.border)}
294
+ >
295
+ {options.map((opt) => (
296
+ <option key={opt.value} value={opt.value}>
297
+ {opt.label}
298
+ </option>
299
+ ))}
300
+ </select>
301
+ );
302
+ }
303
+
304
+ /** 确认对话框 */
305
+ function ConfirmDialog({
306
+ title,
307
+ message,
308
+ onConfirm,
309
+ onCancel,
310
+ }: {
311
+ title: string;
312
+ message: string;
313
+ onConfirm: () => void;
314
+ onCancel: () => void;
315
+ }) {
316
+ return (
317
+ <div className="fixed inset-0 z-[90] flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}>
318
+ <div
319
+ className="rounded-xl p-6 max-w-sm w-full mx-4 shadow-xl"
320
+ style={{ backgroundColor: s.bgSecondary, border: `1px solid ${s.border}` }}
321
+ >
322
+ <h3 className="text-base font-bold mb-2" style={{ color: s.textPrimary }}>{title}</h3>
323
+ <p className="text-sm mb-5" style={{ color: s.textSecondary }}>{message}</p>
324
+ <div className="flex justify-end gap-3">
325
+ <button
326
+ onClick={onCancel}
327
+ className="px-4 py-1.5 rounded-lg text-sm border"
328
+ style={{
329
+ borderColor: s.border,
330
+ color: s.textSecondary,
331
+ backgroundColor: 'transparent',
332
+ }}
333
+ >
334
+ 取消
335
+ </button>
336
+ <button
337
+ onClick={onConfirm}
338
+ className="px-4 py-1.5 rounded-lg text-sm"
339
+ style={{
340
+ backgroundColor: '#dc2626',
341
+ color: '#fff',
342
+ }}
343
+ >
344
+ 确认删除
345
+ </button>
346
+ </div>
347
+ </div>
348
+ </div>
349
+ );
350
+ }
351
+
352
+ // ==================== 权限标签列表(支持增删) ====================
353
+
354
+ function PermTagList({
355
+ allPerms,
356
+ selected,
357
+ onChange,
358
+ accentColor,
359
+ }: {
360
+ allPerms: [UIPermission, string][];
361
+ selected: UIPermission[];
362
+ onChange: (perms: UIPermission[]) => void;
363
+ accentColor: string;
364
+ }) {
365
+ const [selectVal, setSelectVal] = useState('');
366
+
367
+ const handleAdd = () => {
368
+ if (selectVal && !selected.includes(selectVal as UIPermission)) {
369
+ onChange([...selected, selectVal as UIPermission]);
370
+ }
371
+ setSelectVal('');
372
+ };
373
+
374
+ const handleRemove = (perm: string) => {
375
+ onChange(selected.filter(p => p !== perm));
376
+ };
377
+
378
+ const available = allPerms.filter(([perm]) => !selected.includes(perm));
379
+
380
+ return (
381
+ <div>
382
+ {selected.length > 0 && (
383
+ <div className="flex flex-wrap gap-1.5 mb-2">
384
+ {selected.map(perm => {
385
+ const label = allPerms.find(([p]) => p === perm)?.[1] || perm;
386
+ return (
387
+ <span key={perm} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md transition-colors"
388
+ style={{ backgroundColor: `${accentColor}15`, border: `1px solid ${accentColor}40`, color: accentColor }}>
389
+ <span title={perm}>{label}</span>
390
+ <button onClick={() => handleRemove(perm)} className="hover:opacity-70 transition-opacity ml-0.5" style={{ color: accentColor }}>✕</button>
391
+ </span>
392
+ );
393
+ })}
394
+ </div>
395
+ )}
396
+ <div className="flex gap-1.5 min-w-0">
397
+ <div className="flex-1 min-w-0 flex items-center rounded-md border overflow-hidden"
398
+ style={{ borderColor: s.border }}>
399
+ <select
400
+ value={selectVal}
401
+ onChange={(e) => setSelectVal(e.target.value)}
402
+ className="flex-1 min-w-0 text-xs px-2 py-1.5 outline-none bg-transparent appearance-none cursor-pointer"
403
+ style={{ color: s.textPrimary }}
404
+ >
405
+ <option value="">选择权限...</option>
406
+ {available.map(([perm, label]) => (
407
+ <option key={perm} value={perm}>{label} ({perm})</option>
408
+ ))}
409
+ </select>
410
+ <button
411
+ onClick={handleAdd}
412
+ disabled={!selectVal}
413
+ className="shrink-0 text-xs px-2.5 py-1.5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed border-l"
414
+ style={{
415
+ backgroundColor: selectVal ? `${accentColor}20` : 'transparent',
416
+ borderColor: s.border,
417
+ color: selectVal ? accentColor : s.textMuted,
418
+ }}
419
+ >
420
+ + 添加
421
+ </button>
422
+ </div>
423
+ </div>
424
+ </div>
425
+ );
426
+ }
427
+
428
+ // ==================== 插件详情模态框 ====================
429
+
430
+ function PluginDetailModal({
431
+ plugin,
432
+ binding,
433
+ onClose,
434
+ onToggle,
435
+ onDelete,
436
+ onEdit,
437
+ onExport,
438
+ onExportCode,
439
+ isXye,
440
+ }: {
441
+ plugin: PluginManifest;
442
+ binding: PluginBinding | undefined;
443
+ onClose: () => void;
444
+ onToggle: (enabled: boolean) => void;
445
+ onDelete: () => void;
446
+ onEdit: () => void;
447
+ onExport: (format: 'xye' | 'json') => void;
448
+ onExportCode: () => void;
449
+ isXye?: boolean;
450
+ }) {
451
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
452
+ const { toast } = useToast();
453
+ const [confirmDelete, setConfirmDelete] = useState(false);
454
+ const [configValues, setConfigValues] = useState<Record<string, unknown>>({});
455
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
456
+ const [savingConfig, setSavingConfig] = useState(false);
457
+
458
+ // 权限声明本地状态
459
+ const [commonPermissions, setCommonPermissions] = useState<UIPermission[]>(plugin.commonPermissions || []);
460
+ const [exclusivePermissions, setExclusivePermissions] = useState<UIPermission[]>(plugin.exclusivePermissions || []);
461
+ const [requiredPermissions, setRequiredPermissions] = useState<UIPermission[]>(plugin.requiredPermissions || []);
462
+
463
+ // 折叠状态(默认全部折叠)
464
+ const [permSectionCollapsed, setPermSectionCollapsed] = useState(true);
465
+ const [commonPermCollapsed, setCommonPermCollapsed] = useState(true);
466
+ const [exclusivePermCollapsed, setExclusivePermCollapsed] = useState(true);
467
+ const [requiredPermCollapsed, setRequiredPermCollapsed] = useState(true);
468
+ const [configSectionCollapsed, setConfigSectionCollapsed] = useState(true);
469
+
470
+ useEffect(() => {
471
+ if (binding?.config) {
472
+ setConfigValues({ ...binding.config });
473
+ }
474
+ setCommonPermissions(plugin.commonPermissions || []);
475
+ setExclusivePermissions(plugin.exclusivePermissions || []);
476
+ setRequiredPermissions(plugin.requiredPermissions || []);
477
+ }, [binding, plugin.commonPermissions, plugin.exclusivePermissions, plugin.requiredPermissions]);
478
+
479
+ // 修改即保存(防抖)
480
+ const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
481
+ const handleConfigChange = useCallback((key: string, value: unknown) => {
482
+ setConfigValues(prev => {
483
+ const next = { ...prev, [key]: value };
484
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
485
+ saveTimerRef.current = setTimeout(async () => {
486
+ try {
487
+ await upsertPluginBinding({
488
+ extensionId: plugin.id,
489
+ scope: 'global',
490
+ enabled: binding?.enabled ?? true,
491
+ config: next,
492
+ });
493
+ } catch { /* 静默失败 */ }
494
+ }, 500);
495
+ return next;
496
+ });
497
+ }, [plugin.id, binding?.enabled]);
498
+
499
+ // 权限变更保存(防抖)
500
+ const permSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
501
+ const handlePermChange = useCallback((type: 'common' | 'exclusive' | 'required', perms: UIPermission[]) => {
502
+ if (type === 'common') setCommonPermissions(perms);
503
+ else if (type === 'exclusive') setExclusivePermissions(perms);
504
+ else setRequiredPermissions(perms);
505
+
506
+ if (permSaveTimerRef.current) clearTimeout(permSaveTimerRef.current);
507
+ permSaveTimerRef.current = setTimeout(async () => {
508
+ try {
509
+ const updatedCommon = type === 'common' ? perms : commonPermissions;
510
+ const updatedExclusive = type === 'exclusive' ? perms : exclusivePermissions;
511
+ const updatedRequired = type === 'required' ? perms : requiredPermissions;
512
+ await updatePlugin(plugin.id, {
513
+ commonPermissions: updatedCommon.length > 0 ? updatedCommon : [],
514
+ exclusivePermissions: updatedExclusive.length > 0 ? updatedExclusive : [],
515
+ requiredPermissions: updatedRequired.length > 0 ? updatedRequired : [],
516
+ });
517
+ } catch { /* 静默失败 */ }
518
+ }, 500);
519
+ }, [plugin.id, commonPermissions, exclusivePermissions, requiredPermissions]);
520
+
521
+ const allPermEntries = Object.entries(UI_PERMISSION_LABELS) as [UIPermission, string][];
522
+ const hasAnyPermissions = commonPermissions.length + exclusivePermissions.length + requiredPermissions.length > 0;
523
+
524
+ const typeInfo = PLUGIN_TYPE_LABELS[plugin.type];
525
+
526
+ return (
527
+ <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}>
528
+ {/* 弹窗主体 - flex列布局,overflow-hidden限制整体 */}
529
+ <div
530
+ className="rounded-xl w-full max-w-2xl max-h-[85vh] mx-4 shadow-xl flex flex-col overflow-hidden"
531
+ style={{ backgroundColor: s.bgSecondary, border: `1px solid ${s.border}` }}
532
+ >
533
+ {/* 头部 - flex-shrink-0 不参与收缩,始终固定在顶部 */}
534
+ <div
535
+ className="px-6 py-4 flex items-center justify-between flex-shrink-0"
536
+ style={{ borderBottom: `1px solid ${s.border}` }}
537
+ >
538
+ <div className="flex items-center gap-3">
539
+ <span className="text-2xl"><PluginIcon icon={plugin.icon} pluginId={plugin.id} fallback={typeInfo.icon} size={24} /></span>
540
+ <div>
541
+ <h2 className="text-base font-bold" style={{ color: s.textPrimary }}>{plugin.name}</h2>
542
+ <p className="text-xs" style={{ color: s.textMuted }}>
543
+ v{plugin.version} &middot; {plugin.author} &middot; {typeInfo.icon} {typeInfo.label}
544
+ </p>
545
+ </div>
546
+ </div>
547
+ <div className="flex items-center gap-3">
548
+ <span className="text-xs" style={{ color: binding?.enabled ? s.accent : s.textMuted }}>
549
+ {binding?.enabled ? '已启用' : '已禁用'}
550
+ </span>
551
+ <ToggleSwitch enabled={binding?.enabled ?? false} onChange={onToggle} />
552
+ <button
553
+ onClick={onClose}
554
+ className="w-8 h-8 rounded-lg flex items-center justify-center text-lg"
555
+ style={{ color: s.textMuted }}
556
+ >
557
+ &times;
558
+ </button>
559
+ </div>
560
+ </div>
561
+
562
+ {/* 内容滚动区 - flex-1占满剩余空间,overflow-y-auto仅此处滚动 */}
563
+ <div className="px-6 py-4 space-y-5 overflow-y-auto flex-1">
564
+ {/* 描述 */}
565
+ <div>
566
+ <h3 className="text-sm font-bold mb-2" style={{ color: s.textPrimary }}>描述</h3>
567
+ <p className="text-sm leading-relaxed" style={{ color: s.textSecondary }}>
568
+ {plugin.description || '暂无描述'}
569
+ </p>
570
+ </div>
571
+
572
+ {/* 元信息 */}
573
+ <div className="grid grid-cols-2 gap-3">
574
+ <div className="rounded-lg p-3" style={{ backgroundColor: s.bgTertiary }}>
575
+ <div className="text-xs mb-1" style={{ color: s.textMuted }}>插件 ID</div>
576
+ <div className="text-xs font-mono" style={{ color: s.textPrimary }}>{plugin.id}</div>
577
+ </div>
578
+ <div className="rounded-lg p-3" style={{ backgroundColor: s.bgTertiary }}>
579
+ <div className="text-xs mb-1" style={{ color: s.textMuted }}>最低版本</div>
580
+ <div className="text-xs" style={{ color: s.textPrimary }}>{plugin.minAppVersion || '未指定'}</div>
581
+ </div>
582
+ <div className="rounded-lg p-3" style={{ backgroundColor: s.bgTertiary }}>
583
+ <div className="text-xs mb-1" style={{ color: s.textMuted }}>创建时间</div>
584
+ <div className="text-xs" style={{ color: s.textPrimary }}>{plugin.createdAt}</div>
585
+ </div>
586
+ <div className="rounded-lg p-3" style={{ backgroundColor: s.bgTertiary }}>
587
+ <div className="text-xs mb-1" style={{ color: s.textMuted }}>更新时间</div>
588
+ <div className="text-xs" style={{ color: s.textPrimary }}>{plugin.updatedAt}</div>
589
+ </div>
590
+ </div>
591
+
592
+ {/* 权限声明 */}
593
+ <div>
594
+ <div className="flex items-center justify-between mb-2">
595
+ <h3
596
+ className="text-sm font-bold flex items-center gap-1.5 cursor-pointer select-none"
597
+ style={{ color: s.textPrimary }}
598
+ onClick={() => setPermSectionCollapsed(!permSectionCollapsed)}
599
+ >
600
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
601
+ style={{ color: s.textMuted, transform: permSectionCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
602
+ <polyline points="6 9 12 15 18 9" />
603
+ </svg>
604
+ 权限声明
605
+ {hasAnyPermissions && (
606
+ <span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>
607
+ {commonPermissions.length + exclusivePermissions.length + requiredPermissions.length}
608
+ </span>
609
+ )}
610
+ </h3>
611
+ </div>
612
+ {!permSectionCollapsed && (
613
+ <>
614
+ {!hasAnyPermissions && (
615
+ <p className="text-xs" style={{ color: s.textMuted }}>该插件未声明任何权限</p>
616
+ )}
617
+ {/* 共享权限 */}
618
+ <div className="border rounded-lg p-3 mt-2" style={{ borderColor: `${s.accent}40` }}>
619
+ <div
620
+ className="flex items-center justify-between mb-2 cursor-pointer select-none"
621
+ onClick={() => setCommonPermCollapsed(!commonPermCollapsed)}
622
+ >
623
+ <div className="flex items-center gap-1.5">
624
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
625
+ style={{ color: s.accent, transform: commonPermCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
626
+ <polyline points="6 9 12 15 18 9" />
627
+ </svg>
628
+ <span className="text-xs font-medium" style={{ color: s.accent }}>共享权限</span>
629
+ {commonPermissions.length > 0 && commonPermCollapsed && (
630
+ <span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: `${s.accent}15`, color: s.accent }}>{commonPermissions.length}</span>
631
+ )}
632
+ </div>
633
+ <span className="text-xs" style={{ color: s.textMuted }}>可被多个插件同时声明</span>
634
+ </div>
635
+ {!commonPermCollapsed && (
636
+ <PermTagList
637
+ allPerms={allPermEntries}
638
+ selected={commonPermissions}
639
+ onChange={(perms) => handlePermChange('common', perms)}
640
+ accentColor={s.accent}
641
+ />
642
+ )}
643
+ </div>
644
+ {/* 排他权限 */}
645
+ <div className="border rounded-lg p-3 mt-2" style={{ borderColor: '#f9731640' }}>
646
+ <div
647
+ className="flex items-center justify-between mb-2 cursor-pointer select-none"
648
+ onClick={() => setExclusivePermCollapsed(!exclusivePermCollapsed)}
649
+ >
650
+ <div className="flex items-center gap-1.5">
651
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
652
+ style={{ color: '#f97316', transform: exclusivePermCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
653
+ <polyline points="6 9 12 15 18 9" />
654
+ </svg>
655
+ <span className="text-xs font-medium" style={{ color: '#f97316' }}>排他权限</span>
656
+ {exclusivePermissions.length > 0 && exclusivePermCollapsed && (
657
+ <span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: '#f9731615', color: '#f97316' }}>{exclusivePermissions.length}</span>
658
+ )}
659
+ </div>
660
+ <span className="text-xs" style={{ color: s.textMuted }}>同时只能被一个插件声明</span>
661
+ </div>
662
+ {!exclusivePermCollapsed && (
663
+ <PermTagList
664
+ allPerms={allPermEntries}
665
+ selected={exclusivePermissions}
666
+ onChange={(perms) => handlePermChange('exclusive', perms)}
667
+ accentColor="#f97316"
668
+ />
669
+ )}
670
+ </div>
671
+ {/* 必要权限 */}
672
+ <div className="border rounded-lg p-3 mt-2" style={{ borderColor: '#3b82f640' }}>
673
+ <div
674
+ className="flex items-center justify-between mb-2 cursor-pointer select-none"
675
+ onClick={() => setRequiredPermCollapsed(!requiredPermCollapsed)}
676
+ >
677
+ <div className="flex items-center gap-1.5">
678
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
679
+ style={{ color: '#3b82f6', transform: requiredPermCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
680
+ <polyline points="6 9 12 15 18 9" />
681
+ </svg>
682
+ <span className="text-xs font-medium" style={{ color: '#3b82f6' }}>必要权限</span>
683
+ {requiredPermissions.length > 0 && requiredPermCollapsed && (
684
+ <span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: '#3b82f615', color: '#3b82f6' }}>{requiredPermissions.length}</span>
685
+ )}
686
+ </div>
687
+ <span className="text-xs" style={{ color: s.textMuted }}>没有此权限则插件不允许启动</span>
688
+ </div>
689
+ {!requiredPermCollapsed && (
690
+ <PermTagList
691
+ allPerms={allPermEntries}
692
+ selected={requiredPermissions}
693
+ onChange={(perms) => handlePermChange('required', perms)}
694
+ accentColor="#3b82f6"
695
+ />
696
+ )}
697
+ </div>
698
+ </>
699
+ )}
700
+ </div>
701
+
702
+ {/* 配置项编辑 */}
703
+ {plugin.configSchema && plugin.configSchema.length > 0 && (
704
+ <div>
705
+ <h3
706
+ className="text-sm font-bold flex items-center gap-1.5 cursor-pointer select-none mb-2"
707
+ style={{ color: s.textPrimary }}
708
+ onClick={() => setConfigSectionCollapsed(!configSectionCollapsed)}
709
+ >
710
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
711
+ style={{ color: s.textMuted, transform: configSectionCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
712
+ <polyline points="6 9 12 15 18 9" />
713
+ </svg>
714
+ 配置项
715
+ {configSectionCollapsed && (
716
+ <span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>
717
+ {plugin.configSchema.length}
718
+ </span>
719
+ )}
720
+ </h3>
721
+ {!configSectionCollapsed && (
722
+ <div className="space-y-3">
723
+ {plugin.configSchema.map((field) => (
724
+ <div key={field.key}>
725
+ <div className="flex items-center justify-between mb-1">
726
+ <div>
727
+ <span className="text-xs font-medium" style={{ color: s.textSecondary }}>{field.label}</span>
728
+ {field.description && (
729
+ <p className="text-[10px] mt-0.5" style={{ color: s.textMuted }}>{field.description}</p>
730
+ )}
731
+ </div>
732
+ {field.type === 'boolean' && (
733
+ <ToggleSwitch
734
+ enabled={!!(field.key in configValues ? configValues[field.key] : field.defaultValue)}
735
+ onChange={(v) => handleConfigChange(field.key, v)}
736
+ />
737
+ )}
738
+ </div>
739
+ {field.type !== 'boolean' && (
740
+ field.type === 'select' && field.options ? (
741
+ <SelectField
742
+ value={String(configValues[field.key] ?? field.defaultValue)}
743
+ onChange={(v) => handleConfigChange(field.key, v)}
744
+ options={field.options}
745
+ />
746
+ ) : field.type === 'textarea' ? (
747
+ <TextAreaField
748
+ value={String(configValues[field.key] ?? field.defaultValue)}
749
+ onChange={(v) => handleConfigChange(field.key, v)}
750
+ placeholder={field.description}
751
+ />
752
+ ) : (
753
+ <TextField
754
+ value={String(configValues[field.key] ?? field.defaultValue)}
755
+ onChange={(v) =>
756
+ handleConfigChange(field.key, field.type === 'number' ? Number(v) : v)
757
+ }
758
+ type={field.type === 'number' ? 'number' : 'text'}
759
+ placeholder={field.description}
760
+ />
761
+ )
762
+ )}
763
+ </div>
764
+ ))}
765
+ </div>
766
+ )}
767
+ </div>
768
+ )}
769
+
770
+ {/* 代码查看 */}
771
+ <div>
772
+ <h3 className="text-sm font-bold mb-2" style={{ color: s.textPrimary }}>插件代码</h3>
773
+ <div className="rounded-lg overflow-hidden border" style={{ borderColor: s.border }}>
774
+ <div
775
+ className="px-3 py-1.5 text-xs font-mono flex items-center justify-between"
776
+ style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}
777
+ >
778
+ <span>plugin.js</span>
779
+ <span>{plugin.code.split('\n').length} 行</span>
780
+ </div>
781
+ <textarea
782
+ readOnly
783
+ value={plugin.code}
784
+ rows={Math.min(Math.max(plugin.code.split('\n').length, 6), 20)}
785
+ className="w-full px-3 py-2 text-xs font-mono resize-none outline-none"
786
+ style={{
787
+ backgroundColor: s.bgPrimary,
788
+ color: s.textSecondary,
789
+ border: 'none',
790
+ }}
791
+ />
792
+ </div>
793
+ </div>
794
+ </div>
795
+
796
+ {/* 底部操作按钮 - flex-shrink-0 不参与收缩,始终固定在底部 */}
797
+ <div
798
+ className="px-6 py-3 flex items-center gap-3 flex-shrink-0"
799
+ style={{ borderTop: `1px solid ${s.border}` }}
800
+ >
801
+ <button
802
+ onClick={onEdit}
803
+ className="px-4 py-1.5 rounded-lg text-sm border"
804
+ style={{
805
+ borderColor: s.border,
806
+ color: s.textSecondary,
807
+ backgroundColor: 'transparent',
808
+ }}
809
+ >
810
+ 编辑插件
811
+ </button>
812
+ <button
813
+ onClick={() => onExport('xye')}
814
+ className="px-4 py-1.5 rounded-lg text-sm border flex items-center gap-1.5"
815
+ style={{
816
+ borderColor: s.border,
817
+ color: s.textSecondary,
818
+ backgroundColor: 'transparent',
819
+ }}
820
+ title="导出为 .xye 插件包"
821
+ >
822
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
823
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
824
+ <polyline points="17 8 12 3 7 8" />
825
+ <line x1="12" y1="3" x2="12" y2="15" />
826
+ </svg>
827
+ 导出 .xye
828
+ </button>
829
+ {!isXye && (
830
+ <button
831
+ onClick={() => onExport('json')}
832
+ className="px-4 py-1.5 rounded-lg text-sm border flex items-center gap-1.5"
833
+ style={{
834
+ borderColor: s.border,
835
+ color: s.textSecondary,
836
+ backgroundColor: 'transparent',
837
+ }}
838
+ title="导出为 .json 文件(仅单 JS 插件可用)"
839
+ >
840
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
841
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
842
+ <polyline points="17 8 12 3 7 8" />
843
+ <line x1="12" y1="3" x2="12" y2="15" />
844
+ </svg>
845
+ 导出 .json
846
+ </button>
847
+ )}
848
+ <button
849
+ onClick={onExportCode}
850
+ className="px-4 py-1.5 rounded-lg text-sm border flex items-center gap-1.5"
851
+ style={{
852
+ borderColor: s.border,
853
+ color: s.textSecondary,
854
+ backgroundColor: 'transparent',
855
+ }}
856
+ title="仅导出插件代码为 .js 文件"
857
+ >
858
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
859
+ <polyline points="16 18 22 12 16 6" />
860
+ <polyline points="8 6 2 12 8 18" />
861
+ </svg>
862
+ 导出代码
863
+ </button>
864
+ <button
865
+ onClick={() => setConfirmDelete(true)}
866
+ className="px-4 py-1.5 rounded-lg text-sm border"
867
+ style={{
868
+ borderColor: 'rgba(220,38,38,0.4)',
869
+ color: '#ef4444',
870
+ backgroundColor: 'rgba(220,38,38,0.08)',
871
+ }}
872
+ >
873
+ 删除插件
874
+ </button>
875
+ </div>
876
+
877
+ {confirmDelete && (
878
+ <ConfirmDialog
879
+ title="删除插件"
880
+ message={`确定要删除插件「${plugin.name}」吗?此操作不可撤销。`}
881
+ onConfirm={() => {
882
+ setConfirmDelete(false);
883
+ onDelete();
884
+ }}
885
+ onCancel={() => setConfirmDelete(false)}
886
+ />
887
+ )}
888
+ </div>
889
+ </div>
890
+ );
891
+ }
892
+
893
+ // ==================== 主页面 ====================
894
+
895
+ function ExtensionsPageContent() {
896
+ const { navigate } = useRouterHistory();
897
+ const { reloadPlugins, reloadSinglePlugin } = usePluginContext();
898
+ const { toast } = useToast();
899
+
900
+ // 数据状态
901
+ const [plugins, setPlugins] = useState<PluginManifest[]>([]);
902
+ const [bindings, setBindings] = useState<PluginBinding[]>([]);
903
+ const [loading, setLoading] = useState(true);
904
+
905
+ // UI 状态
906
+ const [activeType, setActiveType] = useState<PluginType | 'all'>('all');
907
+ const [searchQuery, setSearchQuery] = useState('');
908
+ const [detailPlugin, setDetailPlugin] = useState<PluginManifest | null>(null);
909
+
910
+ // 加载数据
911
+ const loadData = useCallback(async () => {
912
+ setLoading(true);
913
+ try {
914
+ const [pluginList, bindingList] = await Promise.all([
915
+ getPlugins(),
916
+ getPluginBindings('global'),
917
+ ]);
918
+ setPlugins(pluginList);
919
+ setBindings(bindingList);
920
+ } catch (e) {
921
+ console.error('加载插件数据失败:', e);
922
+ toast('加载插件数据失败', 'error');
923
+ } finally {
924
+ setLoading(false);
925
+ }
926
+ }, []);
927
+
928
+ useEffect(() => {
929
+ loadData();
930
+ }, [loadData]);
931
+
932
+ // 获取插件绑定状态
933
+ const getBinding = useCallback(
934
+ (pluginId: string) => bindings.find((b) => b.extensionId === pluginId),
935
+ [bindings]
936
+ );
937
+
938
+ // 筛选插件
939
+ const filteredPlugins = plugins.filter((p) => {
940
+ if (activeType !== 'all' && p.type !== activeType) return false;
941
+ if (searchQuery.trim()) {
942
+ const q = searchQuery.toLowerCase();
943
+ return (
944
+ p.name.toLowerCase().includes(q) ||
945
+ p.author.toLowerCase().includes(q) ||
946
+ p.id.toLowerCase().includes(q)
947
+ );
948
+ }
949
+ return true;
950
+ });
951
+
952
+ // 启用/禁用切换
953
+ const handleToggle = useCallback(
954
+ async (plugin: PluginManifest, enabled: boolean) => {
955
+ const binding = getBinding(plugin.id);
956
+ const ok = await upsertPluginBinding({
957
+ extensionId: plugin.id,
958
+ scope: 'global',
959
+ enabled,
960
+ config: binding?.config ?? {},
961
+ sortOrder: binding?.sortOrder ?? 0,
962
+ });
963
+ if (ok) {
964
+ toast(enabled ? `已启用「${plugin.name}」` : `已禁用「${plugin.name}」`, 'success');
965
+ // 刷新绑定列表
966
+ const updatedBindings = await getPluginBindings('global');
967
+ setBindings(updatedBindings);
968
+ reloadSinglePlugin(plugin.id).then();
969
+
970
+ // 全局禁用时:同步禁用所有世界级绑定,防止游戏内残留启用状态
971
+ if (!enabled) {
972
+ const allWorldBindings = await getPluginBindings('world');
973
+ const worldBindingsForPlugin = allWorldBindings.filter(b => b.extensionId === plugin.id);
974
+ for (const wb of worldBindingsForPlugin) {
975
+ await upsertPluginBinding({
976
+ extensionId: plugin.id,
977
+ scope: 'world',
978
+ worldId: wb.worldId,
979
+ enabled: false,
980
+ config: wb.config || {},
981
+ sortOrder: wb.sortOrder || 0,
982
+ });
983
+ }
984
+ }
985
+
986
+ // 广播变更事件,通知游戏页实时响应
987
+ broadcastPluginBindingChange({ pluginId: plugin.id, enabled, source: 'global' });
988
+ } else {
989
+ toast('操作失败', 'error');
990
+ }
991
+ },
992
+ [getBinding, reloadSinglePlugin]
993
+ );
994
+
995
+ // 拖拽排序:将插件移动到新位置,更新 sort_order
996
+ const handleReorder = useCallback(async (pluginId: string, newIndex: number) => {
997
+ // 获取当前分类下的所有绑定(按 sort_order 排序)
998
+ const currentBinding = bindings.find(b => b.extensionId === pluginId);
999
+ const currentPlugin = plugins.find(p => p.id === pluginId);
1000
+ if (!currentBinding || !currentPlugin) return;
1001
+
1002
+ // 同类型插件按 sortOrder 排序
1003
+ const sameTypeBindings = bindings
1004
+ .filter(b => {
1005
+ const p = plugins.find(pl => pl.id === b.extensionId);
1006
+ return p && p.type === currentPlugin.type;
1007
+ })
1008
+ .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
1009
+
1010
+ // 从旧位置移除,插入新位置
1011
+ const oldIndex = sameTypeBindings.findIndex(b => b.extensionId === pluginId);
1012
+ const reordered = [...sameTypeBindings];
1013
+ reordered.splice(oldIndex, 1);
1014
+ reordered.splice(newIndex, 0, currentBinding);
1015
+
1016
+ // 更新所有受影响绑定的 sort_order
1017
+ for (let i = 0; i < reordered.length; i++) {
1018
+ const b = reordered[i];
1019
+ if (b.sortOrder !== i) {
1020
+ await fetch('/api/plugins/bindings', {
1021
+ method: 'PUT',
1022
+ headers: { 'Content-Type': 'application/json' },
1023
+ body: JSON.stringify({ id: b.id, sortOrder: i }),
1024
+ });
1025
+ }
1026
+ }
1027
+
1028
+ // 刷新绑定列表
1029
+ const updatedBindings = await getPluginBindings('global');
1030
+ setBindings(updatedBindings);
1031
+ }, [bindings, plugins]);
1032
+
1033
+ // 一键禁用所有插件
1034
+ const handleDisableAll = useCallback(async () => {
1035
+ const enabledBindings = bindings.filter(b => b.enabled);
1036
+ if (enabledBindings.length === 0) return;
1037
+ for (const binding of enabledBindings) {
1038
+ await upsertPluginBinding({
1039
+ id: binding.id,
1040
+ extensionId: binding.extensionId,
1041
+ scope: 'global',
1042
+ enabled: false,
1043
+ config: binding.config,
1044
+ sortOrder: binding.sortOrder,
1045
+ });
1046
+ // 同步禁用所有世界级绑定
1047
+ const allWorldBindings = await getPluginBindings('world');
1048
+ const worldBindingsForPlugin = allWorldBindings.filter(b => b.extensionId === binding.extensionId);
1049
+ for (const wb of worldBindingsForPlugin) {
1050
+ await upsertPluginBinding({
1051
+ extensionId: binding.extensionId,
1052
+ scope: 'world',
1053
+ worldId: wb.worldId,
1054
+ enabled: false,
1055
+ config: wb.config || {},
1056
+ sortOrder: wb.sortOrder || 0,
1057
+ });
1058
+ }
1059
+ broadcastPluginBindingChange({ pluginId: binding.extensionId, enabled: false, source: 'global' });
1060
+ }
1061
+ const updatedBindings = await getPluginBindings('global');
1062
+ setBindings(updatedBindings);
1063
+ reloadPlugins();
1064
+ toast(`已禁用全部 ${enabledBindings.length} 个插件`, 'success');
1065
+ }, [bindings, reloadPlugins]);
1066
+
1067
+ // 删除插件
1068
+ const handleDelete = useCallback(
1069
+ async (pluginId: string) => {
1070
+ await deletePlugin(pluginId);
1071
+ toast('插件已删除', 'success');
1072
+ setDetailPlugin(null);
1073
+ loadData();
1074
+ reloadPlugins();
1075
+ },
1076
+ [loadData, reloadPlugins]
1077
+ );
1078
+
1079
+ // 打开创建页面
1080
+ const handleCreate = useCallback(() => {
1081
+ navigate('/extensions/create');
1082
+ }, []);
1083
+
1084
+ // 打开编辑页面
1085
+ const handleEdit = useCallback((plugin: PluginManifest) => {
1086
+ setDetailPlugin(null);
1087
+ navigate('/extensions/edit/' + plugin.id);
1088
+ }, []);
1089
+
1090
+ // 导入插件
1091
+ const fileInputRef = useRef<HTMLInputElement>(null);
1092
+
1093
+ const handleImport = useCallback(async () => {
1094
+ fileInputRef.current?.click();
1095
+ }, []);
1096
+
1097
+ const handleFileChange = useCallback(
1098
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
1099
+ const files = e.target.files;
1100
+ if (!files || files.length === 0) return;
1101
+
1102
+ try {
1103
+ // 分离 .xye 包和 .json 文件
1104
+ const xyeFiles: File[] = [];
1105
+ const jsonFiles: File[] = [];
1106
+ for (let i = 0; i < files.length; i++) {
1107
+ if (files[i].name.endsWith('.xye')) {
1108
+ xyeFiles.push(files[i]);
1109
+ } else {
1110
+ jsonFiles.push(files[i]);
1111
+ }
1112
+ }
1113
+
1114
+ let totalImported = 0;
1115
+ let totalSkipped = 0;
1116
+ let totalProcessed = 0;
1117
+
1118
+ // 处理 .xye 包(通过后端 API)
1119
+ for (const xyeFile of xyeFiles) {
1120
+ try {
1121
+ const formData = new FormData();
1122
+ formData.append('file', xyeFile);
1123
+ const res = await fetch('/api/plugins/import-package', {
1124
+ method: 'POST',
1125
+ body: formData,
1126
+ });
1127
+ if (res.ok) {
1128
+ const data = await res.json();
1129
+ totalImported += data.imported || 0;
1130
+ totalSkipped += data.skipped || 0;
1131
+ totalProcessed += data.total || 0;
1132
+ } else {
1133
+ const err = await res.json().catch(() => ({}));
1134
+ toast(`导入 ${xyeFile.name} 失败: ${err.error || '未知错误'}`, 'error');
1135
+ }
1136
+ } catch {
1137
+ toast(`导入 ${xyeFile.name} 失败`, 'error');
1138
+ }
1139
+ }
1140
+
1141
+ // 处理 .json 文件(通过现有 JSON API)
1142
+ if (jsonFiles.length > 0) {
1143
+ const allPlugins: unknown[] = [];
1144
+ for (const jsonFile of jsonFiles) {
1145
+ const text = await jsonFile.text();
1146
+ const data = JSON.parse(text);
1147
+ if (data.format === 'xinyu-extension-v1' && Array.isArray(data.plugins)) {
1148
+ allPlugins.push(...data.plugins);
1149
+ } else if (Array.isArray(data)) {
1150
+ allPlugins.push(...data);
1151
+ } else if (data.id && data.code) {
1152
+ allPlugins.push(data);
1153
+ }
1154
+ }
1155
+
1156
+ if (allPlugins.length > 0) {
1157
+ const result = await importPlugins(allPlugins);
1158
+ totalImported += result.imported;
1159
+ totalSkipped += result.skipped;
1160
+ totalProcessed += result.total;
1161
+ }
1162
+ }
1163
+
1164
+ if (totalProcessed > 0) {
1165
+ toast(
1166
+ `导入完成:成功 ${totalImported} 个,跳过 ${totalSkipped} 个,共 ${totalProcessed} 个`,
1167
+ totalImported > 0 ? 'success' : 'warning'
1168
+ );
1169
+ loadData();
1170
+ reloadPlugins();
1171
+ } else if (xyeFiles.length === 0) {
1172
+ toast('未找到有效的插件数据', 'warning');
1173
+ }
1174
+ } catch {
1175
+ toast('导入失败:文件格式错误', 'error');
1176
+ }
1177
+
1178
+ // 重置 input
1179
+ if (fileInputRef.current) {
1180
+ fileInputRef.current.value = '';
1181
+ }
1182
+ },
1183
+ [loadData, reloadPlugins]
1184
+ );
1185
+
1186
+ // 导出全部插件
1187
+ const handleExport = useCallback(async () => {
1188
+ try {
1189
+ const data = await exportPlugins();
1190
+ const blob = new Blob([data], { type: 'application/json' });
1191
+ const url = URL.createObjectURL(blob);
1192
+ const a = document.createElement('a');
1193
+ a.href = url;
1194
+ a.download = `xinyu-plugins-${new Date().toISOString().slice(0, 10)}.json`;
1195
+ a.click();
1196
+ URL.revokeObjectURL(url);
1197
+ toast('插件已导出', 'success');
1198
+ } catch {
1199
+ toast('导出失败', 'error');
1200
+ }
1201
+ }, [toast]);
1202
+
1203
+ // 导出单个插件的代码文件(.js)
1204
+ const handleExportCode = useCallback(async (pluginId: string, pluginName: string, code: string) => {
1205
+ try {
1206
+ // kebab-case 转 camelCase: xinyu.cache-optimizer -> xinyuCacheOptimizer
1207
+ const fileName = pluginId.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) + '.js';
1208
+ const blob = new Blob([code], { type: 'text/javascript' });
1209
+ const url = URL.createObjectURL(blob);
1210
+ const a = document.createElement('a');
1211
+ a.href = url;
1212
+ a.download = fileName;
1213
+ a.click();
1214
+ URL.revokeObjectURL(url);
1215
+ toast(`已导出「${pluginName}」代码`, 'success');
1216
+ } catch {
1217
+ toast('导出代码失败', 'error');
1218
+ }
1219
+ }, [toast]);
1220
+
1221
+ // 导出单个插件
1222
+ const handleExportPlugin = useCallback(async (pluginId: string, pluginName: string, format: 'xye' | 'json' = 'xye') => {
1223
+ try {
1224
+ if (format === 'xye') {
1225
+ const response = await fetch(`/api/plugins/export-xye?id=${encodeURIComponent(pluginId)}`);
1226
+ if (!response.ok) {
1227
+ toast('导出失败', 'error');
1228
+ return;
1229
+ }
1230
+ const blob = await response.blob();
1231
+ const url = URL.createObjectURL(blob);
1232
+ const a = document.createElement('a');
1233
+ a.href = url;
1234
+ a.download = `${pluginId}.xye`;
1235
+ a.click();
1236
+ URL.revokeObjectURL(url);
1237
+ toast(`已导出「${pluginName}」为 .xye`, 'success');
1238
+ } else {
1239
+ const response = await fetch(`/api/plugins/export?id=${encodeURIComponent(pluginId)}`);
1240
+ if (!response.ok) {
1241
+ toast('导出失败', 'error');
1242
+ return;
1243
+ }
1244
+ const data = await response.text();
1245
+ const blob = new Blob([data], { type: 'application/json' });
1246
+ const url = URL.createObjectURL(blob);
1247
+ const a = document.createElement('a');
1248
+ a.href = url;
1249
+ a.download = `${pluginId}.json`;
1250
+ a.click();
1251
+ URL.revokeObjectURL(url);
1252
+ toast(`已导出「${pluginName}」为 .json`, 'success');
1253
+ }
1254
+ } catch {
1255
+ toast('导出失败', 'error');
1256
+ }
1257
+ }, [toast]);
1258
+
1259
+ return (
1260
+ <div className="min-h-screen" style={{ backgroundColor: s.bgPrimary }}>
1261
+
1262
+ {/* 顶部导航栏 */}
1263
+ <PageHeader
1264
+ title="星语 · 插件管理"
1265
+ showBack={true}
1266
+ icon={<span style={{ color: 'var(--color-accent)' }}>&#10022;</span>}
1267
+ sticky
1268
+ actions={
1269
+ <div className="flex items-center gap-2">
1270
+ <button
1271
+ onClick={handleImport}
1272
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5"
1273
+ style={{
1274
+ borderColor: s.border,
1275
+ color: s.textSecondary,
1276
+ backgroundColor: 'transparent',
1277
+ }}
1278
+ title="导入插件 JSON 文件(支持单个插件或多插件包)"
1279
+ >
1280
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1281
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1282
+ <polyline points="7 10 12 15 17 10" />
1283
+ <line x1="12" y1="15" x2="12" y2="3" />
1284
+ </svg>
1285
+ 导入插件
1286
+ </button>
1287
+ <button
1288
+ onClick={handleExport}
1289
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5"
1290
+ style={{
1291
+ borderColor: s.border,
1292
+ color: s.textSecondary,
1293
+ backgroundColor: 'transparent',
1294
+ }}
1295
+ title="导出全部插件为一个 JSON 文件"
1296
+ >
1297
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1298
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1299
+ <polyline points="17 8 12 3 7 8" />
1300
+ <line x1="12" y1="3" x2="12" y2="15" />
1301
+ </svg>
1302
+ 导出全部
1303
+ </button>
1304
+ <button
1305
+ onClick={() => navigate('/extensions/tutorial')}
1306
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5"
1307
+ style={{
1308
+ borderColor: s.border,
1309
+ color: s.textSecondary,
1310
+ backgroundColor: 'transparent',
1311
+ }}
1312
+ title="插件开发教程"
1313
+ >
1314
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1315
+ <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
1316
+ <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
1317
+ </svg>
1318
+ 开发教程
1319
+ </button>
1320
+ <button
1321
+ onClick={handleCreate}
1322
+ className="px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5"
1323
+ style={{
1324
+ backgroundColor: s.accent,
1325
+ color: s.bgPrimary,
1326
+ }}
1327
+ >
1328
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
1329
+ <line x1="12" y1="5" x2="12" y2="19" />
1330
+ <line x1="5" y1="12" x2="19" y2="12" />
1331
+ </svg>
1332
+ 创建插件
1333
+ </button>
1334
+ {bindings.some(b => b.enabled) && (
1335
+ <button
1336
+ onClick={handleDisableAll}
1337
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5"
1338
+ style={{
1339
+ borderColor: '#ef4444',
1340
+ color: '#ef4444',
1341
+ backgroundColor: 'transparent',
1342
+ }}
1343
+ title="禁用所有已启用的插件"
1344
+ >
1345
+ <i className="fa-solid fa-power-off" style={{ fontSize: '11px' }} />
1346
+ 全部禁用
1347
+ </button>
1348
+ )}
1349
+ {bindings.some(b => b.enabled) && (
1350
+ <button
1351
+ onClick={async () => {
1352
+ await reloadPlugins();
1353
+ toast('已刷新全部插件', 'success');
1354
+ }}
1355
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5"
1356
+ style={{
1357
+ borderColor: s.border,
1358
+ color: s.textSecondary,
1359
+ backgroundColor: 'transparent',
1360
+ }}
1361
+ title="重新加载所有插件"
1362
+ >
1363
+ <i className="fa-solid fa-arrows-rotate" style={{ fontSize: '11px' }} />
1364
+ 刷新全部
1365
+ </button>
1366
+ )}
1367
+ </div>
1368
+ }
1369
+ />
1370
+
1371
+ {/* 主体内容 */}
1372
+ <div className="flex" style={{ height: 'calc(100vh - 52px)' }}>
1373
+ {/* 左侧分类筛选 */}
1374
+ <nav
1375
+ className="w-48 shrink-0 p-3 flex flex-col gap-1 overflow-y-auto"
1376
+ style={{
1377
+ backgroundColor: s.bgSecondary,
1378
+ borderRight: `1px solid ${s.border}`,
1379
+ }}
1380
+ >
1381
+ {ALL_TYPES.map((item) => {
1382
+ const isActive =
1383
+ item.type === 'all'
1384
+ ? activeType === 'all'
1385
+ : activeType === item.type;
1386
+ return (
1387
+ <button
1388
+ key={item.type}
1389
+ onClick={() => setActiveType(item.type)}
1390
+ className="w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors"
1391
+ style={{
1392
+ backgroundColor: isActive ? s.bgTertiary : 'transparent',
1393
+ color: isActive ? s.accent : s.textSecondary,
1394
+ fontWeight: isActive ? 600 : 400,
1395
+ }}
1396
+ >
1397
+ <span>{item.icon}</span>
1398
+ <span>{item.label}</span>
1399
+ </button>
1400
+ );
1401
+ })}
1402
+
1403
+ {/* 分隔线 */}
1404
+ <div className="my-2" style={{ borderTop: `1px solid ${s.border}` }} />
1405
+
1406
+ {/* 搜索框 */}
1407
+ <div className="relative">
1408
+ <svg
1409
+ className="absolute left-2.5 top-1/2 -translate-y-1/2"
1410
+ width="14"
1411
+ height="14"
1412
+ viewBox="0 0 24 24"
1413
+ fill="none"
1414
+ stroke="currentColor"
1415
+ strokeWidth="2"
1416
+ strokeLinecap="round"
1417
+ strokeLinejoin="round"
1418
+ style={{ color: s.textMuted }}
1419
+ >
1420
+ <circle cx="11" cy="11" r="8" />
1421
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
1422
+ </svg>
1423
+ <input
1424
+ type="text"
1425
+ value={searchQuery}
1426
+ onChange={(e) => setSearchQuery(e.target.value)}
1427
+ placeholder="搜索插件..."
1428
+ className="w-full pl-8 pr-3 py-1.5 rounded-lg text-sm border outline-none transition-colors"
1429
+ style={{
1430
+ backgroundColor: s.bgTertiary,
1431
+ borderColor: s.border,
1432
+ color: s.textPrimary,
1433
+ }}
1434
+ onFocus={(e) => (e.target.style.borderColor = s.accent)}
1435
+ onBlur={(e) => (e.target.style.borderColor = s.border)}
1436
+ />
1437
+ </div>
1438
+ </nav>
1439
+
1440
+ {/* 右侧内容区 */}
1441
+ <main className="flex-1 p-4 overflow-y-auto">
1442
+ {loading ? (
1443
+ <div className="flex flex-col items-center justify-center py-20 gap-3">
1444
+ <MathCurveLoader size={64} />
1445
+ </div>
1446
+ ) : filteredPlugins.length === 0 ? (
1447
+ <div className="flex flex-col items-center justify-center py-20 gap-3">
1448
+ <span className="text-4xl opacity-40">&#128230;</span>
1449
+ <p className="text-sm" style={{ color: s.textMuted }}>
1450
+ {searchQuery || activeType !== 'all'
1451
+ ? '没有找到匹配的插件'
1452
+ : '还没有安装任何插件'}
1453
+ </p>
1454
+ {!searchQuery && activeType === 'all' && (
1455
+ <div className="flex items-center gap-3">
1456
+ <button
1457
+ onClick={handleCreate}
1458
+ className="px-4 py-1.5 rounded-lg text-sm border"
1459
+ style={{
1460
+ borderColor: s.accent,
1461
+ color: s.accent,
1462
+ backgroundColor: 'transparent',
1463
+ }}
1464
+ >
1465
+ 创建第一个插件
1466
+ </button>
1467
+ <button
1468
+ onClick={handleImport}
1469
+ className="px-4 py-1.5 rounded-lg text-sm border flex items-center gap-2"
1470
+ style={{
1471
+ borderColor: s.border,
1472
+ color: s.textSecondary,
1473
+ backgroundColor: 'transparent',
1474
+ }}
1475
+ >
1476
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1477
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1478
+ <polyline points="7 10 12 15 17 10" />
1479
+ <line x1="12" y1="15" x2="12" y2="3" />
1480
+ </svg>
1481
+ 导入插件
1482
+ </button>
1483
+ </div>
1484
+ )}
1485
+ </div>
1486
+ ) : (
1487
+ <div className="space-y-5">
1488
+ {activeType === 'all'
1489
+ // 全部视图:按分类分组
1490
+ ? ALL_TYPES.filter(t => t.type !== 'all').map((typeItem) => {
1491
+ const groupPlugins = filteredPlugins
1492
+ .filter(p => p.type === typeItem.type)
1493
+ .map(p => ({ plugin: p, binding: getBinding(p.id) }))
1494
+ .sort((a, b) => (a.binding?.sortOrder ?? 0) - (b.binding?.sortOrder ?? 0));
1495
+ if (groupPlugins.length === 0) return null;
1496
+ return (
1497
+ <div key={typeItem.type}>
1498
+ <div className="flex items-center gap-2 mb-2">
1499
+ <span className="text-sm">{typeItem.icon}</span>
1500
+ <span className="text-xs font-medium" style={{ color: s.textSecondary }}>
1501
+ {typeItem.label}
1502
+ </span>
1503
+ <span className="text-xs" style={{ color: s.textMuted }}>
1504
+ ({groupPlugins.length})
1505
+ </span>
1506
+ </div>
1507
+ <SortablePluginList
1508
+ plugins={groupPlugins}
1509
+ s={s}
1510
+ onToggle={handleToggle}
1511
+ onReorder={handleReorder}
1512
+ onSelect={setDetailPlugin}
1513
+ />
1514
+ </div>
1515
+ );
1516
+ })
1517
+ // 分类视图:同分类内可排序
1518
+ : (() => {
1519
+ const groupPlugins = filteredPlugins
1520
+ .map(p => ({ plugin: p, binding: getBinding(p.id) }))
1521
+ .sort((a, b) => (a.binding?.sortOrder ?? 0) - (b.binding?.sortOrder ?? 0));
1522
+ return (
1523
+ <SortablePluginList
1524
+ plugins={groupPlugins}
1525
+ s={s}
1526
+ onToggle={handleToggle}
1527
+ onReorder={handleReorder}
1528
+ onSelect={setDetailPlugin}
1529
+ />
1530
+ );
1531
+ })()
1532
+ }
1533
+ </div>
1534
+ )}
1535
+ </main>
1536
+ </div>
1537
+
1538
+ {/* 隐藏的文件输入 */}
1539
+ <input
1540
+ ref={fileInputRef}
1541
+ type="file"
1542
+ accept=".json,.xye"
1543
+ multiple
1544
+ className="hidden"
1545
+ onChange={handleFileChange}
1546
+ />
1547
+
1548
+ {/* 插件详情模态框 */}
1549
+ {detailPlugin && (
1550
+ <PluginDetailModal
1551
+ plugin={detailPlugin}
1552
+ binding={getBinding(detailPlugin.id)}
1553
+ onClose={() => setDetailPlugin(null)}
1554
+ onToggle={(enabled) => handleToggle(detailPlugin, enabled)}
1555
+ onDelete={() => handleDelete(detailPlugin.id)}
1556
+ onEdit={() => handleEdit(detailPlugin)}
1557
+ onExport={(format) => handleExportPlugin(detailPlugin.id, detailPlugin.name, format)}
1558
+ onExportCode={() => handleExportCode(detailPlugin.id, detailPlugin.name, detailPlugin.code)}
1559
+ isXye={!!detailPlugin.codePath && !detailPlugin.codePath.endsWith('/plugin.js')}
1560
+ />
1561
+ )}
1562
+ </div>
1563
+ );
1564
+ }
1565
+
1566
+ export default function ExtensionsPage() {
1567
+ return (
1568
+ <React.Suspense>
1569
+ <ExtensionsPageContent />
1570
+ </React.Suspense>
1571
+ );
1572
+ }