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
package/lib/db-init.ts ADDED
@@ -0,0 +1,35 @@
1
+ // lib/db-init.ts - 数据库初始化(服务器启动时执行一次)
2
+
3
+ import { initDb } from './db';
4
+
5
+ // 使用 globalThis 确保跨模块、跨请求、跨 HMR 的真正全局单例
6
+ // 即使 Next.js 开发模式热替换重置了模块作用域,globalThis 上的值也不会丢失
7
+ const INIT_KEY = '__xinyu_db_init__';
8
+
9
+ /**
10
+ * 确保数据库已初始化。
11
+ * 使用 globalThis + Promise 守卫:整个 Node.js 进程生命周期内只执行一次初始化。
12
+ * 无论被多少个模块、多少个请求调用,都返回同一个 Promise。
13
+ */
14
+ export function ensureDb(): Promise<void> {
15
+ // @ts-expect-error -- globalThis 上的自定义 key
16
+ if (globalThis[INIT_KEY]) return globalThis[INIT_KEY];
17
+
18
+ console.log('正在初始化数据库...');
19
+
20
+ // @ts-expect-error -- globalThis 上的自定义 key
21
+ globalThis[INIT_KEY] = initDb()
22
+ .then(() => {
23
+ console.log('数据库初始化完成');
24
+ })
25
+ .catch((e: unknown) => {
26
+ console.error('数据库初始化失败:', e);
27
+ // 初始化失败时清除,允许下次重试
28
+ // @ts-expect-error -- globalThis 上的自定义 key
29
+ delete globalThis[INIT_KEY];
30
+ throw e;
31
+ });
32
+
33
+ // @ts-expect-error -- globalThis 上的自定义 key
34
+ return globalThis[INIT_KEY];
35
+ }
package/lib/db.ts ADDED
@@ -0,0 +1,244 @@
1
+ // lib/db.ts - MySQL 数据库连接工具
2
+
3
+ import mysql from 'mysql2/promise';
4
+
5
+ let pool: mysql.Pool | null = null;
6
+
7
+ export function getDbPool(): mysql.Pool {
8
+ if (!pool) {
9
+ pool = mysql.createPool({
10
+ host: process.env.DB_HOST || 'localhost',
11
+ port: parseInt(process.env.DB_PORT || '3306'),
12
+ user: process.env.DB_USER || 'root',
13
+ password: process.env.DB_PASSWORD || '',
14
+ database: process.env.DB_NAME || 'xinyu',
15
+ waitForConnections: true,
16
+ connectionLimit: 20,
17
+ queueLimit: 100,
18
+ idleTimeout: 60000,
19
+ enableKeepAlive: true,
20
+ keepAliveInitialDelay: 30000,
21
+ });
22
+ }
23
+ return pool;
24
+ }
25
+
26
+ /**
27
+ * 初始化数据库表结构(首次启动时调用)
28
+ */
29
+ export async function initDb(): Promise<void> {
30
+ const db = getDbPool();
31
+ await db.execute(`
32
+ CREATE TABLE IF NOT EXISTS app_settings (
33
+ \`key\` VARCHAR(100) PRIMARY KEY,
34
+ \`value\` JSON NOT NULL,
35
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
36
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
37
+ `);
38
+
39
+ // 游戏会话表
40
+ await db.execute(`
41
+ CREATE TABLE IF NOT EXISTS game_sessions (
42
+ id VARCHAR(100) PRIMARY KEY,
43
+ world_setting JSON NOT NULL,
44
+ messages JSON NOT NULL,
45
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
46
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
47
+ INDEX idx_updated_at (updated_at)
48
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
49
+ `);
50
+
51
+ // 世界卡片模板表
52
+ await db.execute(`
53
+ CREATE TABLE IF NOT EXISTS world_templates (
54
+ id VARCHAR(100) PRIMARY KEY,
55
+ name VARCHAR(200) NOT NULL,
56
+ svg_content LONGTEXT,
57
+ fields JSON NOT NULL,
58
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
59
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
60
+ INDEX idx_updated_at (updated_at)
61
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
62
+ `);
63
+
64
+ // 插件定义表
65
+ await db.execute(`
66
+ CREATE TABLE IF NOT EXISTS extensions (
67
+ id VARCHAR(200) PRIMARY KEY,
68
+ name VARCHAR(200) NOT NULL,
69
+ version VARCHAR(50) NOT NULL,
70
+ description TEXT,
71
+ author VARCHAR(100),
72
+ type VARCHAR(50) NOT NULL,
73
+ icon VARCHAR(50),
74
+ min_app_version VARCHAR(50),
75
+ code_path VARCHAR(500) NOT NULL DEFAULT '',
76
+ config_schema JSON,
77
+ ui_slots JSON,
78
+ ui_permissions JSON,
79
+ public_exports JSON,
80
+ dependencies JSON,
81
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
82
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
83
+ INDEX idx_type (type),
84
+ INDEX idx_updated_at (updated_at)
85
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
86
+ `);
87
+
88
+ // 插件绑定表(控制启用/禁用和作用域)
89
+ await db.execute(`
90
+ CREATE TABLE IF NOT EXISTS extension_bindings (
91
+ id INT AUTO_INCREMENT PRIMARY KEY,
92
+ extension_id VARCHAR(200) NOT NULL,
93
+ scope ENUM('global', 'world') NOT NULL DEFAULT 'global',
94
+ world_id VARCHAR(100) NOT NULL DEFAULT '',
95
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
96
+ config JSON NOT NULL,
97
+ sort_order INT NOT NULL DEFAULT 0,
98
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
99
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
100
+ UNIQUE KEY uk_ext_scope_world (extension_id, scope, world_id),
101
+ INDEX idx_world_id (world_id),
102
+ FOREIGN KEY (extension_id) REFERENCES extensions(id) ON DELETE CASCADE
103
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
104
+ `);
105
+
106
+ // ========== 数据迁移 ==========
107
+
108
+ // 迁移 1: 修复 extension_bindings 的 world_id 列(NULL -> '')
109
+ // 如果表已存在且 world_id 仍允许 NULL,需要修改列定义
110
+ try {
111
+ await db.execute(`
112
+ ALTER TABLE extension_bindings
113
+ MODIFY COLUMN world_id VARCHAR(100) NOT NULL DEFAULT ''
114
+ `);
115
+ } catch {
116
+ // 列已经是 NOT NULL,忽略错误
117
+ }
118
+
119
+ // 迁移 2: 清理 world_id = NULL 的旧数据,改为空字符串
120
+ try {
121
+ await db.execute(`
122
+ UPDATE extension_bindings SET world_id = '' WHERE world_id IS NULL
123
+ `);
124
+ } catch {
125
+ // 忽略
126
+ }
127
+
128
+ // 迁移 3: 删除 extension_bindings 中的重复行,保留每个 (extension_id, scope) 组合中最新的一条
129
+ try {
130
+ // 先查出需要保留的 id(每组中 id 最大的)
131
+ const [rows] = await db.execute(
132
+ `SELECT MAX(id) AS keep_id FROM extension_bindings GROUP BY extension_id, scope`
133
+ ) as [Array<{ keep_id: number }>, unknown];
134
+
135
+ if (rows.length > 0) {
136
+ const keepIds = rows.map((r) => r.keep_id);
137
+ const placeholders = keepIds.map(() => '?').join(',');
138
+
139
+ // 删除不在保留列表中的行
140
+ await db.execute(
141
+ `DELETE FROM extension_bindings WHERE id NOT IN (${placeholders})`,
142
+ keepIds as number[]
143
+ );
144
+ }
145
+ } catch (e) {
146
+ console.error('清理重复绑定记录失败:', e);
147
+ }
148
+
149
+ // 迁移 4: 为 extensions 表新增 public_exports 和 dependencies 列
150
+ try {
151
+ await db.execute(`
152
+ ALTER TABLE extensions
153
+ ADD COLUMN public_exports JSON AFTER ui_slots
154
+ `);
155
+ } catch {
156
+ // 列已存在,忽略错误
157
+ }
158
+ try {
159
+ await db.execute(`
160
+ ALTER TABLE extensions
161
+ ADD COLUMN dependencies JSON AFTER public_exports
162
+ `);
163
+ } catch {
164
+ // 列已存在,忽略错误
165
+ }
166
+
167
+ // 迁移 5: 为 world_templates 表新增 plugins 列
168
+ try {
169
+ await db.execute(`
170
+ ALTER TABLE world_templates
171
+ ADD COLUMN plugins JSON AFTER fields
172
+ `);
173
+ } catch {
174
+ // 列已存在,忽略错误
175
+ }
176
+
177
+ // 迁移 6: 为 extensions 表新增 ui_permissions 列
178
+ try {
179
+ await db.execute(`
180
+ ALTER TABLE extensions
181
+ ADD COLUMN ui_permissions JSON AFTER ui_slots
182
+ `);
183
+ } catch {
184
+ // 列已存在,忽略错误
185
+ }
186
+
187
+ // 迁移 7: 新建 plugin_storage 表(插件持久化 key-value 存储)
188
+ try {
189
+ await db.execute(`
190
+ CREATE TABLE IF NOT EXISTS plugin_storage (
191
+ id INT AUTO_INCREMENT PRIMARY KEY,
192
+ plugin_id VARCHAR(100) NOT NULL,
193
+ storage_key VARCHAR(200) NOT NULL,
194
+ storage_value JSON,
195
+ world_id VARCHAR(100) DEFAULT '',
196
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
197
+ UNIQUE KEY uk_plugin_key_world (plugin_id, storage_key, world_id),
198
+ INDEX idx_plugin_id (plugin_id)
199
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
200
+ `);
201
+ } catch {
202
+ // 表已存在,忽略错误
203
+ }
204
+
205
+ // 迁移 8: 将 extensions.code (LONGTEXT) 迁移到文件存储 + code_path (VARCHAR)
206
+ try {
207
+ const { promises: fs } = await import('fs');
208
+ const path = await import('path');
209
+ const codeRoot = path.join(process.cwd(), 'data', 'plugins');
210
+
211
+ // 检查 code 列是否仍存在
212
+ const [cols] = await db.execute(
213
+ `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'extensions' AND COLUMN_NAME = 'code'`
214
+ ) as [Array<{ COLUMN_NAME: string }>, unknown];
215
+
216
+ if (cols.length > 0) {
217
+ // 确保目录存在
218
+ await fs.mkdir(codeRoot, { recursive: true });
219
+
220
+ // 读取所有有 code 的插件
221
+ const [rows] = await db.execute(
222
+ `SELECT id, code FROM extensions WHERE code IS NOT NULL AND code != ''`
223
+ ) as [Array<{ id: string; code: string }>, unknown];
224
+
225
+ for (const row of rows) {
226
+ const pluginDir = path.join(codeRoot, row.id);
227
+ await fs.mkdir(pluginDir, { recursive: true });
228
+ const filePath = path.join(pluginDir, 'plugin.js');
229
+ await fs.writeFile(filePath, row.code, 'utf-8');
230
+ const relativePath = `data/plugins/${row.id}/plugin.js`;
231
+ await db.execute(
232
+ `UPDATE extensions SET code_path = ? WHERE id = ?`,
233
+ [relativePath, row.id]
234
+ );
235
+ }
236
+
237
+ // 迁移完成后删除 code 列
238
+ await db.execute(`ALTER TABLE extensions DROP COLUMN code`);
239
+ console.log(`[迁移 8] 已将 ${rows.length} 个插件的 code 迁移到文件存储`);
240
+ }
241
+ } catch (e) {
242
+ console.error('迁移 8 (code → code_path) 失败:', e);
243
+ }
244
+ }
@@ -0,0 +1,185 @@
1
+ // lib/manifest-parser.ts - 从代码注释中解析 manifest 信息
2
+
3
+ import { PluginType, PluginConfigField, UIPermission, UI_PERMISSION_LABELS, PLUGIN_TYPE_LABELS } from './plugin-types';
4
+
5
+ export interface ParsedManifest {
6
+ id?: string;
7
+ name?: string;
8
+ version?: string;
9
+ author?: string;
10
+ icon?: string;
11
+ type?: PluginType;
12
+ description?: string;
13
+ permissions?: UIPermission[];
14
+ exclusivePermissions?: UIPermission[];
15
+ requiredPermissions?: UIPermission[];
16
+ configSchema?: PluginConfigField[];
17
+ }
18
+
19
+ /** 配置项类型的合法值 */
20
+ const VALID_CONFIG_TYPES: PluginConfigField['type'][] = ['text', 'number', 'boolean', 'select', 'textarea'];
21
+
22
+ /**
23
+ * 从插件代码注释中解析 manifest 信息
24
+ * 支持格式:
25
+ * // @plugin-id author.plugin-name
26
+ * // @name 插件名称
27
+ * // @version 1.0.0
28
+ * // @author 作者
29
+ * // @icon 🎮
30
+ * // @type game-mechanics
31
+ * // @description 插件描述
32
+ * // @permission slot:status-bar
33
+ * // @permission style:inject, modal:confirm
34
+ *
35
+ * 配置项定义(JSON 格式):
36
+ * // @config {"key":"enableHighlight","label":"启用高亮","type":"boolean","defaultValue":true,"description":"是否启用代码高亮"}
37
+ * // @config {"key":"maxDepth","label":"最大深度","type":"number","defaultValue":3}
38
+ * // @config {"key":"theme","label":"主题","type":"select","defaultValue":"default","options":[{"label":"默认边框","value":"default"},{"label":"淡边框","value":"subtle"}]}
39
+ * // @configs [{"key":"enabled","label":"启用","type":"boolean","defaultValue":true},{"key":"maxRetry","label":"最大重试","type":"number","defaultValue":3}]
40
+ */
41
+ export function parseManifestFromCode(code: string): ParsedManifest {
42
+ const result: ParsedManifest = {};
43
+
44
+ // 按行拆分,用于支持多行 JSON(@config/@configs)
45
+ const lines = code.split('\n');
46
+
47
+ // 匹配 // @key value 格式的注释(value 支持到行尾)
48
+ // 注意:@plugin-id 包含连字符,需要额外匹配
49
+ const pattern = /\/\/\s*@(\w[\w-]*)\s*(.*)/g;
50
+ let match: RegExpExecArray | null;
51
+
52
+ while ((match = pattern.exec(code)) !== null) {
53
+ const key = match[1].toLowerCase();
54
+ let value = match[2].trim();
55
+
56
+ // 对 config/configs,收集后续以 // 开头的注释行,拼接为完整 JSON
57
+ if (key === 'config' || key === 'configs') {
58
+ const currentLineIdx = code.substring(0, match.index).split('\n').length - 1;
59
+ // 从下一行开始,收集连续的 // 注释行
60
+ const jsonLines: string[] = [value];
61
+ for (let i = currentLineIdx + 1; i < lines.length; i++) {
62
+ const line = lines[i].trim();
63
+ if (line.startsWith('//') && !/^\/\/\s*@/.test(line)) {
64
+ jsonLines.push(line.replace(/^\/\/\s*/, ''));
65
+ } else {
66
+ break;
67
+ }
68
+ }
69
+ value = jsonLines.join('\n').trim();
70
+ }
71
+
72
+ switch (key) {
73
+ case 'plugin-id':
74
+ result.id = value.trim();
75
+ break;
76
+ case 'name':
77
+ result.name = value;
78
+ break;
79
+ case 'version':
80
+ result.version = value;
81
+ break;
82
+ case 'author':
83
+ result.author = value;
84
+ break;
85
+ case 'icon':
86
+ result.icon = value.trim();
87
+ break;
88
+ case 'type': {
89
+ const validTypes = Object.keys(PLUGIN_TYPE_LABELS) as PluginType[];
90
+ if (validTypes.includes(value as PluginType)) {
91
+ result.type = value as PluginType;
92
+ }
93
+ break;
94
+ }
95
+ case 'description':
96
+ result.description = value;
97
+ break;
98
+ case 'permission': {
99
+ if (!result.permissions) result.permissions = [];
100
+ value.split(/[,,\s]+/).forEach(p => {
101
+ const trimmed = p.trim() as UIPermission;
102
+ if (trimmed && UI_PERMISSION_LABELS[trimmed] && !(result.permissions || []).includes(trimmed)) {
103
+ (result.permissions || (result.permissions = [])).push(trimmed);
104
+ }
105
+ });
106
+ break;
107
+ }
108
+ case 'exclusive': {
109
+ if (!result.exclusivePermissions) result.exclusivePermissions = [];
110
+ value.split(/[,,\s]+/).forEach(p => {
111
+ const trimmed = p.trim() as UIPermission;
112
+ if (trimmed && UI_PERMISSION_LABELS[trimmed] && !result.exclusivePermissions.includes(trimmed)) {
113
+ result.exclusivePermissions.push(trimmed);
114
+ }
115
+ });
116
+ break;
117
+ }
118
+ case 'required': {
119
+ if (!result.requiredPermissions) result.requiredPermissions = [];
120
+ value.split(/[,,\s]+/).forEach(p => {
121
+ const trimmed = p.trim() as UIPermission;
122
+ if (trimmed && UI_PERMISSION_LABELS[trimmed] && !result.requiredPermissions.includes(trimmed)) {
123
+ result.requiredPermissions.push(trimmed);
124
+ }
125
+ });
126
+ break;
127
+ }
128
+ case 'config': {
129
+ // @config {单个配置项 JSON}
130
+ const field = parseConfigField(value);
131
+ if (field) {
132
+ if (!result.configSchema) result.configSchema = [];
133
+ result.configSchema.push(field);
134
+ }
135
+ break;
136
+ }
137
+ case 'configs': {
138
+ // @configs [{配置项数组 JSON}]
139
+ const fields = parseConfigFields(value);
140
+ if (fields.length > 0) {
141
+ if (!result.configSchema) result.configSchema = [];
142
+ result.configSchema.push(...fields);
143
+ }
144
+ break;
145
+ }
146
+ }
147
+ }
148
+
149
+ return result;
150
+ }
151
+
152
+ /** 解析单个 @config JSON 为 PluginConfigField */
153
+ function parseConfigField(jsonStr: string): PluginConfigField | null {
154
+ try {
155
+ const raw = JSON.parse(jsonStr);
156
+ if (!raw.key || !raw.label || !raw.type) return null;
157
+ if (!VALID_CONFIG_TYPES.includes(raw.type)) return null;
158
+
159
+ const field: PluginConfigField = {
160
+ key: raw.key,
161
+ label: raw.label,
162
+ type: raw.type,
163
+ defaultValue: raw.defaultValue ?? '',
164
+ description: raw.description || undefined,
165
+ options: Array.isArray(raw.options) ? raw.options.map((o: { label: string; value: string }) => ({
166
+ label: String(o.label || ''),
167
+ value: String(o.value || ''),
168
+ })) : undefined,
169
+ };
170
+ return field;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ /** 解析 @configs JSON 数组为 PluginConfigField[] */
177
+ function parseConfigFields(jsonStr: string): PluginConfigField[] {
178
+ try {
179
+ const arr = JSON.parse(jsonStr);
180
+ if (!Array.isArray(arr)) return [];
181
+ return arr.map((item: unknown) => parseConfigField(JSON.stringify(item))).filter((f: PluginConfigField | null): f is PluginConfigField => f !== null);
182
+ } catch {
183
+ return [];
184
+ }
185
+ }
@@ -0,0 +1,110 @@
1
+ // lib/parseWorldCard.ts - SVG 世界卡片解析工具
2
+
3
+ import { WorldSetting, WorldCardField } from './types';
4
+
5
+ /**
6
+ * 从 SVG 文本中提取世界卡片数据(原始 JSON)
7
+ */
8
+ export function parseWorldCardRaw(svgText: string): Record<string, unknown> {
9
+ const scriptRegex = /<script[^>]*type=["']application\/json["'][^>]*id=["']world-data["'][^>]*>([\s\S]*?)<\/script>/i;
10
+ const match = svgText.match(scriptRegex);
11
+
12
+ if (!match || !match[1]) {
13
+ throw new Error('未在世界卡片中找到有效的世界设定数据。');
14
+ }
15
+
16
+ try {
17
+ return JSON.parse(match[1].trim());
18
+ } catch {
19
+ throw new Error('世界卡片中的 JSON 数据格式无效。');
20
+ }
21
+ }
22
+
23
+ /**
24
+ * 从 SVG 文本中提取世界设定(向后兼容,返回原始数据)
25
+ */
26
+ export function parseWorldCard(svgText: string): WorldSetting {
27
+ return parseWorldCardRaw(svgText);
28
+ }
29
+
30
+ /**
31
+ * 从 File 对象读取并解析世界卡片(向后兼容)
32
+ */
33
+ export async function parseWorldCardFromFile(file: File): Promise<WorldSetting> {
34
+ return new Promise((resolve, reject) => {
35
+ const reader = new FileReader();
36
+ reader.onload = (e) => {
37
+ try {
38
+ const text = e.target?.result as string;
39
+ resolve(parseWorldCard(text));
40
+ } catch (err) {
41
+ reject(err);
42
+ }
43
+ };
44
+ reader.onerror = () => reject(new Error('文件读取失败'));
45
+ reader.readAsText(file);
46
+ });
47
+ }
48
+
49
+ /**
50
+ * 将动态字段数据转换为 WorldSetting(通用转换,保留所有字段 + 元数据)
51
+ * @param fields 完整字段列表(WorldCardField[])
52
+ */
53
+ export function fieldsToWorldSetting(fields: WorldCardField[]): WorldSetting {
54
+ const map: Record<string, unknown> = {};
55
+ for (const f of fields) {
56
+ map[f.key] = f.value;
57
+ }
58
+ // 注入字段元数据(label、type、options 等),用于恢复原始字段渲染
59
+ const fieldMeta: Record<string, Record<string, unknown>> = {};
60
+ for (const f of fields) {
61
+ const meta: Record<string, unknown> = { label: f.label, type: f.type };
62
+ if (f.placeholder) meta.placeholder = f.placeholder;
63
+ if (f.required) meta.required = true;
64
+ if (f.editableBeforeGame !== undefined) meta.editableBeforeGame = f.editableBeforeGame;
65
+ if (f.options) meta.options = f.options;
66
+ if (f.allowCustomOption) meta.allowCustomOption = true;
67
+ if (f.min !== undefined) meta.min = f.min;
68
+ if (f.max !== undefined) meta.max = f.max;
69
+ if (f.step !== undefined) meta.step = f.step;
70
+ fieldMeta[f.key] = meta;
71
+ }
72
+ if (Object.keys(fieldMeta).length > 0) map['_fieldMeta'] = fieldMeta;
73
+ // 注入字段顺序,用于恢复原始字段排列
74
+ if (fields.length > 0) map['_fieldOrder'] = fields.map(f => f.key);
75
+ return map;
76
+ }
77
+
78
+ /**
79
+ * 将字段列表转为包含元数据的完整世界数据对象(用于 SVG 注入)
80
+ * @param fields 字段列表
81
+ * @param plugins 可选的插件 ID 列表,会被注入到 world-data 中
82
+ */
83
+ export function fieldsToWorldData(fields: WorldCardField[], plugins?: string[]): Record<string, unknown> {
84
+ // 复用 fieldsToWorldSetting 的逻辑(已包含 _fieldMeta)
85
+ const data = fieldsToWorldSetting(fields);
86
+ // 注入插件列表
87
+ if (plugins && plugins.length > 0) {
88
+ data['_plugins'] = plugins;
89
+ }
90
+ return data;
91
+ }
92
+
93
+ /**
94
+ * 将 SVG 代码注入 world-data script 标签(替换已有的或新增)
95
+ */
96
+ export function injectWorldData(svgCode: string, worldData: Record<string, unknown>): string {
97
+ const json = JSON.stringify(worldData, null, 2);
98
+ const scriptTag = `<script type="application/json" id="world-data">\n${json}\n</script>`;
99
+
100
+ // 移除已有的 world-data script
101
+ let cleaned = svgCode.replace(/<script[^>]*id="world-data"[^>]*>[\s\S]*?<\/script>/gi, '').trim();
102
+
103
+ // 在 </svg> 前注入
104
+ if (cleaned.endsWith('</svg>')) {
105
+ cleaned = cleaned.slice(0, -6) + '\n' + scriptTag + '\n</svg>';
106
+ } else {
107
+ cleaned += '\n' + scriptTag + '\n</svg>';
108
+ }
109
+ return cleaned;
110
+ }