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.
- package/.env.example +21 -0
- package/README.md +36 -0
- package/app/api/chat/route.ts +84 -0
- package/app/api/generate-svg/route.ts +171 -0
- package/app/api/generate-theme/route.ts +137 -0
- package/app/api/plugins/bindings/route.ts +173 -0
- package/app/api/plugins/export/route.ts +122 -0
- package/app/api/plugins/export-xye/route.ts +156 -0
- package/app/api/plugins/files/route.ts +146 -0
- package/app/api/plugins/files-list/route.ts +168 -0
- package/app/api/plugins/files-upload/route.ts +101 -0
- package/app/api/plugins/files-write/route.ts +272 -0
- package/app/api/plugins/import/route.ts +140 -0
- package/app/api/plugins/import-package/route.ts +231 -0
- package/app/api/plugins/resources/route.ts +109 -0
- package/app/api/plugins/route.ts +308 -0
- package/app/api/plugins/scan/route.ts +280 -0
- package/app/api/plugins/storage/route.ts +146 -0
- package/app/api/sessions/route.ts +165 -0
- package/app/api/settings/route.ts +40 -0
- package/app/api/suggest-fields/route.ts +129 -0
- package/app/api/templates/route.ts +159 -0
- package/app/api/test-api/route.ts +63 -0
- package/app/editor/page.tsx +1466 -0
- package/app/extensions/create/page.tsx +1422 -0
- package/app/extensions/edit/[id]/page.tsx +2342 -0
- package/app/extensions/page.tsx +1572 -0
- package/app/extensions/tutorial/page.tsx +4258 -0
- package/app/favicon.ico +0 -0
- package/app/fonts/GeistMonoVF.woff +0 -0
- package/app/fonts/GeistVF.woff +0 -0
- package/app/game/[id]/page.tsx +996 -0
- package/app/globals.css +3 -0
- package/app/layout.tsx +26 -0
- package/app/loading.tsx +26 -0
- package/app/page.tsx +345 -0
- package/app/settings/page.tsx +1490 -0
- package/bin/cli.js +262 -0
- package/components/ChatInput.tsx +106 -0
- package/components/ChatWindow.tsx +52 -0
- package/components/FullPageLoader.tsx +107 -0
- package/components/LoadingDots.tsx +20 -0
- package/components/MathCurveLoader.tsx +173 -0
- package/components/MessageBubble.tsx +147 -0
- package/components/WorldCardPreview.tsx +98 -0
- package/components/WorldCardUploader.tsx +58 -0
- package/components/ui/ConfirmDialog.tsx +135 -0
- package/components/ui/PageHeader.tsx +99 -0
- package/components/ui/PermissionConflictDialog.tsx +206 -0
- package/components/ui/PluginConfigForm.tsx +192 -0
- package/components/ui/PluginFloatingLayer.tsx +52 -0
- package/components/ui/PluginIcon.tsx +53 -0
- package/components/ui/PluginModalRenderer.tsx +185 -0
- package/components/ui/PluginProvider.tsx +1038 -0
- package/components/ui/PluginSlotRenderer.tsx +76 -0
- package/components/ui/ThemeCustomizer.tsx +174 -0
- package/components/ui/ThemeProvider.tsx +125 -0
- package/components/ui/ThemeSwitcher.tsx +140 -0
- package/components/ui/ToastProvider.tsx +141 -0
- package/lib/builtin-plugins.ts +11 -0
- package/lib/db-init.ts +35 -0
- package/lib/db.ts +244 -0
- package/lib/manifest-parser.ts +185 -0
- package/lib/parseWorldCard.ts +110 -0
- package/lib/plugin-dom-sandbox.ts +327 -0
- package/lib/plugin-events.ts +88 -0
- package/lib/plugin-files.ts +186 -0
- package/lib/plugin-html-sanitizer.ts +79 -0
- package/lib/plugin-resource-tracker.ts +175 -0
- package/lib/plugin-runtime.ts +2287 -0
- package/lib/plugin-security.ts +151 -0
- package/lib/plugin-types.ts +416 -0
- package/lib/prompt-builder.ts +55 -0
- package/lib/router-history.ts +119 -0
- package/lib/storage.ts +381 -0
- package/lib/themes.ts +129 -0
- package/lib/types.ts +117 -0
- package/lib/version.ts +55 -0
- package/next.config.mjs +43 -0
- package/package.json +56 -0
- package/plugins/xinyu.bag-system.xye +0 -0
- package/plugins/xinyu.cache-optimizer.xye +0 -0
- package/plugins/xinyu.dice-arbiter.xye +0 -0
- package/plugins/xinyu.game-auto-start-choices.xye +0 -0
- package/plugins/xinyu.markdown-render.xye +0 -0
- package/plugins/xinyu.slot-ui-beautify.xye +0 -0
- package/plugins/xinyu.world-info.xye +0 -0
- package/postcss.config.mjs +8 -0
- package/public/templates/atlantis.svg +63 -0
- package/public/templates/cyber-city.svg +68 -0
- package/public/templates/jianghu.svg +69 -0
- package/public/templates//351/255/224/346/263/225/344/270/226/347/225/214.svg +137 -0
- package/styles/themes.css +111 -0
- package/tailwind.config.ts +18 -0
- package/tsconfig.json +26 -0
- package/version.json +6 -0
|
@@ -0,0 +1,1422 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import Editor, { type OnMount } from '@monaco-editor/react';
|
|
6
|
+
import { createPlugin, getPlugin, scanPluginSecurity } from '@/lib/storage';
|
|
7
|
+
import { parseManifestFromCode } from '@/lib/manifest-parser';
|
|
8
|
+
import { useTheme } from '@/components/ui/ThemeProvider';
|
|
9
|
+
import {
|
|
10
|
+
PluginType,
|
|
11
|
+
PluginManifest,
|
|
12
|
+
PluginConfigField,
|
|
13
|
+
PluginDependency,
|
|
14
|
+
SecurityScanResult,
|
|
15
|
+
PLUGIN_TYPE_LABELS,
|
|
16
|
+
UIPermission,
|
|
17
|
+
UI_PERMISSION_LABELS,
|
|
18
|
+
} from '@/lib/plugin-types';
|
|
19
|
+
import { useToast } from '@/components/ui/ToastProvider';
|
|
20
|
+
import PageHeader from '@/components/ui/PageHeader';
|
|
21
|
+
import { useRouterHistory } from '@/lib/router-history';
|
|
22
|
+
import MathCurveLoader from '@/components/MathCurveLoader';
|
|
23
|
+
|
|
24
|
+
// ==================== 常量 ====================
|
|
25
|
+
|
|
26
|
+
const CONFIG_FIELD_TYPES: PluginConfigField['type'][] = ['text', 'number', 'boolean', 'select', 'textarea'];
|
|
27
|
+
const CONFIG_FIELD_TYPE_LABELS: Record<PluginConfigField['type'], string> = {
|
|
28
|
+
text: '文本',
|
|
29
|
+
number: '数字',
|
|
30
|
+
boolean: '布尔',
|
|
31
|
+
select: '下拉选择',
|
|
32
|
+
textarea: '多行文本',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Monaco Editor 可选主题 */
|
|
36
|
+
const EDITOR_THEMES = [
|
|
37
|
+
{ id: 'vs', label: '浅色', color: '#f0f0f0' },
|
|
38
|
+
{ id: 'vs-dark', label: '深色', color: '#1e1e1e' },
|
|
39
|
+
{ id: 'hc-black', label: '高对比', color: '#000000' },
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
// ==================== 通用样式辅助 ====================
|
|
43
|
+
|
|
44
|
+
const s = {
|
|
45
|
+
bgPrimary: 'var(--color-bg-primary)',
|
|
46
|
+
bgSecondary: 'var(--color-bg-secondary)',
|
|
47
|
+
bgTertiary: 'var(--color-bg-tertiary)',
|
|
48
|
+
textPrimary: 'var(--color-text-primary)',
|
|
49
|
+
textSecondary: 'var(--color-text-secondary)',
|
|
50
|
+
textMuted: 'var(--color-text-muted)',
|
|
51
|
+
accent: 'var(--color-accent)',
|
|
52
|
+
accentHover: 'var(--color-accent-hover)',
|
|
53
|
+
border: 'var(--color-border)',
|
|
54
|
+
shadow: 'var(--color-shadow)',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ==================== 子组件 ====================
|
|
58
|
+
|
|
59
|
+
function TextField({
|
|
60
|
+
value,
|
|
61
|
+
onChange,
|
|
62
|
+
placeholder,
|
|
63
|
+
mono = false,
|
|
64
|
+
type = 'text',
|
|
65
|
+
className = '',
|
|
66
|
+
}: {
|
|
67
|
+
value: string;
|
|
68
|
+
onChange: (v: string) => void;
|
|
69
|
+
placeholder?: string;
|
|
70
|
+
mono?: boolean;
|
|
71
|
+
type?: string;
|
|
72
|
+
className?: string;
|
|
73
|
+
}) {
|
|
74
|
+
return (
|
|
75
|
+
<input
|
|
76
|
+
type={type}
|
|
77
|
+
value={value}
|
|
78
|
+
onChange={(e) => onChange(e.target.value)}
|
|
79
|
+
placeholder={placeholder}
|
|
80
|
+
className={`w-full px-3 py-1.5 rounded-lg text-sm border outline-none transition-colors ${className}`}
|
|
81
|
+
style={{
|
|
82
|
+
backgroundColor: s.bgTertiary,
|
|
83
|
+
borderColor: s.border,
|
|
84
|
+
color: s.textPrimary,
|
|
85
|
+
fontFamily: mono ? 'monospace' : 'inherit',
|
|
86
|
+
}}
|
|
87
|
+
onFocus={(e) => (e.target.style.borderColor = s.accent)}
|
|
88
|
+
onBlur={(e) => (e.target.style.borderColor = s.border)}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function TextAreaField({
|
|
94
|
+
value,
|
|
95
|
+
onChange,
|
|
96
|
+
placeholder,
|
|
97
|
+
rows = 4,
|
|
98
|
+
}: {
|
|
99
|
+
value: string;
|
|
100
|
+
onChange: (v: string) => void;
|
|
101
|
+
placeholder?: string;
|
|
102
|
+
rows?: number;
|
|
103
|
+
}) {
|
|
104
|
+
return (
|
|
105
|
+
<textarea
|
|
106
|
+
value={value}
|
|
107
|
+
onChange={(e) => onChange(e.target.value)}
|
|
108
|
+
placeholder={placeholder}
|
|
109
|
+
rows={rows}
|
|
110
|
+
className="w-full px-3 py-2 rounded-lg text-sm border outline-none transition-colors resize-y"
|
|
111
|
+
style={{
|
|
112
|
+
backgroundColor: s.bgTertiary,
|
|
113
|
+
borderColor: s.border,
|
|
114
|
+
color: s.textPrimary,
|
|
115
|
+
}}
|
|
116
|
+
onFocus={(e) => (e.target.style.borderColor = s.accent)}
|
|
117
|
+
onBlur={(e) => (e.target.style.borderColor = s.border)}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function SelectField({
|
|
123
|
+
value,
|
|
124
|
+
onChange,
|
|
125
|
+
options,
|
|
126
|
+
}: {
|
|
127
|
+
value: string;
|
|
128
|
+
onChange: (v: string) => void;
|
|
129
|
+
options: { label: string; value: string }[];
|
|
130
|
+
}) {
|
|
131
|
+
return (
|
|
132
|
+
<select
|
|
133
|
+
value={value}
|
|
134
|
+
onChange={(e) => onChange(e.target.value)}
|
|
135
|
+
className="w-full px-3 py-1.5 rounded-lg text-sm border outline-none transition-colors cursor-pointer"
|
|
136
|
+
style={{
|
|
137
|
+
backgroundColor: s.bgTertiary,
|
|
138
|
+
borderColor: s.border,
|
|
139
|
+
color: s.textPrimary,
|
|
140
|
+
}}
|
|
141
|
+
onFocus={(e) => (e.target.style.borderColor = s.accent)}
|
|
142
|
+
onBlur={(e) => (e.target.style.borderColor = s.border)}
|
|
143
|
+
>
|
|
144
|
+
{options.map((opt) => (
|
|
145
|
+
<option key={opt.value} value={opt.value}>
|
|
146
|
+
{opt.label}
|
|
147
|
+
</option>
|
|
148
|
+
))}
|
|
149
|
+
</select>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ==================== 拖拽分隔条组件 ====================
|
|
154
|
+
|
|
155
|
+
function ResizableDivider({
|
|
156
|
+
onResize,
|
|
157
|
+
}: {
|
|
158
|
+
onResize: (deltaX: number) => void;
|
|
159
|
+
}) {
|
|
160
|
+
const isDragging = useRef(false);
|
|
161
|
+
const lastX = useRef(0);
|
|
162
|
+
|
|
163
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
isDragging.current = true;
|
|
166
|
+
lastX.current = e.clientX;
|
|
167
|
+
document.body.style.cursor = 'col-resize';
|
|
168
|
+
document.body.style.userSelect = 'none';
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
173
|
+
if (!isDragging.current) return;
|
|
174
|
+
const delta = e.clientX - lastX.current;
|
|
175
|
+
lastX.current = e.clientX;
|
|
176
|
+
onResize(delta);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const handleMouseUp = () => {
|
|
180
|
+
if (!isDragging.current) return;
|
|
181
|
+
isDragging.current = false;
|
|
182
|
+
document.body.style.cursor = '';
|
|
183
|
+
document.body.style.userSelect = '';
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
187
|
+
window.addEventListener('mouseup', handleMouseUp);
|
|
188
|
+
return () => {
|
|
189
|
+
window.removeEventListener('mousemove', handleMouseMove);
|
|
190
|
+
window.removeEventListener('mouseup', handleMouseUp);
|
|
191
|
+
};
|
|
192
|
+
}, [onResize]);
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
onMouseDown={handleMouseDown}
|
|
197
|
+
className="w-1.5 shrink-0 cursor-col-resize flex items-center justify-center group relative"
|
|
198
|
+
style={{ backgroundColor: s.border }}
|
|
199
|
+
>
|
|
200
|
+
<div
|
|
201
|
+
className="absolute inset-y-0 -left-1 -right-1 z-10"
|
|
202
|
+
/>
|
|
203
|
+
<div
|
|
204
|
+
className="w-0.5 h-8 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
|
205
|
+
style={{ backgroundColor: s.accent }}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ==================== 插件指南数据 ====================
|
|
212
|
+
|
|
213
|
+
interface ApiEntry {
|
|
214
|
+
name: string;
|
|
215
|
+
desc: string;
|
|
216
|
+
example?: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface PluginGuide {
|
|
220
|
+
title: string;
|
|
221
|
+
icon: string;
|
|
222
|
+
description: string;
|
|
223
|
+
availableApis: ApiEntry[];
|
|
224
|
+
bestPractices: string[];
|
|
225
|
+
codeTemplate: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 全局 API(所有插件类型通用) */
|
|
229
|
+
const GLOBAL_APIS: ApiEntry[] = [
|
|
230
|
+
{ name: 'xinyu.ui.registerSlot(slotId, html, opts)', desc: '在预定义插槽位置注入自定义 UI(12 个可用位置)', example: "xinyu.ui.registerSlot('status-bar', '<div>状态信息</div>', { priority: 1 })" },
|
|
231
|
+
{ name: 'xinyu.ui.unregisterSlot(id)', desc: '移除之前注册的插槽内容' },
|
|
232
|
+
{ name: 'xinyu.ui.showModal(options)', desc: '显示自定义模态框(支持标题、内容、按钮)', example: "xinyu.ui.showModal({ title: '标题', content: '<div>内容</div>', actions: [{ text: '确认', primary: true, onClick: function() {} }] })" },
|
|
233
|
+
{ name: 'xinyu.ui.closeModal(modalId)', desc: '关闭指定模态框,返回 showModal 的 id' },
|
|
234
|
+
{ name: 'xinyu.ui.confirm(title, msg, opts)', desc: '显示确认对话框,返回 Promise<boolean>', example: "var ok = await xinyu.ui.confirm('确认?', '描述', { danger: true })" },
|
|
235
|
+
{ name: 'xinyu.ui.dom.create(tag, attrs, children)', desc: '创建 DOM 元素代理(需要 dom:free 权限)', example: "var el = xinyu.ui.dom.create('div', { className: 'my-panel' }, 'Hello')" },
|
|
236
|
+
{ name: 'xinyu.ui.dom.append(slotId, element)', desc: '将元素追加到指定容器(支持插槽 ID)' },
|
|
237
|
+
{ name: 'xinyu.ui.dom.remove(elementId)', desc: '移除插件创建的 DOM 元素' },
|
|
238
|
+
{ name: 'xinyu.ui.dom.update(id, props)', desc: '更新元素的属性、样式、内容' },
|
|
239
|
+
{ name: 'xinyu.ui.dom.query(slotId, selector)', desc: '在容器内查询插件自己的元素' },
|
|
240
|
+
{ name: 'xinyu.ui.dom.on(id, event, handler)', desc: '为插件元素绑定事件' },
|
|
241
|
+
{ name: 'xinyu.ui.injectStyle(css)', desc: '注入自定义 CSS 样式,返回 styleId', example: "xinyu.ui.injectStyle('.my-class { color: var(--color-accent); }')" },
|
|
242
|
+
{ name: 'xinyu.ui.removeStyle(styleId)', desc: '移除之前注入的 CSS 样式' },
|
|
243
|
+
{ name: 'xinyu.ui.onHostEvent(event, handler)', desc: '监听宿主 UI 事件(message:received 等)', example: "xinyu.ui.onHostEvent('message:received', function(data) { ... })" },
|
|
244
|
+
{ name: 'xinyu.ui.offHostEvent(event, handler)', desc: '取消监听宿主事件' },
|
|
245
|
+
{ name: 'xinyu.ui.toast(message, type)', desc: '显示 Toast 通知(info/success/warning/error)' },
|
|
246
|
+
{ name: 'xinyu.plugin.storage', desc: '插件独立持久化存储(get/set/remove/keys)' },
|
|
247
|
+
{ name: 'xinyu.plugin.on(hook, handler)', desc: '注册生命周期钩子(onLoad/onUnload/onGameStart 等)' },
|
|
248
|
+
{ name: 'xinyu.utils.eventBus', desc: '插件间通信的事件总线(on/off/emit)' },
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const PLUGIN_GUIDES: Record<PluginType, PluginGuide> = {
|
|
252
|
+
'game-mechanics': {
|
|
253
|
+
title: '游戏机制插件',
|
|
254
|
+
icon: '\u{1F3AE}',
|
|
255
|
+
description: '扩展游戏核心玩法,如属性系统、骰子、战斗、背包等。通过注册游戏属性和快捷指令来增强角色扮演体验。',
|
|
256
|
+
availableApis: [
|
|
257
|
+
{ name: 'xinyu.game.registerAttribute(attr)', desc: '注册游戏属性(如金币、HP),显示在游戏界面顶部', example: "xinyu.game.registerAttribute({ key: 'gold', label: '金币', type: 'number', value: 100, icon: '\uD83D\uDCB0' })" },
|
|
258
|
+
{ name: 'xinyu.game.setAttribute(key, value)', desc: '在运行时修改属性值', example: "xinyu.game.setAttribute('gold', 50)" },
|
|
259
|
+
{ name: 'xinyu.game.getAttributes()', desc: '获取所有已注册的属性列表' },
|
|
260
|
+
{ name: 'xinyu.game.getState() / setState()', desc: '读写游戏全局状态(任意键值对)' },
|
|
261
|
+
{ name: 'xinyu.chat.registerCommand(cmd)', desc: '注册快捷指令(如 /roll、/attr)', example: "xinyu.chat.registerCommand({ name: '/roll', description: '\uD83C\uDFB2', icon: '\uD83C\uDFB2', handler: function(args) { ... } })" },
|
|
262
|
+
{ name: 'xinyu.chat.insertSystemMessage()', desc: '向对话中插入系统消息(灰色居中提示)' },
|
|
263
|
+
{ name: 'xinyu.ai.onPromptBuild(fn)', desc: '在 AI 系统提示词末尾追加内容(如注入属性状态)', example: "xinyu.ai.onPromptBuild(function(prompt) { return prompt + '\\n\u5F53\u524D\u91D1\u5E01: ' + gold; })" },
|
|
264
|
+
{ name: 'xinyu.utils.rollDice(notation)', desc: '解析骰子表达式(如 2d6+3)', example: "xinyu.utils.rollDice('2d6+3') // { rolls: [3,5], modifier: 3, total: 11 }" },
|
|
265
|
+
{ name: 'xinyu.plugin.storage', desc: '插件独立持久化存储(get/set/remove/keys)' },
|
|
266
|
+
],
|
|
267
|
+
bestPractices: [
|
|
268
|
+
'在 onLoad 钩子中初始化属性和注册指令',
|
|
269
|
+
'使用 ai.onPromptBuild 将游戏状态注入 AI 提示词,让 AI 了解当前属性',
|
|
270
|
+
'用 insertSystemMessage 显示骰子结果和属性变更,而非直接修改 AI 回复',
|
|
271
|
+
'属性变更后记得调用 setAttribute 更新界面显示',
|
|
272
|
+
'使用 plugin.storage 持久化插件数据,避免数据丢失',
|
|
273
|
+
],
|
|
274
|
+
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}`,
|
|
275
|
+
},
|
|
276
|
+
'message-render': {
|
|
277
|
+
title: '消息渲染插件',
|
|
278
|
+
icon: '\u{1F3A8}',
|
|
279
|
+
description: '自定义 AI 消息的渲染方式,如 Markdown 解析、代码高亮、特殊格式(表格、卡片)等。不影响 AI 生成内容,只改变显示效果。',
|
|
280
|
+
availableApis: [
|
|
281
|
+
{ 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)" },
|
|
282
|
+
],
|
|
283
|
+
bestPractices: [
|
|
284
|
+
'渲染器仅对 AI 消息生效(matcher 中检查 role === "assistant")',
|
|
285
|
+
'返回的 HTML 字符串会被 dangerouslySetInnerHTML 渲染,注意 XSS 安全',
|
|
286
|
+
'不要修改原始消息内容,只改变显示形式',
|
|
287
|
+
'多个渲染器按注册顺序执行,第一个匹配的生效',
|
|
288
|
+
'使用 CSS 变量保持与主题系统一致(如 var(--color-accent))',
|
|
289
|
+
],
|
|
290
|
+
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}`,
|
|
291
|
+
},
|
|
292
|
+
'ai-prompt': {
|
|
293
|
+
title: 'AI Prompt 插件',
|
|
294
|
+
icon: '\u{1F916}',
|
|
295
|
+
description: '修改 AI 的系统提示词或拦截消息流,改变 AI 的行为方式。如注入 NPC 人格、修改叙事风格、添加世界观约束等。',
|
|
296
|
+
availableApis: [
|
|
297
|
+
{ name: 'xinyu.ai.onPromptBuild(fn)', desc: '修改最终发送给 AI 的系统提示词', example: "xinyu.ai.onPromptBuild(function(prompt) { return prompt + '\\n\u989D\u5916\u89C4\u5219\uFF1A...'; })" },
|
|
298
|
+
{ name: 'xinyu.ai.onBeforeSend(fn)', desc: '在发送前修改消息数组(可添加/删除/重排消息)', example: "xinyu.ai.onBeforeSend(function(messages) { return messages; })" },
|
|
299
|
+
{ name: 'xinyu.ai.onAfterReceive(fn)', desc: '修改 AI 回复内容(后处理)', example: "xinyu.ai.onAfterReceive(function(content) { return content.replace(/xxx/g, 'yyy'); })" },
|
|
300
|
+
{ name: 'xinyu.ai.onRequestConfig(fn)', desc: '修改 AI 请求参数(temperature、maxTokens、model)', example: "xinyu.ai.onRequestConfig(function(cfg) { cfg.temperature = 0.9; return cfg; })" },
|
|
301
|
+
{ name: 'xinyu.chat.registerCommand(cmd)', desc: '注册指令来动态调整 AI 行为' },
|
|
302
|
+
{ name: 'xinyu.plugin.storage', desc: '存储 NPC 设定等持久化数据' },
|
|
303
|
+
],
|
|
304
|
+
bestPractices: [
|
|
305
|
+
'onPromptBuild 是最常用的 Hook,适合追加世界观规则和 NPC 设定',
|
|
306
|
+
'追加的提示词应简洁明确,避免过长导致 token 浪费',
|
|
307
|
+
'使用 plugin.storage 保存用户设定的 NPC 人格等数据',
|
|
308
|
+
'onAfterReceive 可用于过滤或格式化 AI 回复,但不要过度修改',
|
|
309
|
+
'onRequestConfig 可临时调整 AI 参数(如战斗场景降低 temperature)',
|
|
310
|
+
],
|
|
311
|
+
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}`,
|
|
312
|
+
},
|
|
313
|
+
'input-enhance': {
|
|
314
|
+
title: '输入增强插件',
|
|
315
|
+
icon: '\u2328\uFE0F',
|
|
316
|
+
description: '增强用户输入体验,如在输入框上方添加快捷操作按钮、自动补全、快捷指令面板等。',
|
|
317
|
+
availableApis: [
|
|
318
|
+
{ 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'); } })" },
|
|
319
|
+
{ name: 'xinyu.chat.send(content)', desc: '程序化发送消息(等同于用户输入)' },
|
|
320
|
+
{ name: 'xinyu.chat.registerCommand(cmd)', desc: '注册快捷指令(用户输入 /xxx 触发)' },
|
|
321
|
+
{ name: 'xinyu.ui.registerSidebarPanel(panel)', desc: '注册侧边栏面板(高级功能)' },
|
|
322
|
+
{ name: 'xinyu.ui.toast(message, type)', desc: '显示 Toast 通知' },
|
|
323
|
+
],
|
|
324
|
+
bestPractices: [
|
|
325
|
+
'工具栏按钮的 order 值越小越靠前',
|
|
326
|
+
'按钮的 onClick 中使用 xinyu.chat.send() 发送预设消息',
|
|
327
|
+
'快捷指令名以 / 开头,handler 返回值会作为系统消息显示',
|
|
328
|
+
'不要注册过多按钮(建议不超过 6 个),避免界面拥挤',
|
|
329
|
+
'使用 toast 向用户反馈操作结果',
|
|
330
|
+
],
|
|
331
|
+
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}`,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// ==================== 主页面 ====================
|
|
336
|
+
|
|
337
|
+
function CreatePluginPageContent() {
|
|
338
|
+
const { activeTheme } = useTheme();
|
|
339
|
+
const router = useRouter();
|
|
340
|
+
const { navigate } = useRouterHistory();
|
|
341
|
+
const { toast } = useToast();
|
|
342
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
343
|
+
const [midWidth, setMidWidth] = useState(380);
|
|
344
|
+
|
|
345
|
+
const [name, setName] = useState('');
|
|
346
|
+
const [pluginId, setPluginId] = useState('');
|
|
347
|
+
const [version, setVersion] = useState('1.0.0');
|
|
348
|
+
const [author, setAuthor] = useState('');
|
|
349
|
+
const [icon, setIcon] = useState('');
|
|
350
|
+
const [type, setType] = useState<PluginType>('game-mechanics');
|
|
351
|
+
const [description, setDescription] = useState('');
|
|
352
|
+
const [code, setCode] = useState(`function setup(xinyu) {\n // 在此编写插件逻辑\n \n}\n`);
|
|
353
|
+
const [configSchema, setConfigSchema] = useState<PluginConfigField[]>([]);
|
|
354
|
+
const [commonPermissions, setCommonPermissions] = useState<UIPermission[]>([]);
|
|
355
|
+
const [exclusivePermissions, setExclusivePermissions] = useState<UIPermission[]>([]);
|
|
356
|
+
const [requiredPermissions, setRequiredPermissions] = useState<UIPermission[]>([]);
|
|
357
|
+
const [configSectionCollapsed, setConfigSectionCollapsed] = useState(true);
|
|
358
|
+
const [collapsedConfigItems, setCollapsedConfigItems] = useState<Set<number>>(new Set());
|
|
359
|
+
const [depSectionCollapsed, setDepSectionCollapsed] = useState(true);
|
|
360
|
+
const [permSectionCollapsed, setPermSectionCollapsed] = useState(true);
|
|
361
|
+
const [dependencies, setDependencies] = useState<PluginDependency[]>([]);
|
|
362
|
+
const [depStatuses, setDepStatuses] = useState<Record<string, 'satisfied' | 'missing' | 'version_mismatch'>>({});
|
|
363
|
+
const [showAddDep, setShowAddDep] = useState(false);
|
|
364
|
+
const [newDepId, setNewDepId] = useState('');
|
|
365
|
+
const [newDepVersion, setNewDepVersion] = useState('');
|
|
366
|
+
const [newDepOptional, setNewDepOptional] = useState(false);
|
|
367
|
+
const [saving, setSaving] = useState(false);
|
|
368
|
+
const [scanResult, setScanResult] = useState<SecurityScanResult | null>(null);
|
|
369
|
+
const [scanning, setScanning] = useState(false);
|
|
370
|
+
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
|
|
371
|
+
const [editorTheme, setEditorTheme] = useState<string>('vs-dark');
|
|
372
|
+
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
|
373
|
+
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
setEditorTheme(activeTheme.isDark ? 'vs-dark' : 'vs');
|
|
376
|
+
}, [activeTheme.isDark]);
|
|
377
|
+
|
|
378
|
+
const handleEditorMount: OnMount = useCallback((editor) => {
|
|
379
|
+
editorRef.current = editor;
|
|
380
|
+
}, []);
|
|
381
|
+
|
|
382
|
+
const typeOptions = Object.entries(PLUGIN_TYPE_LABELS).map(([key, val]) => ({
|
|
383
|
+
label: `${val.icon} ${val.label}`,
|
|
384
|
+
value: key,
|
|
385
|
+
}));
|
|
386
|
+
|
|
387
|
+
const handleResizeMid = useCallback((deltaX: number) => {
|
|
388
|
+
setMidWidth((prev) => Math.max(280, Math.min(600, prev + deltaX)));
|
|
389
|
+
}, []);
|
|
390
|
+
|
|
391
|
+
const handleAddConfigField = () => {
|
|
392
|
+
setConfigSchema((prev) => [
|
|
393
|
+
...prev,
|
|
394
|
+
{ key: `field_${Date.now()}`, label: '', type: 'text', defaultValue: '' },
|
|
395
|
+
]);
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const handleRemoveConfigField = (index: number) => {
|
|
399
|
+
setConfigSchema((prev) => prev.filter((_, i) => i !== index));
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const handleUpdateConfigField = (index: number, field: Partial<PluginConfigField>) => {
|
|
403
|
+
setConfigSchema((prev) => prev.map((f, i) => (i === index ? { ...f, ...field } : f)));
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// ==================== 依赖管理 ====================
|
|
407
|
+
|
|
408
|
+
const checkDependencyStatuses = useCallback(async (deps: PluginDependency[]) => {
|
|
409
|
+
const statuses: Record<string, 'satisfied' | 'missing' | 'version_mismatch'> = {};
|
|
410
|
+
for (const dep of deps) {
|
|
411
|
+
try {
|
|
412
|
+
const res = await fetch(`/api/plugins?id=${dep.pluginId}`);
|
|
413
|
+
if (!res.ok) {
|
|
414
|
+
statuses[dep.pluginId] = 'missing';
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const plugin = await res.json();
|
|
418
|
+
if (dep.versionRange && plugin.version) {
|
|
419
|
+
const installed = plugin.version.replace(/^\^|~/, '').split('.').map(Number);
|
|
420
|
+
const required = dep.versionRange.replace(/^\^|~/, '').split('.').map(Number);
|
|
421
|
+
const majorOk = installed[0] === required[0];
|
|
422
|
+
const minorOk = installed[0] === required[0] ? installed[1] >= required[1] : false;
|
|
423
|
+
statuses[dep.pluginId] = (majorOk && minorOk) ? 'satisfied' : 'version_mismatch';
|
|
424
|
+
} else {
|
|
425
|
+
statuses[dep.pluginId] = 'satisfied';
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
statuses[dep.pluginId] = 'missing';
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
setDepStatuses(statuses);
|
|
432
|
+
}, []);
|
|
433
|
+
|
|
434
|
+
const scanDependencies = useCallback(() => {
|
|
435
|
+
const regex = /(?:loadDependency|callPlugin|isPluginAvailable|getPluginExports)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
436
|
+
const matches = new Set<string>();
|
|
437
|
+
let match;
|
|
438
|
+
while ((match = regex.exec(code)) !== null) {
|
|
439
|
+
matches.add(match[1]);
|
|
440
|
+
}
|
|
441
|
+
if (matches.size === 0) {
|
|
442
|
+
toast('未在代码中发现依赖调用', 'info');
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
setDependencies(prev => {
|
|
446
|
+
const existing = new Map(prev.map(d => [d.pluginId, d]));
|
|
447
|
+
for (const pluginId of Array.from(matches)) {
|
|
448
|
+
if (!existing.has(pluginId)) {
|
|
449
|
+
existing.set(pluginId, { pluginId });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const updated = Array.from(existing.values());
|
|
453
|
+
checkDependencyStatuses(updated);
|
|
454
|
+
return updated;
|
|
455
|
+
});
|
|
456
|
+
toast(`扫描到 ${matches.size} 个依赖`, 'success');
|
|
457
|
+
}, [code, toast, checkDependencyStatuses]);
|
|
458
|
+
|
|
459
|
+
const handleAddDependency = () => {
|
|
460
|
+
if (!newDepId.trim()) {
|
|
461
|
+
toast('请填写插件 ID', 'warning');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (dependencies.some(d => d.pluginId === newDepId.trim())) {
|
|
465
|
+
toast('该依赖已存在', 'warning');
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const newDep: PluginDependency = {
|
|
469
|
+
pluginId: newDepId.trim(),
|
|
470
|
+
...(newDepVersion.trim() ? { versionRange: newDepVersion.trim() } : {}),
|
|
471
|
+
optional: newDepOptional,
|
|
472
|
+
};
|
|
473
|
+
const updated = [...dependencies, newDep];
|
|
474
|
+
setDependencies(updated);
|
|
475
|
+
checkDependencyStatuses(updated);
|
|
476
|
+
setNewDepId('');
|
|
477
|
+
setNewDepVersion('');
|
|
478
|
+
setNewDepOptional(false);
|
|
479
|
+
setShowAddDep(false);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const handleRemoveDependency = (pluginId: string) => {
|
|
483
|
+
const updated = dependencies.filter(d => d.pluginId !== pluginId);
|
|
484
|
+
setDependencies(updated);
|
|
485
|
+
setDepStatuses(prev => {
|
|
486
|
+
const next = { ...prev };
|
|
487
|
+
delete next[pluginId];
|
|
488
|
+
return next;
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const depStatusIcon = (status: string) => {
|
|
493
|
+
switch (status) {
|
|
494
|
+
case 'satisfied': return <span title="已安装" style={{ color: '#22c55e' }}>✔</span>;
|
|
495
|
+
case 'missing': return <span title="未安装" style={{ color: '#ef4444' }}>✘</span>;
|
|
496
|
+
case 'version_mismatch': return <span title="版本不匹配" style={{ color: '#f59e0b' }}>⚠</span>;
|
|
497
|
+
default: return null;
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const depStatusLabel = (status: string) => {
|
|
502
|
+
switch (status) {
|
|
503
|
+
case 'satisfied': return '已安装';
|
|
504
|
+
case 'missing': return '未安装';
|
|
505
|
+
case 'version_mismatch': return '版本不匹配';
|
|
506
|
+
default: return '检查中...';
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
/** 从代码注释解析 manifest 信息并填充表单(静默模式不弹 toast) */
|
|
511
|
+
const applyParsedManifest = async (silent = false) => {
|
|
512
|
+
const parsed = parseManifestFromCode(code);
|
|
513
|
+
let count = 0;
|
|
514
|
+
|
|
515
|
+
// 处理 @plugin-id:检查数据库是否重复
|
|
516
|
+
if (parsed.id) {
|
|
517
|
+
const existing = await getPlugin(parsed.id);
|
|
518
|
+
if (existing) {
|
|
519
|
+
toast(`插件 ID「${parsed.id}」已存在,解析失败`, 'error');
|
|
520
|
+
return 0;
|
|
521
|
+
}
|
|
522
|
+
setPluginId(parsed.id);
|
|
523
|
+
count++;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (parsed.name) { setName(parsed.name); count++; }
|
|
527
|
+
if (parsed.version) { setVersion(parsed.version); count++; }
|
|
528
|
+
if (parsed.author) { setAuthor(parsed.author); count++; }
|
|
529
|
+
if (parsed.icon) { setIcon(parsed.icon); count++; }
|
|
530
|
+
if (parsed.type) { setType(parsed.type); count++; }
|
|
531
|
+
if (parsed.description) { setDescription(parsed.description); count++; }
|
|
532
|
+
if (parsed.permissions?.length) { setCommonPermissions(parsed.permissions); count++; }
|
|
533
|
+
if (parsed.exclusivePermissions?.length) { setExclusivePermissions(parsed.exclusivePermissions); count++; }
|
|
534
|
+
if (parsed.requiredPermissions?.length) { setRequiredPermissions(parsed.requiredPermissions); count++; }
|
|
535
|
+
if (parsed.configSchema && parsed.configSchema.length > 0) {
|
|
536
|
+
setConfigSchema(parsed.configSchema);
|
|
537
|
+
count += parsed.configSchema.length;
|
|
538
|
+
}
|
|
539
|
+
if (!silent) {
|
|
540
|
+
if (count > 0) {
|
|
541
|
+
toast(`已从注释解析 ${count} 项信息`, 'success');
|
|
542
|
+
} else {
|
|
543
|
+
toast('未在代码中发现 manifest 注释', 'warning');
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return count;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const handleSave = async ({completed = false} = {}) => {
|
|
550
|
+
if (!name.trim() || !pluginId.trim() || !code.trim()) {
|
|
551
|
+
toast('请填写名称、ID 和代码', 'warning');
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// 保存前自动从代码解析 manifest 信息,直接使用解析结果构建数据
|
|
555
|
+
const parsed = parseManifestFromCode(code);
|
|
556
|
+
|
|
557
|
+
// 处理 @plugin-id:代码注释中的 ID 优先,但需检查重复
|
|
558
|
+
let finalId = pluginId.trim();
|
|
559
|
+
if (parsed.id?.trim()) {
|
|
560
|
+
const codeId = parsed.id.trim();
|
|
561
|
+
if (codeId !== finalId) {
|
|
562
|
+
const existing = await getPlugin(codeId);
|
|
563
|
+
if (existing) {
|
|
564
|
+
toast(`插件 ID「${codeId}」已存在,请修改 @plugin-id`, 'error');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
finalId = codeId;
|
|
568
|
+
setPluginId(codeId);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// 检查最终 ID 是否重复
|
|
573
|
+
const idExisting = await getPlugin(finalId);
|
|
574
|
+
if (idExisting) {
|
|
575
|
+
toast(`插件 ID「${finalId}」已存在,请修改插件 ID`, 'error');
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const finalName = parsed.name?.trim() || name.trim();
|
|
580
|
+
const finalVersion = parsed.version?.trim() || version.trim() || '1.0.0';
|
|
581
|
+
const finalAuthor = parsed.author?.trim() || author.trim() || '匿名';
|
|
582
|
+
const finalIcon = parsed.icon?.trim() || icon.trim() || undefined;
|
|
583
|
+
const finalType = parsed.type || type;
|
|
584
|
+
const finalDescription = parsed.description?.trim() || description.trim();
|
|
585
|
+
const finalCommonPermissions = parsed.permissions?.length ? parsed.permissions : (commonPermissions.length > 0 ? commonPermissions : undefined);
|
|
586
|
+
const finalExclusivePermissions = parsed.exclusivePermissions?.length ? parsed.exclusivePermissions : (exclusivePermissions.length > 0 ? exclusivePermissions : undefined);
|
|
587
|
+
const finalRequiredPermissions = parsed.requiredPermissions?.length ? parsed.requiredPermissions : (requiredPermissions.length > 0 ? requiredPermissions : undefined);
|
|
588
|
+
const finalConfigSchema = parsed.configSchema?.length ? parsed.configSchema : (configSchema.length > 0 ? configSchema : undefined);
|
|
589
|
+
|
|
590
|
+
// 同步更新表单状态(用户可见)
|
|
591
|
+
if (parsed.name) setName(parsed.name);
|
|
592
|
+
if (parsed.version) setVersion(parsed.version);
|
|
593
|
+
if (parsed.author) setAuthor(parsed.author);
|
|
594
|
+
if (parsed.icon) setIcon(parsed.icon);
|
|
595
|
+
if (parsed.type) setType(parsed.type);
|
|
596
|
+
if (parsed.description) setDescription(parsed.description);
|
|
597
|
+
if (parsed.permissions?.length) setCommonPermissions(parsed.permissions);
|
|
598
|
+
if (parsed.configSchema?.length) setConfigSchema(parsed.configSchema);
|
|
599
|
+
|
|
600
|
+
setSaving(true);
|
|
601
|
+
try {
|
|
602
|
+
const now = new Date().toISOString();
|
|
603
|
+
const plugin: PluginManifest = {
|
|
604
|
+
id: finalId,
|
|
605
|
+
name: finalName,
|
|
606
|
+
version: finalVersion,
|
|
607
|
+
author: finalAuthor,
|
|
608
|
+
type: finalType,
|
|
609
|
+
icon: finalIcon,
|
|
610
|
+
description: finalDescription,
|
|
611
|
+
code: code.trim(),
|
|
612
|
+
configSchema: finalConfigSchema,
|
|
613
|
+
commonPermissions: finalCommonPermissions,
|
|
614
|
+
exclusivePermissions: finalExclusivePermissions,
|
|
615
|
+
requiredPermissions: finalRequiredPermissions,
|
|
616
|
+
dependencies: dependencies.length > 0 ? dependencies : undefined,
|
|
617
|
+
createdAt: now,
|
|
618
|
+
updatedAt: now,
|
|
619
|
+
};
|
|
620
|
+
const ok = await createPlugin(plugin);
|
|
621
|
+
if (ok) {
|
|
622
|
+
toast('插件已创建', 'success');
|
|
623
|
+
if (!completed) {
|
|
624
|
+
navigate('/extensions/edit/' + encodeURIComponent(finalId), {record: false});
|
|
625
|
+
} else {
|
|
626
|
+
navigate('/extensions', {record: false});
|
|
627
|
+
}
|
|
628
|
+
router.refresh();
|
|
629
|
+
} else {
|
|
630
|
+
toast('保存失败,请重试', 'error');
|
|
631
|
+
}
|
|
632
|
+
} catch {
|
|
633
|
+
toast('保存失败', 'error');
|
|
634
|
+
} finally {
|
|
635
|
+
setSaving(false);
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const handleScan = async () => {
|
|
640
|
+
if (!code.trim()) {
|
|
641
|
+
toast('请先编写插件代码', 'warning');
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
setScanning(true);
|
|
645
|
+
setScanResult(null);
|
|
646
|
+
try {
|
|
647
|
+
const result = await scanPluginSecurity(code, { id: pluginId, name, type, author });
|
|
648
|
+
setScanResult(result);
|
|
649
|
+
} catch {
|
|
650
|
+
toast('安全检测失败', 'error');
|
|
651
|
+
} finally {
|
|
652
|
+
setScanning(false);
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const riskColors: Record<string, string> = { low: '#22c55e', medium: '#f59e0b', high: '#ef4444', critical: '#dc2626' };
|
|
657
|
+
const riskLabels: Record<string, string> = { low: '低风险', medium: '中风险', high: '高风险', critical: '严重风险' };
|
|
658
|
+
|
|
659
|
+
return (
|
|
660
|
+
<div className="h-screen flex flex-col" style={{ backgroundColor: s.bgPrimary }}>
|
|
661
|
+
{/* ===== 顶部导航栏 ===== */}
|
|
662
|
+
<PageHeader
|
|
663
|
+
title="星语 · 创建插件"
|
|
664
|
+
showBack={true}
|
|
665
|
+
actions={
|
|
666
|
+
<div className="flex items-center gap-2">
|
|
667
|
+
<button
|
|
668
|
+
onClick={() => navigate('/extensions/tutorial')}
|
|
669
|
+
className="flex px-3 py-1.5 rounded-lg text-xs border transition-colors items-center gap-1.5"
|
|
670
|
+
style={{
|
|
671
|
+
borderColor: s.border,
|
|
672
|
+
color: s.textSecondary,
|
|
673
|
+
backgroundColor: 'transparent',
|
|
674
|
+
}}
|
|
675
|
+
title="查看插件开发教程"
|
|
676
|
+
>
|
|
677
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
678
|
+
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
|
679
|
+
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
|
680
|
+
</svg>
|
|
681
|
+
开发教程
|
|
682
|
+
</button>
|
|
683
|
+
<button
|
|
684
|
+
onClick={() => {
|
|
685
|
+
const fileName = pluginId.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) + '.js';
|
|
686
|
+
const blob = new Blob([code], { type: 'text/javascript' });
|
|
687
|
+
const url = URL.createObjectURL(blob);
|
|
688
|
+
const a = document.createElement('a');
|
|
689
|
+
a.href = url;
|
|
690
|
+
a.download = fileName;
|
|
691
|
+
a.click();
|
|
692
|
+
URL.revokeObjectURL(url);
|
|
693
|
+
}}
|
|
694
|
+
className="px-3 py-1.5 rounded-lg text-xs border flex items-center gap-1.5 transition-colors"
|
|
695
|
+
style={{
|
|
696
|
+
borderColor: s.border,
|
|
697
|
+
color: s.textSecondary,
|
|
698
|
+
backgroundColor: 'transparent',
|
|
699
|
+
}}
|
|
700
|
+
title="仅导出插件代码为 .js 文件"
|
|
701
|
+
>
|
|
702
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
703
|
+
<polyline points="16 18 22 12 16 6" />
|
|
704
|
+
<polyline points="8 6 2 12 8 18" />
|
|
705
|
+
</svg>
|
|
706
|
+
导出代码
|
|
707
|
+
</button>
|
|
708
|
+
<button
|
|
709
|
+
onClick={() => navigate('/extensions', {record: false, pop: true})}
|
|
710
|
+
className="px-4 py-1.5 rounded-lg text-xs border transition-colors"
|
|
711
|
+
style={{
|
|
712
|
+
borderColor: s.border,
|
|
713
|
+
color: s.textSecondary,
|
|
714
|
+
backgroundColor: 'transparent',
|
|
715
|
+
}}
|
|
716
|
+
>
|
|
717
|
+
取消创建
|
|
718
|
+
</button>
|
|
719
|
+
<button
|
|
720
|
+
onClick={() => handleSave({completed: false})}
|
|
721
|
+
disabled={saving}
|
|
722
|
+
className="px-4 py-1.5 rounded-lg text-xs border transition-colors"
|
|
723
|
+
style={{
|
|
724
|
+
borderColor: s.border,
|
|
725
|
+
color: s.textSecondary,
|
|
726
|
+
backgroundColor: 'transparent',
|
|
727
|
+
}}
|
|
728
|
+
>
|
|
729
|
+
{saving ? '创建中...' : '创建插件'}
|
|
730
|
+
</button>
|
|
731
|
+
<button
|
|
732
|
+
onClick={() => handleSave()}
|
|
733
|
+
disabled={saving}
|
|
734
|
+
className="px-5 py-1.5 rounded-lg text-xs font-bold transition-opacity"
|
|
735
|
+
style={{
|
|
736
|
+
backgroundColor: s.accent,
|
|
737
|
+
color: s.bgPrimary,
|
|
738
|
+
opacity: saving ? 0.6 : 1,
|
|
739
|
+
}}
|
|
740
|
+
>
|
|
741
|
+
{saving ? '创建中...' : '完成创建'}
|
|
742
|
+
</button>
|
|
743
|
+
</div>
|
|
744
|
+
}
|
|
745
|
+
/>
|
|
746
|
+
|
|
747
|
+
{/* ===== 主体:两列布局 ===== */}
|
|
748
|
+
<div ref={containerRef} className="flex-1 flex overflow-hidden">
|
|
749
|
+
{/* 左侧:基础信息 + 配置项 + 安全检测 */}
|
|
750
|
+
<div
|
|
751
|
+
className="shrink-0 overflow-y-auto"
|
|
752
|
+
style={{
|
|
753
|
+
width: midWidth,
|
|
754
|
+
backgroundColor: s.bgPrimary,
|
|
755
|
+
}}
|
|
756
|
+
>
|
|
757
|
+
<div className="p-5 space-y-5">
|
|
758
|
+
{/* 基础信息 */}
|
|
759
|
+
<div>
|
|
760
|
+
<div className="flex items-center justify-between mb-3">
|
|
761
|
+
<h3 className="text-sm font-bold" style={{ color: s.textPrimary }}>基础信息</h3>
|
|
762
|
+
<button
|
|
763
|
+
onClick={() => applyParsedManifest()}
|
|
764
|
+
className="px-2 py-1 rounded text-xs border transition-colors"
|
|
765
|
+
style={{ borderColor: s.border, color: s.textMuted, backgroundColor: 'transparent' }}
|
|
766
|
+
title="从代码注释中解析 @name、@version、@author、@config 等信息"
|
|
767
|
+
>
|
|
768
|
+
<i className="fa-solid fa-wand-magic-sparkles" style={{ fontSize: '10px', marginRight: '4px' }} />
|
|
769
|
+
从代码解析
|
|
770
|
+
</button>
|
|
771
|
+
</div>
|
|
772
|
+
<div className="space-y-3">
|
|
773
|
+
<div>
|
|
774
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>名称 *</label>
|
|
775
|
+
<TextField value={name} onChange={setName} placeholder="插件名称" />
|
|
776
|
+
</div>
|
|
777
|
+
<div>
|
|
778
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>ID *</label>
|
|
779
|
+
<TextField value={pluginId} onChange={setPluginId} placeholder="author.plugin-name" mono />
|
|
780
|
+
</div>
|
|
781
|
+
<div className="grid grid-cols-2 gap-3">
|
|
782
|
+
<div>
|
|
783
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>版本</label>
|
|
784
|
+
<TextField value={version} onChange={setVersion} placeholder="1.0.0" />
|
|
785
|
+
</div>
|
|
786
|
+
<div>
|
|
787
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>作者</label>
|
|
788
|
+
<TextField value={author} onChange={setAuthor} placeholder="作者名" />
|
|
789
|
+
</div>
|
|
790
|
+
</div>
|
|
791
|
+
<div className="grid grid-cols-2 gap-3">
|
|
792
|
+
<div>
|
|
793
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>图标 (Emoji)</label>
|
|
794
|
+
<TextField value={icon} onChange={setIcon} placeholder="例如:🎲" />
|
|
795
|
+
</div>
|
|
796
|
+
<div>
|
|
797
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>类型</label>
|
|
798
|
+
<SelectField value={type} onChange={(v) => setType(v as PluginType)} options={typeOptions} />
|
|
799
|
+
</div>
|
|
800
|
+
</div>
|
|
801
|
+
<div>
|
|
802
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>描述</label>
|
|
803
|
+
<TextAreaField value={description} onChange={setDescription} placeholder="插件功能描述..." rows={5} />
|
|
804
|
+
</div>
|
|
805
|
+
</div>
|
|
806
|
+
</div>
|
|
807
|
+
|
|
808
|
+
{/* 配置项定义 */}
|
|
809
|
+
<div>
|
|
810
|
+
<div className="flex items-center justify-between mb-2">
|
|
811
|
+
<div className="flex items-center gap-2 cursor-pointer select-none" onClick={() => setConfigSectionCollapsed(v => !v)}>
|
|
812
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
813
|
+
style={{ color: s.textMuted, transform: configSectionCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
|
|
814
|
+
<polyline points="6 9 12 15 18 9" />
|
|
815
|
+
</svg>
|
|
816
|
+
<h3 className="text-sm font-bold" style={{ color: s.textPrimary }}>配置项定义(可选)</h3>
|
|
817
|
+
{configSchema.length > 0 && (
|
|
818
|
+
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>{configSchema.length}</span>
|
|
819
|
+
)}
|
|
820
|
+
</div>
|
|
821
|
+
{!configSectionCollapsed && (
|
|
822
|
+
<button
|
|
823
|
+
onClick={handleAddConfigField}
|
|
824
|
+
className="px-3 py-1 rounded-lg text-xs border"
|
|
825
|
+
style={{ borderColor: s.accent, color: s.accent, backgroundColor: 'transparent' }}
|
|
826
|
+
>
|
|
827
|
+
+ 添加
|
|
828
|
+
</button>
|
|
829
|
+
)}
|
|
830
|
+
</div>
|
|
831
|
+
{!configSectionCollapsed && (
|
|
832
|
+
configSchema.length === 0 ? (
|
|
833
|
+
<p className="text-xs" style={{ color: s.textMuted }}>暂无配置项</p>
|
|
834
|
+
) : (
|
|
835
|
+
<div className="space-y-3">
|
|
836
|
+
{configSchema.map((field, idx) => (
|
|
837
|
+
<div key={idx} className="rounded-lg border" style={{ backgroundColor: s.bgTertiary, borderColor: s.border }}>
|
|
838
|
+
{/* 卡片头部(可折叠) */}
|
|
839
|
+
<div
|
|
840
|
+
className="flex items-center justify-between px-3 py-2 cursor-pointer select-none"
|
|
841
|
+
onClick={() => setCollapsedConfigItems(prev => {
|
|
842
|
+
const next = new Set(prev);
|
|
843
|
+
if (next.has(idx)) { next.delete(idx); } else { next.add(idx); }
|
|
844
|
+
return next;
|
|
845
|
+
})}
|
|
846
|
+
>
|
|
847
|
+
<div className="flex items-center gap-2">
|
|
848
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
849
|
+
style={{ color: s.textMuted, transform: collapsedConfigItems.has(idx) ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
|
|
850
|
+
<polyline points="6 9 12 15 18 9" />
|
|
851
|
+
</svg>
|
|
852
|
+
<span className="text-xs font-bold" style={{ color: s.textPrimary }}>
|
|
853
|
+
{field.label || field.key || `配置项 #${idx + 1}`}
|
|
854
|
+
</span>
|
|
855
|
+
<span className="text-xs" style={{ color: s.textMuted }}>{field.type}</span>
|
|
856
|
+
</div>
|
|
857
|
+
<button onClick={(e) => { e.stopPropagation(); handleRemoveConfigField(idx); }} className="text-xs px-2 py-0.5 rounded" style={{ color: '#ef4444' }}>删除</button>
|
|
858
|
+
</div>
|
|
859
|
+
{/* 卡片内容 */}
|
|
860
|
+
{!collapsedConfigItems.has(idx) && (
|
|
861
|
+
<div className="px-3 pb-3 space-y-2">
|
|
862
|
+
<div className="grid grid-cols-2 gap-2">
|
|
863
|
+
<div>
|
|
864
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>键名</label>
|
|
865
|
+
<TextField value={field.key} onChange={(v) => handleUpdateConfigField(idx, { key: v })} placeholder="key" mono />
|
|
866
|
+
</div>
|
|
867
|
+
<div>
|
|
868
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>标签</label>
|
|
869
|
+
<TextField value={field.label} onChange={(v) => handleUpdateConfigField(idx, { label: v })} placeholder="显示名称" />
|
|
870
|
+
</div>
|
|
871
|
+
</div>
|
|
872
|
+
<div className="grid grid-cols-2 gap-2">
|
|
873
|
+
<div>
|
|
874
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>类型</label>
|
|
875
|
+
<SelectField
|
|
876
|
+
value={field.type}
|
|
877
|
+
onChange={(v) => handleUpdateConfigField(idx, { type: v as PluginConfigField['type'] })}
|
|
878
|
+
options={CONFIG_FIELD_TYPES.map((t) => ({ label: CONFIG_FIELD_TYPE_LABELS[t], value: t }))}
|
|
879
|
+
/>
|
|
880
|
+
</div>
|
|
881
|
+
<div>
|
|
882
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>默认值</label>
|
|
883
|
+
{field.type === 'select' ? (
|
|
884
|
+
<SelectField
|
|
885
|
+
value={String(field.defaultValue ?? '')}
|
|
886
|
+
onChange={(v) => handleUpdateConfigField(idx, { defaultValue: v })}
|
|
887
|
+
options={[{ label: '无', value: '' }, ...(field.options || []).map(opt => ({ label: opt.label, value: opt.value }))]}
|
|
888
|
+
/>
|
|
889
|
+
) : field.type === 'boolean' ? (
|
|
890
|
+
<button
|
|
891
|
+
type="button"
|
|
892
|
+
onClick={() => handleUpdateConfigField(idx, { defaultValue: !field.defaultValue })}
|
|
893
|
+
className="relative w-9 h-5 rounded-full transition-colors shrink-0"
|
|
894
|
+
style={{
|
|
895
|
+
backgroundColor: !!field.defaultValue ? 'var(--color-accent)' : 'var(--color-bg-primary)',
|
|
896
|
+
border: `1px solid ${!!field.defaultValue ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
|
897
|
+
}}
|
|
898
|
+
>
|
|
899
|
+
<div
|
|
900
|
+
className="absolute top-0.5 w-3.5 h-3.5 rounded-full transition-all"
|
|
901
|
+
style={{
|
|
902
|
+
backgroundColor: !!field.defaultValue ? '#fff' : 'var(--color-text-muted)',
|
|
903
|
+
left: !!field.defaultValue ? '18px' : '2px',
|
|
904
|
+
}}
|
|
905
|
+
/>
|
|
906
|
+
</button>
|
|
907
|
+
) : (
|
|
908
|
+
<TextField value={String(field.defaultValue)} onChange={(v) => handleUpdateConfigField(idx, { defaultValue: v })} placeholder="默认值" />
|
|
909
|
+
)}
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
912
|
+
<div>
|
|
913
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>描述</label>
|
|
914
|
+
<TextField value={field.description ?? ''} onChange={(v) => handleUpdateConfigField(idx, { description: v })} placeholder="配置项说明" />
|
|
915
|
+
</div>
|
|
916
|
+
{field.type === 'select' && (
|
|
917
|
+
<div>
|
|
918
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
919
|
+
<label className="text-xs" style={{ color: s.textMuted }}>选项列表</label>
|
|
920
|
+
<button
|
|
921
|
+
type="button"
|
|
922
|
+
onClick={() => handleUpdateConfigField(idx, {
|
|
923
|
+
options: [...(field.options || []), { label: '', value: '' }],
|
|
924
|
+
})}
|
|
925
|
+
className="text-xs px-2 py-0.5 rounded transition-colors"
|
|
926
|
+
style={{ color: s.accent, backgroundColor: 'transparent' }}
|
|
927
|
+
>
|
|
928
|
+
+ 添加选项
|
|
929
|
+
</button>
|
|
930
|
+
</div>
|
|
931
|
+
<div className="flex flex-col gap-1.5">
|
|
932
|
+
{(field.options || []).map((opt, oi) => (
|
|
933
|
+
<div key={oi} className="flex items-center gap-1.5 min-w-0">
|
|
934
|
+
<input
|
|
935
|
+
type="text"
|
|
936
|
+
value={opt.label}
|
|
937
|
+
onChange={(e) => {
|
|
938
|
+
const newOpts = [...(field.options || [])];
|
|
939
|
+
newOpts[oi] = { ...newOpts[oi], label: e.target.value, value: e.target.value };
|
|
940
|
+
handleUpdateConfigField(idx, { options: newOpts });
|
|
941
|
+
}}
|
|
942
|
+
placeholder="标签"
|
|
943
|
+
className="flex-1 min-w-0 px-2 py-1 rounded text-xs border outline-none"
|
|
944
|
+
style={{
|
|
945
|
+
borderColor: s.border,
|
|
946
|
+
backgroundColor: s.bgPrimary,
|
|
947
|
+
color: s.textPrimary,
|
|
948
|
+
}}
|
|
949
|
+
/>
|
|
950
|
+
<input
|
|
951
|
+
type="text"
|
|
952
|
+
value={opt.value}
|
|
953
|
+
onChange={(e) => {
|
|
954
|
+
const newOpts = [...(field.options || [])];
|
|
955
|
+
newOpts[oi] = { ...newOpts[oi], value: e.target.value };
|
|
956
|
+
handleUpdateConfigField(idx, { options: newOpts });
|
|
957
|
+
}}
|
|
958
|
+
placeholder="值"
|
|
959
|
+
className="flex-1 min-w-0 px-2 py-1 rounded text-xs border outline-none"
|
|
960
|
+
style={{
|
|
961
|
+
borderColor: s.border,
|
|
962
|
+
backgroundColor: s.bgPrimary,
|
|
963
|
+
color: s.textPrimary,
|
|
964
|
+
}}
|
|
965
|
+
/>
|
|
966
|
+
<button
|
|
967
|
+
type="button"
|
|
968
|
+
onClick={() => {
|
|
969
|
+
const newOpts = (field.options || []).filter((_, i) => i !== oi);
|
|
970
|
+
handleUpdateConfigField(idx, { options: newOpts });
|
|
971
|
+
}}
|
|
972
|
+
className="p-1 rounded transition-colors shrink-0"
|
|
973
|
+
style={{ color: s.textMuted }}
|
|
974
|
+
title="删除选项"
|
|
975
|
+
>
|
|
976
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
977
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
978
|
+
</svg>
|
|
979
|
+
</button>
|
|
980
|
+
</div>
|
|
981
|
+
))}
|
|
982
|
+
{(field.options || []).length === 0 && (
|
|
983
|
+
<p className="text-xs" style={{ color: s.textMuted }}>暂无选项,点击上方按钮添加</p>
|
|
984
|
+
)}
|
|
985
|
+
</div>
|
|
986
|
+
</div>
|
|
987
|
+
)}
|
|
988
|
+
</div>
|
|
989
|
+
)}
|
|
990
|
+
</div>
|
|
991
|
+
))}
|
|
992
|
+
</div>
|
|
993
|
+
))}
|
|
994
|
+
</div>
|
|
995
|
+
|
|
996
|
+
{/* UI 权限声明 */}
|
|
997
|
+
<div>
|
|
998
|
+
<div className="flex items-center justify-between mb-2">
|
|
999
|
+
<div className="flex items-center gap-2 cursor-pointer select-none" onClick={() => setPermSectionCollapsed(v => !v)}>
|
|
1000
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
1001
|
+
style={{ color: s.textMuted, transform: permSectionCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
|
|
1002
|
+
<polyline points="6 9 12 15 18 9" />
|
|
1003
|
+
</svg>
|
|
1004
|
+
<h3 className="text-sm font-bold" style={{ color: s.textPrimary }}>UI 权限声明(可选)</h3>
|
|
1005
|
+
{(commonPermissions.length + exclusivePermissions.length + requiredPermissions.length) > 0 && (
|
|
1006
|
+
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>{commonPermissions.length + exclusivePermissions.length + requiredPermissions.length}</span>
|
|
1007
|
+
)}
|
|
1008
|
+
</div>
|
|
1009
|
+
</div>
|
|
1010
|
+
{!permSectionCollapsed && (
|
|
1011
|
+
<>
|
|
1012
|
+
<p className="text-xs mb-3" style={{ color: s.textMuted }}>
|
|
1013
|
+
声明插件需要使用的 UI 能力。未声明时使用默认权限。支持在代码中编写 <code style={{ background: s.bgTertiary, padding: '1px 4px', borderRadius: '3px' }}>{'// @permission xxxx:......'}</code> 注释自动解析。
|
|
1014
|
+
</p>
|
|
1015
|
+
|
|
1016
|
+
{/* 共享权限 */}
|
|
1017
|
+
<div className="border rounded-lg p-3 mt-2" style={{ borderColor: '#3f3f3f' }}>
|
|
1018
|
+
<div className="flex items-center justify-between mb-2">
|
|
1019
|
+
<span className="text-xs font-medium" style={{ color: s.textPrimary }}>共享权限</span>
|
|
1020
|
+
<span className="text-xs" style={{ color: s.textMuted }}>可被多个插件同时声明</span>
|
|
1021
|
+
</div>
|
|
1022
|
+
<PermSelector
|
|
1023
|
+
allPerms={Object.entries(UI_PERMISSION_LABELS) as [UIPermission, string][]}
|
|
1024
|
+
selected={commonPermissions}
|
|
1025
|
+
onChange={setCommonPermissions}
|
|
1026
|
+
accentColor={s.accent}
|
|
1027
|
+
theme={s}
|
|
1028
|
+
/>
|
|
1029
|
+
</div>
|
|
1030
|
+
|
|
1031
|
+
{/* 排他权限 */}
|
|
1032
|
+
<div className="border rounded-lg p-3 mt-2" style={{ borderColor: '#f97316' }}>
|
|
1033
|
+
<div className="flex items-center justify-between mb-2">
|
|
1034
|
+
<span className="text-xs font-medium" style={{ color: '#f97316' }}>排他权限</span>
|
|
1035
|
+
<span className="text-xs" style={{ color: s.textMuted }}>同时只能被一个插件声明</span>
|
|
1036
|
+
</div>
|
|
1037
|
+
<PermSelector
|
|
1038
|
+
allPerms={Object.entries(UI_PERMISSION_LABELS) as [UIPermission, string][]}
|
|
1039
|
+
selected={exclusivePermissions}
|
|
1040
|
+
onChange={setExclusivePermissions}
|
|
1041
|
+
accentColor="#f97316"
|
|
1042
|
+
theme={s}
|
|
1043
|
+
/>
|
|
1044
|
+
</div>
|
|
1045
|
+
|
|
1046
|
+
{/* 必要权限 */}
|
|
1047
|
+
<div className="border rounded-lg p-3 mt-2" style={{ borderColor: '#3b82f6' }}>
|
|
1048
|
+
<div className="flex items-center justify-between mb-2">
|
|
1049
|
+
<span className="text-xs font-medium" style={{ color: '#3b82f6' }}>必要权限</span>
|
|
1050
|
+
<span className="text-xs" style={{ color: s.textMuted }}>没有此权限则插件不允许启动</span>
|
|
1051
|
+
</div>
|
|
1052
|
+
<PermSelector
|
|
1053
|
+
allPerms={Object.entries(UI_PERMISSION_LABELS) as [UIPermission, string][]}
|
|
1054
|
+
selected={requiredPermissions}
|
|
1055
|
+
onChange={setRequiredPermissions}
|
|
1056
|
+
accentColor="#3b82f6"
|
|
1057
|
+
theme={s}
|
|
1058
|
+
/>
|
|
1059
|
+
<p className="text-xs mt-2" style={{ color: s.textMuted }}>⚠️ 必要权限必须在共享权限或排他权限中声明</p>
|
|
1060
|
+
</div>
|
|
1061
|
+
</>
|
|
1062
|
+
)}
|
|
1063
|
+
</div>
|
|
1064
|
+
|
|
1065
|
+
{/* 依赖管理 */}
|
|
1066
|
+
<div>
|
|
1067
|
+
<div className="flex items-center justify-between mb-2">
|
|
1068
|
+
<div className="flex items-center gap-2 cursor-pointer select-none" onClick={() => setDepSectionCollapsed(v => !v)}>
|
|
1069
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
1070
|
+
style={{ color: s.textMuted, transform: depSectionCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease' }}>
|
|
1071
|
+
<polyline points="6 9 12 15 18 9" />
|
|
1072
|
+
</svg>
|
|
1073
|
+
<h3 className="text-sm font-bold flex items-center gap-1.5" style={{ color: s.textPrimary }}>
|
|
1074
|
+
依赖管理
|
|
1075
|
+
</h3>
|
|
1076
|
+
{dependencies.length > 0 && (
|
|
1077
|
+
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>{dependencies.length}</span>
|
|
1078
|
+
)}
|
|
1079
|
+
</div>
|
|
1080
|
+
{!depSectionCollapsed && (
|
|
1081
|
+
<div className="flex items-center gap-1.5">
|
|
1082
|
+
<button
|
|
1083
|
+
onClick={() => setShowAddDep(!showAddDep)}
|
|
1084
|
+
className="px-3 py-1 rounded-lg text-xs border"
|
|
1085
|
+
style={{ borderColor: s.accent, color: s.accent, backgroundColor: 'transparent' }}
|
|
1086
|
+
>
|
|
1087
|
+
手动添加
|
|
1088
|
+
</button>
|
|
1089
|
+
</div>
|
|
1090
|
+
)}
|
|
1091
|
+
</div>
|
|
1092
|
+
{!depSectionCollapsed && (
|
|
1093
|
+
<>
|
|
1094
|
+
|
|
1095
|
+
{/* 手动添加内联表单 */}
|
|
1096
|
+
{showAddDep && (
|
|
1097
|
+
<div className="rounded-lg p-3 border mb-3 space-y-2" style={{ backgroundColor: s.bgTertiary, borderColor: s.border }}>
|
|
1098
|
+
<div>
|
|
1099
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>插件 ID *</label>
|
|
1100
|
+
<TextField value={newDepId} onChange={setNewDepId} placeholder="例如:builtin.dice" mono />
|
|
1101
|
+
</div>
|
|
1102
|
+
<div>
|
|
1103
|
+
<label className="block text-xs mb-1" style={{ color: s.textMuted }}>版本范围(可选)</label>
|
|
1104
|
+
<TextField value={newDepVersion} onChange={setNewDepVersion} placeholder="例如:^1.0.0" mono />
|
|
1105
|
+
</div>
|
|
1106
|
+
<div className="flex items-center gap-2">
|
|
1107
|
+
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: s.textSecondary }}>
|
|
1108
|
+
<input
|
|
1109
|
+
type="checkbox"
|
|
1110
|
+
checked={newDepOptional}
|
|
1111
|
+
onChange={(e) => setNewDepOptional(e.target.checked)}
|
|
1112
|
+
className="rounded"
|
|
1113
|
+
/>
|
|
1114
|
+
可选依赖
|
|
1115
|
+
</label>
|
|
1116
|
+
</div>
|
|
1117
|
+
<div className="flex items-center gap-2 pt-1">
|
|
1118
|
+
<button
|
|
1119
|
+
onClick={handleAddDependency}
|
|
1120
|
+
className="px-3 py-1 rounded-lg text-xs font-bold"
|
|
1121
|
+
style={{ backgroundColor: s.accent, color: s.bgPrimary }}
|
|
1122
|
+
>
|
|
1123
|
+
确认添加
|
|
1124
|
+
</button>
|
|
1125
|
+
<button
|
|
1126
|
+
onClick={() => { setShowAddDep(false); setNewDepId(''); setNewDepVersion(''); setNewDepOptional(false); }}
|
|
1127
|
+
className="px-3 py-1 rounded-lg text-xs border"
|
|
1128
|
+
style={{ borderColor: s.border, color: s.textSecondary, backgroundColor: 'transparent' }}
|
|
1129
|
+
>
|
|
1130
|
+
取消
|
|
1131
|
+
</button>
|
|
1132
|
+
</div>
|
|
1133
|
+
</div>
|
|
1134
|
+
)}
|
|
1135
|
+
|
|
1136
|
+
{/* 依赖列表 */}
|
|
1137
|
+
{dependencies.length === 0 ? (
|
|
1138
|
+
<p className="text-xs" style={{ color: s.textMuted }}>暂无依赖。点击“从代码扫描”自动检测,或“手动添加”。</p>
|
|
1139
|
+
) : (
|
|
1140
|
+
<div className="rounded-lg border overflow-hidden" style={{ borderColor: s.border }}>
|
|
1141
|
+
{/* 表头 */}
|
|
1142
|
+
<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 }}>
|
|
1143
|
+
<span>插件 ID</span>
|
|
1144
|
+
<span>版本范围</span>
|
|
1145
|
+
<span>状态</span>
|
|
1146
|
+
<span>操作</span>
|
|
1147
|
+
</div>
|
|
1148
|
+
{/* 依赖项 */}
|
|
1149
|
+
{dependencies.map((dep) => (
|
|
1150
|
+
<div
|
|
1151
|
+
key={dep.pluginId}
|
|
1152
|
+
className="grid grid-cols-[1fr_80px_80px_40px] gap-2 px-3 py-2 text-xs items-center"
|
|
1153
|
+
style={{ borderTop: `1px solid ${s.border}`, color: s.textPrimary }}
|
|
1154
|
+
>
|
|
1155
|
+
<span className="font-mono truncate" title={dep.pluginId}>
|
|
1156
|
+
{dep.pluginId}
|
|
1157
|
+
{dep.optional && (
|
|
1158
|
+
<span className="ml-1 px-1.5 py-0.5 rounded text-[10px]" style={{ backgroundColor: s.bgTertiary, color: s.textMuted }}>可选</span>
|
|
1159
|
+
)}
|
|
1160
|
+
</span>
|
|
1161
|
+
<span className="font-mono" style={{ color: s.textSecondary }}>
|
|
1162
|
+
{dep.versionRange || '\u2014'}
|
|
1163
|
+
</span>
|
|
1164
|
+
<span className="flex items-center gap-1">
|
|
1165
|
+
{depStatusIcon(depStatuses[dep.pluginId])}
|
|
1166
|
+
<span style={{ color: depStatuses[dep.pluginId] === 'satisfied' ? '#22c55e' : depStatuses[dep.pluginId] === 'missing' ? '#ef4444' : '#f59e0b' }}>
|
|
1167
|
+
{depStatusLabel(depStatuses[dep.pluginId])}
|
|
1168
|
+
</span>
|
|
1169
|
+
</span>
|
|
1170
|
+
<button
|
|
1171
|
+
onClick={() => handleRemoveDependency(dep.pluginId)}
|
|
1172
|
+
className="text-xs px-1 py-0.5 rounded transition-colors hover:opacity-80"
|
|
1173
|
+
style={{ color: '#ef4444' }}
|
|
1174
|
+
title="删除依赖"
|
|
1175
|
+
>
|
|
1176
|
+
🗑
|
|
1177
|
+
</button>
|
|
1178
|
+
</div>
|
|
1179
|
+
))}
|
|
1180
|
+
</div>
|
|
1181
|
+
)}
|
|
1182
|
+
|
|
1183
|
+
{/* 提示 */}
|
|
1184
|
+
{dependencies.length > 0 && (
|
|
1185
|
+
<p className="text-xs mt-2" style={{ color: s.textMuted }}>
|
|
1186
|
+
💡 使用 <code className="font-mono px-1 py-0.5 rounded" style={{ backgroundColor: s.bgTertiary }}>xinyu.plugin.loadDependency()</code> 在代码中加载依赖
|
|
1187
|
+
</p>
|
|
1188
|
+
)}
|
|
1189
|
+
</>
|
|
1190
|
+
)}
|
|
1191
|
+
</div>
|
|
1192
|
+
|
|
1193
|
+
{/* AI 安全检测 */}
|
|
1194
|
+
<div>
|
|
1195
|
+
<div className="flex items-center justify-between mb-2">
|
|
1196
|
+
<h3 className="text-sm font-bold" style={{ color: s.textPrimary }}>安全检测</h3>
|
|
1197
|
+
<button
|
|
1198
|
+
onClick={handleScan}
|
|
1199
|
+
disabled={scanning}
|
|
1200
|
+
className="px-3 py-1 rounded-lg text-xs border"
|
|
1201
|
+
style={{ borderColor: s.accent, color: s.accent, backgroundColor: 'transparent' }}
|
|
1202
|
+
>
|
|
1203
|
+
{scanning ? '检测中...' : 'AI 安全检测'}
|
|
1204
|
+
</button>
|
|
1205
|
+
</div>
|
|
1206
|
+
{scanResult && (
|
|
1207
|
+
<div className="rounded-lg p-4 border" style={{ backgroundColor: s.bgTertiary, borderColor: riskColors[scanResult.riskLevel] }}>
|
|
1208
|
+
<div className="flex items-center gap-3 mb-3">
|
|
1209
|
+
<span className="px-2 py-0.5 rounded text-xs font-bold" style={{ backgroundColor: riskColors[scanResult.riskLevel], color: '#fff' }}>
|
|
1210
|
+
{riskLabels[scanResult.riskLevel]} ({scanResult.score}/100)
|
|
1211
|
+
</span>
|
|
1212
|
+
<span className="text-sm" style={{ color: s.textPrimary }}>{scanResult.summary}</span>
|
|
1213
|
+
</div>
|
|
1214
|
+
{scanResult.findings.length > 0 && (
|
|
1215
|
+
<div className="space-y-2">
|
|
1216
|
+
{scanResult.findings.map((finding, i) => (
|
|
1217
|
+
<div key={i} className="text-xs" style={{ color: s.textSecondary }}>
|
|
1218
|
+
<span className="font-bold" style={{ color: riskColors[finding.level] }}>[{finding.category}]</span>{' '}
|
|
1219
|
+
{finding.description}
|
|
1220
|
+
{finding.recommendation && <span style={{ color: s.textMuted }}> - 建议: {finding.recommendation}</span>}
|
|
1221
|
+
</div>
|
|
1222
|
+
))}
|
|
1223
|
+
</div>
|
|
1224
|
+
)}
|
|
1225
|
+
{scanResult.recommendation && (
|
|
1226
|
+
<p className="text-xs mt-2" style={{ color: s.textMuted }}>建议: {scanResult.recommendation}</p>
|
|
1227
|
+
)}
|
|
1228
|
+
</div>
|
|
1229
|
+
)}
|
|
1230
|
+
</div>
|
|
1231
|
+
|
|
1232
|
+
{/* 底部留白 */}
|
|
1233
|
+
<div className="h-8" />
|
|
1234
|
+
</div>
|
|
1235
|
+
</div>
|
|
1236
|
+
|
|
1237
|
+
{/* 拖拽分隔条 2 */}
|
|
1238
|
+
<ResizableDivider onResize={handleResizeMid} />
|
|
1239
|
+
|
|
1240
|
+
{/* 右侧:代码编辑器 */}
|
|
1241
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
1242
|
+
<div
|
|
1243
|
+
className="shrink-0 px-4 py-2 text-xs flex items-center justify-between"
|
|
1244
|
+
style={{ backgroundColor: s.bgTertiary, color: s.textMuted, borderBottom: `1px solid ${s.border}` }}
|
|
1245
|
+
>
|
|
1246
|
+
<div className="flex items-center gap-3">
|
|
1247
|
+
<span className="font-mono">plugin.js</span>
|
|
1248
|
+
<div className="relative">
|
|
1249
|
+
<button
|
|
1250
|
+
onClick={() => setThemeMenuOpen(!themeMenuOpen)}
|
|
1251
|
+
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors"
|
|
1252
|
+
style={{
|
|
1253
|
+
backgroundColor: s.bgPrimary,
|
|
1254
|
+
color: s.textSecondary,
|
|
1255
|
+
border: `1px solid ${s.border}`,
|
|
1256
|
+
}}
|
|
1257
|
+
title="切换编辑器主题"
|
|
1258
|
+
>
|
|
1259
|
+
<i className="fa-solid fa-palette" style={{ fontSize: '10px' }} />
|
|
1260
|
+
<span>{EDITOR_THEMES.find(t => t.id === editorTheme)?.label}</span>
|
|
1261
|
+
<i className="fa-solid fa-chevron-down" style={{ fontSize: '8px' }} />
|
|
1262
|
+
</button>
|
|
1263
|
+
{themeMenuOpen && (
|
|
1264
|
+
<>
|
|
1265
|
+
<div className="fixed inset-0 z-10" onClick={() => setThemeMenuOpen(false)} />
|
|
1266
|
+
<div
|
|
1267
|
+
className="absolute left-0 top-full mt-1 py-1 rounded-lg shadow-lg z-20 min-w-[140px]"
|
|
1268
|
+
style={{
|
|
1269
|
+
backgroundColor: s.bgSecondary,
|
|
1270
|
+
border: `1px solid ${s.border}`,
|
|
1271
|
+
}}
|
|
1272
|
+
>
|
|
1273
|
+
{EDITOR_THEMES.map((t) => (
|
|
1274
|
+
<button
|
|
1275
|
+
key={t.id}
|
|
1276
|
+
onClick={() => { setEditorTheme(t.id); setThemeMenuOpen(false); }}
|
|
1277
|
+
className="w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors"
|
|
1278
|
+
style={{
|
|
1279
|
+
backgroundColor: editorTheme === t.id ? s.bgTertiary : 'transparent',
|
|
1280
|
+
color: editorTheme === t.id ? s.accent : s.textSecondary,
|
|
1281
|
+
}}
|
|
1282
|
+
>
|
|
1283
|
+
<span
|
|
1284
|
+
className="w-3 h-3 rounded-sm shrink-0"
|
|
1285
|
+
style={{ backgroundColor: t.color }}
|
|
1286
|
+
/>
|
|
1287
|
+
{t.label}
|
|
1288
|
+
</button>
|
|
1289
|
+
))}
|
|
1290
|
+
</div>
|
|
1291
|
+
</>
|
|
1292
|
+
)}
|
|
1293
|
+
</div>
|
|
1294
|
+
</div>
|
|
1295
|
+
<span>{code.split('\n').length} 行</span>
|
|
1296
|
+
</div>
|
|
1297
|
+
<div className="flex-1 overflow-hidden">
|
|
1298
|
+
<Editor
|
|
1299
|
+
height="100%"
|
|
1300
|
+
language="javascript"
|
|
1301
|
+
theme={editorTheme}
|
|
1302
|
+
value={code}
|
|
1303
|
+
onChange={(value) => setCode(value ?? '')}
|
|
1304
|
+
onMount={handleEditorMount}
|
|
1305
|
+
options={{
|
|
1306
|
+
fontSize: 12,
|
|
1307
|
+
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
|
|
1308
|
+
lineHeight: 18,
|
|
1309
|
+
minimap: { enabled: false },
|
|
1310
|
+
scrollBeyondLastLine: false,
|
|
1311
|
+
wordWrap: 'on',
|
|
1312
|
+
tabSize: 2,
|
|
1313
|
+
padding: { top: 8, bottom: 8 },
|
|
1314
|
+
overviewRulerBorder: false,
|
|
1315
|
+
scrollbar: {
|
|
1316
|
+
verticalScrollbarSize: 8,
|
|
1317
|
+
horizontalScrollbarSize: 8,
|
|
1318
|
+
},
|
|
1319
|
+
}}
|
|
1320
|
+
/>
|
|
1321
|
+
</div>
|
|
1322
|
+
</div>
|
|
1323
|
+
</div>
|
|
1324
|
+
</div>
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/** 权限选择器组件:下拉选择 + 标签列表 */
|
|
1329
|
+
function PermSelector({
|
|
1330
|
+
allPerms,
|
|
1331
|
+
selected,
|
|
1332
|
+
onChange,
|
|
1333
|
+
accentColor,
|
|
1334
|
+
theme: s,
|
|
1335
|
+
}: {
|
|
1336
|
+
allPerms: [string, string][];
|
|
1337
|
+
selected: string[];
|
|
1338
|
+
onChange: (perms: UIPermission[]) => void;
|
|
1339
|
+
accentColor: string;
|
|
1340
|
+
theme: Record<string, string>;
|
|
1341
|
+
}) {
|
|
1342
|
+
const available = allPerms.filter(([perm]) => !selected.includes(perm));
|
|
1343
|
+
const [selectVal, setSelectVal] = useState('');
|
|
1344
|
+
|
|
1345
|
+
const handleAdd = () => {
|
|
1346
|
+
if (selectVal && !selected.includes(selectVal)) {
|
|
1347
|
+
onChange([...selected as UIPermission[], selectVal as UIPermission]);
|
|
1348
|
+
}
|
|
1349
|
+
setSelectVal('');
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
const handleRemove = (perm: string) => {
|
|
1353
|
+
onChange(selected.filter(p => p !== perm) as UIPermission[]);
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
return (
|
|
1357
|
+
<div>
|
|
1358
|
+
{/* 标签列表 */}
|
|
1359
|
+
{selected.length > 0 && (
|
|
1360
|
+
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
1361
|
+
{selected.map(perm => {
|
|
1362
|
+
const label = allPerms.find(([p]) => p === perm)?.[1] || perm;
|
|
1363
|
+
return (
|
|
1364
|
+
<span key={perm} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md transition-colors"
|
|
1365
|
+
style={{ backgroundColor: `${accentColor}15`, border: `1px solid ${accentColor}40`, color: accentColor }}>
|
|
1366
|
+
{label}
|
|
1367
|
+
<button onClick={() => handleRemove(perm)} className="hover:opacity-70 transition-opacity ml-0.5" style={{ color: accentColor }}>✕</button>
|
|
1368
|
+
</span>
|
|
1369
|
+
);
|
|
1370
|
+
})}
|
|
1371
|
+
</div>
|
|
1372
|
+
)}
|
|
1373
|
+
|
|
1374
|
+
{/* 下拉选择 + 添加按钮 */}
|
|
1375
|
+
<div className="flex gap-1.5 min-w-0">
|
|
1376
|
+
<div className="flex-1 min-w-0 flex items-center rounded-md border overflow-hidden"
|
|
1377
|
+
style={{ borderColor: s.border }}>
|
|
1378
|
+
<select
|
|
1379
|
+
value={selectVal}
|
|
1380
|
+
onChange={(e) => setSelectVal(e.target.value)}
|
|
1381
|
+
className="flex-1 min-w-0 text-xs px-2 py-1.5 outline-none bg-transparent appearance-none cursor-pointer"
|
|
1382
|
+
style={{ color: s.textPrimary }}
|
|
1383
|
+
>
|
|
1384
|
+
<option value="">选择权限...</option>
|
|
1385
|
+
{available.map(([perm, label]) => (
|
|
1386
|
+
<option key={perm} value={perm}>{label} ({perm})</option>
|
|
1387
|
+
))}
|
|
1388
|
+
</select>
|
|
1389
|
+
<button
|
|
1390
|
+
onClick={handleAdd}
|
|
1391
|
+
disabled={!selectVal}
|
|
1392
|
+
className="shrink-0 text-xs px-2.5 py-1.5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed border-l"
|
|
1393
|
+
style={{
|
|
1394
|
+
backgroundColor: selectVal ? `${accentColor}20` : 'transparent',
|
|
1395
|
+
borderColor: s.border,
|
|
1396
|
+
color: selectVal ? accentColor : s.textMuted,
|
|
1397
|
+
}}
|
|
1398
|
+
>
|
|
1399
|
+
+ 添加
|
|
1400
|
+
</button>
|
|
1401
|
+
</div>
|
|
1402
|
+
</div>
|
|
1403
|
+
</div>
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function PageLoader() {
|
|
1408
|
+
return (
|
|
1409
|
+
<div className="flex flex-col items-center justify-center gap-4" style={{ minHeight: '100vh', backgroundColor: 'var(--color-bg-primary)' }}>
|
|
1410
|
+
<MathCurveLoader size={80} strokeWidthScale={0.8} />
|
|
1411
|
+
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>加载中...</p>
|
|
1412
|
+
</div>
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
export default function CreatePluginPage() {
|
|
1417
|
+
return (
|
|
1418
|
+
<React.Suspense fallback={<PageLoader />}>
|
|
1419
|
+
<CreatePluginPageContent />
|
|
1420
|
+
</React.Suspense>
|
|
1421
|
+
);
|
|
1422
|
+
}
|