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,99 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { useRouterHistory } from '@/lib/router-history';
5
+
6
+ interface PageHeaderProps {
7
+ /** 页面标题 */
8
+ title: React.ReactNode;
9
+ /** 副标题(可选) */
10
+ subtitle?: string;
11
+ /** 是否显示返回按钮(默认 true) */
12
+ showBack?: boolean;
13
+ /** 返回前确认回调,返回 true 表示可以返回 */
14
+ onBeforeBack?: () => boolean | Promise<boolean>;
15
+ /** 标题前的图标/标记(可选) */
16
+ icon?: React.ReactNode;
17
+ /** 标题右侧的标签(可选,如模板名称) */
18
+ badge?: React.ReactNode;
19
+ /** 右侧操作区(可选) */
20
+ actions?: React.ReactNode;
21
+ /** 是否固定在顶部 */
22
+ sticky?: boolean;
23
+ /** 额外的 className */
24
+ className?: string;
25
+ /** 额外的 style */
26
+ style?: React.CSSProperties;
27
+ }
28
+
29
+ export default function PageHeader({
30
+ title,
31
+ subtitle,
32
+ showBack = true,
33
+ onBeforeBack,
34
+ icon,
35
+ badge,
36
+ actions,
37
+ sticky = false,
38
+ className = '',
39
+ style,
40
+ }: PageHeaderProps) {
41
+ const { back } = useRouterHistory();
42
+
43
+ const handleBack = async () => {
44
+ if (onBeforeBack) {
45
+ const canGo = await onBeforeBack();
46
+ if (!canGo) return;
47
+ }
48
+ back();
49
+ };
50
+
51
+ return (
52
+ <header
53
+ className={`flex items-center justify-between px-4 py-2.5 border-b shrink-0 ${sticky ? 'sticky top-0 z-30' : ''} ${className}`}
54
+ style={{
55
+ borderColor: 'var(--color-border)',
56
+ backgroundColor: 'var(--color-bg-secondary)',
57
+ ...style,
58
+ }}
59
+ >
60
+ {/* 左侧:返回按钮 + 标题 */}
61
+ <div className="flex items-center gap-2.5 min-w-0">
62
+ {showBack && (
63
+ <button
64
+ onClick={handleBack}
65
+ className="p-1.5 rounded-lg transition-colors shrink-0"
66
+ style={{ color: 'var(--color-text-secondary)' }}
67
+ title="返回"
68
+ >
69
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
70
+ <path d="M19 12H5M12 19l-7-7 7-7" />
71
+ </svg>
72
+ </button>
73
+ )}
74
+ <div className="flex items-center gap-2 min-w-0">
75
+ {icon && <span className="shrink-0">{icon}</span>}
76
+ <div className="min-w-0">
77
+ <div className="flex items-center gap-2 min-w-0">
78
+ <h1
79
+ className="text-sm font-bold truncate"
80
+ style={{ color: 'var(--color-accent)' }}
81
+ >
82
+ {title}
83
+ </h1>
84
+ {badge}
85
+ </div>
86
+ {subtitle && (
87
+ <p className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
88
+ {subtitle}
89
+ </p>
90
+ )}
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ {/* 右侧:操作区 */}
96
+ {actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
97
+ </header>
98
+ );
99
+ }
@@ -0,0 +1,206 @@
1
+ // components/ui/PermissionConflictDialog.tsx - 权限冲突弹窗
2
+ 'use client';
3
+
4
+ import React, { useState } from 'react';
5
+ import { usePluginContext } from './PluginProvider';
6
+ import { UI_PERMISSION_LABELS } from '@/lib/plugin-types';
7
+
8
+ export function PermissionConflictDialog() {
9
+ const { permissionConflicts, resolvePermissionConflict, dismissPermissionConflicts } = usePluginContext();
10
+
11
+ if (!permissionConflicts || permissionConflicts.length === 0) return null;
12
+
13
+ return (
14
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center">
15
+ {/* 遮罩 */}
16
+ <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={dismissPermissionConflicts} />
17
+
18
+ {/* 弹窗 */}
19
+ <div className="relative bg-[#1e1e2e] border border-[#313244] rounded-xl shadow-2xl max-w-lg w-full mx-4 max-h-[80vh] flex flex-col">
20
+ {/* 标题 */}
21
+ <div className="flex items-center gap-3 p-4 border-b border-[#313244]">
22
+ <span className="text-xl">⚠️</span>
23
+ <div>
24
+ <h2 className="text-sm font-bold text-[#cdd6f4]">插件启动失败:权限冲突</h2>
25
+ <p className="text-xs text-[#a6adc8] mt-0.5">
26
+ 以下插件占用了冲突的权限,请处理
27
+ </p>
28
+ </div>
29
+ <button
30
+ onClick={dismissPermissionConflicts}
31
+ className="ml-auto text-[#a6adc8] hover:text-[#cdd6f4] transition-colors text-lg leading-none"
32
+ >
33
+
34
+ </button>
35
+ </div>
36
+
37
+ {/* 冲突列表 */}
38
+ <div className="flex-1 overflow-y-auto p-4 space-y-3">
39
+ {permissionConflicts.map((conflict) => (
40
+ <ConflictGroup
41
+ key={conflict.manifest.id}
42
+ manifestName={conflict.manifest.name}
43
+ manifestIcon={conflict.manifest.icon || '📦'}
44
+ conflicts={conflict.conflicts}
45
+ missingRequired={conflict.missingRequired}
46
+ onResolve={resolvePermissionConflict}
47
+ />
48
+ ))}
49
+ </div>
50
+
51
+ {/* 底部提示 */}
52
+ <div className="p-4 border-t border-[#313244]">
53
+ <p className="text-xs text-[#a6adc8]">
54
+ 💡 取消冲突权限:从该插件中移除非必要的权限声明
55
+ </p>
56
+ <p className="text-xs text-[#a6adc8] mt-1">
57
+ 💡 禁用插件:完全禁用该插件
58
+ </p>
59
+ <p className="text-xs text-[#a6adc8] mt-1">
60
+ ✅ 所有冲突解决后,插件将自动重新启动
61
+ </p>
62
+ </div>
63
+ </div>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ interface ConflictGroupProps {
69
+ manifestName: string;
70
+ manifestIcon: string;
71
+ conflicts: Array<{
72
+ permission: string;
73
+ conflictingPluginId: string;
74
+ conflictingPluginName: string;
75
+ conflictType: 'exclusive-vs-any' | 'common-vs-exclusive';
76
+ }>;
77
+ missingRequired: string[];
78
+ onResolve: (conflictingPluginId: string, permission: string, action: 'remove-permission' | 'disable-plugin') => Promise<void>;
79
+ }
80
+
81
+ function ConflictGroup({ manifestName, manifestIcon, conflicts, missingRequired, onResolve }: ConflictGroupProps) {
82
+ // 按冲突插件分组
83
+ const grouped = new Map<string, Array<{
84
+ permission: string;
85
+ conflictingPluginName: string;
86
+ conflictType: 'exclusive-vs-any' | 'common-vs-exclusive';
87
+ }>>();
88
+
89
+ for (const c of conflicts) {
90
+ if (!grouped.has(c.conflictingPluginId)) {
91
+ grouped.set(c.conflictingPluginId, []);
92
+ }
93
+ grouped.get(c.conflictingPluginId)!.push({
94
+ permission: c.permission,
95
+ conflictingPluginName: c.conflictingPluginName,
96
+ conflictType: c.conflictType,
97
+ });
98
+ }
99
+
100
+ return (
101
+ <div className="space-y-2">
102
+ <div className="flex items-center gap-2">
103
+ <span>{manifestIcon}</span>
104
+ <span className="text-sm font-medium text-[#cdd6f4]">{manifestName}</span>
105
+ </div>
106
+
107
+ {missingRequired.length > 0 && (
108
+ <div className="bg-[#45475a]/50 rounded-lg p-3 border border-[#f38ba8]/30">
109
+ <p className="text-xs text-[#f38ba8]">
110
+ ⛔ 必要权限未声明:{missingRequired.join(', ')}
111
+ </p>
112
+ <p className="text-xs text-[#a6adc8] mt-1">
113
+ 请在插件编辑页面中添加这些权限到共享权限或排他权限中
114
+ </p>
115
+ </div>
116
+ )}
117
+
118
+ {Array.from(grouped.entries()).map(([pluginId, items]) => (
119
+ <ConflictItem
120
+ key={pluginId}
121
+ pluginId={pluginId}
122
+ pluginName={items[0].conflictingPluginName}
123
+ items={items}
124
+ onResolve={onResolve}
125
+ />
126
+ ))}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ interface ConflictItemProps {
132
+ pluginId: string;
133
+ pluginName: string;
134
+ items: Array<{
135
+ permission: string;
136
+ conflictType: 'exclusive-vs-any' | 'common-vs-exclusive';
137
+ }>;
138
+ onResolve: (conflictingPluginId: string, permission: string, action: 'remove-permission' | 'disable-plugin') => Promise<void>;
139
+ }
140
+
141
+ function ConflictItem({ pluginId, pluginName, items, onResolve }: ConflictItemProps) {
142
+ const [resolved, setResolved] = useState(false);
143
+ const [resolving, setResolving] = useState(false);
144
+
145
+ const handleResolve = async (action: 'remove-permission' | 'disable-plugin') => {
146
+ setResolving(true);
147
+ try {
148
+ // 逐个解决该插件的所有冲突
149
+ for (const item of items) {
150
+ await onResolve(pluginId, item.permission, action);
151
+ }
152
+ setResolved(true);
153
+ setTimeout(() => {}, 1000); // 保持显示 1 秒后由父组件移除
154
+ } catch (e) {
155
+ console.error('解决冲突失败:', e);
156
+ } finally {
157
+ setResolving(false);
158
+ }
159
+ };
160
+
161
+ const permLabels = items.map(item => {
162
+ const label = UI_PERMISSION_LABELS[item.permission as keyof typeof UI_PERMISSION_LABELS] || item.permission;
163
+ const typeLabel = item.conflictType === 'exclusive-vs-any' ? '排他' : '共享';
164
+ return `${label} (${typeLabel})`;
165
+ }).join('、');
166
+
167
+ if (resolved) {
168
+ return (
169
+ <div className="bg-[#a6e3a1]/10 border border-[#a6e3a1]/30 rounded-lg p-3 transition-all duration-500">
170
+ <div className="flex items-center gap-2">
171
+ <span className="text-[#a6e3a1]">✅</span>
172
+ <span className="text-sm text-[#a6e3a1]">{pluginName} — 已解决</span>
173
+ </div>
174
+ </div>
175
+ );
176
+ }
177
+
178
+ return (
179
+ <div className="bg-[#45475a]/50 border border-[#313244] rounded-lg p-3 transition-all duration-300">
180
+ <div className="flex items-start justify-between gap-2">
181
+ <div className="flex-1 min-w-0">
182
+ <p className="text-sm text-[#cdd6f4] font-medium">{pluginName}</p>
183
+ <p className="text-xs text-[#a6adc8] mt-1 break-all">
184
+ 冲突权限:{permLabels}
185
+ </p>
186
+ </div>
187
+ </div>
188
+ <div className="flex gap-2 mt-3">
189
+ <button
190
+ onClick={() => handleResolve('remove-permission')}
191
+ disabled={resolving}
192
+ className="flex-1 text-xs px-3 py-1.5 rounded-md bg-[#f9e2af]/10 text-[#f9e2af] border border-[#f9e2af]/30 hover:bg-[#f9e2af]/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
193
+ >
194
+ 取消冲突权限
195
+ </button>
196
+ <button
197
+ onClick={() => handleResolve('disable-plugin')}
198
+ disabled={resolving}
199
+ className="flex-1 text-xs px-3 py-1.5 rounded-md bg-[#f38ba8]/10 text-[#f38ba8] border border-[#f38ba8]/30 hover:bg-[#f38ba8]/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
200
+ >
201
+ 禁用插件
202
+ </button>
203
+ </div>
204
+ </div>
205
+ );
206
+ }
@@ -0,0 +1,192 @@
1
+ // components/ui/PluginConfigForm.tsx - 插件配置表单组件
2
+ 'use client';
3
+
4
+ import React, { useCallback, useEffect, useRef } from 'react';
5
+ import { PluginConfigField } from '@/lib/plugin-types';
6
+ import { usePluginContext } from '@/components/ui/PluginProvider';
7
+ import { useToast } from '@/components/ui/ToastProvider';
8
+ import { upsertPluginBinding } from '@/lib/storage';
9
+ import { PluginBinding } from '@/lib/plugin-types';
10
+
11
+ interface PluginConfigFormProps {
12
+ /** 插件 ID */
13
+ pluginId: string;
14
+ /** 配置 schema 定义 */
15
+ configSchema: PluginConfigField[];
16
+ /** 当前配置值(受控模式,由父组件管理) */
17
+ configValues: Record<string, unknown>;
18
+ /** 配置值变更回调 */
19
+ onConfigChange: (key: string, value: unknown) => void;
20
+ /** 当前插件的绑定信息(用于保存时获取 enabled 状态) */
21
+ binding?: PluginBinding;
22
+ /** 游戏世界 ID */
23
+ gameId: string;
24
+ }
25
+
26
+ /** 通用表单控件样式类名 */
27
+ const INPUT_BASE = 'w-full px-2 py-1 text-xs rounded-md border outline-none transition-colors';
28
+ const INPUT_STYLE = {
29
+ backgroundColor: 'var(--color-bg-primary)',
30
+ color: 'var(--color-text-primary)',
31
+ borderColor: 'var(--color-border)',
32
+ } as const;
33
+
34
+ export function PluginConfigForm({
35
+ pluginId,
36
+ configSchema,
37
+ configValues,
38
+ onConfigChange,
39
+ binding,
40
+ gameId,
41
+ }: PluginConfigFormProps) {
42
+ const pluginCtx = usePluginContext();
43
+ const { toast } = useToast();
44
+ const configSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
45
+ const pendingConfigRef = useRef<Record<string, unknown>>({});
46
+ // 记录首次同步的配置快照,用于过滤"初始化赋值"误触保存
47
+ const initialConfigRef = useRef<Record<string, unknown> | null>(null);
48
+
49
+ // 防抖保存配置
50
+ const saveConfig = useCallback(async (config: Record<string, unknown>) => {
51
+ if (configSaveTimerRef.current) clearTimeout(configSaveTimerRef.current);
52
+ pendingConfigRef.current = config;
53
+
54
+ configSaveTimerRef.current = setTimeout(async () => {
55
+ try {
56
+ const enabled = binding?.enabled ?? true;
57
+ const ok = await upsertPluginBinding({
58
+ extensionId: pluginId,
59
+ scope: 'world',
60
+ worldId: gameId,
61
+ enabled,
62
+ config,
63
+ });
64
+ if (!ok) {
65
+ toast('保存配置失败', 'error');
66
+ }
67
+ } catch {
68
+ toast('保存配置失败', 'error');
69
+ }
70
+ }, 500);
71
+ }, [pluginId, gameId, binding?.enabled, toast]);
72
+
73
+ // 包装 onConfigChange,加入运行时更新和持久化
74
+ const handleConfigChange = useCallback((key: string, value: unknown) => {
75
+ onConfigChange(key, value);
76
+ }, [onConfigChange]);
77
+
78
+ // 监听 configValues 变化,触发运行时更新和持久化
79
+ // 使用 ref 跳过首次挂载(首次挂载时 configValues 由父组件初始化,无需保存)
80
+ const isFirstRenderRef = useRef(true);
81
+ useEffect(() => {
82
+ if (isFirstRenderRef.current) {
83
+ isFirstRenderRef.current = false;
84
+ // 首次挂载:仅同步运行时配置,不保存到 DB,不触发 hook
85
+ pluginCtx.updatePluginConfig(pluginId, configValues, { persist: false, broadcast: false, triggerHook: false });
86
+ // 记录快照,后续如果 configValues 变化到与此相同(父组件异步初始化),也跳过
87
+ initialConfigRef.current = configValues;
88
+ return;
89
+ }
90
+ // 父组件异步初始化 configValues 时会触发第二次执行,如果和快照相同则跳过
91
+ if (initialConfigRef.current && JSON.stringify(configValues) === JSON.stringify(initialConfigRef.current)) {
92
+ initialConfigRef.current = null; // 只跳过一次
93
+ return;
94
+ }
95
+ // 后续变更:保存到 DB + 更新运行时
96
+ saveConfig(configValues);
97
+ pluginCtx.updatePluginConfig(pluginId, configValues);
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [configValues]);
100
+
101
+ // 清理定时器
102
+ useEffect(() => {
103
+ return () => {
104
+ if (configSaveTimerRef.current) clearTimeout(configSaveTimerRef.current);
105
+ };
106
+ }, []);
107
+
108
+ return (
109
+ <div className="space-y-2 pt-1">
110
+ {configSchema.map((field) => (
111
+ <div key={field.key}>
112
+ <div className="flex items-center justify-between mb-1">
113
+ <div>
114
+ <span
115
+ className="text-xs font-medium"
116
+ style={{ color: 'var(--color-text-secondary)' }}
117
+ >
118
+ {field.label}
119
+ </span>
120
+ {field.description && (
121
+ <p
122
+ className="mt-0.5"
123
+ style={{ color: 'var(--color-text-muted)', fontSize: '10px' }}
124
+ >
125
+ {field.description}
126
+ </p>
127
+ )}
128
+ </div>
129
+ {field.type === 'boolean' && (
130
+ <button
131
+ type="button"
132
+ onClick={() => handleConfigChange(field.key, !configValues[field.key])}
133
+ className="relative w-9 h-5 rounded-full transition-colors shrink-0"
134
+ style={{
135
+ backgroundColor: configValues[field.key]
136
+ ? 'var(--color-accent)'
137
+ : 'var(--color-bg-primary)',
138
+ border: `1px solid ${configValues[field.key] ? 'var(--color-accent)' : 'var(--color-border)'}`,
139
+ }}
140
+ >
141
+ <div
142
+ className="absolute top-0.5 w-3.5 h-3.5 rounded-full transition-all"
143
+ style={{
144
+ backgroundColor: configValues[field.key] ? '#fff' : 'var(--color-text-muted)',
145
+ left: configValues[field.key] ? '18px' : '2px',
146
+ }}
147
+ />
148
+ </button>
149
+ )}
150
+ </div>
151
+ {field.type !== 'boolean' && (
152
+ field.type === 'select' && field.options ? (
153
+ <select
154
+ value={String(configValues[field.key] ?? field.defaultValue)}
155
+ onChange={(e) => handleConfigChange(field.key, e.target.value)}
156
+ className={INPUT_BASE}
157
+ style={INPUT_STYLE}
158
+ >
159
+ {field.options.map((opt) => (
160
+ <option key={opt.value} value={opt.value}>
161
+ {opt.label}
162
+ </option>
163
+ ))}
164
+ </select>
165
+ ) : field.type === 'textarea' ? (
166
+ <textarea
167
+ value={String(configValues[field.key] ?? field.defaultValue)}
168
+ onChange={(e) => handleConfigChange(field.key, e.target.value)}
169
+ placeholder={field.description}
170
+ rows={3}
171
+ className={`${INPUT_BASE} resize-vertical`}
172
+ style={INPUT_STYLE}
173
+ />
174
+ ) : (
175
+ <input
176
+ type={field.type === 'number' ? 'number' : 'text'}
177
+ value={String(configValues[field.key] ?? field.defaultValue)}
178
+ onChange={(e) => handleConfigChange(
179
+ field.key,
180
+ field.type === 'number' ? Number(e.target.value) : e.target.value,
181
+ )}
182
+ placeholder={field.description}
183
+ className={INPUT_BASE}
184
+ style={INPUT_STYLE}
185
+ />
186
+ )
187
+ )}
188
+ </div>
189
+ ))}
190
+ </div>
191
+ );
192
+ }
@@ -0,0 +1,52 @@
1
+ // components/ui/PluginFloatingLayer.tsx - 浮动层容器组件
2
+ // 渲染插件在 floating 插槽中注册的所有内容
3
+ 'use client';
4
+
5
+ import React, { useCallback } from 'react';
6
+ import { SlotRegistration } from '@/lib/plugin-types';
7
+ import { getPluginRuntime } from '@/lib/plugin-runtime';
8
+
9
+ interface PluginFloatingLayerProps {
10
+ registrations: SlotRegistration[];
11
+ }
12
+
13
+ export function PluginFloatingLayer({ registrations }: PluginFloatingLayerProps) {
14
+ // 事件委托:处理 data-action 属性的点击事件
15
+ const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
16
+ const target = e.target as HTMLElement;
17
+ const actionEl = target.closest<HTMLElement>('[data-action]');
18
+ if (!actionEl) return;
19
+
20
+ const actionName = actionEl.getAttribute('data-action');
21
+ const pluginId = actionEl.closest<HTMLElement>('[data-plugin-id]')?.getAttribute('data-plugin-id');
22
+ const payload = actionEl.getAttribute('data-payload') || undefined;
23
+
24
+ if (actionName && pluginId) {
25
+ e.preventDefault();
26
+ e.stopPropagation();
27
+ const runtime = getPluginRuntime();
28
+ runtime.dispatchAction(pluginId, actionName, actionEl, payload);
29
+ }
30
+ }, []);
31
+
32
+ // 始终渲染容器(即使没有注册内容),确保 getContainerElement('floating') 能找到它
33
+ return (
34
+ <div
35
+ id="xinyu-floating"
36
+ data-plugin-slot="floating"
37
+ className="fixed inset-0 pointer-events-none z-50"
38
+ style={{ overflow: 'hidden' }}
39
+ onClick={handleClick}
40
+ >
41
+ {registrations.map((reg) => (
42
+ <div
43
+ key={reg.id}
44
+ data-plugin-registration-id={reg.id}
45
+ data-plugin-id={reg.pluginId}
46
+ className="pointer-events-auto"
47
+ dangerouslySetInnerHTML={{ __html: reg.content }}
48
+ />
49
+ ))}
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,53 @@
1
+ // components/ui/PluginIcon.tsx - 插件图标组件(支持 emoji 和资源文件图片)
2
+
3
+ 'use client';
4
+
5
+ import React, { useState } from 'react';
6
+
7
+ /**
8
+ * 判断图标值是否为资源文件路径(非 emoji)
9
+ */
10
+ function isResourcePath(icon: string): boolean {
11
+ if (!icon || icon.startsWith('http')) return false;
12
+ return /\.\w{2,5}$/.test(icon);
13
+ }
14
+
15
+ interface PluginIconProps {
16
+ /** 图标值:emoji 字符串或资源文件路径(如 icon.png) */
17
+ icon?: string;
18
+ /** 插件 ID,用于构建资源文件 URL */
19
+ pluginId?: string;
20
+ /** 默认图标(当 icon 为空时显示) */
21
+ fallback?: string;
22
+ /** CSS 类名 */
23
+ className?: string;
24
+ /** 内联样式 */
25
+ style?: React.CSSProperties;
26
+ /** 图片尺寸(px),默认 24 */
27
+ size?: number;
28
+ }
29
+
30
+ export function PluginIcon({ icon, pluginId, fallback = '🧩', className, style, size = 24 }: PluginIconProps) {
31
+ const [imgError, setImgError] = useState(false);
32
+ const displayIcon = icon || fallback;
33
+
34
+ if (isResourcePath(displayIcon) && pluginId && !imgError) {
35
+ return (
36
+ <img
37
+ src={`/api/plugins/resources?pluginId=${encodeURIComponent(pluginId)}&path=${encodeURIComponent(displayIcon)}`}
38
+ alt=""
39
+ width={size}
40
+ height={size}
41
+ className={className}
42
+ style={{ objectFit: 'contain', ...style }}
43
+ onError={() => setImgError(true)}
44
+ />
45
+ );
46
+ }
47
+
48
+ return (
49
+ <span className={className} style={style} role="img">
50
+ {imgError ? fallback : displayIcon}
51
+ </span>
52
+ );
53
+ }