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,1466 @@
|
|
|
1
|
+
/* eslint-disable react-hooks/exhaustive-deps */
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import React, { useState, useCallback, useEffect, useMemo, useRef, Suspense } from 'react';
|
|
5
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
6
|
+
import { useRouterHistory } from '@/lib/router-history';
|
|
7
|
+
import { WorldCardField, WorldCardTemplate, FieldType } from '@/lib/types';
|
|
8
|
+
import { getAppSettings, saveWorldTemplate, getWorldTemplate, saveGameSession, StoredSession, getPlugins } from '@/lib/storage';
|
|
9
|
+
import { fieldsToWorldSetting, fieldsToWorldData, injectWorldData } from '@/lib/parseWorldCard';
|
|
10
|
+
import { useToast } from '@/components/ui/ToastProvider';
|
|
11
|
+
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
12
|
+
import PageHeader from '@/components/ui/PageHeader';
|
|
13
|
+
import { PluginIcon } from '@/components/ui/PluginIcon';
|
|
14
|
+
import {fail} from "node:assert";
|
|
15
|
+
|
|
16
|
+
// ==================== 预设生成规则 ====================
|
|
17
|
+
|
|
18
|
+
const PRESET_RULES = [
|
|
19
|
+
{ id: 'fantasy', name: '奇幻史诗', rules: '使用深紫/深蓝背景配金色装饰线条,标题用大号衬线字体,内容区用古典排版,角落添加魔法阵或符文装饰。' },
|
|
20
|
+
{ id: 'cyber', name: '赛博朋克', rules: '使用纯黑背景配荧光绿/霓虹色强调,添加扫描线纹理效果,使用等宽字体,角落用几何线条装饰,添加故障艺术元素。' },
|
|
21
|
+
{ id: 'classical', name: '古典历史', rules: '使用暖黄/米色纸张质感背景,棕色/暗红色装饰边框和花纹,角落添加传统云纹或回纹装饰,使用衬线字体。' },
|
|
22
|
+
{ id: 'horror', name: '恐怖悬疑', rules: '使用深红/暗紫/黑色配色,锐利锯齿线条装饰,不对称布局,添加裂纹或血迹效果,文字使用扭曲感字体。' },
|
|
23
|
+
{ id: 'lighthearted', name: '轻松日常', rules: '使用明亮柔和的配色(浅蓝、浅粉、浅绿),圆润的边角和装饰元素,添加小图标或emoji装饰,整体风格清新可爱。' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// ==================== 默认字段 ====================
|
|
27
|
+
|
|
28
|
+
const DEFAULT_FIELDS: WorldCardField[] = [];
|
|
29
|
+
|
|
30
|
+
// ==================== 工具函数 ====================
|
|
31
|
+
|
|
32
|
+
function generateFieldId(): string {
|
|
33
|
+
return 'f' + Date.now().toString(36) + Math.random().toString(36).substring(2, 6);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function generateTemplateId(): string {
|
|
37
|
+
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const TYPE_LABELS: Record<FieldType, string> = {
|
|
41
|
+
text: '文本', textarea: '多行', select: '选择', checkbox: '勾选', slider: '滑块',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ==================== 可拖拽字段列表组件 ====================
|
|
45
|
+
|
|
46
|
+
/** 可拖拽调整高度的分割条 */
|
|
47
|
+
function ResizeHandle({ onResize }: { onResize: (deltaY: number) => void }) {
|
|
48
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
let lastY = e.clientY;
|
|
51
|
+
const onMouseMove = (ev: MouseEvent) => {
|
|
52
|
+
const deltaY = ev.clientY - lastY;
|
|
53
|
+
lastY = ev.clientY;
|
|
54
|
+
onResize(deltaY);
|
|
55
|
+
};
|
|
56
|
+
const onMouseUp = () => {
|
|
57
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
58
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
59
|
+
document.body.style.cursor = '';
|
|
60
|
+
document.body.style.userSelect = '';
|
|
61
|
+
};
|
|
62
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
63
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
64
|
+
document.body.style.cursor = 'row-resize';
|
|
65
|
+
document.body.style.userSelect = 'none';
|
|
66
|
+
}, [onResize]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
onMouseDown={handleMouseDown}
|
|
71
|
+
className="shrink-0 cursor-row-resize group relative"
|
|
72
|
+
style={{ height: '5px', backgroundColor: 'transparent' }}
|
|
73
|
+
>
|
|
74
|
+
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-0.5 rounded-full transition-colors"
|
|
75
|
+
style={{ backgroundColor: 'var(--color-border)' }} />
|
|
76
|
+
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
|
77
|
+
style={{ backgroundColor: 'var(--color-accent)' }} />
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function DraggableFieldList({
|
|
83
|
+
fields,
|
|
84
|
+
selectedId,
|
|
85
|
+
onSelect,
|
|
86
|
+
onReorder,
|
|
87
|
+
onRemove,
|
|
88
|
+
readonly = false,
|
|
89
|
+
}: {
|
|
90
|
+
fields: WorldCardField[];
|
|
91
|
+
selectedId: string | null;
|
|
92
|
+
onSelect: (id: string) => void;
|
|
93
|
+
onReorder: (fromIndex: number, toIndex: number) => void;
|
|
94
|
+
onRemove: (id: string) => void;
|
|
95
|
+
readonly?: boolean;
|
|
96
|
+
}) {
|
|
97
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
98
|
+
const [dropIndicator, setDropIndicator] = useState<{ index: number; position: 'before' | 'after' } | null>(null);
|
|
99
|
+
const dragItemRef = useRef<number | null>(null);
|
|
100
|
+
|
|
101
|
+
const handleDragStart = (index: number) => {
|
|
102
|
+
setDragIndex(index);
|
|
103
|
+
dragItemRef.current = index;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
if (dragItemRef.current === null || dragItemRef.current === index) return;
|
|
109
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
110
|
+
const midY = rect.top + rect.height / 2;
|
|
111
|
+
const position = e.clientY < midY ? 'before' : 'after';
|
|
112
|
+
setDropIndicator({ index, position });
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleDragLeave = () => {
|
|
116
|
+
setDropIndicator(null);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleDrop = (e: React.DragEvent, index: number) => {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
if (dragItemRef.current === null || dragItemRef.current === index) {
|
|
122
|
+
setDragIndex(null);
|
|
123
|
+
setDropIndicator(null);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const from = dragItemRef.current;
|
|
127
|
+
let to = index;
|
|
128
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
129
|
+
const midY = rect.top + rect.height / 2;
|
|
130
|
+
if (e.clientY > midY && from < to) {
|
|
131
|
+
// 不变
|
|
132
|
+
} else if (e.clientY > midY && from > to) {
|
|
133
|
+
to = index + 1;
|
|
134
|
+
} else if (e.clientY <= midY && from < to) {
|
|
135
|
+
to = index - 1;
|
|
136
|
+
}
|
|
137
|
+
if (from !== to) {
|
|
138
|
+
onReorder(from, to);
|
|
139
|
+
}
|
|
140
|
+
setDragIndex(null);
|
|
141
|
+
setDropIndicator(null);
|
|
142
|
+
dragItemRef.current = null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const handleDragEnd = () => {
|
|
146
|
+
setDragIndex(null);
|
|
147
|
+
setDropIndicator(null);
|
|
148
|
+
dragItemRef.current = null;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className="space-y-0.5">
|
|
153
|
+
{fields.map((field, index) => {
|
|
154
|
+
const isDragging = dragIndex === index;
|
|
155
|
+
const showTopBorder = dropIndicator?.index === index && dropIndicator.position === 'before' && dragIndex !== index;
|
|
156
|
+
const showBottomBorder = dropIndicator?.index === index && dropIndicator.position === 'after' && dragIndex !== index;
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
key={field.id}
|
|
161
|
+
draggable={!readonly}
|
|
162
|
+
onDragStart={() => !readonly && handleDragStart(index)}
|
|
163
|
+
onDragOver={(e) => !readonly && handleDragOver(e, index)}
|
|
164
|
+
onDragLeave={() => !readonly && handleDragLeave()}
|
|
165
|
+
onDrop={(e) => !readonly && handleDrop(e, index)}
|
|
166
|
+
onDragEnd={() => !readonly && handleDragEnd()}
|
|
167
|
+
className={`relative flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors group ${
|
|
168
|
+
isDragging ? 'opacity-40' : ''
|
|
169
|
+
}`}
|
|
170
|
+
style={{
|
|
171
|
+
backgroundColor: selectedId === field.id ? 'var(--color-bg-tertiary)' : 'transparent',
|
|
172
|
+
borderTop: showTopBorder ? '2px solid var(--color-accent)' : undefined,
|
|
173
|
+
borderBottom: showBottomBorder ? '2px solid var(--color-accent)' : undefined,
|
|
174
|
+
marginBottom: showBottomBorder ? '-1px' : undefined,
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
{/* 拖拽手柄 */}
|
|
178
|
+
{!readonly && (
|
|
179
|
+
<i className="fa-solid fa-grip-vertical shrink-0 cursor-grab active:cursor-grabbing"
|
|
180
|
+
style={{ color: 'var(--color-text-muted)', opacity: 0.4, fontSize: '12px' }} />
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{/* 选中按钮 - 替代原来带 onClick 的 div */}
|
|
184
|
+
<button
|
|
185
|
+
onClick={() => onSelect(field.id)}
|
|
186
|
+
className="flex-1 flex items-center gap-1.5 text-left min-w-0"
|
|
187
|
+
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
|
188
|
+
>
|
|
189
|
+
<span className="flex-1 text-sm truncate" style={{ color: 'var(--color-text-primary)' }}>
|
|
190
|
+
{field.label}
|
|
191
|
+
</span>
|
|
192
|
+
|
|
193
|
+
{/* 类型标签 */}
|
|
194
|
+
<span className="text-xs px-1.5 py-0.5 rounded shrink-0" style={{
|
|
195
|
+
backgroundColor: 'var(--color-bg-tertiary)',
|
|
196
|
+
color: 'var(--color-text-muted)',
|
|
197
|
+
fontSize: '10px',
|
|
198
|
+
}}>
|
|
199
|
+
{TYPE_LABELS[field.type]}
|
|
200
|
+
</span>
|
|
201
|
+
|
|
202
|
+
{/* 编辑权限图标 */}
|
|
203
|
+
<span className="shrink-0 flex items-center justify-center" style={{ width: '16px', height: '16px', color: field.editableBeforeGame ? '#22c55e' : 'var(--color-text-muted)', fontSize: '12px' }} title={field.editableBeforeGame ? '可编辑' : '锁定'}>
|
|
204
|
+
<i className={field.editableBeforeGame ? 'fa-solid fa-pen' : 'fa-solid fa-lock'} />
|
|
205
|
+
</span>
|
|
206
|
+
|
|
207
|
+
{/* 必填标记 */}
|
|
208
|
+
{readonly ? (
|
|
209
|
+
<span className="shrink-0 flex items-center justify-center" style={{ width: '16px', height: '16px', fontSize: '10px', color: field.required ? '#ef4444' : 'transparent' }}>*</span>
|
|
210
|
+
) : field.required ? (
|
|
211
|
+
<span className="shrink-0 flex items-center justify-center" style={{ width: '16px', height: '16px', fontSize: '10px', color: '#ef4444' }}>*</span>
|
|
212
|
+
) : null}
|
|
213
|
+
</button>
|
|
214
|
+
|
|
215
|
+
{/* 删除按钮(readonly 模式下不显示) */}
|
|
216
|
+
{!readonly && !field.required && (
|
|
217
|
+
<button
|
|
218
|
+
onClick={(e) => { e.stopPropagation(); onRemove(field.id); }}
|
|
219
|
+
className="opacity-0 group-hover:opacity-100 shrink-0 flex items-center justify-center rounded transition-opacity"
|
|
220
|
+
style={{ width: '16px', height: '16px', color: 'var(--color-text-muted)', fontSize: '12px' }}
|
|
221
|
+
title="删除"
|
|
222
|
+
>
|
|
223
|
+
<i className="fa-solid fa-xmark" />
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
})}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ==================== 列宽调整分隔条 ====================
|
|
234
|
+
|
|
235
|
+
function Resizer({ onResize }: { onResize: (delta: number) => void }) {
|
|
236
|
+
const isResizing = useRef(false);
|
|
237
|
+
const startX = useRef(0);
|
|
238
|
+
|
|
239
|
+
const handleMouseDown = (e: React.MouseEvent) => {
|
|
240
|
+
isResizing.current = true;
|
|
241
|
+
startX.current = e.clientX;
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
|
|
244
|
+
const handleMouseMove = (ev: MouseEvent) => {
|
|
245
|
+
if (!isResizing.current) return;
|
|
246
|
+
const delta = ev.clientX - startX.current;
|
|
247
|
+
startX.current = ev.clientX;
|
|
248
|
+
onResize(delta);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const handleMouseUp = () => {
|
|
252
|
+
isResizing.current = false;
|
|
253
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
254
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
255
|
+
document.body.style.cursor = '';
|
|
256
|
+
document.body.style.userSelect = '';
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
260
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
261
|
+
document.body.style.cursor = 'col-resize';
|
|
262
|
+
document.body.style.userSelect = 'none';
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div
|
|
267
|
+
onMouseDown={handleMouseDown}
|
|
268
|
+
className="w-1 shrink-0 cursor-col-resize hover:bg-opacity-50 transition-colors"
|
|
269
|
+
style={{ backgroundColor: 'var(--color-border)' }}
|
|
270
|
+
title="拖拽调整列宽"
|
|
271
|
+
/>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ==================== SVG 预览组件(自适应 + 缩放) ====================
|
|
276
|
+
|
|
277
|
+
const SVG_VIEWBOX_W = 400;
|
|
278
|
+
const SVG_VIEWBOX_H = 560;
|
|
279
|
+
|
|
280
|
+
function SvgPreview({ svgCode, scale, containerRef }: {
|
|
281
|
+
svgCode: string;
|
|
282
|
+
scale: number;
|
|
283
|
+
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
284
|
+
}) {
|
|
285
|
+
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
286
|
+
|
|
287
|
+
// 根据容器尺寸计算 SVG 显示大小(默认高度 = 容器高度,宽度按比例)
|
|
288
|
+
React.useEffect(() => {
|
|
289
|
+
const update = () => {
|
|
290
|
+
const el = containerRef.current;
|
|
291
|
+
if (!el) return;
|
|
292
|
+
const rect = el.getBoundingClientRect();
|
|
293
|
+
// 减去 padding (12px * 2) 和边距
|
|
294
|
+
const availH = rect.height - 24;
|
|
295
|
+
const availW = rect.width - 24;
|
|
296
|
+
// 默认:高度填满,宽度按比例
|
|
297
|
+
const fitH = availH;
|
|
298
|
+
const fitW = fitH * (SVG_VIEWBOX_W / SVG_VIEWBOX_H);
|
|
299
|
+
// 如果宽度超出容器,则宽度填满,高度按比例
|
|
300
|
+
if (fitW > availW) {
|
|
301
|
+
const w = availW;
|
|
302
|
+
const h = w * (SVG_VIEWBOX_H / SVG_VIEWBOX_W);
|
|
303
|
+
setSize({ w, h });
|
|
304
|
+
} else {
|
|
305
|
+
setSize({ w: fitW, h: fitH });
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
update();
|
|
309
|
+
const ro = new ResizeObserver(update);
|
|
310
|
+
if (containerRef.current) ro.observe(containerRef.current);
|
|
311
|
+
return () => ro.disconnect();
|
|
312
|
+
}, [containerRef]);
|
|
313
|
+
|
|
314
|
+
const finalW = size.w * scale;
|
|
315
|
+
const finalH = size.h * scale;
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<div className="rounded-lg border shadow-lg overflow-hidden shrink-0" style={{
|
|
319
|
+
borderColor: 'var(--color-border)',
|
|
320
|
+
boxShadow: '0 4px 16px var(--color-shadow)',
|
|
321
|
+
backgroundColor: 'var(--color-bg-secondary)',
|
|
322
|
+
width: `${finalW}px`,
|
|
323
|
+
height: `${finalH}px`,
|
|
324
|
+
transition: 'width 0.2s ease, height 0.2s ease',
|
|
325
|
+
}}>
|
|
326
|
+
<iframe
|
|
327
|
+
srcDoc={svgCode}
|
|
328
|
+
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
|
329
|
+
title="世界卡片预览"
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ==================== 自动扩展高度 Textarea ====================
|
|
336
|
+
|
|
337
|
+
function AutoResizeTextarea({
|
|
338
|
+
value,
|
|
339
|
+
onChange,
|
|
340
|
+
placeholder,
|
|
341
|
+
disabled,
|
|
342
|
+
style,
|
|
343
|
+
onFocus,
|
|
344
|
+
onBlur,
|
|
345
|
+
}: {
|
|
346
|
+
value: string;
|
|
347
|
+
onChange: (val: string) => void;
|
|
348
|
+
placeholder?: string;
|
|
349
|
+
disabled?: boolean;
|
|
350
|
+
style?: React.CSSProperties;
|
|
351
|
+
onFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
|
352
|
+
onBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
|
353
|
+
}) {
|
|
354
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
355
|
+
const maxHeightRef = useRef(400); // 默认最大高度
|
|
356
|
+
|
|
357
|
+
// 计算最大高度:父容器可用空间
|
|
358
|
+
React.useEffect(() => {
|
|
359
|
+
const el = ref.current;
|
|
360
|
+
if (!el) return;
|
|
361
|
+
const updateMax = () => {
|
|
362
|
+
const parent = el.parentElement;
|
|
363
|
+
if (parent) {
|
|
364
|
+
// 获取父容器在视口中的位置,计算到底部的剩余空间
|
|
365
|
+
const parentRect = parent.getBoundingClientRect();
|
|
366
|
+
const viewportHeight = window.innerHeight;
|
|
367
|
+
const elTop = el.getBoundingClientRect().top - parentRect.top;
|
|
368
|
+
const available = viewportHeight - parentRect.top - elTop - 16; // 16px 底部留白
|
|
369
|
+
maxHeightRef.current = Math.max(120, available);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
updateMax();
|
|
373
|
+
window.addEventListener('resize', updateMax);
|
|
374
|
+
return () => window.removeEventListener('resize', updateMax);
|
|
375
|
+
}, []);
|
|
376
|
+
|
|
377
|
+
// 自动调整高度
|
|
378
|
+
React.useEffect(() => {
|
|
379
|
+
const el = ref.current;
|
|
380
|
+
if (!el) return;
|
|
381
|
+
el.style.height = 'auto';
|
|
382
|
+
const scrollH = el.scrollHeight;
|
|
383
|
+
el.style.height = `${Math.min(scrollH, maxHeightRef.current)}px`;
|
|
384
|
+
}, [value]);
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<textarea
|
|
388
|
+
ref={ref}
|
|
389
|
+
value={value}
|
|
390
|
+
onChange={(e) => onChange(e.target.value)}
|
|
391
|
+
placeholder={placeholder}
|
|
392
|
+
rows={1}
|
|
393
|
+
disabled={disabled}
|
|
394
|
+
onFocus={onFocus}
|
|
395
|
+
onBlur={onBlur}
|
|
396
|
+
className="w-full px-3 py-1.5 rounded-lg text-sm border outline-none resize-y transition-colors"
|
|
397
|
+
style={{ ...style, minHeight: '40px', maxHeight: `${maxHeightRef.current}px`, overflow: 'hidden' }}
|
|
398
|
+
/>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ==================== 字段值输入组件 ====================
|
|
403
|
+
|
|
404
|
+
function FieldValueInput({ field, onChange, readonly = false, showCustomInput = true }: {
|
|
405
|
+
field: WorldCardField;
|
|
406
|
+
onChange: (value: string | string[] | boolean | number) => void;
|
|
407
|
+
readonly?: boolean;
|
|
408
|
+
showCustomInput?: boolean;
|
|
409
|
+
}) {
|
|
410
|
+
const s: React.CSSProperties = {
|
|
411
|
+
backgroundColor: 'var(--color-bg-tertiary)',
|
|
412
|
+
borderColor: 'var(--color-border)',
|
|
413
|
+
color: 'var(--color-text-primary)',
|
|
414
|
+
opacity: readonly ? 0.6 : 1,
|
|
415
|
+
};
|
|
416
|
+
const focus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { if (!readonly) e.currentTarget.style.borderColor = 'var(--color-accent)'; };
|
|
417
|
+
const blur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { e.currentTarget.style.borderColor = 'var(--color-border)'; };
|
|
418
|
+
|
|
419
|
+
switch (field.type) {
|
|
420
|
+
case 'textarea':
|
|
421
|
+
return <AutoResizeTextarea value={String(field.value || '')} onChange={(val) => onChange(val)}
|
|
422
|
+
placeholder={field.placeholder} disabled={readonly}
|
|
423
|
+
style={s} onFocus={focus} onBlur={blur} />;
|
|
424
|
+
case 'select': {
|
|
425
|
+
const opts = field.options || [];
|
|
426
|
+
const cur = String(field.value || '');
|
|
427
|
+
const isCustom = field.allowCustomOption && !opts.some(o => o.value === cur) && cur;
|
|
428
|
+
return (
|
|
429
|
+
<div className="space-y-1">
|
|
430
|
+
<select value={isCustom ? '__custom__' : cur}
|
|
431
|
+
onChange={(e) => { if (e.target.value !== '__custom__') onChange(e.target.value); }}
|
|
432
|
+
className="w-full px-3 py-1.5 rounded-lg text-sm border outline-none transition-colors"
|
|
433
|
+
style={s} onFocus={focus} onBlur={blur} disabled={readonly}>
|
|
434
|
+
<option value="">请选择...</option>
|
|
435
|
+
{opts.map((o, i) => <option key={i} value={o.value}>{o.label}</option>)}
|
|
436
|
+
{isCustom && <option value="__custom__">{cur} (自定义)</option>}
|
|
437
|
+
</select>
|
|
438
|
+
{field.allowCustomOption && showCustomInput && (
|
|
439
|
+
<input value={isCustom ? cur : ''} onChange={(e) => onChange(e.target.value)}
|
|
440
|
+
placeholder="输入自定义选项..."
|
|
441
|
+
className="w-full px-3 py-1 rounded-lg text-xs border outline-none transition-colors"
|
|
442
|
+
style={s} onFocus={focus} onBlur={blur} disabled={readonly} />
|
|
443
|
+
)}
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
case 'checkbox':
|
|
448
|
+
return (
|
|
449
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
450
|
+
<input type="checkbox" checked={Boolean(field.value)} onChange={(e) => onChange(e.target.checked)} className="rounded" disabled={readonly} />
|
|
451
|
+
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>{field.value ? '是' : '否'}</span>
|
|
452
|
+
</label>
|
|
453
|
+
);
|
|
454
|
+
case 'slider':
|
|
455
|
+
return (
|
|
456
|
+
<div className="space-y-1">
|
|
457
|
+
<input type="range" min={field.min ?? 0} max={field.max ?? 100} step={field.step ?? 1}
|
|
458
|
+
value={Number(field.value ?? 50)} onChange={(e) => onChange(Number(e.target.value))}
|
|
459
|
+
className="w-full" style={{ accentColor: 'var(--color-accent)' }} disabled={readonly} />
|
|
460
|
+
<div className="text-xs text-center" style={{ color: 'var(--color-text-muted)' }}>{String(field.value ?? 50)}</div>
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
default:
|
|
464
|
+
return <input value={String(field.value || '')} onChange={(e) => onChange(e.target.value)}
|
|
465
|
+
placeholder={field.placeholder}
|
|
466
|
+
className="w-full px-3 py-1.5 rounded-lg text-sm border outline-none transition-colors"
|
|
467
|
+
style={s} onFocus={focus} onBlur={blur} disabled={readonly} />;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ==================== 字段详情编辑面板 ====================
|
|
472
|
+
|
|
473
|
+
function FieldDetailPanel({ field, onUpdate, readonly = false }: {
|
|
474
|
+
field: WorldCardField;
|
|
475
|
+
onUpdate: (updates: Partial<WorldCardField>) => void;
|
|
476
|
+
readonly?: boolean;
|
|
477
|
+
}) {
|
|
478
|
+
const inputCls = "w-full px-2 py-1 rounded text-xs border outline-none transition-colors";
|
|
479
|
+
const inputS: React.CSSProperties = {
|
|
480
|
+
backgroundColor: 'var(--color-bg-tertiary)',
|
|
481
|
+
borderColor: 'var(--color-border)',
|
|
482
|
+
color: 'var(--color-text-primary)',
|
|
483
|
+
opacity: readonly ? 0.6 : 1,
|
|
484
|
+
};
|
|
485
|
+
const focus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { if (!readonly) e.currentTarget.style.borderColor = 'var(--color-accent)'; };
|
|
486
|
+
const blur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { e.currentTarget.style.borderColor = 'var(--color-border)'; };
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<div className="p-3 space-y-3 overflow-y-auto h-full">
|
|
490
|
+
<h3 className="text-sm font-bold" style={{ color: 'var(--color-text-primary)' }}>
|
|
491
|
+
{field.label}
|
|
492
|
+
</h3>
|
|
493
|
+
|
|
494
|
+
{/* 键名 + 显示名 */}
|
|
495
|
+
<div className="grid grid-cols-2 gap-2">
|
|
496
|
+
<div>
|
|
497
|
+
<label className="text-xs mb-0.5 block" style={{ color: 'var(--color-text-muted)' }}>键名</label>
|
|
498
|
+
<input value={field.key} onChange={(e) => onUpdate({ key: e.target.value })}
|
|
499
|
+
className={inputCls} style={inputS} onFocus={focus} onBlur={blur} disabled={readonly} />
|
|
500
|
+
</div>
|
|
501
|
+
<div>
|
|
502
|
+
<label className="text-xs mb-0.5 block" style={{ color: 'var(--color-text-muted)' }}>显示名</label>
|
|
503
|
+
<input value={field.label} onChange={(e) => onUpdate({ label: e.target.value })}
|
|
504
|
+
className={inputCls} style={inputS} onFocus={focus} onBlur={blur} disabled={readonly} />
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
|
|
508
|
+
{/* 类型 + 占位文本(编辑模式隐藏占位文本) */}
|
|
509
|
+
<div className={readonly ? '' : 'grid grid-cols-2 gap-2'}>
|
|
510
|
+
<div>
|
|
511
|
+
<label className="text-xs mb-0.5 block" style={{ color: 'var(--color-text-muted)' }}>类型</label>
|
|
512
|
+
<select value={field.type}
|
|
513
|
+
onChange={(e) => {
|
|
514
|
+
const t = e.target.value as FieldType;
|
|
515
|
+
const u: Partial<WorldCardField> = { type: t };
|
|
516
|
+
if (t === 'checkbox') u.value = false;
|
|
517
|
+
else if (t === 'slider') u.value = field.min ?? 50;
|
|
518
|
+
else if (t === 'select') { u.value = ''; u.options = field.options || []; u.allowCustomOption = field.allowCustomOption || false; }
|
|
519
|
+
else u.value = '';
|
|
520
|
+
if (t === 'slider' && field.min === undefined) { u.min = 0; u.max = 100; u.step = 1; }
|
|
521
|
+
if (t !== 'select') { u.options = undefined; u.allowCustomOption = undefined; }
|
|
522
|
+
if (t !== 'slider') { u.min = undefined; u.max = undefined; u.step = undefined; }
|
|
523
|
+
onUpdate(u);
|
|
524
|
+
}}
|
|
525
|
+
className={inputCls} style={inputS} disabled={readonly}>
|
|
526
|
+
<option value="text">文本</option>
|
|
527
|
+
<option value="textarea">多行文本</option>
|
|
528
|
+
<option value="select">下拉选择</option>
|
|
529
|
+
<option value="checkbox">复选框</option>
|
|
530
|
+
<option value="slider">滑块</option>
|
|
531
|
+
</select>
|
|
532
|
+
</div>
|
|
533
|
+
{!readonly && (
|
|
534
|
+
<div>
|
|
535
|
+
<label className="text-xs mb-0.5 block" style={{ color: 'var(--color-text-muted)' }}>占位提示</label>
|
|
536
|
+
<input value={field.placeholder || ''} onChange={(e) => onUpdate({ placeholder: e.target.value })}
|
|
537
|
+
className={inputCls} style={inputS} onFocus={focus} onBlur={blur} />
|
|
538
|
+
</div>
|
|
539
|
+
)}
|
|
540
|
+
</div>
|
|
541
|
+
|
|
542
|
+
{/* 开关(编辑模式隐藏) */}
|
|
543
|
+
{!readonly && (
|
|
544
|
+
<div className="flex items-center gap-4">
|
|
545
|
+
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: 'var(--color-text-secondary)' }}>
|
|
546
|
+
<input type="checkbox" checked={field.required || false}
|
|
547
|
+
onChange={(e) => onUpdate({ required: e.target.checked })} className="rounded" />
|
|
548
|
+
必填
|
|
549
|
+
</label>
|
|
550
|
+
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: 'var(--color-text-secondary)' }}>
|
|
551
|
+
<input type="checkbox" checked={field.editableBeforeGame ?? true}
|
|
552
|
+
onChange={(e) => onUpdate({ editableBeforeGame: e.target.checked })} className="rounded" />
|
|
553
|
+
游戏前可编辑
|
|
554
|
+
</label>
|
|
555
|
+
</div>
|
|
556
|
+
)}
|
|
557
|
+
|
|
558
|
+
{/* 默认值 / 编辑值 */}
|
|
559
|
+
<div>
|
|
560
|
+
<label className="text-xs mb-0.5 block" style={{ color: 'var(--color-text-muted)' }}>
|
|
561
|
+
{readonly ? '编辑值' : '默认值'}
|
|
562
|
+
</label>
|
|
563
|
+
<FieldValueInput field={field} onChange={(value) => onUpdate({ value })} readonly={readonly && !field.editableBeforeGame} showCustomInput={readonly} />
|
|
564
|
+
</div>
|
|
565
|
+
|
|
566
|
+
{/* select 选项管理(编辑模式隐藏) */}
|
|
567
|
+
{field.type === 'select' && !readonly && (
|
|
568
|
+
<div>
|
|
569
|
+
<div className="flex items-center justify-between mb-1">
|
|
570
|
+
<label className="text-xs" style={{ color: 'var(--color-text-muted)' }}>选项列表</label>
|
|
571
|
+
<label className="flex items-center gap-1 text-xs cursor-pointer" style={{ color: 'var(--color-text-secondary)' }}>
|
|
572
|
+
<input type="checkbox" checked={field.allowCustomOption || false}
|
|
573
|
+
onChange={(e) => onUpdate({ allowCustomOption: e.target.checked })} className="rounded" disabled={readonly} />
|
|
574
|
+
允许自定义
|
|
575
|
+
</label>
|
|
576
|
+
</div>
|
|
577
|
+
<div className="space-y-1">
|
|
578
|
+
{(field.options || []).map((opt, i) => (
|
|
579
|
+
<div key={i} className="flex items-center gap-1">
|
|
580
|
+
<input value={opt.label}
|
|
581
|
+
onChange={(e) => {
|
|
582
|
+
const n = [...(field.options || [])]; n[i] = { label: e.target.value, value: e.target.value };
|
|
583
|
+
onUpdate({ options: n });
|
|
584
|
+
}}
|
|
585
|
+
className="flex-1 px-2 py-0.5 rounded text-xs border outline-none" style={inputS} disabled={readonly} />
|
|
586
|
+
{!readonly && (
|
|
587
|
+
<button onClick={() => onUpdate({ options: (field.options || []).filter((_, idx) => idx !== i) })}
|
|
588
|
+
className="p-0.5 rounded shrink-0" style={{ color: 'var(--color-text-muted)', fontSize: '10px' }}>
|
|
589
|
+
<i className="fa-solid fa-xmark" />
|
|
590
|
+
</button>
|
|
591
|
+
)}
|
|
592
|
+
</div>
|
|
593
|
+
))}
|
|
594
|
+
{!readonly && (
|
|
595
|
+
<button onClick={() => onUpdate({ options: [...(field.options || []), { label: '', value: '' }] })}
|
|
596
|
+
className="text-xs px-2 py-0.5 rounded border transition-colors"
|
|
597
|
+
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-muted)' }}>+ 添加选项</button>
|
|
598
|
+
)}
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
)}
|
|
602
|
+
|
|
603
|
+
{/* slider 参数(编辑模式隐藏) */}
|
|
604
|
+
{field.type === 'slider' && !readonly && (
|
|
605
|
+
<div className="grid grid-cols-3 gap-2">
|
|
606
|
+
{([
|
|
607
|
+
{ k: 'min' as const, label: '最小值' },
|
|
608
|
+
{ k: 'max' as const, label: '最大值' },
|
|
609
|
+
{ k: 'step' as const, label: '步长' },
|
|
610
|
+
]).map(({ k, label }) => (
|
|
611
|
+
<div key={k}>
|
|
612
|
+
<label className="text-xs mb-0.5 block" style={{ color: 'var(--color-text-muted)' }}>{label}</label>
|
|
613
|
+
<input type="number" value={field[k] ?? (k === 'min' ? 0 : k === 'max' ? 100 : 1)}
|
|
614
|
+
onChange={(e) => onUpdate({ [k]: Number(e.target.value) })}
|
|
615
|
+
className="w-full px-2 py-0.5 rounded text-xs border outline-none" style={inputS} disabled={readonly} />
|
|
616
|
+
</div>
|
|
617
|
+
))}
|
|
618
|
+
</div>
|
|
619
|
+
)}
|
|
620
|
+
</div>
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ==================== 编辑器页面 ====================
|
|
625
|
+
|
|
626
|
+
function EditorPageContent() {
|
|
627
|
+
const router = useRouter();
|
|
628
|
+
const { navigate, back } = useRouterHistory();
|
|
629
|
+
const searchParams = useSearchParams();
|
|
630
|
+
|
|
631
|
+
const mode = searchParams.get('mode') || 'create';
|
|
632
|
+
const templateId = searchParams.get('templateId') || '';
|
|
633
|
+
|
|
634
|
+
// ---- 状态 ----
|
|
635
|
+
const [templateName, setTemplateName] = useState('');
|
|
636
|
+
const [fields, setFields] = useState<WorldCardField[]>([]);
|
|
637
|
+
const [svgCode, setSvgCode] = useState('');
|
|
638
|
+
const [activeTab, setActiveTab] = useState<'preview' | 'code'>('preview');
|
|
639
|
+
const [isGenerating, setIsGenerating] = useState(false);
|
|
640
|
+
const [svgScale, setSvgScale] = useState(1);
|
|
641
|
+
const previewContainerRef = useRef<HTMLDivElement>(null);
|
|
642
|
+
const importInputRef = useRef<HTMLInputElement>(null);
|
|
643
|
+
const col1Ref = useRef<HTMLDivElement>(null);
|
|
644
|
+
const { toast } = useToast();
|
|
645
|
+
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
|
646
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
647
|
+
const [confirmConfig, setConfirmConfig] = useState<{
|
|
648
|
+
title: string;
|
|
649
|
+
message: string;
|
|
650
|
+
danger?: boolean;
|
|
651
|
+
onConfirm: () => void;
|
|
652
|
+
confirmText?: string;
|
|
653
|
+
cancelText?: string;
|
|
654
|
+
ignoreText?: string;
|
|
655
|
+
onIgnore?: () => void;
|
|
656
|
+
}>({ title: '', message: '', onConfirm: () => {} });
|
|
657
|
+
const [showAddFieldPanel, setShowAddFieldPanel] = useState(false);
|
|
658
|
+
const [isSuggesting, setIsSuggesting] = useState(false);
|
|
659
|
+
const [selectedRuleId, setSelectedRuleId] = useState<string>('');
|
|
660
|
+
const [customRules, setCustomRules] = useState('');
|
|
661
|
+
const [selectedPlugins, setSelectedPlugins] = useState<string[]>([]);
|
|
662
|
+
const [availablePlugins, setAvailablePlugins] = useState<Array<{id: string; name: string; type: string; icon?: string}>>([]);
|
|
663
|
+
const [pluginsExpanded, setPluginsExpanded] = useState(false);
|
|
664
|
+
const [fieldsExpanded, setFieldsExpanded] = useState(true);
|
|
665
|
+
const [fieldsPanelHeight, setFieldsPanelHeight] = useState<number | null>(null); // null = 自适应
|
|
666
|
+
const [pluginSearchText, setPluginSearchText] = useState('');
|
|
667
|
+
|
|
668
|
+
// 列宽
|
|
669
|
+
const [col1Width, setCol1Width] = useState(220);
|
|
670
|
+
const [col2Width, setCol2Width] = useState(300);
|
|
671
|
+
|
|
672
|
+
// ---- 初始化 ----
|
|
673
|
+
useEffect(() => {
|
|
674
|
+
if (mode === 'create') {
|
|
675
|
+
setFields(DEFAULT_FIELDS.map(f => ({ ...f })));
|
|
676
|
+
setTemplateName('');
|
|
677
|
+
setSelectedFieldId(DEFAULT_FIELDS[0]?.id || null);
|
|
678
|
+
} else if (templateId) {
|
|
679
|
+
getWorldTemplate(templateId).then(tpl => {
|
|
680
|
+
if (tpl) {
|
|
681
|
+
setTemplateName(tpl.name);
|
|
682
|
+
setFields(tpl.fields.map(f => ({ ...f })));
|
|
683
|
+
setSvgCode(tpl.svgContent || '');
|
|
684
|
+
setSelectedFieldId(tpl.fields[0]?.id || null);
|
|
685
|
+
setSelectedPlugins(tpl.plugins || []);
|
|
686
|
+
} else {
|
|
687
|
+
toast('未找到模板数据', 'error');
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}, [mode, templateId]);
|
|
692
|
+
|
|
693
|
+
// ---- 加载可用插件列表 ----
|
|
694
|
+
useEffect(() => {
|
|
695
|
+
getPlugins().then(plugins => {
|
|
696
|
+
setAvailablePlugins(plugins.map(p => ({ id: p.id, name: p.name, type: p.type, icon: p.icon })));
|
|
697
|
+
});
|
|
698
|
+
}, []);
|
|
699
|
+
|
|
700
|
+
// ---- 字段操作 ----
|
|
701
|
+
const updateField = useCallback((id: string, updates: Partial<WorldCardField>) => {
|
|
702
|
+
setFields(prev => prev.map(f => f.id === id ? { ...f, ...updates } : f));
|
|
703
|
+
}, []);
|
|
704
|
+
|
|
705
|
+
const addField = useCallback((type: FieldType) => {
|
|
706
|
+
const newField: WorldCardField = {
|
|
707
|
+
id: generateFieldId(),
|
|
708
|
+
key: `custom_${Date.now().toString(36)}`,
|
|
709
|
+
label: `自定义字段 ${fields.length + 1}`,
|
|
710
|
+
type,
|
|
711
|
+
value: type === 'checkbox' ? false : type === 'slider' ? 50 : '',
|
|
712
|
+
editableBeforeGame: true,
|
|
713
|
+
...(type === 'select' ? { options: [], allowCustomOption: false } : {}),
|
|
714
|
+
...(type === 'slider' ? { min: 0, max: 100, step: 1 } : {}),
|
|
715
|
+
};
|
|
716
|
+
setFields(prev => [...prev, newField]);
|
|
717
|
+
setSelectedFieldId(newField.id);
|
|
718
|
+
setShowAddFieldPanel(false);
|
|
719
|
+
}, [fields.length]);
|
|
720
|
+
|
|
721
|
+
const removeField = useCallback((id: string) => {
|
|
722
|
+
setFields(prev => {
|
|
723
|
+
const next = prev.filter(f => f.id !== id);
|
|
724
|
+
if (selectedFieldId === id) {
|
|
725
|
+
setSelectedFieldId(next[0]?.id || null);
|
|
726
|
+
}
|
|
727
|
+
return next;
|
|
728
|
+
});
|
|
729
|
+
}, [selectedFieldId]);
|
|
730
|
+
|
|
731
|
+
const reorderField = useCallback((fromIndex: number, toIndex: number) => {
|
|
732
|
+
setFields(prev => {
|
|
733
|
+
const arr = [...prev];
|
|
734
|
+
const [moved] = arr.splice(fromIndex, 1);
|
|
735
|
+
arr.splice(toIndex < fromIndex ? toIndex : toIndex, 0, moved);
|
|
736
|
+
return arr;
|
|
737
|
+
});
|
|
738
|
+
}, []);
|
|
739
|
+
|
|
740
|
+
// ---- 生成规则 ----
|
|
741
|
+
const getActiveRules = useCallback(() => {
|
|
742
|
+
if (selectedRuleId) {
|
|
743
|
+
const preset = PRESET_RULES.find(r => r.id === selectedRuleId);
|
|
744
|
+
if (preset) return preset.rules;
|
|
745
|
+
}
|
|
746
|
+
return customRules.trim();
|
|
747
|
+
}, [selectedRuleId, customRules]);
|
|
748
|
+
|
|
749
|
+
// ---- AI 智能补全字段 ----
|
|
750
|
+
const handleSuggestFields = useCallback(async () => {
|
|
751
|
+
if (fields.length === 0) { toast('请先添加字段', 'warning'); return; }
|
|
752
|
+
setIsSuggesting(true);
|
|
753
|
+
try {
|
|
754
|
+
const appSettings = await getAppSettings();
|
|
755
|
+
const response = await fetch('/api/suggest-fields', {
|
|
756
|
+
method: 'POST',
|
|
757
|
+
headers: { 'Content-Type': 'application/json' },
|
|
758
|
+
body: JSON.stringify({
|
|
759
|
+
templateName,
|
|
760
|
+
existingFields: fields.map(f => ({ key: f.key, label: f.label, type: f.type, value: f.value, placeholder: f.placeholder })),
|
|
761
|
+
apiConfig: { apiKey: appSettings.aiApiKey, apiBase: appSettings.aiApiBase, model: appSettings.aiModel },
|
|
762
|
+
}),
|
|
763
|
+
});
|
|
764
|
+
const data = await response.json();
|
|
765
|
+
if (!response.ok) throw new Error(data.error || '补全失败');
|
|
766
|
+
const updates: Record<string, Partial<WorldCardField>> = data.updates || {};
|
|
767
|
+
const updateKeys = Object.keys(updates);
|
|
768
|
+
if (updateKeys.length === 0) { toast('AI 未生成补全内容', 'warning'); return; }
|
|
769
|
+
// 将 AI 返回的补全内容应用到对应字段
|
|
770
|
+
setFields(prev => prev.map(f => {
|
|
771
|
+
const u = updates[f.key];
|
|
772
|
+
if (!u) return f;
|
|
773
|
+
const updated = { ...f };
|
|
774
|
+
if (u.value !== undefined) updated.value = u.value;
|
|
775
|
+
if (u.placeholder !== undefined) updated.placeholder = u.placeholder;
|
|
776
|
+
if (u.options !== undefined) updated.options = u.options;
|
|
777
|
+
if (u.allowCustomOption !== undefined) updated.allowCustomOption = u.allowCustomOption;
|
|
778
|
+
if (u.min !== undefined) updated.min = u.min;
|
|
779
|
+
if (u.max !== undefined) updated.max = u.max;
|
|
780
|
+
if (u.step !== undefined) updated.step = u.step;
|
|
781
|
+
// select 类型:补全了选项但 value 为空时,自动填充第一个选项
|
|
782
|
+
if (updated.type === 'select' && Array.isArray(updated.options) && updated.options.length > 0) {
|
|
783
|
+
const curVal = String(updated.value || '');
|
|
784
|
+
const hasMatch = updated.options.some(o => o.value === curVal);
|
|
785
|
+
if (!hasMatch) {
|
|
786
|
+
updated.value = updated.options[0].value;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return updated;
|
|
790
|
+
}));
|
|
791
|
+
toast(`已补全 ${updateKeys.length} 个字段`, 'success');
|
|
792
|
+
} catch (err) {
|
|
793
|
+
toast(err instanceof Error ? err.message : '补全失败', 'error');
|
|
794
|
+
} finally {
|
|
795
|
+
setIsSuggesting(false);
|
|
796
|
+
}
|
|
797
|
+
}, [templateName, fields]);
|
|
798
|
+
|
|
799
|
+
// ---- 生成 SVG ----
|
|
800
|
+
const handleGenerate = useCallback(async () => {
|
|
801
|
+
const hasTitle = fields.some(f => f.key === 'title' && String(f.value).trim());
|
|
802
|
+
if (!hasTitle) { toast('请至少填写游戏标题', 'warning'); return; }
|
|
803
|
+
setIsGenerating(true); setActiveTab('preview');
|
|
804
|
+
try {
|
|
805
|
+
const worldData = fieldsToWorldData(fields, selectedPlugins);
|
|
806
|
+
const appSettings = await getAppSettings();
|
|
807
|
+
const response = await fetch('/api/generate-svg', {
|
|
808
|
+
method: 'POST',
|
|
809
|
+
headers: { 'Content-Type': 'application/json' },
|
|
810
|
+
body: JSON.stringify({
|
|
811
|
+
worldData,
|
|
812
|
+
customRules: getActiveRules(),
|
|
813
|
+
apiConfig: { apiKey: appSettings.aiApiKey, apiBase: appSettings.aiApiBase, model: appSettings.aiModel },
|
|
814
|
+
}),
|
|
815
|
+
});
|
|
816
|
+
const data = await response.json();
|
|
817
|
+
if (!response.ok) throw new Error(data.error || '生成失败');
|
|
818
|
+
setSvgCode(data.svg);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
toast(err instanceof Error ? err.message : '生成失败', 'error');
|
|
821
|
+
} finally {
|
|
822
|
+
setIsGenerating(false);
|
|
823
|
+
}
|
|
824
|
+
}, [fields, getActiveRules]);
|
|
825
|
+
|
|
826
|
+
// ---- 保存模板 ----
|
|
827
|
+
const handleSaveTemplate = useCallback(async () => {
|
|
828
|
+
if (!templateName.trim()) { toast('请输入模板名称', 'warning'); return; }
|
|
829
|
+
const id = templateId || generateTemplateId();
|
|
830
|
+
// 保存时注入完整的 world-data
|
|
831
|
+
const finalSvg = svgCode ? injectWorldData(svgCode, fieldsToWorldData(fields, selectedPlugins)) : svgCode;
|
|
832
|
+
let existingTemplate: WorldCardTemplate | undefined;
|
|
833
|
+
if (templateId) {
|
|
834
|
+
existingTemplate = await getWorldTemplate(templateId);
|
|
835
|
+
}
|
|
836
|
+
const template: WorldCardTemplate = {
|
|
837
|
+
id,
|
|
838
|
+
name: templateName.trim(),
|
|
839
|
+
svgContent: finalSvg,
|
|
840
|
+
fields,
|
|
841
|
+
plugins: selectedPlugins,
|
|
842
|
+
createdAt: existingTemplate?.createdAt || new Date().toISOString(),
|
|
843
|
+
updatedAt: new Date().toISOString(),
|
|
844
|
+
};
|
|
845
|
+
await saveWorldTemplate(template);
|
|
846
|
+
toast('模板已保存', 'success');
|
|
847
|
+
if (!templateId) {
|
|
848
|
+
router.replace(`/editor?mode=editTemplate&templateId=${id}`);
|
|
849
|
+
}
|
|
850
|
+
}, [templateName, svgCode, fields, templateId, router, selectedPlugins]);
|
|
851
|
+
|
|
852
|
+
// ---- 开始游戏 ----
|
|
853
|
+
const handleStartGame = useCallback(async ({forceStart = false, recordRouter = true} = {}) => {
|
|
854
|
+
// 检查插件是否准备齐全
|
|
855
|
+
const installedIds = new Set(availablePlugins.map(p => p.id));
|
|
856
|
+
const missingPlugins = selectedPlugins.filter(pid => !installedIds.has(pid));
|
|
857
|
+
|
|
858
|
+
if (missingPlugins.length > 0 && !forceStart) {
|
|
859
|
+
// 有插件缺失,弹窗提示
|
|
860
|
+
const missingList = missingPlugins.map(pid => `• ${pid}`).join('\n');
|
|
861
|
+
setConfirmConfig({
|
|
862
|
+
title: '插件未安装',
|
|
863
|
+
message: `以下 ${missingPlugins.length} 个插件未安装:\n${missingList}\n\n这些插件的功能将不可用,是否继续?`,
|
|
864
|
+
danger: true,
|
|
865
|
+
confirmText: '前往安装',
|
|
866
|
+
cancelText: '取消',
|
|
867
|
+
ignoreText: '忽略并继续',
|
|
868
|
+
onConfirm: () => {
|
|
869
|
+
setConfirmOpen(false);
|
|
870
|
+
navigate('/extensions');
|
|
871
|
+
},
|
|
872
|
+
onIgnore: () => {
|
|
873
|
+
setConfirmOpen(false);
|
|
874
|
+
handleStartGame({recordRouter: true}); // 强制开始
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
setConfirmOpen(true);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// 开始游戏(过滤掉缺失的插件)
|
|
882
|
+
try {
|
|
883
|
+
const validPlugins = forceStart ? selectedPlugins.filter(pid => installedIds.has(pid)) : selectedPlugins;
|
|
884
|
+
const worldSetting = {
|
|
885
|
+
...fieldsToWorldSetting(fields),
|
|
886
|
+
_plugins: validPlugins,
|
|
887
|
+
};
|
|
888
|
+
const gameId = generateTemplateId();
|
|
889
|
+
const session: StoredSession = {
|
|
890
|
+
id: gameId, worldSetting, messages: [],
|
|
891
|
+
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
892
|
+
};
|
|
893
|
+
await saveGameSession(session);
|
|
894
|
+
navigate(`/game/${gameId}`, {record: recordRouter});
|
|
895
|
+
} catch (err) {
|
|
896
|
+
toast(err instanceof Error ? err.message : '创建游戏失败', 'error');
|
|
897
|
+
}
|
|
898
|
+
}, [fields, router, selectedPlugins, availablePlugins, mode, templateId]);
|
|
899
|
+
|
|
900
|
+
// ---- 下载 ----
|
|
901
|
+
const handleDownload = useCallback(() => {
|
|
902
|
+
if (!svgCode) return;
|
|
903
|
+
// 下载时注入完整的 world-data(包含 plugins)
|
|
904
|
+
const finalSvg = injectWorldData(svgCode, fieldsToWorldData(fields, selectedPlugins));
|
|
905
|
+
const blob = new Blob([finalSvg], { type: 'image/svg+xml' });
|
|
906
|
+
const url = URL.createObjectURL(blob);
|
|
907
|
+
const a = document.createElement('a');
|
|
908
|
+
a.href = url; a.download = `${templateName || 'world-card'}.svg`;
|
|
909
|
+
a.click(); URL.revokeObjectURL(url);
|
|
910
|
+
}, [svgCode, templateName, fields, selectedPlugins]);
|
|
911
|
+
|
|
912
|
+
// ---- 导入 SVG 卡片 ----
|
|
913
|
+
const handleImportSvg = useCallback(async (file: File) => {
|
|
914
|
+
try {
|
|
915
|
+
const svgText = await file.text();
|
|
916
|
+
if (!svgText.includes('<svg')) { toast('不是有效的 SVG 文件', 'error'); return; }
|
|
917
|
+
setSvgCode(svgText);
|
|
918
|
+
// 尝试解析 world-data
|
|
919
|
+
const dataMatch = svgText.match(/<script[^>]*id="world-data"[^>]*>([\s\S]*?)<\/script>/i);
|
|
920
|
+
if (dataMatch) {
|
|
921
|
+
try {
|
|
922
|
+
const data = JSON.parse(dataMatch[1]);
|
|
923
|
+
if (data.title) setTemplateName(String(data.title));
|
|
924
|
+
// 解析 _plugins 字段
|
|
925
|
+
if (Array.isArray(data._plugins)) {
|
|
926
|
+
setSelectedPlugins((data._plugins as unknown[]).filter((p): p is string => typeof p === 'string'));
|
|
927
|
+
}
|
|
928
|
+
// 将 data 的 key-value 转为字段(保留已有字段,合并新的,跳过 _ 开头的内部字段)
|
|
929
|
+
const existingKeys = new Set(fields.map(f => f.key));
|
|
930
|
+
const newFields: WorldCardField[] = [];
|
|
931
|
+
for (const [k, v] of Object.entries(data)) {
|
|
932
|
+
if (k.startsWith('_')) continue; // 跳过内部字段
|
|
933
|
+
if (existingKeys.has(k)) continue;
|
|
934
|
+
newFields.push({
|
|
935
|
+
id: `field_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
936
|
+
key: k, label: k, type: 'text',
|
|
937
|
+
value: Array.isArray(v) ? v.join('、') : String(v ?? ''),
|
|
938
|
+
placeholder: '', required: false, editableBeforeGame: true,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
if (newFields.length > 0) setFields(prev => [...prev, ...newFields]);
|
|
942
|
+
toast(`已导入卡片,解析出 ${newFields.length} 个新字段${Array.isArray(data._plugins) ? ',' + data._plugins.length + ' 个插件' : ''}`, 'success');
|
|
943
|
+
} catch {
|
|
944
|
+
setSvgCode(svgText);
|
|
945
|
+
toast('已导入 SVG,但无法解析世界数据', 'warning');
|
|
946
|
+
}
|
|
947
|
+
} else {
|
|
948
|
+
toast('已导入 SVG', 'success');
|
|
949
|
+
}
|
|
950
|
+
} catch {
|
|
951
|
+
toast('导入失败', 'error');
|
|
952
|
+
}
|
|
953
|
+
}, [fields, toast]);
|
|
954
|
+
|
|
955
|
+
const selectedField = useMemo(() => fields.find(f => f.id === selectedFieldId) || null, [fields, selectedFieldId]);
|
|
956
|
+
|
|
957
|
+
const pageTitle = useMemo(() => {
|
|
958
|
+
if (mode === 'create') return '创建世界卡片';
|
|
959
|
+
if (mode === 'edit') return '编辑世界设定';
|
|
960
|
+
return '编辑模板';
|
|
961
|
+
}, [mode]);
|
|
962
|
+
|
|
963
|
+
const isCreateMode = mode === 'create' || mode === 'editTemplate';
|
|
964
|
+
const isEditMode = mode === 'edit';
|
|
965
|
+
|
|
966
|
+
return (
|
|
967
|
+
<>
|
|
968
|
+
<div className="h-screen flex flex-col overflow-hidden" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
|
969
|
+
{/* 顶部导航 */}
|
|
970
|
+
<PageHeader
|
|
971
|
+
title={pageTitle}
|
|
972
|
+
showBack={true}
|
|
973
|
+
onBeforeBack={() => {
|
|
974
|
+
const hasContent = fields.some(f => String(f.value).trim()) || svgCode;
|
|
975
|
+
if (hasContent && !templateId) {
|
|
976
|
+
setConfirmConfig({
|
|
977
|
+
title: '世界卡片未保存',
|
|
978
|
+
message: '当前有未保存的内容,确定要离开吗?',
|
|
979
|
+
onConfirm: () => { setConfirmOpen(false); back(); },
|
|
980
|
+
});
|
|
981
|
+
setConfirmOpen(true);
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
return true;
|
|
985
|
+
}}
|
|
986
|
+
badge={templateName ? (
|
|
987
|
+
<span className="text-xs px-2 py-0.5 rounded-md" style={{
|
|
988
|
+
backgroundColor: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)',
|
|
989
|
+
}}>{templateName}</span>
|
|
990
|
+
) : undefined}
|
|
991
|
+
actions={
|
|
992
|
+
<div className="flex items-center gap-2">
|
|
993
|
+
{isCreateMode && (
|
|
994
|
+
<>
|
|
995
|
+
<input ref={importInputRef} type="file" accept=".svg" className="hidden"
|
|
996
|
+
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleImportSvg(f); e.target.value = ''; }} />
|
|
997
|
+
<button onClick={() => importInputRef.current?.click()}
|
|
998
|
+
className="px-3 py-1 rounded-lg text-xs border transition-colors"
|
|
999
|
+
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
|
1000
|
+
title="导入SVG卡片">
|
|
1001
|
+
导入卡片
|
|
1002
|
+
</button>
|
|
1003
|
+
</>
|
|
1004
|
+
)}
|
|
1005
|
+
{isCreateMode && svgCode && (
|
|
1006
|
+
<>
|
|
1007
|
+
<button onClick={handleSaveTemplate} className="px-3 py-1 rounded-lg text-xs border transition-colors"
|
|
1008
|
+
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}>保存卡片</button>
|
|
1009
|
+
<button onClick={handleDownload} className="px-3 py-1 rounded-lg text-xs border transition-colors"
|
|
1010
|
+
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}>下载卡片</button>
|
|
1011
|
+
<button onClick={() => handleStartGame({recordRouter: false})} className="px-3 py-1 rounded-lg text-xs font-medium transition-colors"
|
|
1012
|
+
style={{ backgroundColor: 'var(--color-accent)', color: '#fff' }}>开始游戏</button>
|
|
1013
|
+
</>
|
|
1014
|
+
)}
|
|
1015
|
+
{isEditMode && (
|
|
1016
|
+
<button onClick={() => handleStartGame({recordRouter: false})} className="px-3 py-1 rounded-lg text-xs font-medium transition-colors"
|
|
1017
|
+
style={{ backgroundColor: 'var(--color-accent)', color: '#fff' }}>开始游戏</button>
|
|
1018
|
+
)}
|
|
1019
|
+
</div>
|
|
1020
|
+
}
|
|
1021
|
+
/>
|
|
1022
|
+
|
|
1023
|
+
{/* 主体 */}
|
|
1024
|
+
<div className="flex flex-1 overflow-hidden">
|
|
1025
|
+
{/* 第一列:字段列表 */}
|
|
1026
|
+
<div ref={col1Ref} className="shrink-0 overflow-y-auto flex flex-col" style={{ width: col1Width, minWidth: 160 }}>
|
|
1027
|
+
{/* 模板名称(创建/编辑模板模式) */}
|
|
1028
|
+
{isCreateMode && (
|
|
1029
|
+
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
|
1030
|
+
<input value={templateName} onChange={(e) => setTemplateName(e.target.value)}
|
|
1031
|
+
placeholder="卡片名称..."
|
|
1032
|
+
className="w-full px-2 py-1 rounded text-xs border outline-none transition-colors"
|
|
1033
|
+
style={{
|
|
1034
|
+
backgroundColor: 'var(--color-bg-tertiary)', borderColor: 'var(--color-border)',
|
|
1035
|
+
color: 'var(--color-text-primary)',
|
|
1036
|
+
}}
|
|
1037
|
+
onFocus={(e) => (e.target.style.borderColor = 'var(--color-accent)')}
|
|
1038
|
+
onBlur={(e) => (e.target.style.borderColor = 'var(--color-border)')}
|
|
1039
|
+
/>
|
|
1040
|
+
</div>
|
|
1041
|
+
)}
|
|
1042
|
+
|
|
1043
|
+
{/* 字段管理(可折叠面板) */}
|
|
1044
|
+
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
|
1045
|
+
<button
|
|
1046
|
+
onClick={() => setFieldsExpanded(!fieldsExpanded)}
|
|
1047
|
+
className="w-full flex items-center justify-between px-3 py-2 transition-colors"
|
|
1048
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
1049
|
+
>
|
|
1050
|
+
<span className="flex items-center gap-1.5 text-xs font-bold">
|
|
1051
|
+
<i className="fa-solid fa-list" style={{ fontSize: '11px' }} />
|
|
1052
|
+
字段
|
|
1053
|
+
<span className="px-1.5 py-0.5 rounded text-xs"
|
|
1054
|
+
style={{ backgroundColor: 'var(--color-bg-tertiary)', fontSize: '10px' }}>
|
|
1055
|
+
{fields.length}
|
|
1056
|
+
</span>
|
|
1057
|
+
</span>
|
|
1058
|
+
<div className="flex items-center gap-1">
|
|
1059
|
+
{!isEditMode && (
|
|
1060
|
+
<div className="flex items-center gap-1">
|
|
1061
|
+
<div className="relative">
|
|
1062
|
+
<button onClick={(e) => { e.stopPropagation(); setShowAddFieldPanel(!showAddFieldPanel); }}
|
|
1063
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs border transition-colors"
|
|
1064
|
+
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-muted)', fontSize: '10px' }}
|
|
1065
|
+
title="添加字段"
|
|
1066
|
+
>
|
|
1067
|
+
<i className="fa-solid fa-plus" />
|
|
1068
|
+
</button>
|
|
1069
|
+
{showAddFieldPanel && (
|
|
1070
|
+
<div className="absolute right-0 top-full mt-1 z-20 p-2 rounded-lg border shadow-lg"
|
|
1071
|
+
style={{ borderColor: 'var(--color-border)', backgroundColor: 'var(--color-bg-secondary)', boxShadow: '0 4px 16px var(--color-shadow)' }}>
|
|
1072
|
+
<div className="flex flex-col gap-1">
|
|
1073
|
+
{([
|
|
1074
|
+
{ type: 'text' as FieldType, label: '文本' },
|
|
1075
|
+
{ type: 'textarea' as FieldType, label: '多行文本' },
|
|
1076
|
+
{ type: 'select' as FieldType, label: '下拉选择' },
|
|
1077
|
+
{ type: 'checkbox' as FieldType, label: '复选框' },
|
|
1078
|
+
{ type: 'slider' as FieldType, label: '滑块' },
|
|
1079
|
+
]).map(item => (
|
|
1080
|
+
<button key={item.type} onClick={(e) => { e.stopPropagation(); addField(item.type); }}
|
|
1081
|
+
className="px-3 py-1 rounded text-xs border transition-colors text-left whitespace-nowrap"
|
|
1082
|
+
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}>
|
|
1083
|
+
{item.label}
|
|
1084
|
+
</button>
|
|
1085
|
+
))}
|
|
1086
|
+
</div>
|
|
1087
|
+
</div>
|
|
1088
|
+
)}
|
|
1089
|
+
</div>
|
|
1090
|
+
<button
|
|
1091
|
+
onClick={(e) => { e.stopPropagation(); handleSuggestFields(); }}
|
|
1092
|
+
disabled={isSuggesting}
|
|
1093
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs border transition-colors"
|
|
1094
|
+
style={{
|
|
1095
|
+
borderColor: isSuggesting ? 'var(--color-accent)' : 'var(--color-border)',
|
|
1096
|
+
color: isSuggesting ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
|
1097
|
+
fontSize: '10px',
|
|
1098
|
+
opacity: isSuggesting ? 0.5 : 1,
|
|
1099
|
+
}}
|
|
1100
|
+
title="AI 智能补全字段"
|
|
1101
|
+
>
|
|
1102
|
+
<i className={`fa-solid ${isSuggesting ? 'fa-spinner fa-spin' : 'fa-wand-magic-sparkles'}`} />
|
|
1103
|
+
</button>
|
|
1104
|
+
</div>
|
|
1105
|
+
)}
|
|
1106
|
+
<i className={`fa-solid fa-chevron-${fieldsExpanded ? 'up' : 'down'}`}
|
|
1107
|
+
style={{ fontSize: '10px' }} />
|
|
1108
|
+
</div>
|
|
1109
|
+
</button>
|
|
1110
|
+
|
|
1111
|
+
{fieldsExpanded && (
|
|
1112
|
+
<div className="overflow-y-auto px-2 pb-2"
|
|
1113
|
+
style={fieldsPanelHeight ? { height: fieldsPanelHeight } : { flex: 1 }}>
|
|
1114
|
+
<DraggableFieldList
|
|
1115
|
+
fields={fields}
|
|
1116
|
+
selectedId={selectedFieldId}
|
|
1117
|
+
onSelect={setSelectedFieldId}
|
|
1118
|
+
onReorder={reorderField}
|
|
1119
|
+
onRemove={removeField}
|
|
1120
|
+
readonly={isEditMode}
|
|
1121
|
+
/>
|
|
1122
|
+
</div>
|
|
1123
|
+
)}
|
|
1124
|
+
</div>
|
|
1125
|
+
|
|
1126
|
+
{/* 字段面板与插件面板之间的拖拽调整条 */}
|
|
1127
|
+
{(isCreateMode || mode === 'edit') && (availablePlugins.length > 0 || selectedPlugins.length > 0) && fieldsExpanded && (
|
|
1128
|
+
<ResizeHandle onResize={(deltaY) => {
|
|
1129
|
+
setFieldsPanelHeight(prev => {
|
|
1130
|
+
const current = prev || 300;
|
|
1131
|
+
// 计算最大高度:第一列容器高度 - 字段面板标题栏(~36px) - 插件面板标题栏(~36px) - resizeHandle(~5px) - 余量
|
|
1132
|
+
const containerHeight = col1Ref.current?.clientHeight || 600;
|
|
1133
|
+
const maxH = containerHeight - 120;
|
|
1134
|
+
return Math.max(100, Math.min(maxH, current + deltaY));
|
|
1135
|
+
});
|
|
1136
|
+
}} />
|
|
1137
|
+
)}
|
|
1138
|
+
|
|
1139
|
+
{/* 插件配置区域(创建/编辑模板/编辑世界设定模式都显示) */}
|
|
1140
|
+
{(isCreateMode || mode === 'edit') && (availablePlugins.length > 0 || selectedPlugins.length > 0) && (
|
|
1141
|
+
<div className="shrink-0 border-t" style={{ borderColor: 'var(--color-border)' }}>
|
|
1142
|
+
{/* 标题栏:可折叠 */}
|
|
1143
|
+
<button
|
|
1144
|
+
onClick={() => setPluginsExpanded(!pluginsExpanded)}
|
|
1145
|
+
className="w-full flex items-center justify-between px-3 py-2 transition-colors"
|
|
1146
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
1147
|
+
>
|
|
1148
|
+
<span className="flex items-center gap-1.5 text-xs font-bold">
|
|
1149
|
+
<i className="fa-solid fa-puzzle-piece" style={{ fontSize: '11px' }} />
|
|
1150
|
+
插件配置
|
|
1151
|
+
{selectedPlugins.length > 0 && (
|
|
1152
|
+
<span className="px-1.5 py-0.5 rounded text-xs"
|
|
1153
|
+
style={{ backgroundColor: 'var(--color-bg-tertiary)', fontSize: '10px' }}>
|
|
1154
|
+
已选 {selectedPlugins.length}
|
|
1155
|
+
</span>
|
|
1156
|
+
)}
|
|
1157
|
+
</span>
|
|
1158
|
+
<i className={`fa-solid fa-chevron-${pluginsExpanded ? 'up' : 'down'}`}
|
|
1159
|
+
style={{ fontSize: '10px' }} />
|
|
1160
|
+
</button>
|
|
1161
|
+
|
|
1162
|
+
{/* 展开内容 */}
|
|
1163
|
+
{pluginsExpanded && (
|
|
1164
|
+
<div className="px-2 pb-2 space-y-1.5">
|
|
1165
|
+
{/* 搜索框 */}
|
|
1166
|
+
{availablePlugins.length > 5 && (
|
|
1167
|
+
<input
|
|
1168
|
+
value={pluginSearchText}
|
|
1169
|
+
onChange={(e) => setPluginSearchText(e.target.value)}
|
|
1170
|
+
placeholder="搜索插件..."
|
|
1171
|
+
className="w-full px-2 py-1 rounded text-xs border outline-none transition-colors"
|
|
1172
|
+
style={{
|
|
1173
|
+
backgroundColor: 'var(--color-bg-tertiary)',
|
|
1174
|
+
borderColor: 'var(--color-border)',
|
|
1175
|
+
color: 'var(--color-text-primary)',
|
|
1176
|
+
}}
|
|
1177
|
+
onFocus={(e) => (e.target.style.borderColor = 'var(--color-accent)')}
|
|
1178
|
+
onBlur={(e) => (e.target.style.borderColor = 'var(--color-border)')}
|
|
1179
|
+
/>
|
|
1180
|
+
)}
|
|
1181
|
+
|
|
1182
|
+
{/* 未安装插件提示 */}
|
|
1183
|
+
{(() => {
|
|
1184
|
+
const installedIds = new Set(availablePlugins.map(p => p.id));
|
|
1185
|
+
const missingPlugins = selectedPlugins.filter(pid => !installedIds.has(pid));
|
|
1186
|
+
if (missingPlugins.length === 0) return null;
|
|
1187
|
+
return (
|
|
1188
|
+
<div className="px-2 py-1.5 rounded-lg" style={{ backgroundColor: 'rgba(234,179,8,0.08)', border: '1px solid rgba(234,179,8,0.2)' }}>
|
|
1189
|
+
<div className="flex items-center gap-1.5 mb-1">
|
|
1190
|
+
<i className="fa-solid fa-triangle-exclamation" style={{ fontSize: '10px', color: '#eab308' }} />
|
|
1191
|
+
<span className="text-xs font-medium" style={{ color: '#eab308' }}>
|
|
1192
|
+
{missingPlugins.length} 个插件未安装
|
|
1193
|
+
</span>
|
|
1194
|
+
</div>
|
|
1195
|
+
<div className="space-y-0.5">
|
|
1196
|
+
{missingPlugins.map(pid => (
|
|
1197
|
+
<div key={pid} className="flex items-center justify-between">
|
|
1198
|
+
<span className="text-xs truncate" style={{ color: 'var(--color-text-secondary)' }}>{pid}</span>
|
|
1199
|
+
<button
|
|
1200
|
+
onClick={() => setSelectedPlugins(prev => prev.filter(p => p !== pid))}
|
|
1201
|
+
className="text-xs px-1.5 py-0.5 rounded transition-colors shrink-0 ml-2"
|
|
1202
|
+
style={{ color: 'var(--color-text-muted)', fontSize: '10px' }}
|
|
1203
|
+
title="移除">
|
|
1204
|
+
<i className="fa-solid fa-xmark" />
|
|
1205
|
+
</button>
|
|
1206
|
+
</div>
|
|
1207
|
+
))}
|
|
1208
|
+
</div>
|
|
1209
|
+
<button
|
|
1210
|
+
onClick={() => navigate('/extensions')}
|
|
1211
|
+
className="mt-1.5 w-full text-center text-xs px-2 py-1 rounded transition-colors"
|
|
1212
|
+
style={{ color: 'var(--color-accent)', backgroundColor: 'rgba(var(--color-accent-rgb, 99,102,241), 0.08)' }}>
|
|
1213
|
+
前往安装插件
|
|
1214
|
+
</button>
|
|
1215
|
+
</div>
|
|
1216
|
+
);
|
|
1217
|
+
})()}
|
|
1218
|
+
|
|
1219
|
+
{/* 插件列表:选中的排在前面 */}
|
|
1220
|
+
<div className="overflow-y-auto space-y-0.5">
|
|
1221
|
+
{(() => {
|
|
1222
|
+
const keyword = pluginSearchText.trim().toLowerCase();
|
|
1223
|
+
const filtered = keyword
|
|
1224
|
+
? availablePlugins.filter(p => p.id.toLowerCase().includes(keyword) || p.name.toLowerCase().includes(keyword))
|
|
1225
|
+
: availablePlugins;
|
|
1226
|
+
// 排序:选中的排在前面
|
|
1227
|
+
const sorted = [...filtered].sort((a, b) => {
|
|
1228
|
+
const aSel = selectedPlugins.includes(a.id) ? 0 : 1;
|
|
1229
|
+
const bSel = selectedPlugins.includes(b.id) ? 0 : 1;
|
|
1230
|
+
return aSel - bSel;
|
|
1231
|
+
});
|
|
1232
|
+
return sorted.map(plugin => {
|
|
1233
|
+
const isSelected = selectedPlugins.includes(plugin.id);
|
|
1234
|
+
return (
|
|
1235
|
+
<label
|
|
1236
|
+
key={plugin.id}
|
|
1237
|
+
className="flex items-center gap-2 px-2 py-1 rounded-lg cursor-pointer transition-colors"
|
|
1238
|
+
style={{
|
|
1239
|
+
backgroundColor: isSelected ? 'var(--color-bg-tertiary)' : 'transparent',
|
|
1240
|
+
}}
|
|
1241
|
+
>
|
|
1242
|
+
<input
|
|
1243
|
+
type="checkbox"
|
|
1244
|
+
checked={isSelected}
|
|
1245
|
+
onChange={(e) => {
|
|
1246
|
+
if (e.target.checked) {
|
|
1247
|
+
setSelectedPlugins(prev => [...prev, plugin.id]);
|
|
1248
|
+
} else {
|
|
1249
|
+
setSelectedPlugins(prev => prev.filter(pid => pid !== plugin.id));
|
|
1250
|
+
}
|
|
1251
|
+
}}
|
|
1252
|
+
className="rounded shrink-0"
|
|
1253
|
+
style={{ accentColor: 'var(--color-accent)' }}
|
|
1254
|
+
/>
|
|
1255
|
+
{plugin.icon && (
|
|
1256
|
+
<span className="shrink-0"><PluginIcon icon={plugin.icon} pluginId={plugin.id} size={12} /></span>
|
|
1257
|
+
)}
|
|
1258
|
+
<span className="flex-1 text-xs truncate" style={{ color: 'var(--color-text-primary)' }}>
|
|
1259
|
+
{plugin.name}
|
|
1260
|
+
</span>
|
|
1261
|
+
<span className="text-xs truncate shrink-0" style={{ color: 'var(--color-text-muted)', fontSize: '10px', maxWidth: '80px' }}>
|
|
1262
|
+
{plugin.id}
|
|
1263
|
+
</span>
|
|
1264
|
+
</label>
|
|
1265
|
+
);
|
|
1266
|
+
});
|
|
1267
|
+
})()}
|
|
1268
|
+
{(() => {
|
|
1269
|
+
const keyword = pluginSearchText.trim().toLowerCase();
|
|
1270
|
+
const filtered = keyword
|
|
1271
|
+
? availablePlugins.filter(p => p.id.toLowerCase().includes(keyword) || p.name.toLowerCase().includes(keyword))
|
|
1272
|
+
: availablePlugins;
|
|
1273
|
+
return filtered.length === 0 && (
|
|
1274
|
+
<p className="text-xs text-center py-2" style={{ color: 'var(--color-text-muted)' }}>
|
|
1275
|
+
无匹配插件
|
|
1276
|
+
</p>
|
|
1277
|
+
);
|
|
1278
|
+
})()}
|
|
1279
|
+
</div>
|
|
1280
|
+
</div>
|
|
1281
|
+
)}
|
|
1282
|
+
</div>
|
|
1283
|
+
)}
|
|
1284
|
+
</div>
|
|
1285
|
+
|
|
1286
|
+
<Resizer onResize={(delta) => setCol1Width(w => Math.max(140, Math.min(400, w + delta)))} />
|
|
1287
|
+
|
|
1288
|
+
{/* 第二列:字段详情 */}
|
|
1289
|
+
<div className="shrink-0 overflow-hidden border-r flex flex-col" style={{
|
|
1290
|
+
width: col2Width, minWidth: 200, borderColor: 'var(--color-border)',
|
|
1291
|
+
}}>
|
|
1292
|
+
<div className="flex-1 overflow-hidden">
|
|
1293
|
+
{selectedField ? (
|
|
1294
|
+
<FieldDetailPanel field={selectedField} onUpdate={(u) => updateField(selectedField.id, u)} readonly={isEditMode} />
|
|
1295
|
+
) : (
|
|
1296
|
+
<div className="h-full flex items-center justify-center">
|
|
1297
|
+
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>选择一个字段查看详情</p>
|
|
1298
|
+
</div>
|
|
1299
|
+
)}
|
|
1300
|
+
</div>
|
|
1301
|
+
</div>
|
|
1302
|
+
|
|
1303
|
+
<Resizer onResize={(delta) => setCol2Width(w => Math.max(180, Math.min(500, w + delta)))} />
|
|
1304
|
+
|
|
1305
|
+
{/* 第三列:SVG 预览 + 生成(编辑模式无生成) */}
|
|
1306
|
+
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
|
1307
|
+
{/* Tab 切换 */}
|
|
1308
|
+
<div className="flex items-center gap-1 px-3 py-1.5 border-b shrink-0"
|
|
1309
|
+
style={{ borderColor: 'var(--color-border)', backgroundColor: 'var(--color-bg-secondary)' }}
|
|
1310
|
+
>
|
|
1311
|
+
<button onClick={() => setActiveTab('preview')}
|
|
1312
|
+
className="px-2.5 py-1 rounded-md text-xs font-medium transition-colors"
|
|
1313
|
+
style={{
|
|
1314
|
+
backgroundColor: activeTab === 'preview' ? 'var(--color-bg-tertiary)' : 'transparent',
|
|
1315
|
+
color: activeTab === 'preview' ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
|
1316
|
+
}}>预览</button>
|
|
1317
|
+
{!isEditMode && (
|
|
1318
|
+
<button onClick={() => setActiveTab('code')}
|
|
1319
|
+
className="px-2.5 py-1 rounded-md text-xs font-medium transition-colors"
|
|
1320
|
+
style={{
|
|
1321
|
+
backgroundColor: activeTab === 'code' ? 'var(--color-bg-tertiary)' : 'transparent',
|
|
1322
|
+
color: activeTab === 'code' ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
|
1323
|
+
}}>代码</button>
|
|
1324
|
+
)}
|
|
1325
|
+
{activeTab === 'preview' && svgCode && (
|
|
1326
|
+
<div className="ml-auto flex items-center gap-1">
|
|
1327
|
+
<button onClick={() => setSvgScale(s => Math.max(0.25, +(s - 0.25).toFixed(2)))}
|
|
1328
|
+
className="p-1 rounded transition-colors" style={{ color: 'var(--color-text-muted)', fontSize: '11px' }}
|
|
1329
|
+
title="缩小">
|
|
1330
|
+
<i className="fa-solid fa-minus" />
|
|
1331
|
+
</button>
|
|
1332
|
+
<span className="text-xs tabular-nums" style={{ color: 'var(--color-text-muted)', minWidth: '36px', textAlign: 'center' }}>
|
|
1333
|
+
{Math.round(svgScale * 100)}%
|
|
1334
|
+
</span>
|
|
1335
|
+
<button onClick={() => setSvgScale(s => Math.min(3, +(s + 0.25).toFixed(2)))}
|
|
1336
|
+
className="p-1 rounded transition-colors" style={{ color: 'var(--color-text-muted)', fontSize: '11px' }}
|
|
1337
|
+
title="放大">
|
|
1338
|
+
<i className="fa-solid fa-plus" />
|
|
1339
|
+
</button>
|
|
1340
|
+
<button onClick={() => setSvgScale(1)}
|
|
1341
|
+
className="px-1.5 py-0.5 rounded text-xs transition-colors"
|
|
1342
|
+
style={{ color: 'var(--color-text-muted)', fontSize: '10px' }}
|
|
1343
|
+
title="重置缩放">
|
|
1344
|
+
适应
|
|
1345
|
+
</button>
|
|
1346
|
+
</div>
|
|
1347
|
+
)}
|
|
1348
|
+
{activeTab === 'code' && (
|
|
1349
|
+
<span className="ml-auto text-xs" style={{ color: 'var(--color-text-muted)' }}>{svgCode.length} 字符</span>
|
|
1350
|
+
)}
|
|
1351
|
+
</div>
|
|
1352
|
+
|
|
1353
|
+
{/* SVG 内容区 */}
|
|
1354
|
+
<div ref={previewContainerRef} className="flex-1 overflow-auto p-3 flex flex-col items-center">
|
|
1355
|
+
{!svgCode && !isGenerating && activeTab === 'preview' ? (
|
|
1356
|
+
<div className="flex flex-col items-center justify-center h-full gap-2 opacity-40">
|
|
1357
|
+
<i className="fa-regular fa-image" style={{ color: 'var(--color-text-muted)', fontSize: '36px' }} />
|
|
1358
|
+
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{isCreateMode ? '填写字段后生成卡片' : '暂无卡片预览'}</p>
|
|
1359
|
+
</div>
|
|
1360
|
+
) : activeTab === 'preview' ? (
|
|
1361
|
+
<SvgPreview svgCode={svgCode} scale={svgScale} containerRef={previewContainerRef} />
|
|
1362
|
+
) : (
|
|
1363
|
+
<textarea
|
|
1364
|
+
value={svgCode}
|
|
1365
|
+
onChange={(e) => setSvgCode(e.target.value)}
|
|
1366
|
+
className="w-full h-full rounded-lg border p-2 text-xs outline-none resize-none font-mono leading-relaxed"
|
|
1367
|
+
style={{
|
|
1368
|
+
backgroundColor: 'var(--color-bg-tertiary)',
|
|
1369
|
+
borderColor: 'var(--color-border)',
|
|
1370
|
+
color: 'var(--color-text-primary)',
|
|
1371
|
+
tabSize: 2,
|
|
1372
|
+
}}
|
|
1373
|
+
spellCheck={false}
|
|
1374
|
+
/>
|
|
1375
|
+
)}
|
|
1376
|
+
</div>
|
|
1377
|
+
|
|
1378
|
+
{/* 底部:生成规则 + 生成按钮(仅创建/编辑模板模式) */}
|
|
1379
|
+
{isCreateMode && (
|
|
1380
|
+
<div className="shrink-0 border-t px-3 py-2 space-y-2" style={{ borderColor: 'var(--color-border)' }}>
|
|
1381
|
+
{/* 风格预设 */}
|
|
1382
|
+
<div className="flex flex-wrap gap-1">
|
|
1383
|
+
{PRESET_RULES.map((rule) => (
|
|
1384
|
+
<button key={rule.id}
|
|
1385
|
+
onClick={() => setSelectedRuleId(rule.id === selectedRuleId ? '' : rule.id)}
|
|
1386
|
+
className="px-2 py-0.5 rounded text-xs border transition-colors"
|
|
1387
|
+
style={{
|
|
1388
|
+
borderColor: selectedRuleId === rule.id ? 'var(--color-accent)' : 'var(--color-border)',
|
|
1389
|
+
backgroundColor: selectedRuleId === rule.id ? 'var(--color-bg-tertiary)' : 'transparent',
|
|
1390
|
+
color: selectedRuleId === rule.id ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
|
1391
|
+
}}
|
|
1392
|
+
>{rule.name}</button>
|
|
1393
|
+
))}
|
|
1394
|
+
</div>
|
|
1395
|
+
{/* 自定义规则 */}
|
|
1396
|
+
<textarea
|
|
1397
|
+
value={selectedRuleId ? PRESET_RULES.find(r => r.id === selectedRuleId)?.rules || '' : customRules}
|
|
1398
|
+
onChange={(e) => { if (selectedRuleId) { setCustomRules(e.target.value); setSelectedRuleId(''); } else { setCustomRules(e.target.value); } }}
|
|
1399
|
+
placeholder="自定义 SVG 生成规则..."
|
|
1400
|
+
rows={2}
|
|
1401
|
+
className="w-full px-2 py-1.5 rounded-lg text-xs border outline-none resize-none transition-colors"
|
|
1402
|
+
style={{
|
|
1403
|
+
backgroundColor: 'var(--color-bg-tertiary)', borderColor: 'var(--color-border)',
|
|
1404
|
+
color: 'var(--color-text-primary)',
|
|
1405
|
+
}}
|
|
1406
|
+
onFocus={(e) => (e.target.style.borderColor = 'var(--color-accent)')}
|
|
1407
|
+
onBlur={(e) => (e.target.style.borderColor = 'var(--color-border)')}
|
|
1408
|
+
/>
|
|
1409
|
+
{/* 生成按钮 */}
|
|
1410
|
+
<button onClick={handleGenerate} disabled={isGenerating}
|
|
1411
|
+
className="w-full px-3 py-2 rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-1.5"
|
|
1412
|
+
style={{
|
|
1413
|
+
backgroundColor: !isGenerating ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
|
|
1414
|
+
color: !isGenerating ? '#fff' : 'var(--color-text-muted)',
|
|
1415
|
+
cursor: !isGenerating ? 'pointer' : 'not-allowed',
|
|
1416
|
+
}}
|
|
1417
|
+
>
|
|
1418
|
+
{isGenerating ? (
|
|
1419
|
+
<>
|
|
1420
|
+
<div className="w-3 h-3 border-2 border-t-transparent rounded-full animate-spin"
|
|
1421
|
+
style={{ borderColor: 'currentColor', borderTopColor: 'transparent' }} />
|
|
1422
|
+
生成中...
|
|
1423
|
+
</>
|
|
1424
|
+
) : (
|
|
1425
|
+
<>
|
|
1426
|
+
<i className="fa-solid fa-bolt" style={{ fontSize: '14px' }} />
|
|
1427
|
+
生成世界卡片
|
|
1428
|
+
</>
|
|
1429
|
+
)}
|
|
1430
|
+
</button>
|
|
1431
|
+
</div>
|
|
1432
|
+
)}
|
|
1433
|
+
</div>
|
|
1434
|
+
</div>
|
|
1435
|
+
</div>
|
|
1436
|
+
|
|
1437
|
+
<ConfirmDialog
|
|
1438
|
+
open={confirmOpen}
|
|
1439
|
+
title={confirmConfig.title}
|
|
1440
|
+
message={confirmConfig.message}
|
|
1441
|
+
danger={confirmConfig.danger}
|
|
1442
|
+
confirmText={confirmConfig.confirmText}
|
|
1443
|
+
cancelText={confirmConfig.cancelText}
|
|
1444
|
+
ignoreText={confirmConfig.ignoreText}
|
|
1445
|
+
onConfirm={confirmConfig.onConfirm}
|
|
1446
|
+
onIgnore={confirmConfig.onIgnore}
|
|
1447
|
+
onCancel={() => setConfirmOpen(false)}
|
|
1448
|
+
/>
|
|
1449
|
+
</>
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// ==================== 导出 ====================
|
|
1454
|
+
|
|
1455
|
+
export default function EditorPage() {
|
|
1456
|
+
return (
|
|
1457
|
+
<Suspense fallback={
|
|
1458
|
+
<div className="h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
|
1459
|
+
<div className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin"
|
|
1460
|
+
style={{ borderColor: 'var(--color-accent)', borderTopColor: 'transparent' }} />
|
|
1461
|
+
</div>
|
|
1462
|
+
}>
|
|
1463
|
+
<EditorPageContent />
|
|
1464
|
+
</Suspense>
|
|
1465
|
+
);
|
|
1466
|
+
}
|