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,2342 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
4
+ import { useRouter, useParams } from 'next/navigation';
5
+ import Editor, { type OnMount } from '@monaco-editor/react';
6
+ import { getPlugin, updatePlugin, scanPluginSecurity } from '@/lib/storage';
7
+ import { parseManifestFromCode } from '@/lib/manifest-parser';
8
+ import { useTheme } from '@/components/ui/ThemeProvider';
9
+ import {
10
+ PluginType,
11
+ PluginConfigField,
12
+ PluginDependency,
13
+ SecurityScanResult,
14
+ PLUGIN_TYPE_LABELS,
15
+ UIPermission,
16
+ UI_PERMISSION_LABELS,
17
+ } from '@/lib/plugin-types';
18
+ import { useToast } from '@/components/ui/ToastProvider';
19
+ import FullPageLoader from '@/components/FullPageLoader';
20
+ import PageHeader from '@/components/ui/PageHeader';
21
+ import { PluginIcon } from '@/components/ui/PluginIcon';
22
+ import { useRouterHistory } from '@/lib/router-history';
23
+ import MathCurveLoader from '@/components/MathCurveLoader';
24
+
25
+ // ==================== 常量 ====================
26
+
27
+ const CONFIG_FIELD_TYPES: PluginConfigField['type'][] = ['text', 'number', 'boolean', 'select', 'textarea'];
28
+ const CONFIG_FIELD_TYPE_LABELS: Record<PluginConfigField['type'], string> = {
29
+ text: '文本',
30
+ number: '数字',
31
+ boolean: '布尔',
32
+ select: '下拉选择',
33
+ textarea: '多行文本',
34
+ };
35
+
36
+ // ==================== 通用样式辅助 ====================
37
+
38
+ const s = {
39
+ bgPrimary: 'var(--color-bg-primary)',
40
+ bgSecondary: 'var(--color-bg-secondary)',
41
+ bgTertiary: 'var(--color-bg-tertiary)',
42
+ textPrimary: 'var(--color-text-primary)',
43
+ textSecondary: 'var(--color-text-secondary)',
44
+ textMuted: 'var(--color-text-muted)',
45
+ accent: 'var(--color-accent)',
46
+ accentHover: 'var(--color-accent-hover)',
47
+ border: 'var(--color-border)',
48
+ shadow: 'var(--color-shadow)',
49
+ };
50
+
51
+ /** Monaco Editor 可选主题 */
52
+ const EDITOR_THEMES = [
53
+ { id: 'vs', label: '浅色', color: '#f0f0f0' },
54
+ { id: 'vs-dark', label: '深色', color: '#1e1e1e' },
55
+ { id: 'hc-black', label: '高对比', color: '#000000' },
56
+ ] as const;
57
+
58
+ // ==================== 子组件 ====================
59
+
60
+ function TextField({
61
+ value,
62
+ onChange,
63
+ placeholder,
64
+ mono = false,
65
+ type = 'text',
66
+ className = '',
67
+ readOnly = false,
68
+ }: {
69
+ value: string;
70
+ onChange: (v: string) => void;
71
+ placeholder?: string;
72
+ mono?: boolean;
73
+ type?: string;
74
+ className?: string;
75
+ readOnly?: boolean;
76
+ }) {
77
+ return (
78
+ <input
79
+ type={type}
80
+ value={value}
81
+ onChange={(e) => onChange(e.target.value)}
82
+ placeholder={placeholder}
83
+ readOnly={readOnly}
84
+ className={`w-full px-3 py-1.5 rounded-lg text-sm border outline-none transition-colors ${className}`}
85
+ style={{
86
+ backgroundColor: s.bgTertiary,
87
+ borderColor: s.border,
88
+ color: s.textPrimary,
89
+ fontFamily: mono ? 'monospace' : 'inherit',
90
+ }}
91
+ onFocus={(e) => (e.target.style.borderColor = s.accent)}
92
+ onBlur={(e) => (e.target.style.borderColor = s.border)}
93
+ />
94
+ );
95
+ }
96
+
97
+ function TextAreaField({
98
+ value,
99
+ onChange,
100
+ placeholder,
101
+ rows = 4,
102
+ }: {
103
+ value: string;
104
+ onChange: (v: string) => void;
105
+ placeholder?: string;
106
+ rows?: number;
107
+ }) {
108
+ return (
109
+ <textarea
110
+ value={value}
111
+ onChange={(e) => onChange(e.target.value)}
112
+ placeholder={placeholder}
113
+ rows={rows}
114
+ className="w-full px-3 py-2 rounded-lg text-sm border outline-none transition-colors resize-y"
115
+ style={{
116
+ backgroundColor: s.bgTertiary,
117
+ borderColor: s.border,
118
+ color: s.textPrimary,
119
+ }}
120
+ onFocus={(e) => (e.target.style.borderColor = s.accent)}
121
+ onBlur={(e) => (e.target.style.borderColor = s.border)}
122
+ />
123
+ );
124
+ }
125
+
126
+ function SelectField({
127
+ value,
128
+ onChange,
129
+ options,
130
+ }: {
131
+ value: string;
132
+ onChange: (v: string) => void;
133
+ options: { label: string; value: string }[];
134
+ }) {
135
+ return (
136
+ <select
137
+ value={value}
138
+ onChange={(e) => onChange(e.target.value)}
139
+ className="w-full px-3 py-1.5 rounded-lg text-sm border outline-none transition-colors cursor-pointer"
140
+ style={{
141
+ backgroundColor: s.bgTertiary,
142
+ borderColor: s.border,
143
+ color: s.textPrimary,
144
+ }}
145
+ onFocus={(e) => (e.target.style.borderColor = s.accent)}
146
+ onBlur={(e) => (e.target.style.borderColor = s.border)}
147
+ >
148
+ {options.map((opt) => (
149
+ <option key={opt.value} value={opt.value}>
150
+ {opt.label}
151
+ </option>
152
+ ))}
153
+ </select>
154
+ );
155
+ }
156
+
157
+ // ==================== 文件树组件 ====================
158
+
159
+ function FileTree({
160
+ files,
161
+ activeFile,
162
+ depth,
163
+ onSelect,
164
+ onContextMenu,
165
+ theme,
166
+ }: {
167
+ files: Array<{ name: string; path: string; icon: string; editable: boolean; type: 'file' | 'dir'; children?: typeof files }>;
168
+ activeFile: string;
169
+ depth: number;
170
+ onSelect: (path: string, editable: boolean) => void;
171
+ onContextMenu: (e: React.MouseEvent, file: { name: string; path: string; editable: boolean; type: 'file' | 'dir' }) => void;
172
+ theme: Record<string, string>;
173
+ }) {
174
+ const [expandedDirs, setExpandedDirs] = useState<Set<string>>(() => {
175
+ // 默认展开所有目录
176
+ const set = new Set<string>();
177
+ const collect = (items: typeof files) => {
178
+ for (const item of items) {
179
+ if (item.type === 'dir') {
180
+ set.add(item.path);
181
+ if (item.children) collect(item.children);
182
+ }
183
+ }
184
+ };
185
+ collect(files);
186
+ return set;
187
+ });
188
+
189
+ const toggleDir = (dirPath: string) => {
190
+ setExpandedDirs(prev => {
191
+ const next = new Set(prev);
192
+ if (next.has(dirPath)) {
193
+ next.delete(dirPath);
194
+ } else {
195
+ next.add(dirPath);
196
+ }
197
+ return next;
198
+ });
199
+ };
200
+
201
+ return (
202
+ <div>
203
+ {files.map((file) => (
204
+ <div key={file.path}>
205
+ <button
206
+ className="w-full text-left px-3 py-1 text-xs flex items-center gap-1.5 transition-colors"
207
+ style={{
208
+ paddingLeft: `${12 + depth * 16}px`,
209
+ backgroundColor: activeFile === file.path ? theme.bgTertiary : 'transparent',
210
+ color: activeFile === file.path ? theme.accent : theme.textSecondary,
211
+ cursor: 'pointer',
212
+ opacity: file.type === 'file' && !file.editable ? 0.6 : 1,
213
+ }}
214
+ onClick={() => {
215
+ if (file.type === 'dir') {
216
+ toggleDir(file.path);
217
+ } else {
218
+ onSelect(file.path, file.editable);
219
+ }
220
+ }}
221
+ onContextMenu={(e) => onContextMenu(e, file)}
222
+ title={file.type === 'dir' ? file.path : (file.editable ? file.path : `${file.path}(只读)`)}
223
+ >
224
+ {file.type === 'dir' ? (
225
+ <i
226
+ className="fa-solid fa-chevron-right"
227
+ style={{
228
+ fontSize: '8px',
229
+ width: '14px',
230
+ textAlign: 'center',
231
+ transition: 'transform 0.15s',
232
+ transform: expandedDirs.has(file.path) ? 'rotate(90deg)' : 'rotate(0deg)',
233
+ flexShrink: 0,
234
+ }}
235
+ />
236
+ ) : (
237
+ <span style={{ width: '14px', flexShrink: 0 }} />
238
+ )}
239
+ <i className={file.type === 'dir'
240
+ ? (expandedDirs.has(file.path) ? 'fa-solid fa-folder-open' : 'fa-solid fa-folder')
241
+ : file.icon
242
+ } style={{ fontSize: '11px', width: '14px', textAlign: 'center', flexShrink: 0 }} />
243
+ <span className="truncate">{file.name}</span>
244
+ {!file.editable && file.type === 'file' && (
245
+ <i className="fa-solid fa-eye text-[9px] ml-auto opacity-50" />
246
+ )}
247
+ </button>
248
+ {file.type === 'dir' && file.children && expandedDirs.has(file.path) && (
249
+ <FileTree
250
+ files={file.children}
251
+ activeFile={activeFile}
252
+ depth={depth + 1}
253
+ onSelect={onSelect}
254
+ onContextMenu={onContextMenu}
255
+ theme={theme}
256
+ />
257
+ )}
258
+ </div>
259
+ ))}
260
+ </div>
261
+ );
262
+ }
263
+
264
+ // ==================== 文件树右键菜单组件 ====================
265
+
266
+ function FileContextMenu({
267
+ x,
268
+ y,
269
+ file,
270
+ activeFile,
271
+ onClose,
272
+ onNewFile,
273
+ onNewFolder,
274
+ onDelete,
275
+ onRename,
276
+ theme,
277
+ }: {
278
+ x: number;
279
+ y: number;
280
+ file: { name: string; path: string; editable: boolean; type: 'file' | 'dir' };
281
+ activeFile: string;
282
+ onClose: () => void;
283
+ onNewFile: (dirPath: string) => void;
284
+ onNewFolder: (dirPath: string) => void;
285
+ onDelete: (path: string) => void;
286
+ onRename: (oldPath: string, newPath: string) => void;
287
+ theme: Record<string, string>;
288
+ }) {
289
+ const [renaming, setRenaming] = useState(false);
290
+ const [newName, setNewName] = useState(file.name);
291
+ const [creating, setCreating] = useState<'file' | 'dir' | null>(null);
292
+ const [newItemName, setNewItemName] = useState('');
293
+ const inputRef = useRef<HTMLInputElement>(null);
294
+
295
+ useEffect(() => {
296
+ if (renaming || creating) {
297
+ inputRef.current?.focus();
298
+ inputRef.current?.select();
299
+ }
300
+ }, [renaming, creating]);
301
+
302
+ const handleRenameConfirm = () => {
303
+ if (!newName.trim()) { setRenaming(false); return; }
304
+ const dir = file.path.includes('/') ? file.path.substring(0, file.path.lastIndexOf('/') + 1) : '';
305
+ const newPath = dir + newName.trim();
306
+ if (newPath === file.path) { setRenaming(false); return; }
307
+ onRename(file.path, newPath);
308
+ setRenaming(false);
309
+ };
310
+
311
+ const handleCreateConfirm = () => {
312
+ if (!newItemName.trim()) { setCreating(null); return; }
313
+ const dir = file.type === 'dir' ? file.path + '/' : (file.path.includes('/') ? file.path.substring(0, file.path.lastIndexOf('/') + 1) : '');
314
+ if (creating === 'file') {
315
+ onNewFile(dir + newItemName.trim());
316
+ } else {
317
+ onNewFolder(dir + newItemName.trim());
318
+ }
319
+ setCreating(null);
320
+ setNewItemName('');
321
+ };
322
+
323
+ const dirPath = file.type === 'dir' ? file.path : (file.path.includes('/') ? file.path.substring(0, file.path.lastIndexOf('/')) : '');
324
+
325
+ const [contextMenuOpen, setContextMenuOpen] = useState(true);
326
+
327
+ return (
328
+ <>
329
+ <div className="fixed inset-0 z-30" onClick={() => { setContextMenuOpen(false); onClose(); }} onContextMenu={(e) => { e.preventDefault(); setContextMenuOpen(false); onClose(); }} />
330
+ {contextMenuOpen && (
331
+ <div
332
+ className="fixed z-40 py-1 rounded-lg shadow-lg min-w-[160px]"
333
+ style={{
334
+ left: Math.min(x, window.innerWidth - 180),
335
+ top: Math.min(y, window.innerHeight - 200),
336
+ backgroundColor: theme.bgSecondary,
337
+ border: `1px solid ${theme.border}`,
338
+ }}
339
+ >
340
+ {/* 新建文件 */}
341
+ <button
342
+ className="w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors hover:opacity-80"
343
+ style={{ color: theme.textPrimary }}
344
+ onClick={() => { setCreating('file'); setNewItemName(''); setContextMenuOpen(false); }}
345
+ >
346
+ <i className="fa-solid fa-file-circle-plus" style={{ fontSize: '10px', width: '14px', textAlign: 'center' }} />
347
+ 新建文件
348
+ </button>
349
+ {/* 新建文件夹 */}
350
+ <button
351
+ className="w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors hover:opacity-80"
352
+ style={{ color: theme.textPrimary }}
353
+ onClick={() => { setCreating('dir'); setNewItemName(''); setContextMenuOpen(false); }}
354
+ >
355
+ <i className="fa-solid fa-folder-plus" style={{ fontSize: '10px', width: '14px', textAlign: 'center' }} />
356
+ 新建文件夹
357
+ </button>
358
+ {/* 重命名 */}
359
+ {file.path !== 'manifest.json' && (
360
+ <button
361
+ className="w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors hover:opacity-80"
362
+ style={{ color: theme.textPrimary }}
363
+ onClick={() => { setRenaming(true); setNewName(file.name); setContextMenuOpen(false); }}
364
+ >
365
+ <i className="fa-solid fa-pen" style={{ fontSize: '10px', width: '14px', textAlign: 'center' }} />
366
+ 重命名
367
+ </button>
368
+ )}
369
+ {/* 删除 */}
370
+ {file.path !== 'manifest.json' && (
371
+ <button
372
+ className="w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors hover:opacity-80"
373
+ style={{ color: '#e74c3c' }}
374
+ onClick={() => { onDelete(file.path); setContextMenuOpen(false); onClose(); }}
375
+ >
376
+ <i className="fa-solid fa-trash" style={{ fontSize: '10px', width: '14px', textAlign: 'center' }} />
377
+ 删除
378
+ </button>
379
+ )}
380
+ </div>
381
+ )}
382
+ {/* 重命名输入框 */}
383
+ {renaming && (
384
+ <>
385
+ <div className="fixed inset-0 z-30" onClick={() => setRenaming(false)} />
386
+ <div
387
+ className="fixed z-40 p-3 rounded-lg shadow-lg"
388
+ style={{
389
+ left: Math.min(x, window.innerWidth - 250),
390
+ top: Math.min(y, window.innerHeight - 60),
391
+ backgroundColor: theme.bgSecondary,
392
+ border: `1px solid ${theme.border}`,
393
+ }}
394
+ >
395
+ <div className="text-[10px] mb-1" style={{ color: theme.textMuted }}>重命名</div>
396
+ <input
397
+ ref={inputRef}
398
+ value={newName}
399
+ onChange={(e) => setNewName(e.target.value)}
400
+ onKeyDown={(e) => { if (e.key === 'Enter') handleRenameConfirm(); if (e.key === 'Escape') setRenaming(false); }}
401
+ className="w-full px-2 py-1 rounded text-xs outline-none"
402
+ style={{ backgroundColor: theme.bgTertiary, color: theme.textPrimary, border: `1px solid ${theme.border}` }}
403
+ />
404
+ </div>
405
+ </>
406
+ )}
407
+ {/* 新建输入框 */}
408
+ {creating && (
409
+ <>
410
+ <div className="fixed inset-0 z-30" onClick={() => setCreating(null)} />
411
+ <div
412
+ className="fixed z-40 p-3 rounded-lg shadow-lg"
413
+ style={{
414
+ left: Math.min(x, window.innerWidth - 250),
415
+ top: Math.min(y, window.innerHeight - 60),
416
+ backgroundColor: theme.bgSecondary,
417
+ border: `1px solid ${theme.border}`,
418
+ }}
419
+ >
420
+ <div className="text-[10px] mb-1" style={{ color: theme.textMuted }}>
421
+ {creating === 'file' ? '新建文件' : '新建文件夹'}(在 {dirPath || '根目录'} 下)
422
+ </div>
423
+ <input
424
+ ref={inputRef}
425
+ value={newItemName}
426
+ onChange={(e) => setNewItemName(e.target.value)}
427
+ onKeyDown={(e) => { if (e.key === 'Enter') handleCreateConfirm(); if (e.key === 'Escape') setCreating(null); }}
428
+ placeholder={creating === 'file' ? '文件名.js' : '文件夹名'}
429
+ className="w-full px-2 py-1 rounded text-xs outline-none"
430
+ style={{ backgroundColor: theme.bgTertiary, color: theme.textPrimary, border: `1px solid ${theme.border}` }}
431
+ />
432
+ </div>
433
+ </>
434
+ )}
435
+ </>
436
+ );
437
+ }
438
+
439
+ // ==================== 拖拽分隔条组件 ====================
440
+
441
+ function ResizableDivider({
442
+ onResize,
443
+ }: {
444
+ onResize: (deltaX: number) => void;
445
+ }) {
446
+ const isDragging = useRef(false);
447
+ const lastX = useRef(0);
448
+
449
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
450
+ e.preventDefault();
451
+ isDragging.current = true;
452
+ lastX.current = e.clientX;
453
+ document.body.style.cursor = 'col-resize';
454
+ document.body.style.userSelect = 'none';
455
+ }, []);
456
+
457
+ useEffect(() => {
458
+ const handleMouseMove = (e: MouseEvent) => {
459
+ if (!isDragging.current) return;
460
+ const delta = e.clientX - lastX.current;
461
+ lastX.current = e.clientX;
462
+ onResize(delta);
463
+ };
464
+
465
+ const handleMouseUp = () => {
466
+ if (!isDragging.current) return;
467
+ isDragging.current = false;
468
+ document.body.style.cursor = '';
469
+ document.body.style.userSelect = '';
470
+ };
471
+
472
+ window.addEventListener('mousemove', handleMouseMove);
473
+ window.addEventListener('mouseup', handleMouseUp);
474
+ return () => {
475
+ window.removeEventListener('mousemove', handleMouseMove);
476
+ window.removeEventListener('mouseup', handleMouseUp);
477
+ };
478
+ }, [onResize]);
479
+
480
+ return (
481
+ <div
482
+ onMouseDown={handleMouseDown}
483
+ className="w-1.5 shrink-0 cursor-col-resize flex items-center justify-center group relative"
484
+ style={{ backgroundColor: s.border }}
485
+ >
486
+ <div
487
+ className="absolute inset-y-0 -left-1 -right-1 z-10"
488
+ />
489
+ <div
490
+ className="w-0.5 h-8 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
491
+ style={{ backgroundColor: s.accent }}
492
+ />
493
+ </div>
494
+ );
495
+ }
496
+
497
+ // ==================== 插件指南数据 ====================
498
+
499
+ interface ApiEntry {
500
+ name: string;
501
+ desc: string;
502
+ example?: string;
503
+ }
504
+
505
+ interface PluginGuide {
506
+ title: string;
507
+ icon: string;
508
+ description: string;
509
+ availableApis: ApiEntry[];
510
+ bestPractices: string[];
511
+ codeTemplate: string;
512
+ }
513
+
514
+ /** 全局 API(所有插件类型通用) */
515
+ const GLOBAL_APIS: ApiEntry[] = [
516
+ { name: 'xinyu.ui.registerSlot(slotId, html, opts)', desc: '在预定义插槽位置注入自定义 UI(12 个可用位置)', example: "xinyu.ui.registerSlot('status-bar', '<div>状态信息</div>', { priority: 1 })" },
517
+ { name: 'xinyu.ui.unregisterSlot(id)', desc: '移除之前注册的插槽内容' },
518
+ { name: 'xinyu.ui.showModal(options)', desc: '显示自定义模态框(支持标题、内容、按钮)', example: "xinyu.ui.showModal({ title: '标题', content: '<div>内容</div>', actions: [{ text: '确认', primary: true, onClick: function() {} }] })" },
519
+ { name: 'xinyu.ui.closeModal(modalId)', desc: '关闭指定模态框,返回 showModal 的 id' },
520
+ { name: 'xinyu.ui.confirm(title, msg, opts)', desc: '显示确认对话框,返回 Promise<boolean>', example: "var ok = await xinyu.ui.confirm('确认?', '描述', { danger: true })" },
521
+ { name: 'xinyu.ui.dom.create(tag, attrs, children)', desc: '创建 DOM 元素代理(需要 dom:free 权限)', example: "var el = xinyu.ui.dom.create('div', { className: 'my-panel' }, 'Hello')" },
522
+ { name: 'xinyu.ui.dom.append(slotId, element)', desc: '将元素追加到指定容器(支持插槽 ID)' },
523
+ { name: 'xinyu.ui.dom.remove(elementId)', desc: '移除插件创建的 DOM 元素' },
524
+ { name: 'xinyu.ui.dom.update(id, props)', desc: '更新元素的属性、样式、内容' },
525
+ { name: 'xinyu.ui.dom.query(slotId, selector)', desc: '在容器内查询插件自己的元素' },
526
+ { name: 'xinyu.ui.dom.on(id, event, handler)', desc: '为插件元素绑定事件' },
527
+ { name: 'xinyu.ui.injectStyle(css)', desc: '注入自定义 CSS 样式,返回 styleId', example: "xinyu.ui.injectStyle('.my-class { color: var(--color-accent); }')" },
528
+ { name: 'xinyu.ui.removeStyle(styleId)', desc: '移除之前注入的 CSS 样式' },
529
+ { name: 'xinyu.ui.onHostEvent(event, handler)', desc: '监听宿主 UI 事件(message:received 等)', example: "xinyu.ui.onHostEvent('message:received', function(data) { ... })" },
530
+ { name: 'xinyu.ui.offHostEvent(event, handler)', desc: '取消监听宿主事件' },
531
+ { name: 'xinyu.ui.toast(message, type)', desc: '显示 Toast 通知(info/success/warning/error)' },
532
+ { name: 'xinyu.plugin.storage', desc: '插件独立持久化存储(get/set/remove/keys)' },
533
+ { name: 'xinyu.plugin.on(hook, handler)', desc: '注册生命周期钩子(onLoad/onUnload/onGameStart 等)' },
534
+ { name: 'xinyu.utils.eventBus', desc: '插件间通信的事件总线(on/off/emit)' },
535
+ ];
536
+
537
+ const PLUGIN_GUIDES: Record<PluginType, PluginGuide> = {
538
+ 'game-mechanics': {
539
+ title: '游戏机制插件',
540
+ icon: '\u{1F3AE}',
541
+ description: '扩展游戏核心玩法,如属性系统、骰子、战斗、背包等。通过注册游戏属性和快捷指令来增强角色扮演体验。',
542
+ availableApis: [
543
+ { name: 'xinyu.game.registerAttribute(attr)', desc: '注册游戏属性(如金币、HP),显示在游戏界面顶部', example: "xinyu.game.registerAttribute({ key: 'gold', label: '金币', type: 'number', value: 100, icon: '\uD83D\uDCB0' })" },
544
+ { name: 'xinyu.game.setAttribute(key, value)', desc: '在运行时修改属性值', example: "xinyu.game.setAttribute('gold', 50)" },
545
+ { name: 'xinyu.game.getAttributes()', desc: '获取所有已注册的属性列表' },
546
+ { name: 'xinyu.game.getState() / setState()', desc: '读写游戏全局状态(任意键值对)' },
547
+ { name: 'xinyu.chat.registerCommand(cmd)', desc: '注册快捷指令(如 /roll、/attr)', example: "xinyu.chat.registerCommand({ name: '/roll', description: '\uD83C\uDFB2', icon: '\uD83C\uDFB2', handler: function(args) { ... } })" },
548
+ { name: 'xinyu.chat.insertSystemMessage()', desc: '向对话中插入系统消息(灰色居中提示)' },
549
+ { name: 'xinyu.ai.onPromptBuild(fn)', desc: '在 AI 系统提示词末尾追加内容(如注入属性状态)', example: "xinyu.ai.onPromptBuild(function(prompt) { return prompt + '\\n\u5F53\u524D\u91D1\u5E01: ' + gold; })" },
550
+ { name: 'xinyu.utils.rollDice(notation)', desc: '解析骰子表达式(如 2d6+3)', example: "xinyu.utils.rollDice('2d6+3') // { rolls: [3,5], modifier: 3, total: 11 }" },
551
+ { name: 'xinyu.plugin.storage', desc: '插件独立持久化存储(get/set/remove/keys)' },
552
+ ],
553
+ bestPractices: [
554
+ '在 onLoad 钩子中初始化属性和注册指令',
555
+ '使用 ai.onPromptBuild 将游戏状态注入 AI 提示词,让 AI 了解当前属性',
556
+ '用 insertSystemMessage 显示骰子结果和属性变更,而非直接修改 AI 回复',
557
+ '属性变更后记得调用 setAttribute 更新界面显示',
558
+ '使用 plugin.storage 持久化插件数据,避免数据丢失',
559
+ ],
560
+ codeTemplate: `function setup(xinyu) {\n // 1. 注册游戏属性\n xinyu.game.registerAttribute({\n key: 'gold',\n label: '\u91D1\u5E01',\n type: 'number',\n value: 100,\n icon: '\uD83D\uDCB0',\n group: '\u57FA\u7840\u5C5E\u6027'\n });\n\n // 2. 注册快捷指令\n xinyu.chat.registerCommand({\n name: '/roll',\n description: '\u6295\u9AB0\u5B50',\n icon: '\uD83C\uDFB2',\n handler: function(args) {\n var result = xinyu.utils.rollDice(args || '1d20');\n xinyu.chat.insertSystemMessage(\n '\uD83C\uDFB2 ' + result.notation + ' = [' + result.rolls.join(', ') + '] = ' + result.total\n );\n }\n });\n\n // 3. 注入 AI Prompt\n xinyu.ai.onPromptBuild(function(prompt) {\n var attrs = xinyu.game.getAttributes();\n var status = attrs.map(function(a) { return a.icon + ' ' + a.label + ': ' + a.value; });\n return prompt + '\\n\\n## \u89D2\u8272\u72B6\u6001\\n' + status.join('\\n');\n });\n}`,
561
+ },
562
+ 'message-render': {
563
+ title: '消息渲染插件',
564
+ icon: '\u{1F3A8}',
565
+ description: '自定义 AI 消息的渲染方式,如 Markdown 解析、代码高亮、特殊格式(表格、卡片)等。不影响 AI 生成内容,只改变显示效果。',
566
+ availableApis: [
567
+ { name: 'xinyu.chat.registerMessageRenderer(matcher, renderer)', desc: '注册消息渲染器,matcher 匹配消息,renderer 返回 HTML', example: "xinyu.chat.registerMessageRenderer(\n function(msg) { return msg.role === 'assistant'; },\n function(msg) { return msg.content.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>'); }\n)" },
568
+ ],
569
+ bestPractices: [
570
+ '渲染器仅对 AI 消息生效(matcher 中检查 role === "assistant")',
571
+ '返回的 HTML 字符串会被 dangerouslySetInnerHTML 渲染,注意 XSS 安全',
572
+ '不要修改原始消息内容,只改变显示形式',
573
+ '多个渲染器按注册顺序执行,第一个匹配的生效',
574
+ '使用 CSS 变量保持与主题系统一致(如 var(--color-accent))',
575
+ ],
576
+ codeTemplate: `function setup(xinyu) {\n function renderMarkdown(text) {\n var html = text;\n html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');\n html = html.replace(/\\*(.+?)\\*/g, '<em>$1</em>');\n html = html.replace(/\`(.+?)\`/g, '<code style=\"background:rgba(255,255,255,0.1);padding:1px 4px;border-radius:3px;\">$1</code>');\n return html;\n }\n\n xinyu.chat.registerMessageRenderer(\n function(msg) { return msg.role === 'assistant'; },\n function(msg) { return renderMarkdown(msg.content); }\n );\n}`,
577
+ },
578
+ 'ai-prompt': {
579
+ title: 'AI Prompt 插件',
580
+ icon: '\u{1F916}',
581
+ description: '修改 AI 的系统提示词或拦截消息流,改变 AI 的行为方式。如注入 NPC 人格、修改叙事风格、添加世界观约束等。',
582
+ availableApis: [
583
+ { name: 'xinyu.ai.onPromptBuild(fn)', desc: '修改最终发送给 AI 的系统提示词', example: "xinyu.ai.onPromptBuild(function(prompt) { return prompt + '\\n\u989D\u5916\u89C4\u5219\uFF1A...'; })" },
584
+ { name: 'xinyu.ai.onBeforeSend(fn)', desc: '在发送前修改消息数组(可添加/删除/重排消息)', example: "xinyu.ai.onBeforeSend(function(messages) { return messages; })" },
585
+ { name: 'xinyu.ai.onAfterReceive(fn)', desc: '修改 AI 回复内容(后处理)', example: "xinyu.ai.onAfterReceive(function(content) { return content.replace(/xxx/g, 'yyy'); })" },
586
+ { name: 'xinyu.ai.onRequestConfig(fn)', desc: '修改 AI 请求参数(temperature、maxTokens、model)', example: "xinyu.ai.onRequestConfig(function(cfg) { cfg.temperature = 0.9; return cfg; })" },
587
+ { name: 'xinyu.chat.registerCommand(cmd)', desc: '注册指令来动态调整 AI 行为' },
588
+ { name: 'xinyu.plugin.storage', desc: '存储 NPC 设定等持久化数据' },
589
+ ],
590
+ bestPractices: [
591
+ 'onPromptBuild 是最常用的 Hook,适合追加世界观规则和 NPC 设定',
592
+ '追加的提示词应简洁明确,避免过长导致 token 浪费',
593
+ '使用 plugin.storage 保存用户设定的 NPC 人格等数据',
594
+ 'onAfterReceive 可用于过滤或格式化 AI 回复,但不要过度修改',
595
+ 'onRequestConfig 可临时调整 AI 参数(如战斗场景降低 temperature)',
596
+ ],
597
+ codeTemplate: `function setup(xinyu) {\n xinyu.chat.registerCommand({\n name: '/npc',\n description: '\u8BBE\u5B9A NPC \u4EBA\u683C',\n icon: '\uD83E\uDDD9',\n handler: function(args) {\n var parts = args.trim().split(/\\s+/);\n var name = parts[0];\n var personality = parts.slice(1).join(' ');\n xinyu.plugin.storage.set('npc_name', name);\n xinyu.plugin.storage.set('npc_personality', personality);\n xinyu.chat.insertSystemMessage('\uD83E\uDDD9 NPC \"' + name + '\" \u4EBA\u683C\u5DF2\u8BBE\u5B9A');\n }\n });\n\n xinyu.ai.onPromptBuild(function(prompt) {\n var npcName = xinyu.plugin.storage.get('npc_name');\n var npcPersonality = xinyu.plugin.storage.get('npc_personality');\n if (npcName && npcPersonality) {\n return prompt + '\\n\\n## NPC \u8BBE\u5B9A\\n\"' + npcName + '\": ' + npcPersonality;\n }\n return prompt;\n });\n}`,
598
+ },
599
+ 'input-enhance': {
600
+ title: '输入增强插件',
601
+ icon: '\u2328\uFE0F',
602
+ description: '增强用户输入体验,如在输入框上方添加快捷操作按钮、自动补全、快捷指令面板等。',
603
+ availableApis: [
604
+ { name: 'xinyu.ui.registerInputToolbarButton(btn)', desc: '在输入框上方添加快捷按钮', example: "xinyu.ui.registerInputToolbarButton({ id: 'look', label: '\u89C2\u5BDF', icon: '\uD83D\uDC40', order: 1, onClick: function() { xinyu.chat.send('\u6211\u89C2\u5BDF\u5468\u56F4\u73AF\u5883'); } })" },
605
+ { name: 'xinyu.chat.send(content)', desc: '程序化发送消息(等同于用户输入)' },
606
+ { name: 'xinyu.chat.registerCommand(cmd)', desc: '注册快捷指令(用户输入 /xxx 触发)' },
607
+ { name: 'xinyu.ui.registerSidebarPanel(panel)', desc: '注册侧边栏面板(高级功能)' },
608
+ { name: 'xinyu.ui.toast(message, type)', desc: '显示 Toast 通知' },
609
+ ],
610
+ bestPractices: [
611
+ '工具栏按钮的 order 值越小越靠前',
612
+ '按钮的 onClick 中使用 xinyu.chat.send() 发送预设消息',
613
+ '快捷指令名以 / 开头,handler 返回值会作为系统消息显示',
614
+ '不要注册过多按钮(建议不超过 6 个),避免界面拥挤',
615
+ '使用 toast 向用户反馈操作结果',
616
+ ],
617
+ codeTemplate: `function setup(xinyu) {\n xinyu.ui.registerInputToolbarButton({\n id: 'look',\n label: '\u89C2\u5BDF',\n icon: '\uD83D\uDC40',\n order: 1,\n onClick: function() {\n xinyu.chat.send('\u6211\u4ED4\u7EC6\u89C2\u5BDF\u5468\u56F4\u7684\u73AF\u5883');\n }\n });\n\n xinyu.ui.registerInputToolbarButton({\n id: 'inventory',\n label: '\u7269\u54C1',\n icon: '\uD83C\uDF92',\n order: 2,\n onClick: function() {\n xinyu.chat.send('\u6211\u68C0\u67E5\u81EA\u5DF1\u7684\u968F\u8EAB\u7269\u54C1');\n }\n });\n\n xinyu.chat.registerCommand({\n name: '/help',\n description: '\u663E\u793A\u5E2E\u52A9\u4FE1\u606F',\n icon: '\u2753',\n handler: function() {\n xinyu.chat.insertSystemMessage('\uD83D\uDCD6 \u53EF\u7528\u6307\u4EE4\uFF1A/help - \u5E2E\u52A9');\n }\n });\n}`,
618
+ },
619
+ };
620
+
621
+ // ==================== 主页面 ====================
622
+
623
+ function EditPluginPageContent() {
624
+ const { activeTheme } = useTheme();
625
+ const router = useRouter();
626
+ const { navigate, back, canGoBack } = useRouterHistory();
627
+ const params = useParams();
628
+ const editId = params.id as string;
629
+ const { toast } = useToast();
630
+ const containerRef = useRef<HTMLDivElement>(null);
631
+ const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
632
+ const [midWidth, setMidWidth] = useState(380);
633
+
634
+ const [loading, setLoading] = useState(true);
635
+ const [name, setName] = useState('');
636
+ const [pluginId, setPluginId] = useState('');
637
+ const [version, setVersion] = useState('1.0.0');
638
+ const [author, setAuthor] = useState('');
639
+
640
+ type XyeFileItem = { name: string; path: string; icon: string; editable: boolean; type: 'file' | 'dir'; children?: XyeFileItem[] };
641
+ // .xye 插件包相关状态
642
+ const [isXye, setIsXye] = useState(false);
643
+ const [xyeFiles, setXyeFiles] = useState<XyeFileItem[]>([]);
644
+ const [activeFile, setActiveFile] = useState<string>('');
645
+ const [fileContents, setFileContents] = useState<Record<string, string>>({});
646
+ const [fileTreeOpen, setFileTreeOpen] = useState(true);
647
+ const [fileTreeWidth, setFileTreeWidth] = useState(180);
648
+ const [entryFileName, setEntryFileName] = useState('plugin.js');
649
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file: { name: string; path: string; editable: boolean; type: 'file' | 'dir' } } | null>(null);
650
+ // 工具栏新建输入框状态
651
+ const [toolbarCreating, setToolbarCreating] = useState<'file' | 'dir' | null>(null);
652
+ const [toolbarNewItemName, setToolbarNewItemName] = useState('');
653
+ const toolbarInputRef = useRef<HTMLInputElement>(null);
654
+ const [icon, setIcon] = useState('');
655
+ const [type, setType] = useState<PluginType>('game-mechanics');
656
+ const [description, setDescription] = useState('');
657
+ const [code, setCode] = useState('');
658
+ const [configSchema, setConfigSchema] = useState<PluginConfigField[]>([]);
659
+ const [commonPermissions, setCommonPermissions] = useState<UIPermission[]>([]);
660
+ const [exclusivePermissions, setExclusivePermissions] = useState<UIPermission[]>([]);
661
+ const [requiredPermissions, setRequiredPermissions] = useState<UIPermission[]>([]);
662
+ const [configSectionCollapsed, setConfigSectionCollapsed] = useState(true);
663
+ const [collapsedConfigItems, setCollapsedConfigItems] = useState<Set<number>>(new Set());
664
+ const [depSectionCollapsed, setDepSectionCollapsed] = useState(true);
665
+ const [permSectionCollapsed, setPermSectionCollapsed] = useState(true);
666
+ const [dependencies, setDependencies] = useState<PluginDependency[]>([]);
667
+ const [depStatuses, setDepStatuses] = useState<Record<string, 'satisfied' | 'missing' | 'version_mismatch'>>({});
668
+ const [showAddDep, setShowAddDep] = useState(false);
669
+ const [newDepId, setNewDepId] = useState('');
670
+ const [newDepVersion, setNewDepVersion] = useState('');
671
+ const [newDepOptional, setNewDepOptional] = useState(false);
672
+ const [saving, setSaving] = useState(false);
673
+ const [scanResult, setScanResult] = useState<SecurityScanResult | null>(null);
674
+ const [scanning, setScanning] = useState(false);
675
+ const [editorTheme, setEditorTheme] = useState<string>('vs-dark');
676
+ const [themeMenuOpen, setThemeMenuOpen] = useState(false);
677
+
678
+ // 初始化编辑器主题:跟随应用主题的 isDark 属性
679
+ useEffect(() => {
680
+ setEditorTheme(activeTheme.isDark ? 'vs-dark' : 'vs');
681
+ }, [activeTheme.isDark]);
682
+
683
+ // 工具栏新建输入框自动聚焦
684
+ useEffect(() => {
685
+ if (toolbarCreating) {
686
+ toolbarInputRef.current?.focus();
687
+ }
688
+ }, [toolbarCreating]);
689
+
690
+ const handleEditorMount: OnMount = useCallback((editor) => {
691
+ editorRef.current = editor;
692
+ }, []);
693
+
694
+ useEffect(() => {
695
+ async function load() {
696
+ const plugin = await getPlugin(editId);
697
+ if (!plugin) {
698
+ toast('插件不存在', 'error');
699
+ if (canGoBack()) {
700
+ back();
701
+ } else {
702
+ navigate('/extensions');
703
+ }
704
+ return;
705
+ }
706
+ setName(plugin.name);
707
+ setPluginId(plugin.id);
708
+ setVersion(plugin.version);
709
+ setAuthor(plugin.author);
710
+ setIcon(plugin.icon || '');
711
+ setType(plugin.type);
712
+ setDescription(plugin.description);
713
+ setCode(plugin.code);
714
+ setConfigSchema(plugin.configSchema || []);
715
+ setCommonPermissions(plugin.commonPermissions || []);
716
+ setExclusivePermissions(plugin.exclusivePermissions || []);
717
+ setRequiredPermissions(plugin.requiredPermissions || []);
718
+ setDependencies(plugin.dependencies || []);
719
+
720
+ // 检测是否为 .xye 格式插件
721
+ try {
722
+ const res = await fetch(`/api/plugins/files-list?pluginId=${encodeURIComponent(plugin.id)}`);
723
+ if (res.ok) {
724
+ const data = await res.json();
725
+ if (data.isXye) {
726
+ setIsXye(true);
727
+ setXyeFiles(data.files || []);
728
+ // 从 codePath 提取入口文件名(如 data/plugins/x.y/index.js → index.js)
729
+ const entry = (plugin as unknown as Record<string, string>).codePath
730
+ ? (plugin as unknown as Record<string, string>).codePath.split('/').pop() || 'plugin.js'
731
+ : 'plugin.js';
732
+ setEntryFileName(entry);
733
+ setActiveFile(entry);
734
+ const codeRes = await fetch(`/api/plugins/files-list?pluginId=${encodeURIComponent(plugin.id)}&path=${encodeURIComponent(entry)}`);
735
+ if (codeRes.ok) {
736
+ const codeData = await codeRes.json();
737
+ setCode(codeData.content);
738
+ setFileContents(prev => ({ ...prev, [entry]: codeData.content }));
739
+ }
740
+ // 加载 manifest.json
741
+ const manifestRes = await fetch(`/api/plugins/files-list?pluginId=${encodeURIComponent(plugin.id)}&path=manifest.json`);
742
+ if (manifestRes.ok) {
743
+ const manifestData = await manifestRes.json();
744
+ setFileContents(prev => ({ ...prev, 'manifest.json': manifestData.content }));
745
+ // 从 manifest.json 解析元数据填充表单
746
+ try {
747
+ const manifest = JSON.parse(manifestData.content);
748
+ if (manifest.name) setName(manifest.name);
749
+ if (manifest.version) setVersion(manifest.version);
750
+ if (manifest.author) setAuthor(manifest.author);
751
+ if (manifest.icon) setIcon(manifest.icon);
752
+ if (manifest.type) setType(manifest.type);
753
+ if (manifest.description) setDescription(manifest.description);
754
+ if (manifest.configSchema) setConfigSchema(manifest.configSchema);
755
+ if (manifest.commonPermissions) setCommonPermissions(manifest.commonPermissions);
756
+ if (manifest.exclusivePermissions) setExclusivePermissions(manifest.exclusivePermissions);
757
+ if (manifest.requiredPermissions) setRequiredPermissions(manifest.requiredPermissions);
758
+ if (manifest.dependencies) setDependencies(manifest.dependencies);
759
+ // 从 manifest.json 的 pluginEntry 字段获取入口文件名
760
+ if (manifest.pluginEntry) {
761
+ const entryName = manifest.pluginEntry.startsWith('/')
762
+ ? manifest.pluginEntry.slice(1)
763
+ : manifest.pluginEntry;
764
+ setEntryFileName(entryName);
765
+ // 如果当前激活的文件是旧的 plugin.js,切换到正确的入口文件
766
+ if (activeFile === 'plugin.js' && entryName !== 'plugin.js') {
767
+ setActiveFile(entryName);
768
+ const entryCodeRes = await fetch(`/api/plugins/files-list?pluginId=${encodeURIComponent(plugin.id)}&path=${encodeURIComponent(entryName)}`);
769
+ if (entryCodeRes.ok) {
770
+ const entryCodeData = await entryCodeRes.json();
771
+ setCode(entryCodeData.content);
772
+ setFileContents(prev => ({ ...prev, [entryName]: entryCodeData.content }));
773
+ }
774
+ }
775
+ }
776
+ } catch { /* ignore */ }
777
+ }
778
+ }
779
+ }
780
+ } catch { /* ignore: 非 .xye 插件 */ }
781
+
782
+ setLoading(false);
783
+ }
784
+ load();
785
+ }, [editId, router]);
786
+
787
+ const typeOptions = Object.entries(PLUGIN_TYPE_LABELS).map(([key, val]) => ({
788
+ label: `${val.icon} ${val.label}`,
789
+ value: key,
790
+ }));
791
+
792
+ const handleResizeMid = useCallback((deltaX: number) => {
793
+ setMidWidth((prev) => Math.max(280, Math.min(600, prev + deltaX)));
794
+ }, []);
795
+
796
+ const handleResizeFileTree = useCallback((deltaX: number) => {
797
+ setFileTreeWidth((prev) => Math.max(120, Math.min(400, prev + deltaX)));
798
+ }, []);
799
+
800
+ const handleAddConfigField = () => {
801
+ setConfigSchema((prev) => [
802
+ ...prev,
803
+ { key: `field_${Date.now()}`, label: '', type: 'text', defaultValue: '' },
804
+ ]);
805
+ };
806
+
807
+ const handleRemoveConfigField = (index: number) => {
808
+ setConfigSchema((prev) => prev.filter((_, i) => i !== index));
809
+ };
810
+
811
+ const handleUpdateConfigField = (index: number, field: Partial<PluginConfigField>) => {
812
+ setConfigSchema((prev) => prev.map((f, i) => (i === index ? { ...f, ...field } : f)));
813
+ };
814
+
815
+ // ==================== 依赖管理 ====================
816
+
817
+ const checkDependencyStatuses = useCallback(async (deps: PluginDependency[]) => {
818
+ const statuses: Record<string, 'satisfied' | 'missing' | 'version_mismatch'> = {};
819
+ for (const dep of deps) {
820
+ try {
821
+ const res = await fetch(`/api/plugins?id=${dep.pluginId}`);
822
+ if (!res.ok) {
823
+ statuses[dep.pluginId] = 'missing';
824
+ continue;
825
+ }
826
+ const plugin = await res.json();
827
+ if (dep.versionRange && plugin.version) {
828
+ const installed = plugin.version.replace(/^\^|~/, '').split('.').map(Number);
829
+ const required = dep.versionRange.replace(/^\^|~/, '').split('.').map(Number);
830
+ const majorOk = installed[0] === required[0];
831
+ const minorOk = installed[0] === required[0] ? installed[1] >= required[1] : false;
832
+ statuses[dep.pluginId] = (majorOk && minorOk) ? 'satisfied' : 'version_mismatch';
833
+ } else {
834
+ statuses[dep.pluginId] = 'satisfied';
835
+ }
836
+ } catch {
837
+ statuses[dep.pluginId] = 'missing';
838
+ }
839
+ }
840
+ setDepStatuses(statuses);
841
+ }, []);
842
+
843
+ // 加载时检查依赖状态
844
+ useEffect(() => {
845
+ if (dependencies.length > 0) {
846
+ checkDependencyStatuses(dependencies);
847
+ }
848
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
849
+
850
+ const scanDependencies = useCallback(() => {
851
+ const regex = /(?:loadDependency|callPlugin|isPluginAvailable|getPluginExports)\s*\(\s*['"]([^'"]+)['"]/g;
852
+ const matches = new Set<string>();
853
+ let match;
854
+ while ((match = regex.exec(code)) !== null) {
855
+ matches.add(match[1]);
856
+ }
857
+ if (matches.size === 0) {
858
+ toast('未在代码中发现依赖调用', 'info');
859
+ return;
860
+ }
861
+ setDependencies(prev => {
862
+ const existing = new Map(prev.map(d => [d.pluginId, d]));
863
+ for (const pluginId of Array.from(matches)) {
864
+ if (!existing.has(pluginId)) {
865
+ existing.set(pluginId, { pluginId });
866
+ }
867
+ }
868
+ const updated = Array.from(existing.values());
869
+ checkDependencyStatuses(updated);
870
+ return updated;
871
+ });
872
+ toast(`扫描到 ${matches.size} 个依赖`, 'success');
873
+ }, [code, toast, checkDependencyStatuses]);
874
+
875
+ const handleAddDependency = () => {
876
+ if (!newDepId.trim()) {
877
+ toast('请填写插件 ID', 'warning');
878
+ return;
879
+ }
880
+ if (dependencies.some(d => d.pluginId === newDepId.trim())) {
881
+ toast('该依赖已存在', 'warning');
882
+ return;
883
+ }
884
+ const newDep: PluginDependency = {
885
+ pluginId: newDepId.trim(),
886
+ ...(newDepVersion.trim() ? { versionRange: newDepVersion.trim() } : {}),
887
+ optional: newDepOptional,
888
+ };
889
+ const updated = [...dependencies, newDep];
890
+ setDependencies(updated);
891
+ checkDependencyStatuses(updated);
892
+ setNewDepId('');
893
+ setNewDepVersion('');
894
+ setNewDepOptional(false);
895
+ setShowAddDep(false);
896
+ };
897
+
898
+ const handleRemoveDependency = (pluginId: string) => {
899
+ const updated = dependencies.filter(d => d.pluginId !== pluginId);
900
+ setDependencies(updated);
901
+ setDepStatuses(prev => {
902
+ const next = { ...prev };
903
+ delete next[pluginId];
904
+ return next;
905
+ });
906
+ };
907
+
908
+ const depStatusIcon = (status: string) => {
909
+ switch (status) {
910
+ case 'satisfied': return <span title="已安装" style={{ color: '#22c55e' }}>&#10004;</span>;
911
+ case 'missing': return <span title="未安装" style={{ color: '#ef4444' }}>&#10008;</span>;
912
+ case 'version_mismatch': return <span title="版本不匹配" style={{ color: '#f59e0b' }}>&#9888;</span>;
913
+ default: return null;
914
+ }
915
+ };
916
+
917
+ const depStatusLabel = (status: string) => {
918
+ switch (status) {
919
+ case 'satisfied': return '已安装';
920
+ case 'missing': return '未安装';
921
+ case 'version_mismatch': return '版本不匹配';
922
+ default: return '检查中...';
923
+ }
924
+ };
925
+
926
+ /** 切换 .xye 文件树中的活动文件 */
927
+ const handleFileSelect = useCallback(async (filePath: string, editable: boolean) => {
928
+ if (!editable) {
929
+ toast('该文件不可编辑(资源文件)', 'warning');
930
+ return;
931
+ }
932
+ setActiveFile(filePath);
933
+ // 如果已缓存则直接使用
934
+ if (fileContents[filePath] !== undefined) {
935
+ setCode(fileContents[filePath]);
936
+ return;
937
+ }
938
+ // 否则从服务端加载
939
+ try {
940
+ const res = await fetch(`/api/plugins/files-list?pluginId=${encodeURIComponent(pluginId)}&path=${encodeURIComponent(filePath)}`);
941
+ if (res.ok) {
942
+ const data = await res.json();
943
+ setCode(data.content);
944
+ setFileContents(prev => ({ ...prev, [filePath]: data.content }));
945
+ } else {
946
+ toast('加载文件失败', 'error');
947
+ }
948
+ } catch {
949
+ toast('加载文件失败', 'error');
950
+ }
951
+ }, [pluginId, fileContents]);
952
+
953
+ /** 刷新文件树 */
954
+ const refreshFileTree = useCallback(async () => {
955
+ try {
956
+ const res = await fetch(`/api/plugins/files-list?pluginId=${encodeURIComponent(pluginId)}`);
957
+ if (res.ok) {
958
+ const data = await res.json();
959
+ setXyeFiles(data.files || []);
960
+ }
961
+ } catch { /* ignore */ }
962
+ }, [pluginId]);
963
+
964
+ /** 新建文件 */
965
+ const handleCreateFile = useCallback(async (filePath: string, isDir: boolean) => {
966
+ try {
967
+ const res = await fetch(`/api/plugins/files-write?pluginId=${encodeURIComponent(pluginId)}`, {
968
+ method: 'POST',
969
+ headers: { 'Content-Type': 'application/json' },
970
+ body: JSON.stringify({ path: filePath, type: isDir ? 'dir' : 'file', content: '' }),
971
+ });
972
+ if (res.ok) {
973
+ await refreshFileTree();
974
+ if (!isDir) {
975
+ setActiveFile(filePath);
976
+ setCode('');
977
+ setFileContents(prev => ({ ...prev, [filePath]: '' }));
978
+ }
979
+ toast(isDir ? '文件夹已创建' : '文件已创建', 'success');
980
+ } else {
981
+ const err = await res.json().catch(() => ({}));
982
+ toast(`创建失败: ${err.error || '未知错误'}`, 'error');
983
+ }
984
+ } catch {
985
+ toast('创建失败', 'error');
986
+ }
987
+ }, [pluginId, refreshFileTree]);
988
+
989
+ /** 删除文件 */
990
+ const handleDeleteFile = useCallback(async (filePath: string) => {
991
+ try {
992
+ const res = await fetch(`/api/plugins/files-write?pluginId=${encodeURIComponent(pluginId)}&path=${encodeURIComponent(filePath)}`, {
993
+ method: 'DELETE',
994
+ });
995
+ if (res.ok) {
996
+ await refreshFileTree();
997
+ if (activeFile === filePath) {
998
+ // 回退到入口文件
999
+ const fallback = xyeFiles.find((f: XyeFileItem) => f.type === 'file' && f.editable && f.path !== filePath);
1000
+ if (fallback) {
1001
+ setActiveFile(fallback.path);
1002
+ setCode(fileContents[fallback.path] || '');
1003
+ }
1004
+ }
1005
+ setFileContents(prev => {
1006
+ const next = { ...prev };
1007
+ delete next[filePath];
1008
+ return next;
1009
+ });
1010
+ toast('已删除', 'success');
1011
+ } else {
1012
+ const err = await res.json().catch(() => ({}));
1013
+ toast(`删除失败: ${err.error || '未知错误'}`, 'error');
1014
+ }
1015
+ } catch {
1016
+ toast('删除失败', 'error');
1017
+ }
1018
+ }, [pluginId, activeFile, fileContents, refreshFileTree]);
1019
+
1020
+ /** 重命名文件 */
1021
+ const handleRenameFile = useCallback(async (oldPath: string, newPath: string) => {
1022
+ try {
1023
+ const res = await fetch(`/api/plugins/files-write?pluginId=${encodeURIComponent(pluginId)}&path=${encodeURIComponent(oldPath)}`, {
1024
+ method: 'PATCH',
1025
+ headers: { 'Content-Type': 'application/json' },
1026
+ body: JSON.stringify({ newPath }),
1027
+ });
1028
+ if (res.ok) {
1029
+ await refreshFileTree();
1030
+ if (activeFile === oldPath) {
1031
+ setActiveFile(newPath);
1032
+ const oldContent = fileContents[oldPath];
1033
+ if (oldContent !== undefined) {
1034
+ setFileContents(prev => {
1035
+ const next = { ...prev };
1036
+ delete next[oldPath];
1037
+ next[newPath] = oldContent;
1038
+ return next;
1039
+ });
1040
+ }
1041
+ }
1042
+ toast('已重命名', 'success');
1043
+ } else {
1044
+ const err = await res.json().catch(() => ({}));
1045
+ toast(`重命名失败: ${err.error || '未知错误'}`, 'error');
1046
+ }
1047
+ } catch {
1048
+ toast('重命名失败', 'error');
1049
+ }
1050
+ }, [pluginId, activeFile, fileContents, refreshFileTree]);
1051
+
1052
+ /** 上传资源文件 */
1053
+ const handleUploadFiles = useCallback(async (files: FileList) => {
1054
+ if (!files || files.length === 0) return;
1055
+ try {
1056
+ const formData = new FormData();
1057
+ for (let i = 0; i < files.length; i++) {
1058
+ formData.append('files', files[i]);
1059
+ }
1060
+ const res = await fetch(`/api/plugins/files-upload?pluginId=${encodeURIComponent(pluginId)}`, {
1061
+ method: 'POST',
1062
+ body: formData,
1063
+ });
1064
+ if (res.ok) {
1065
+ const data = await res.json();
1066
+ await refreshFileTree();
1067
+ toast(`已上传 ${data.count} 个文件`, 'success');
1068
+ } else {
1069
+ const err = await res.json().catch(() => ({}));
1070
+ toast(`上传失败: ${err.error || '未知错误'}`, 'error');
1071
+ }
1072
+ } catch {
1073
+ toast('上传失败', 'error');
1074
+ }
1075
+ }, [pluginId, refreshFileTree]);
1076
+
1077
+ /** 从代码注释解析 manifest 信息并填充表单(静默模式不弹 toast) */
1078
+ /** 解析插件信息:.xye 从 manifest.json 解析,单 JS 从代码注释解析 */
1079
+ const applyParsedManifest = async (silent = false) => {
1080
+ let count = 0;
1081
+
1082
+ if (isXye) {
1083
+ // .xye 插件:从 manifest.json 解析
1084
+ try {
1085
+ let manifestContent = fileContents['manifest.json'];
1086
+ if (!manifestContent) {
1087
+ const res = await fetch(`/api/plugins/files-list?pluginId=${encodeURIComponent(pluginId)}&path=manifest.json`);
1088
+ if (res.ok) {
1089
+ const data = await res.json();
1090
+ manifestContent = data.content;
1091
+ setFileContents(prev => ({ ...prev, 'manifest.json': manifestContent }));
1092
+ }
1093
+ }
1094
+ if (!manifestContent) {
1095
+ if (!silent) toast('未找到 manifest.json', 'warning');
1096
+ return 0;
1097
+ }
1098
+ const manifest = JSON.parse(manifestContent);
1099
+ if (manifest.id && manifest.id !== pluginId) {
1100
+ const existing = await getPlugin(manifest.id);
1101
+ if (existing) {
1102
+ if (!silent) toast(`插件 ID「${manifest.id}」已存在,解析跳过`, 'warning');
1103
+ } else {
1104
+ setPluginId(manifest.id);
1105
+ count++;
1106
+ }
1107
+ }
1108
+ if (manifest.name) { setName(manifest.name); count++; }
1109
+ if (manifest.version) { setVersion(manifest.version); count++; }
1110
+ if (manifest.author) { setAuthor(manifest.author); count++; }
1111
+ if (manifest.icon) { setIcon(manifest.icon); count++; }
1112
+ if (manifest.type) { setType(manifest.type); count++; }
1113
+ if (manifest.description) { setDescription(manifest.description); count++; }
1114
+ if (Array.isArray(manifest.configSchema) && manifest.configSchema.length > 0) { setConfigSchema(manifest.configSchema); count++; }
1115
+ if (Array.isArray(manifest.commonPermissions)) { setCommonPermissions(manifest.commonPermissions); count++; }
1116
+ if (Array.isArray(manifest.exclusivePermissions)) { setExclusivePermissions(manifest.exclusivePermissions); count++; }
1117
+ if (Array.isArray(manifest.requiredPermissions)) { setRequiredPermissions(manifest.requiredPermissions); count++; }
1118
+ if (Array.isArray(manifest.dependencies)) { setDependencies(manifest.dependencies); count++; }
1119
+ } catch {
1120
+ if (!silent) toast('manifest.json 解析失败', 'error');
1121
+ return 0;
1122
+ }
1123
+ if (!silent) {
1124
+ toast(count > 0 ? `已从 manifest.json 解析 ${count} 项信息` : 'manifest.json 中无额外信息', count > 0 ? 'success' : 'warning');
1125
+ }
1126
+ } else {
1127
+ // 单 JS 插件:从代码注释解析
1128
+ const parsed = parseManifestFromCode(code);
1129
+ if (parsed.id) {
1130
+ if (parsed.id !== pluginId) {
1131
+ const existing = await getPlugin(parsed.id);
1132
+ if (existing) {
1133
+ toast(`插件 ID「${parsed.id}」已存在,解析失败`, 'error');
1134
+ return 0;
1135
+ }
1136
+ setPluginId(parsed.id);
1137
+ count++;
1138
+ }
1139
+ }
1140
+ if (parsed.name) { setName(parsed.name); count++; }
1141
+ if (parsed.version) { setVersion(parsed.version); count++; }
1142
+ if (parsed.author) { setAuthor(parsed.author); count++; }
1143
+ if (parsed.icon) { setIcon(parsed.icon); count++; }
1144
+ if (parsed.type) { setType(parsed.type); count++; }
1145
+ if (parsed.description) { setDescription(parsed.description); count++; }
1146
+ if (parsed.permissions?.length) { setCommonPermissions(parsed.permissions); count++; }
1147
+ if (parsed.exclusivePermissions?.length) { setExclusivePermissions(parsed.exclusivePermissions); count++; }
1148
+ if (parsed.requiredPermissions?.length) { setRequiredPermissions(parsed.requiredPermissions); count++; }
1149
+ if (parsed.configSchema && parsed.configSchema.length > 0) {
1150
+ setConfigSchema(parsed.configSchema);
1151
+ count += parsed.configSchema.length;
1152
+ }
1153
+ if (!silent) {
1154
+ if (count > 0) {
1155
+ toast(`已从代码注释解析 ${count} 项信息`, 'success');
1156
+ } else {
1157
+ toast('未在代码中发现 manifest 注释', 'warning');
1158
+ }
1159
+ }
1160
+ }
1161
+ return count;
1162
+ };
1163
+
1164
+ const handleSave = async ({recordRouter = true, goBack = true} = {}) => {
1165
+ if (!name.trim() || !pluginId.trim() || !code.trim()) {
1166
+ toast('请填写名称、ID 和代码', 'warning');
1167
+ return;
1168
+ }
1169
+ // 保存前自动从代码解析 manifest 信息,直接使用解析结果构建数据
1170
+ const parsed = parseManifestFromCode(code);
1171
+
1172
+ // 处理 @plugin-id:代码注释中的 ID 优先,但需检查重复(排除自身)
1173
+ let finalId = pluginId;
1174
+ if (parsed.id?.trim() && parsed.id.trim() !== pluginId) {
1175
+ const codeId = parsed.id.trim();
1176
+ const existing = await getPlugin(codeId);
1177
+ if (existing) {
1178
+ toast(`插件 ID「${codeId}」已存在,请修改 @plugin-id`, 'error');
1179
+ return;
1180
+ }
1181
+ finalId = codeId;
1182
+ setPluginId(codeId);
1183
+ }
1184
+
1185
+ const finalName = parsed.name?.trim() || name.trim();
1186
+ const finalVersion = parsed.version?.trim() || version.trim() || '1.0.0';
1187
+ const finalAuthor = parsed.author?.trim() || author.trim() || '匿名';
1188
+ const finalIcon = parsed.icon?.trim() || icon.trim() || undefined;
1189
+ const finalType = parsed.type || type;
1190
+ const finalDescription = parsed.description?.trim() || description.trim();
1191
+ const finalCommonPermissions = parsed.permissions?.length ? parsed.permissions : (commonPermissions.length > 0 ? commonPermissions : undefined);
1192
+ const finalExclusivePermissions = parsed.exclusivePermissions?.length ? parsed.exclusivePermissions : (exclusivePermissions.length > 0 ? exclusivePermissions : undefined);
1193
+ const finalRequiredPermissions = parsed.requiredPermissions?.length ? parsed.requiredPermissions : (requiredPermissions.length > 0 ? requiredPermissions : undefined);
1194
+ const finalConfigSchema = parsed.configSchema?.length ? parsed.configSchema : (configSchema.length > 0 ? configSchema : undefined);
1195
+
1196
+ // 同步更新表单状态(用户可见)
1197
+ if (parsed.name) setName(parsed.name);
1198
+ if (parsed.version) setVersion(parsed.version);
1199
+ if (parsed.author) setAuthor(parsed.author);
1200
+ if (parsed.icon) setIcon(parsed.icon);
1201
+ if (parsed.type) setType(parsed.type);
1202
+ if (parsed.description) setDescription(parsed.description);
1203
+ if (parsed.permissions?.length) setCommonPermissions(parsed.permissions);
1204
+ if (parsed.configSchema?.length) setConfigSchema(parsed.configSchema);
1205
+
1206
+ setSaving(true);
1207
+ try {
1208
+ // .xye 插件:保存前先从 manifest.json 自动解析最新 ID
1209
+ if (isXye) {
1210
+ const manifestContent = activeFile === 'manifest.json'
1211
+ ? code.trim()
1212
+ : fileContents['manifest.json'];
1213
+ if (manifestContent) {
1214
+ try {
1215
+ const manifest = JSON.parse(manifestContent);
1216
+ if (manifest.id && manifest.id !== pluginId) {
1217
+ const existing = await getPlugin(manifest.id);
1218
+ if (existing) {
1219
+ toast(`插件 ID「${manifest.id}」已存在,请修改 manifest.json 中的 id`, 'error');
1220
+ setSaving(false);
1221
+ return;
1222
+ }
1223
+ finalId = manifest.id;
1224
+ setPluginId(manifest.id);
1225
+ }
1226
+ } catch { /* manifest 解析失败时忽略 */ }
1227
+ }
1228
+ }
1229
+
1230
+ // .xye 插件:保存当前活动文件到服务端
1231
+ if (isXye && activeFile) {
1232
+ const saveRes = await fetch(`/api/plugins/files-write?pluginId=${encodeURIComponent(editId)}&path=${encodeURIComponent(activeFile)}`, {
1233
+ method: 'PUT',
1234
+ headers: { 'Content-Type': 'application/json' },
1235
+ body: JSON.stringify({ content: code.trim() }),
1236
+ });
1237
+ if (!saveRes.ok) {
1238
+ toast('保存文件失败', 'error');
1239
+ setSaving(false);
1240
+ return;
1241
+ }
1242
+ // 更新缓存
1243
+ setFileContents(prev => ({ ...prev, [activeFile]: code.trim() }));
1244
+ // files-write API 在写入 manifest.json 时会自动同步数据库元数据
1245
+ // 对于入口文件,仍需更新数据库中的 code 字段
1246
+ if (activeFile === entryFileName) {
1247
+ const updateFields: Record<string, unknown> = { code: code.trim() };
1248
+ if (finalId !== editId) updateFields.newId = finalId;
1249
+ await updatePlugin(editId, updateFields);
1250
+ }
1251
+ } else {
1252
+ // 非 .xye 插件:原有逻辑
1253
+ const updateFields: Record<string, unknown> = {
1254
+ name: finalName,
1255
+ version: finalVersion,
1256
+ author: finalAuthor,
1257
+ type: finalType,
1258
+ icon: finalIcon,
1259
+ description: finalDescription,
1260
+ code: code.trim(),
1261
+ configSchema: finalConfigSchema,
1262
+ commonPermissions: finalCommonPermissions,
1263
+ exclusivePermissions: finalExclusivePermissions,
1264
+ requiredPermissions: finalRequiredPermissions,
1265
+ dependencies: dependencies.length > 0 ? dependencies : undefined,
1266
+ };
1267
+ // 如果 ID 发生变更,传递 newId 让后端同时更新 extensions 和 extension_bindings
1268
+ if (finalId !== editId) {
1269
+ updateFields.newId = finalId;
1270
+ }
1271
+ const ok = await updatePlugin(editId, updateFields);
1272
+ if (!ok) {
1273
+ toast('保存失败,请重试', 'error');
1274
+ setSaving(false);
1275
+ return;
1276
+ }
1277
+ }
1278
+ toast('插件已更新', 'success');
1279
+ if (!goBack) return;
1280
+ if (canGoBack()) {
1281
+ back();
1282
+ } else {
1283
+ navigate('/extensions', {record: recordRouter});
1284
+ }
1285
+ router.refresh();
1286
+ } catch {
1287
+ toast('保存失败', 'error');
1288
+ } finally {
1289
+ setSaving(false);
1290
+ }
1291
+ };
1292
+
1293
+ const handleScan = async () => {
1294
+ if (!code.trim()) {
1295
+ toast('请先编写插件代码', 'warning');
1296
+ return;
1297
+ }
1298
+ setScanning(true);
1299
+ setScanResult(null);
1300
+ try {
1301
+ const result = await scanPluginSecurity(code, { id: pluginId, name, type, author });
1302
+ setScanResult(result);
1303
+ } catch {
1304
+ toast('安全检测失败', 'error');
1305
+ } finally {
1306
+ setScanning(false);
1307
+ }
1308
+ };
1309
+
1310
+ const riskColors: Record<string, string> = { low: '#22c55e', medium: '#f59e0b', high: '#ef4444', critical: '#dc2626' };
1311
+ const riskLabels: Record<string, string> = { low: '低风险', medium: '中风险', high: '高风险', critical: '严重风险' };
1312
+
1313
+ if (loading) {
1314
+ return <FullPageLoader />;
1315
+ }
1316
+
1317
+ const codeLines = code.split('\n');
1318
+
1319
+ return (
1320
+ <div className="h-screen flex flex-col" style={{ backgroundColor: s.bgPrimary }}>
1321
+ {/* ===== 顶部导航栏 ===== */}
1322
+ <PageHeader
1323
+ title="星语 · 编辑插件"
1324
+ showBack={true}
1325
+ actions={
1326
+ <div className="flex items-center gap-2">
1327
+ <button
1328
+ onClick={() => navigate('/extensions/tutorial')}
1329
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5 transition-colors"
1330
+ style={{
1331
+ borderColor: s.border,
1332
+ color: s.textSecondary,
1333
+ backgroundColor: 'transparent',
1334
+ }}
1335
+ title="插件开发教程"
1336
+ >
1337
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1338
+ <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
1339
+ <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
1340
+ </svg>
1341
+ 开发教程
1342
+ </button>
1343
+ <button
1344
+ onClick={async () => {
1345
+ try {
1346
+ const res = await fetch(`/api/plugins/export-xye?id=${encodeURIComponent(pluginId)}`);
1347
+ if (!res.ok) { toast('导出失败', 'error'); return; }
1348
+ const blob = await res.blob();
1349
+ const url = URL.createObjectURL(blob);
1350
+ const a = document.createElement('a');
1351
+ a.href = url;
1352
+ a.download = `${pluginId}.xye`;
1353
+ a.click();
1354
+ URL.revokeObjectURL(url);
1355
+ toast('已导出为 .xye', 'success');
1356
+ } catch { toast('导出失败', 'error'); }
1357
+ }}
1358
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5 transition-colors"
1359
+ style={{
1360
+ borderColor: s.border,
1361
+ color: s.textSecondary,
1362
+ backgroundColor: 'transparent',
1363
+ }}
1364
+ title="导出插件为 .xye 包"
1365
+ >
1366
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1367
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1368
+ <polyline points="17 8 12 3 7 8" />
1369
+ <line x1="12" y1="3" x2="12" y2="15" />
1370
+ </svg>
1371
+ 导出 .xye
1372
+ </button>
1373
+ {!isXye && (
1374
+ <button
1375
+ onClick={async () => {
1376
+ try {
1377
+ const res = await fetch(`/api/plugins/export?id=${encodeURIComponent(pluginId)}`);
1378
+ if (!res.ok) { toast('导出失败', 'error'); return; }
1379
+ const data = await res.text();
1380
+ const blob = new Blob([data], { type: 'application/json' });
1381
+ const url = URL.createObjectURL(blob);
1382
+ const a = document.createElement('a');
1383
+ a.href = url;
1384
+ a.download = `${pluginId}.json`;
1385
+ a.click();
1386
+ URL.revokeObjectURL(url);
1387
+ toast('已导出为 .json', 'success');
1388
+ } catch { toast('导出失败', 'error'); }
1389
+ }}
1390
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5 transition-colors"
1391
+ style={{
1392
+ borderColor: s.border,
1393
+ color: s.textMuted,
1394
+ backgroundColor: 'transparent',
1395
+ }}
1396
+ title="导出为 .json 文件(仅单 JS 插件可用)"
1397
+ >
1398
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1399
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1400
+ <polyline points="17 8 12 3 7 8" />
1401
+ <line x1="12" y1="3" x2="12" y2="15" />
1402
+ </svg>
1403
+ 导出 .json
1404
+ </button>
1405
+ )}
1406
+ <button
1407
+ onClick={() => {
1408
+ const fileName = pluginId.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) + '.js';
1409
+ const blob = new Blob([code], { type: 'text/javascript' });
1410
+ const url = URL.createObjectURL(blob);
1411
+ const a = document.createElement('a');
1412
+ a.href = url;
1413
+ a.download = fileName;
1414
+ a.click();
1415
+ URL.revokeObjectURL(url);
1416
+ }}
1417
+ className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5 transition-colors"
1418
+ style={{
1419
+ borderColor: s.border,
1420
+ color: s.textSecondary,
1421
+ backgroundColor: 'transparent',
1422
+ }}
1423
+ title="仅导出插件代码为 .js 文件"
1424
+ >
1425
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1426
+ <polyline points="16 18 22 12 16 6" />
1427
+ <polyline points="8 6 2 12 8 18" />
1428
+ </svg>
1429
+ 导出代码
1430
+ </button>
1431
+ <button
1432
+ onClick={() => {
1433
+ navigate('/extensions', {record: false, pop: true});
1434
+ }}
1435
+ className="px-4 py-1.5 rounded-lg text-xs border transition-colors"
1436
+ style={{
1437
+ borderColor: s.border,
1438
+ color: s.textSecondary,
1439
+ backgroundColor: 'transparent',
1440
+ }}
1441
+ >
1442
+ 取消保存
1443
+ </button>
1444
+ <button
1445
+ onClick={() => handleSave({recordRouter: false, goBack: false})}
1446
+ disabled={saving}
1447
+ className="px-4 py-1.5 rounded-lg text-xs border transition-colors"
1448
+ style={{
1449
+ borderColor: s.border,
1450
+ color: s.textSecondary,
1451
+ backgroundColor: 'transparent',
1452
+ }}
1453
+ >
1454
+ {saving ? '保存中...' : '保存插件'}
1455
+ </button>
1456
+ <button
1457
+ onClick={() => handleSave({recordRouter: false})}
1458
+ disabled={saving}
1459
+ className="px-5 py-1.5 rounded-lg text-xs font-bold transition-opacity"
1460
+ style={{
1461
+ backgroundColor: s.accent,
1462
+ color: s.bgPrimary,
1463
+ opacity: saving ? 0.6 : 1,
1464
+ }}
1465
+ >
1466
+ {saving ? '保存中...' : '完成编辑'}
1467
+ </button>
1468
+ </div>
1469
+ }
1470
+ />
1471
+
1472
+ {/* ===== 主体:两列布局 ===== */}
1473
+ <div ref={containerRef} className="flex-1 flex overflow-hidden">
1474
+ {/* 左侧:基础信息 + 配置项 + 安全检测 */}
1475
+ <div
1476
+ className="shrink-0 overflow-y-auto"
1477
+ style={{
1478
+ width: midWidth,
1479
+ backgroundColor: s.bgPrimary,
1480
+ }}
1481
+ >
1482
+ <div className="p-5 space-y-5">
1483
+ {/* 基础信息 */}
1484
+ <div>
1485
+ <div className="flex items-center justify-between mb-3">
1486
+ <h3 className="text-sm font-bold" style={{ color: s.textPrimary }}>基础信息</h3>
1487
+ <button
1488
+ onClick={() => applyParsedManifest()}
1489
+ className="px-2 py-1 rounded text-xs border transition-colors"
1490
+ style={{ borderColor: s.border, color: s.textMuted, backgroundColor: 'transparent' }}
1491
+ title={isXye ? '从 manifest.json 解析插件信息' : '从代码注释中解析 @name、@version、@author、@config 等信息'}
1492
+ >
1493
+ <i className="fa-solid fa-wand-magic-sparkles" style={{ fontSize: '10px', marginRight: '4px' }} />
1494
+ 解析插件信息
1495
+ </button>
1496
+ </div>
1497
+ <div className="space-y-3">
1498
+ <div>
1499
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>名称 *</label>
1500
+ <TextField value={name} onChange={setName} placeholder="插件名称" />
1501
+ </div>
1502
+ <div>
1503
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>ID *</label>
1504
+ <TextField value={pluginId} onChange={setPluginId} placeholder="author.plugin-name" mono readOnly className="opacity-60 cursor-not-allowed" />
1505
+ </div>
1506
+ <div className="grid grid-cols-2 gap-3">
1507
+ <div>
1508
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>版本</label>
1509
+ <TextField value={version} onChange={setVersion} placeholder="1.0.0" />
1510
+ </div>
1511
+ <div>
1512
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>作者</label>
1513
+ <TextField value={author} onChange={setAuthor} placeholder="作者名" />
1514
+ </div>
1515
+ </div>
1516
+ <div className="grid grid-cols-2 gap-3">
1517
+ <div>
1518
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>图标</label>
1519
+ <div className="flex items-center gap-2">
1520
+ <div
1521
+ className="shrink-0 w-8 h-8 rounded flex items-center justify-center"
1522
+ style={{ backgroundColor: s.bgTertiary }}
1523
+ >
1524
+ <PluginIcon icon={icon} pluginId={pluginId} fallback="🧩" size={20} />
1525
+ </div>
1526
+ <TextField value={icon} onChange={setIcon} placeholder="Emoji 或资源文件路径(如 icon.png)" />
1527
+ {isXye && (
1528
+ <button
1529
+ type="button"
1530
+ onClick={async () => {
1531
+ // 获取插件目录中的图片文件列表
1532
+ try {
1533
+ const res = await fetch(`/api/plugins/files-list?pluginId=${encodeURIComponent(pluginId)}`);
1534
+ if (res.ok) {
1535
+ const data = await res.json();
1536
+ const imageFiles: string[] = [];
1537
+ const collectImages = (files: typeof data.files) => {
1538
+ for (const f of files) {
1539
+ if (f.type === 'file' && /\.(png|jpg|jpeg|gif|svg|webp|ico)$/i.test(f.path)) {
1540
+ imageFiles.push(f.path);
1541
+ }
1542
+ if (f.type === 'dir' && f.children) collectImages(f.children);
1543
+ }
1544
+ };
1545
+ collectImages(data.files || []);
1546
+ if (imageFiles.length === 0) {
1547
+ toast('插件目录中没有图片文件', 'warning');
1548
+ return;
1549
+ }
1550
+ const selected = prompt(`选择图标文件(输入路径):\n${imageFiles.join('\n')}`, imageFiles[0]);
1551
+ if (selected && imageFiles.includes(selected)) {
1552
+ setIcon(selected);
1553
+ }
1554
+ }
1555
+ } catch { /* ignore */ }
1556
+ }}
1557
+ className="shrink-0 p-1.5 rounded text-xs border transition-colors"
1558
+ style={{ borderColor: s.border, color: s.textMuted, backgroundColor: 'transparent' }}
1559
+ title="从资源文件中选择图标"
1560
+ >
1561
+ <i className="fa-solid fa-image" style={{ fontSize: '11px' }} />
1562
+ </button>
1563
+ )}
1564
+ </div>
1565
+ </div>
1566
+ <div>
1567
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>类型</label>
1568
+ <SelectField value={type} onChange={(v) => setType(v as PluginType)} options={typeOptions} />
1569
+ </div>
1570
+ </div>
1571
+ <div>
1572
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>描述</label>
1573
+ <TextAreaField value={description} onChange={setDescription} placeholder="插件功能描述..." rows={5} />
1574
+ </div>
1575
+ </div>
1576
+ </div>
1577
+
1578
+ {/* 配置项定义 */}
1579
+ <div>
1580
+ <div className="flex items-center justify-between mb-2">
1581
+ <div className="flex items-center gap-2 cursor-pointer select-none" onClick={() => setConfigSectionCollapsed(v => !v)}>
1582
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
1583
+ style={{ color: s.textMuted, transform: configSectionCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
1584
+ <polyline points="6 9 12 15 18 9" />
1585
+ </svg>
1586
+ <h3 className="text-sm font-bold" style={{ color: s.textPrimary }}>配置项定义(可选)</h3>
1587
+ {configSchema.length > 0 && (
1588
+ <span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>{configSchema.length}</span>
1589
+ )}
1590
+ </div>
1591
+ {!configSectionCollapsed && (
1592
+ <button
1593
+ onClick={handleAddConfigField}
1594
+ className="px-3 py-1 rounded-lg text-xs border"
1595
+ style={{ borderColor: s.accent, color: s.accent, backgroundColor: 'transparent' }}
1596
+ >
1597
+ + 添加
1598
+ </button>
1599
+ )}
1600
+ </div>
1601
+ {!configSectionCollapsed && (
1602
+ configSchema.length === 0 ? (
1603
+ <p className="text-xs" style={{ color: s.textMuted }}>暂无配置项</p>
1604
+ ) : (
1605
+ <div className="space-y-3">
1606
+ {configSchema.map((field, idx) => (
1607
+ <div key={idx} className="rounded-lg border" style={{ backgroundColor: s.bgTertiary, borderColor: s.border }}>
1608
+ {/* 卡片头部 */}
1609
+ <div className="flex items-center justify-between px-3 py-2 cursor-pointer select-none"
1610
+ onClick={() => setCollapsedConfigItems(prev => { const next = new Set(prev); if (next.has(idx)) { next.delete(idx); } else { next.add(idx); } return next; })}>
1611
+ <div className="flex items-center gap-2">
1612
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
1613
+ style={{ color: s.textMuted, transform: collapsedConfigItems.has(idx) ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
1614
+ <polyline points="6 9 12 15 18 9" />
1615
+ </svg>
1616
+ <span className="text-xs font-bold" style={{ color: s.textPrimary }}>{field.label || field.key || `配置项 #${idx + 1}`}</span>
1617
+ <span className="text-xs" style={{ color: s.textMuted }}>{field.type}</span>
1618
+ </div>
1619
+ <button onClick={(e) => { e.stopPropagation(); handleRemoveConfigField(idx); }} className="text-xs px-2 py-0.5 rounded" style={{ color: '#ef4444' }}>删除</button>
1620
+ </div>
1621
+ {/* 卡片内容 */}
1622
+ {!collapsedConfigItems.has(idx) && (
1623
+ <div className="px-3 pb-3 space-y-2">
1624
+ <div className="grid grid-cols-2 gap-2">
1625
+ <div>
1626
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>键名</label>
1627
+ <TextField value={field.key} onChange={(v) => handleUpdateConfigField(idx, { key: v })} placeholder="key" mono />
1628
+ </div>
1629
+ <div>
1630
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>标签</label>
1631
+ <TextField value={field.label} onChange={(v) => handleUpdateConfigField(idx, { label: v })} placeholder="显示名称" />
1632
+ </div>
1633
+ </div>
1634
+ <div className="grid grid-cols-2 gap-2">
1635
+ <div>
1636
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>类型</label>
1637
+ <SelectField
1638
+ value={field.type}
1639
+ onChange={(v) => handleUpdateConfigField(idx, { type: v as PluginConfigField['type'] })}
1640
+ options={CONFIG_FIELD_TYPES.map((t) => ({ label: CONFIG_FIELD_TYPE_LABELS[t], value: t }))}
1641
+ />
1642
+ </div>
1643
+ <div>
1644
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>默认值</label>
1645
+ {field.type === 'select' ? (
1646
+ <SelectField
1647
+ value={String(field.defaultValue ?? '')}
1648
+ onChange={(v) => handleUpdateConfigField(idx, { defaultValue: v })}
1649
+ options={[{ label: '无', value: '' }, ...(field.options || []).map(opt => ({ label: opt.label, value: opt.value }))]}
1650
+ />
1651
+ ) : field.type === 'boolean' ? (
1652
+ <button
1653
+ type="button"
1654
+ onClick={() => handleUpdateConfigField(idx, { defaultValue: !field.defaultValue })}
1655
+ className="relative w-9 h-5 rounded-full transition-colors shrink-0"
1656
+ style={{
1657
+ backgroundColor: !!field.defaultValue ? 'var(--color-accent)' : 'var(--color-bg-primary)',
1658
+ border: `1px solid ${!!field.defaultValue ? 'var(--color-accent)' : 'var(--color-border)'}`,
1659
+ }}
1660
+ >
1661
+ <div
1662
+ className="absolute top-0.5 w-3.5 h-3.5 rounded-full transition-all"
1663
+ style={{
1664
+ backgroundColor: !!field.defaultValue ? '#fff' : 'var(--color-text-muted)',
1665
+ left: !!field.defaultValue ? '18px' : '2px',
1666
+ }}
1667
+ />
1668
+ </button>
1669
+ ) : (
1670
+ <TextField value={String(field.defaultValue)} onChange={(v) => handleUpdateConfigField(idx, { defaultValue: v })} placeholder="默认值" />
1671
+ )}
1672
+ </div>
1673
+ </div>
1674
+ <div>
1675
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>描述</label>
1676
+ <TextField value={field.description ?? ''} onChange={(v) => handleUpdateConfigField(idx, { description: v })} placeholder="配置项说明" />
1677
+ </div>
1678
+ {field.type === 'select' && (
1679
+ <div>
1680
+ <div className="flex items-center justify-between mb-1.5">
1681
+ <label className="text-xs" style={{ color: s.textMuted }}>选项列表</label>
1682
+ <button
1683
+ type="button"
1684
+ onClick={() => handleUpdateConfigField(idx, {
1685
+ options: [...(field.options || []), { label: '', value: '' }],
1686
+ })}
1687
+ className="text-xs px-2 py-0.5 rounded transition-colors"
1688
+ style={{ color: s.accent, backgroundColor: 'transparent' }}
1689
+ >
1690
+ + 添加选项
1691
+ </button>
1692
+ </div>
1693
+ <div className="flex flex-col gap-1.5">
1694
+ {(field.options || []).map((opt, oi) => (
1695
+ <div key={oi} className="flex items-center gap-1.5 min-w-0">
1696
+ <input
1697
+ type="text"
1698
+ value={opt.label}
1699
+ onChange={(e) => {
1700
+ const newOpts = [...(field.options || [])];
1701
+ newOpts[oi] = { ...newOpts[oi], label: e.target.value, value: e.target.value };
1702
+ handleUpdateConfigField(idx, { options: newOpts });
1703
+ }}
1704
+ placeholder="标签"
1705
+ className="flex-1 min-w-0 px-2 py-1 rounded text-xs border outline-none"
1706
+ style={{
1707
+ borderColor: s.border,
1708
+ backgroundColor: s.bgPrimary,
1709
+ color: s.textPrimary,
1710
+ }}
1711
+ />
1712
+ <input
1713
+ type="text"
1714
+ value={opt.value}
1715
+ onChange={(e) => {
1716
+ const newOpts = [...(field.options || [])];
1717
+ newOpts[oi] = { ...newOpts[oi], value: e.target.value };
1718
+ handleUpdateConfigField(idx, { options: newOpts });
1719
+ }}
1720
+ placeholder="值"
1721
+ className="flex-1 min-w-0 px-2 py-1 rounded text-xs border outline-none"
1722
+ style={{
1723
+ borderColor: s.border,
1724
+ backgroundColor: s.bgPrimary,
1725
+ color: s.textPrimary,
1726
+ }}
1727
+ />
1728
+ <button
1729
+ type="button"
1730
+ onClick={() => {
1731
+ const newOpts = (field.options || []).filter((_, i) => i !== oi);
1732
+ handleUpdateConfigField(idx, { options: newOpts });
1733
+ }}
1734
+ className="p-1 rounded transition-colors shrink-0"
1735
+ style={{ color: s.textMuted }}
1736
+ title="删除选项"
1737
+ >
1738
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1739
+ <path d="M18 6L6 18M6 6l12 12" />
1740
+ </svg>
1741
+ </button>
1742
+ </div>
1743
+ ))}
1744
+ {(field.options || []).length === 0 && (
1745
+ <p className="text-xs" style={{ color: s.textMuted }}>暂无选项,点击上方按钮添加</p>
1746
+ )}
1747
+ </div>
1748
+ </div>
1749
+ )}
1750
+ </div>
1751
+ )}
1752
+ </div>
1753
+ ))}
1754
+ </div>
1755
+ )
1756
+ )}
1757
+ </div>
1758
+
1759
+ {/* UI 权限声明 */}
1760
+ <div>
1761
+ <div className="flex items-center justify-between mb-2">
1762
+ <h3
1763
+ className="text-sm font-bold flex items-center gap-1.5 cursor-pointer select-none"
1764
+ style={{ color: s.textPrimary }}
1765
+ onClick={() => setPermSectionCollapsed(!permSectionCollapsed)}
1766
+ >
1767
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
1768
+ style={{ color: s.textMuted, transform: permSectionCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
1769
+ <polyline points="6 9 12 15 18 9" />
1770
+ </svg>
1771
+ UI 权限声明(可选)
1772
+ {(commonPermissions.length + exclusivePermissions.length + requiredPermissions.length) > 0 && (
1773
+ <span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>
1774
+ {commonPermissions.length + exclusivePermissions.length + requiredPermissions.length}
1775
+ </span>
1776
+ )}
1777
+ </h3>
1778
+ </div>
1779
+ {!permSectionCollapsed && (<>
1780
+ <p className="text-xs mb-3" style={{ color: s.textMuted }}>
1781
+ 声明插件需要使用的 UI 能力。未声明时使用默认权限。支持在代码中编写 <code style={{ background: s.bgTertiary, padding: '1px 4px', borderRadius: '3px' }}>{'// @permission xxxx:......'}</code> 注释自动解析。
1782
+ </p>
1783
+
1784
+ {/* 共享权限 */}
1785
+ <div className="border rounded-lg p-3 mt-2" style={{ borderColor: '#3f3f3f' }}>
1786
+ <div className="flex items-center justify-between mb-2">
1787
+ <span className="text-xs font-medium" style={{ color: s.textPrimary }}>共享权限</span>
1788
+ <span className="text-xs" style={{ color: s.textMuted }}>可被多个插件同时声明</span>
1789
+ </div>
1790
+ <PermSelector
1791
+ allPerms={Object.entries(UI_PERMISSION_LABELS) as [UIPermission, string][]}
1792
+ selected={commonPermissions}
1793
+ onChange={setCommonPermissions}
1794
+ accentColor={s.accent}
1795
+ theme={s}
1796
+ />
1797
+ </div>
1798
+
1799
+ {/* 排他权限 */}
1800
+ <div className="border rounded-lg p-3 mt-2" style={{ borderColor: '#f97316' }}>
1801
+ <div className="flex items-center justify-between mb-2">
1802
+ <span className="text-xs font-medium" style={{ color: '#f97316' }}>排他权限</span>
1803
+ <span className="text-xs" style={{ color: s.textMuted }}>同时只能被一个插件声明</span>
1804
+ </div>
1805
+ <PermSelector
1806
+ allPerms={Object.entries(UI_PERMISSION_LABELS) as [UIPermission, string][]}
1807
+ selected={exclusivePermissions}
1808
+ onChange={setExclusivePermissions}
1809
+ accentColor="#f97316"
1810
+ theme={s}
1811
+ />
1812
+ </div>
1813
+
1814
+ {/* 必要权限 */}
1815
+ <div className="border rounded-lg p-3 mt-2" style={{ borderColor: '#3b82f6' }}>
1816
+ <div className="flex items-center justify-between mb-2">
1817
+ <span className="text-xs font-medium" style={{ color: '#3b82f6' }}>必要权限</span>
1818
+ <span className="text-xs" style={{ color: s.textMuted }}>没有此权限则插件不允许启动</span>
1819
+ </div>
1820
+ <PermSelector
1821
+ allPerms={Object.entries(UI_PERMISSION_LABELS) as [UIPermission, string][]}
1822
+ selected={requiredPermissions}
1823
+ onChange={setRequiredPermissions}
1824
+ accentColor="#3b82f6"
1825
+ theme={s}
1826
+ />
1827
+ <p className="text-xs mt-2" style={{ color: s.textMuted }}>⚠️ 必要权限必须在共享权限或排他权限中声明</p>
1828
+ </div>
1829
+ </>)}
1830
+ </div>
1831
+
1832
+ {/* 依赖管理 */}
1833
+ <div>
1834
+ <div className="flex items-center justify-between mb-2">
1835
+ <h3
1836
+ className="text-sm font-bold flex items-center gap-1.5 cursor-pointer select-none"
1837
+ style={{ color: s.textPrimary }}
1838
+ onClick={() => setDepSectionCollapsed(!depSectionCollapsed)}
1839
+ >
1840
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
1841
+ style={{ color: s.textMuted, transform: depSectionCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
1842
+ <polyline points="6 9 12 15 18 9" />
1843
+ </svg>
1844
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1845
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
1846
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
1847
+ <line x1="12" y1="22.08" x2="12" y2="12" />
1848
+ </svg>
1849
+ 依赖管理
1850
+ {dependencies.length > 0 && (
1851
+ <span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>
1852
+ {dependencies.length}
1853
+ </span>
1854
+ )}
1855
+ </h3>
1856
+ {!depSectionCollapsed && (
1857
+ <div className="flex items-center gap-1.5">
1858
+ <button
1859
+ onClick={() => setShowAddDep(!showAddDep)}
1860
+ className="px-3 py-1 rounded-lg text-xs border"
1861
+ style={{ borderColor: s.accent, color: s.accent, backgroundColor: 'transparent' }}
1862
+ >
1863
+ 手动添加
1864
+ </button>
1865
+ </div>
1866
+ )}
1867
+ </div>
1868
+
1869
+ {!depSectionCollapsed && (<>
1870
+ {/* 手动添加内联表单 */}
1871
+ {showAddDep && (
1872
+ <div className="rounded-lg p-3 border mb-3 space-y-2" style={{ backgroundColor: s.bgTertiary, borderColor: s.border }}>
1873
+ <div>
1874
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>插件 ID *</label>
1875
+ <TextField value={newDepId} onChange={setNewDepId} placeholder="例如:builtin.dice" mono />
1876
+ </div>
1877
+ <div>
1878
+ <label className="block text-xs mb-1" style={{ color: s.textMuted }}>版本范围(可选)</label>
1879
+ <TextField value={newDepVersion} onChange={setNewDepVersion} placeholder="例如:^1.0.0" mono />
1880
+ </div>
1881
+ <div className="flex items-center gap-2">
1882
+ <label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: s.textSecondary }}>
1883
+ <input
1884
+ type="checkbox"
1885
+ checked={newDepOptional}
1886
+ onChange={(e) => setNewDepOptional(e.target.checked)}
1887
+ className="rounded"
1888
+ />
1889
+ 可选依赖
1890
+ </label>
1891
+ </div>
1892
+ <div className="flex items-center gap-2 pt-1">
1893
+ <button
1894
+ onClick={handleAddDependency}
1895
+ className="px-3 py-1 rounded-lg text-xs font-bold"
1896
+ style={{ backgroundColor: s.accent, color: s.bgPrimary }}
1897
+ >
1898
+ 确认添加
1899
+ </button>
1900
+ <button
1901
+ onClick={() => { setShowAddDep(false); setNewDepId(''); setNewDepVersion(''); setNewDepOptional(false); }}
1902
+ className="px-3 py-1 rounded-lg text-xs border"
1903
+ style={{ borderColor: s.border, color: s.textSecondary, backgroundColor: 'transparent' }}
1904
+ >
1905
+ 取消
1906
+ </button>
1907
+ </div>
1908
+ </div>
1909
+ )}
1910
+
1911
+ {/* 依赖列表 */}
1912
+ {dependencies.length === 0 ? (
1913
+ <p className="text-xs" style={{ color: s.textMuted }}>暂无依赖。点击&ldquo;从代码扫描&rdquo;自动检测,或&ldquo;手动添加&rdquo;。</p>
1914
+ ) : (
1915
+ <div className="rounded-lg border overflow-hidden" style={{ borderColor: s.border }}>
1916
+ {/* 表头 */}
1917
+ <div className="grid grid-cols-[1fr_80px_80px_40px] gap-2 px-3 py-2 text-xs font-bold" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>
1918
+ <span>插件 ID</span>
1919
+ <span>版本范围</span>
1920
+ <span>状态</span>
1921
+ <span>操作</span>
1922
+ </div>
1923
+ {/* 依赖项 */}
1924
+ {dependencies.map((dep) => (
1925
+ <div
1926
+ key={dep.pluginId}
1927
+ className="grid grid-cols-[1fr_80px_80px_40px] gap-2 px-3 py-2 text-xs items-center"
1928
+ style={{ borderTop: `1px solid ${s.border}`, color: s.textPrimary }}
1929
+ >
1930
+ <span className="font-mono truncate" title={dep.pluginId}>
1931
+ {dep.pluginId}
1932
+ {dep.optional && (
1933
+ <span className="ml-1 px-1.5 py-0.5 rounded text-[10px]" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>可选</span>
1934
+ )}
1935
+ </span>
1936
+ <span className="font-mono" style={{ color: s.textSecondary }}>
1937
+ {dep.versionRange || '\u2014'}
1938
+ </span>
1939
+ <span className="flex items-center gap-1">
1940
+ {depStatusIcon(depStatuses[dep.pluginId])}
1941
+ <span style={{ color: depStatuses[dep.pluginId] === 'satisfied' ? '#22c55e' : depStatuses[dep.pluginId] === 'missing' ? '#ef4444' : '#f59e0b' }}>
1942
+ {depStatusLabel(depStatuses[dep.pluginId])}
1943
+ </span>
1944
+ </span>
1945
+ <button
1946
+ onClick={() => handleRemoveDependency(dep.pluginId)}
1947
+ className="text-xs px-1 py-0.5 rounded transition-colors hover:opacity-80"
1948
+ style={{ color: '#ef4444' }}
1949
+ title="删除依赖"
1950
+ >
1951
+ &#128465;
1952
+ </button>
1953
+ </div>
1954
+ ))}
1955
+ </div>
1956
+ )}
1957
+
1958
+ {/* 提示 */}
1959
+ {dependencies.length > 0 && (
1960
+ <p className="text-xs mt-2" style={{ color: s.textMuted }}>
1961
+ &#128161; 使用 <code className="font-mono px-1 py-0.5 rounded" style={{ backgroundColor: s.bgTertiary }}>xinyu.plugin.loadDependency()</code> 在代码中加载依赖
1962
+ </p>
1963
+ )}
1964
+ </>)}
1965
+ </div>
1966
+
1967
+ {/* AI 安全检测 */}
1968
+ <div>
1969
+ <div className="flex items-center justify-between mb-2">
1970
+ <h3 className="text-sm font-bold" style={{ color: s.textPrimary }}>安全检测</h3>
1971
+ <button
1972
+ onClick={handleScan}
1973
+ disabled={scanning}
1974
+ className="px-3 py-1 rounded-lg text-xs border"
1975
+ style={{ borderColor: s.accent, color: s.accent, backgroundColor: 'transparent' }}
1976
+ >
1977
+ {scanning ? '检测中...' : 'AI 安全检测'}
1978
+ </button>
1979
+ </div>
1980
+ {scanResult && (
1981
+ <div className="rounded-lg p-4 border" style={{ backgroundColor: s.bgTertiary, borderColor: riskColors[scanResult.riskLevel] }}>
1982
+ <div className="flex items-center gap-3 mb-3">
1983
+ <span className="px-2 py-0.5 rounded text-xs font-bold" style={{ backgroundColor: riskColors[scanResult.riskLevel], color: '#fff' }}>
1984
+ {riskLabels[scanResult.riskLevel]} ({scanResult.score}/100)
1985
+ </span>
1986
+ <span className="text-sm" style={{ color: s.textPrimary }}>{scanResult.summary}</span>
1987
+ </div>
1988
+ {scanResult.findings.length > 0 && (
1989
+ <div className="space-y-2">
1990
+ {scanResult.findings.map((finding, i) => (
1991
+ <div key={i} className="text-xs" style={{ color: s.textSecondary }}>
1992
+ <span className="font-bold" style={{ color: riskColors[finding.level] }}>[{finding.category}]</span>{' '}
1993
+ {finding.description}
1994
+ {finding.recommendation && <span style={{ color: s.textMuted }}> - 建议: {finding.recommendation}</span>}
1995
+ </div>
1996
+ ))}
1997
+ </div>
1998
+ )}
1999
+ {scanResult.recommendation && (
2000
+ <p className="text-xs mt-2" style={{ color: s.textMuted }}>建议: {scanResult.recommendation}</p>
2001
+ )}
2002
+ </div>
2003
+ )}
2004
+ </div>
2005
+
2006
+ {/* 底部留白 */}
2007
+ <div className="h-8" />
2008
+ </div>
2009
+ </div>
2010
+
2011
+ {/* 拖拽分隔条 2 */}
2012
+ <ResizableDivider onResize={handleResizeMid} />
2013
+
2014
+ {/* 右侧:代码编辑器 */}
2015
+ <div className="flex-1 flex flex-col overflow-hidden">
2016
+ <div
2017
+ className="shrink-0 px-4 py-2 text-xs flex items-center justify-between"
2018
+ style={{ backgroundColor: s.bgTertiary, color: s.textMuted, borderBottom: `1px solid ${s.border}` }}
2019
+ >
2020
+ <div className="flex items-center gap-3">
2021
+ <span className="font-mono">{isXye ? activeFile : 'plugin.js'}</span>
2022
+ {isXye && (
2023
+ <button
2024
+ onClick={() => setFileTreeOpen(!fileTreeOpen)}
2025
+ className="flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors"
2026
+ style={{
2027
+ backgroundColor: s.bgPrimary,
2028
+ color: s.textSecondary,
2029
+ border: `1px solid ${s.border}`,
2030
+ }}
2031
+ title={fileTreeOpen ? '隐藏文件树' : '显示文件树'}
2032
+ >
2033
+ <i className="fa-solid fa-folder-tree" style={{ fontSize: '10px' }} />
2034
+ </button>
2035
+ )}
2036
+ {isXye && (
2037
+ <span className="px-1.5 py-0.5 rounded text-[10px] font-medium" style={{ backgroundColor: 'var(--color-accent)', color: '#fff' }}>
2038
+ XYE
2039
+ </span>
2040
+ )}
2041
+ <div className="relative">
2042
+ <button
2043
+ onClick={() => setThemeMenuOpen(!themeMenuOpen)}
2044
+ className="flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors"
2045
+ style={{
2046
+ backgroundColor: s.bgPrimary,
2047
+ color: s.textSecondary,
2048
+ border: `1px solid ${s.border}`,
2049
+ }}
2050
+ title="切换编辑器主题"
2051
+ >
2052
+ <i className="fa-solid fa-palette" style={{ fontSize: '10px' }} />
2053
+ <span>{EDITOR_THEMES.find(t => t.id === editorTheme)?.label}</span>
2054
+ <i className="fa-solid fa-chevron-down" style={{ fontSize: '8px' }} />
2055
+ </button>
2056
+ {themeMenuOpen && (
2057
+ <>
2058
+ <div className="fixed inset-0 z-10" onClick={() => setThemeMenuOpen(false)} />
2059
+ <div
2060
+ className="absolute left-0 top-full mt-1 py-1 rounded-lg shadow-lg z-20 min-w-[140px]"
2061
+ style={{
2062
+ backgroundColor: s.bgSecondary,
2063
+ border: `1px solid ${s.border}`,
2064
+ }}
2065
+ >
2066
+ {EDITOR_THEMES.map((t) => (
2067
+ <button
2068
+ key={t.id}
2069
+ onClick={() => { setEditorTheme(t.id); setThemeMenuOpen(false); }}
2070
+ className="w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors"
2071
+ style={{
2072
+ backgroundColor: editorTheme === t.id ? s.bgTertiary : 'transparent',
2073
+ color: editorTheme === t.id ? s.accent : s.textSecondary,
2074
+ }}
2075
+ >
2076
+ <span
2077
+ className="w-3 h-3 rounded-sm shrink-0"
2078
+ style={{ backgroundColor: t.color }}
2079
+ />
2080
+ {t.label}
2081
+ </button>
2082
+ ))}
2083
+ </div>
2084
+ </>
2085
+ )}
2086
+ </div>
2087
+ </div>
2088
+ <span>{codeLines.length} 行</span>
2089
+ </div>
2090
+ <div className="flex-1 flex overflow-hidden">
2091
+ {/* .xye 文件树面板 */}
2092
+ {isXye && fileTreeOpen && (
2093
+ <>
2094
+ <div
2095
+ className="shrink-0 overflow-y-auto py-2"
2096
+ style={{
2097
+ width: fileTreeWidth,
2098
+ backgroundColor: s.bgSecondary,
2099
+ }}
2100
+ >
2101
+ <div className="px-3 py-1 text-[10px] font-bold uppercase tracking-wider flex items-center justify-between" style={{ color: s.textMuted }}>
2102
+ <span className="flex items-center gap-1.5">
2103
+ <i className="fa-solid fa-folder-tree" style={{ fontSize: '10px' }} />
2104
+ 文件树
2105
+ </span>
2106
+ <span className="flex items-center gap-0.5">
2107
+ <button
2108
+ onClick={() => { setToolbarCreating('file'); setToolbarNewItemName(''); }}
2109
+ className="p-0.5 rounded transition-colors hover:opacity-70"
2110
+ title="新建文件"
2111
+ >
2112
+ <i className="fa-solid fa-file-circle-plus" style={{ fontSize: '10px' }} />
2113
+ </button>
2114
+ <button
2115
+ onClick={() => { setToolbarCreating('dir'); setToolbarNewItemName(''); }}
2116
+ className="p-0.5 rounded transition-colors hover:opacity-70"
2117
+ title="新建文件夹"
2118
+ >
2119
+ <i className="fa-solid fa-folder-plus" style={{ fontSize: '10px' }} />
2120
+ </button>
2121
+ <button
2122
+ onClick={() => refreshFileTree()}
2123
+ className="p-0.5 rounded transition-colors hover:opacity-70"
2124
+ title="刷新文件树"
2125
+ >
2126
+ <i className="fa-solid fa-arrows-rotate" style={{ fontSize: '10px' }} />
2127
+ </button>
2128
+ <button
2129
+ onClick={() => {
2130
+ const input = document.createElement('input');
2131
+ input.type = 'file';
2132
+ input.multiple = true;
2133
+ input.onchange = (e) => {
2134
+ const target = e.target as HTMLInputElement;
2135
+ if (target.files && target.files.length > 0) {
2136
+ handleUploadFiles(target.files);
2137
+ }
2138
+ };
2139
+ input.click();
2140
+ }}
2141
+ className="p-0.5 rounded transition-colors hover:opacity-70"
2142
+ title="上传文件"
2143
+ >
2144
+ <i className="fa-solid fa-upload" style={{ fontSize: '10px' }} />
2145
+ </button>
2146
+ </span>
2147
+ </div>
2148
+ {/* 工具栏新建输入框 */}
2149
+ {toolbarCreating && (
2150
+ <div className="px-3 py-1.5">
2151
+ <div className="text-[10px] mb-1" style={{ color: s.textMuted }}>
2152
+ {toolbarCreating === 'file' ? '新建文件(在根目录下)' : '新建文件夹(在根目录下)'}
2153
+ </div>
2154
+ <input
2155
+ ref={toolbarInputRef}
2156
+ value={toolbarNewItemName}
2157
+ onChange={(e) => setToolbarNewItemName(e.target.value)}
2158
+ onKeyDown={(e) => {
2159
+ if (e.key === 'Enter' && toolbarNewItemName.trim()) {
2160
+ handleCreateFile(toolbarNewItemName.trim(), toolbarCreating === 'dir');
2161
+ setToolbarCreating(null);
2162
+ setToolbarNewItemName('');
2163
+ }
2164
+ if (e.key === 'Escape') {
2165
+ setToolbarCreating(null);
2166
+ setToolbarNewItemName('');
2167
+ }
2168
+ }}
2169
+ onBlur={() => {
2170
+ // 失焦时如果内容为空则关闭
2171
+ if (!toolbarNewItemName.trim()) {
2172
+ setToolbarCreating(null);
2173
+ }
2174
+ }}
2175
+ placeholder={toolbarCreating === 'file' ? '文件名.js' : '文件夹名'}
2176
+ className="w-full px-2 py-1 rounded text-xs outline-none"
2177
+ style={{ backgroundColor: s.bgTertiary, color: s.textPrimary, border: `1px solid ${s.border}` }}
2178
+ />
2179
+ </div>
2180
+ )}
2181
+ <FileTree
2182
+ files={xyeFiles}
2183
+ activeFile={activeFile}
2184
+ depth={0}
2185
+ onSelect={handleFileSelect}
2186
+ onContextMenu={(e, file) => {
2187
+ e.preventDefault();
2188
+ setContextMenu({ x: e.clientX, y: e.clientY, file });
2189
+ }}
2190
+ theme={s}
2191
+ />
2192
+ </div>
2193
+ <ResizableDivider onResize={handleResizeFileTree} />
2194
+ </>
2195
+ )}
2196
+ {/* 右键菜单 */}
2197
+ {contextMenu && (
2198
+ <FileContextMenu
2199
+ x={contextMenu.x}
2200
+ y={contextMenu.y}
2201
+ file={contextMenu.file}
2202
+ activeFile={activeFile}
2203
+ onClose={() => setContextMenu(null)}
2204
+ onNewFile={(dirPath) => handleCreateFile(dirPath, false)}
2205
+ onNewFolder={(dirPath) => handleCreateFile(dirPath, true)}
2206
+ onDelete={(path) => handleDeleteFile(path)}
2207
+ onRename={(oldPath, newPath) => handleRenameFile(oldPath, newPath)}
2208
+ theme={s}
2209
+ />
2210
+ )}
2211
+ <div className="flex-1 overflow-hidden">
2212
+ <Editor
2213
+ height="100%"
2214
+ language={activeFile.endsWith('.json') ? 'json' : activeFile.endsWith('.css') ? 'css' : activeFile.endsWith('.html') ? 'html' : 'javascript'}
2215
+ theme={editorTheme}
2216
+ value={code}
2217
+ onChange={(value) => {
2218
+ const newCode = value ?? '';
2219
+ setCode(newCode);
2220
+ // .xye 模式下同步更新文件缓存
2221
+ if (isXye && activeFile) {
2222
+ setFileContents(prev => ({ ...prev, [activeFile]: newCode }));
2223
+ }
2224
+ }}
2225
+ onMount={handleEditorMount}
2226
+ options={{
2227
+ fontSize: 12,
2228
+ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
2229
+ lineHeight: 18,
2230
+ minimap: { enabled: false },
2231
+ scrollBeyondLastLine: false,
2232
+ wordWrap: 'on',
2233
+ tabSize: 2,
2234
+ padding: { top: 8, bottom: 8 },
2235
+ overviewRulerBorder: false,
2236
+ scrollbar: {
2237
+ verticalScrollbarSize: 8,
2238
+ horizontalScrollbarSize: 8,
2239
+ },
2240
+ }}
2241
+ />
2242
+ </div>
2243
+ </div>
2244
+ </div>
2245
+ </div>
2246
+ </div>
2247
+ );
2248
+ }
2249
+
2250
+ /** 权限选择器组件:下拉选择 + 标签列表 */
2251
+ function PermSelector({
2252
+ allPerms,
2253
+ selected,
2254
+ onChange,
2255
+ accentColor,
2256
+ theme: s,
2257
+ }: {
2258
+ allPerms: [string, string][];
2259
+ selected: string[];
2260
+ onChange: (perms: UIPermission[]) => void;
2261
+ accentColor: string;
2262
+ theme: Record<string, string>;
2263
+ }) {
2264
+ const available = allPerms.filter(([perm]) => !selected.includes(perm));
2265
+ const [selectVal, setSelectVal] = useState('');
2266
+
2267
+ const handleAdd = () => {
2268
+ if (selectVal && !selected.includes(selectVal)) {
2269
+ onChange([...selected as UIPermission[], selectVal as UIPermission]);
2270
+ }
2271
+ setSelectVal('');
2272
+ };
2273
+
2274
+ const handleRemove = (perm: string) => {
2275
+ onChange(selected.filter(p => p !== perm) as UIPermission[]);
2276
+ };
2277
+
2278
+ return (
2279
+ <div>
2280
+ {selected.length > 0 && (
2281
+ <div className="flex flex-wrap gap-1.5 mb-2">
2282
+ {selected.map(perm => {
2283
+ const label = allPerms.find(([p]) => p === perm)?.[1] || perm;
2284
+ return (
2285
+ <span key={perm} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md transition-colors"
2286
+ style={{ backgroundColor: `${accentColor}15`, border: `1px solid ${accentColor}40`, color: accentColor }}>
2287
+ {label}
2288
+ <button onClick={() => handleRemove(perm)} className="hover:opacity-70 transition-opacity ml-0.5" style={{ color: accentColor }}>✕</button>
2289
+ </span>
2290
+ );
2291
+ })}
2292
+ </div>
2293
+ )}
2294
+
2295
+ <div className="flex gap-1.5 min-w-0">
2296
+ <div className="flex-1 min-w-0 flex items-center rounded-md border overflow-hidden"
2297
+ style={{ borderColor: s.border }}>
2298
+ <select
2299
+ value={selectVal}
2300
+ onChange={(e) => setSelectVal(e.target.value)}
2301
+ className="flex-1 min-w-0 text-xs px-2 py-1.5 outline-none bg-transparent appearance-none cursor-pointer"
2302
+ style={{ color: s.textPrimary }}
2303
+ >
2304
+ <option value="">选择权限...</option>
2305
+ {available.map(([perm, label]) => (
2306
+ <option key={perm} value={perm}>{label} ({perm})</option>
2307
+ ))}
2308
+ </select>
2309
+ <button
2310
+ onClick={handleAdd}
2311
+ disabled={!selectVal}
2312
+ className="shrink-0 text-xs px-2.5 py-1.5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed border-l"
2313
+ style={{
2314
+ backgroundColor: selectVal ? `${accentColor}20` : 'transparent',
2315
+ borderColor: s.border,
2316
+ color: selectVal ? accentColor : s.textMuted,
2317
+ }}
2318
+ >
2319
+ + 添加
2320
+ </button>
2321
+ </div>
2322
+ </div>
2323
+ </div>
2324
+ );
2325
+ }
2326
+
2327
+ function PageLoader() {
2328
+ return (
2329
+ <div className="flex flex-col items-center justify-center gap-4" style={{ minHeight: '100vh', backgroundColor: 'var(--color-bg-primary)' }}>
2330
+ <MathCurveLoader size={80} strokeWidthScale={0.8} />
2331
+ <p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>加载中...</p>
2332
+ </div>
2333
+ );
2334
+ }
2335
+
2336
+ export default function EditPluginPage() {
2337
+ return (
2338
+ <React.Suspense fallback={<PageLoader />}>
2339
+ <EditPluginPageContent />
2340
+ </React.Suspense>
2341
+ );
2342
+ }