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,173 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
// ==================== 数学曲线核心逻辑 ====================
|
|
6
|
+
|
|
7
|
+
/** Original Thinking 曲线配置 */
|
|
8
|
+
const CURVE_CONFIG = {
|
|
9
|
+
baseRadius: 7,
|
|
10
|
+
detailAmplitude: 3,
|
|
11
|
+
petalCount: 7,
|
|
12
|
+
curveScale: 3.9,
|
|
13
|
+
particleCount: 64,
|
|
14
|
+
trailSpan: 0.38,
|
|
15
|
+
durationMs: 4600,
|
|
16
|
+
rotationDurationMs: 28000,
|
|
17
|
+
pulseDurationMs: 4200,
|
|
18
|
+
strokeWidth: 5.5,
|
|
19
|
+
rotate: true,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function normalizeProgress(progress: number) {
|
|
23
|
+
return ((progress % 1) + 1) % 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function curvePoint(progress: number, detailScale: number) {
|
|
27
|
+
const t = progress * Math.PI * 2;
|
|
28
|
+
const { baseRadius, detailAmplitude, petalCount, curveScale } = CURVE_CONFIG;
|
|
29
|
+
const x = baseRadius * Math.cos(t) - detailAmplitude * detailScale * Math.cos(petalCount * t);
|
|
30
|
+
const y = baseRadius * Math.sin(t) - detailAmplitude * detailScale * Math.sin(petalCount * t);
|
|
31
|
+
return {
|
|
32
|
+
x: 50 + x * curveScale,
|
|
33
|
+
y: 50 + y * curveScale,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildPath(detailScale: number, steps = 480) {
|
|
38
|
+
return Array.from({ length: steps + 1 }, (_, index) => {
|
|
39
|
+
const point = curvePoint(index / steps, detailScale);
|
|
40
|
+
return `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
|
|
41
|
+
}).join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getParticle(index: number, progress: number, detailScale: number) {
|
|
45
|
+
const tailOffset = index / (CURVE_CONFIG.particleCount - 1);
|
|
46
|
+
const point = curvePoint(
|
|
47
|
+
normalizeProgress(progress - tailOffset * CURVE_CONFIG.trailSpan),
|
|
48
|
+
detailScale,
|
|
49
|
+
);
|
|
50
|
+
const fade = Math.pow(1 - tailOffset, 0.56);
|
|
51
|
+
return {
|
|
52
|
+
x: point.x,
|
|
53
|
+
y: point.y,
|
|
54
|
+
radius: 0.9 + fade * 2.7,
|
|
55
|
+
opacity: 0.04 + fade * 0.96,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getDetailScale(time: number) {
|
|
60
|
+
const pulseProgress =
|
|
61
|
+
(time % CURVE_CONFIG.pulseDurationMs) / CURVE_CONFIG.pulseDurationMs;
|
|
62
|
+
const pulseAngle = pulseProgress * Math.PI * 2;
|
|
63
|
+
return 0.52 + ((Math.sin(pulseAngle + 0.55) + 1) / 2) * 0.48;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getRotation(time: number) {
|
|
67
|
+
if (!CURVE_CONFIG.rotate) return 0;
|
|
68
|
+
return -((time % CURVE_CONFIG.rotationDurationMs) / CURVE_CONFIG.rotationDurationMs) * 360;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ==================== React 组件 ====================
|
|
72
|
+
|
|
73
|
+
interface MathCurveLoaderProps {
|
|
74
|
+
/** 尺寸,默认 120 */
|
|
75
|
+
size?: number;
|
|
76
|
+
/** 轨迹颜色,默认使用 CSS 变量 --color-accent */
|
|
77
|
+
color?: string;
|
|
78
|
+
/** 轨迹线宽倍率,默认 1 */
|
|
79
|
+
strokeWidthScale?: number;
|
|
80
|
+
/** 自定义 className */
|
|
81
|
+
className?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default function MathCurveLoader({
|
|
85
|
+
size = 120,
|
|
86
|
+
color,
|
|
87
|
+
strokeWidthScale = 1,
|
|
88
|
+
className,
|
|
89
|
+
}: MathCurveLoaderProps) {
|
|
90
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
91
|
+
const groupRef = useRef<SVGGElement>(null);
|
|
92
|
+
const pathRef = useRef<SVGPathElement>(null);
|
|
93
|
+
const particlesRef = useRef<SVGCircleElement[]>([]);
|
|
94
|
+
const rafRef = useRef<number>(0);
|
|
95
|
+
const startTimeRef = useRef<number>(0);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
startTimeRef.current = performance.now();
|
|
99
|
+
|
|
100
|
+
const render = (now: number) => {
|
|
101
|
+
const time = now - startTimeRef.current;
|
|
102
|
+
const progress =
|
|
103
|
+
(time % CURVE_CONFIG.durationMs) / CURVE_CONFIG.durationMs;
|
|
104
|
+
const detailScale = getDetailScale(time);
|
|
105
|
+
const rotation = getRotation(time);
|
|
106
|
+
|
|
107
|
+
// 旋转整个 group
|
|
108
|
+
if (groupRef.current) {
|
|
109
|
+
groupRef.current.setAttribute('transform', `rotate(${rotation} 50 50)`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 更新轨迹路径
|
|
113
|
+
if (pathRef.current) {
|
|
114
|
+
pathRef.current.setAttribute('d', buildPath(detailScale));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 更新粒子
|
|
118
|
+
particlesRef.current.forEach((node, index) => {
|
|
119
|
+
const particle = getParticle(index, progress, detailScale);
|
|
120
|
+
node.setAttribute('cx', particle.x.toFixed(2));
|
|
121
|
+
node.setAttribute('cy', particle.y.toFixed(2));
|
|
122
|
+
node.setAttribute('r', (particle.radius * strokeWidthScale).toFixed(2));
|
|
123
|
+
node.setAttribute('opacity', particle.opacity.toFixed(3));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
rafRef.current = requestAnimationFrame(render);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
rafRef.current = requestAnimationFrame(render);
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
cancelAnimationFrame(rafRef.current);
|
|
133
|
+
};
|
|
134
|
+
}, [strokeWidthScale]);
|
|
135
|
+
|
|
136
|
+
const strokeColor = color || 'var(--color-accent)';
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<svg
|
|
140
|
+
ref={svgRef}
|
|
141
|
+
viewBox="0 0 100 100"
|
|
142
|
+
width={size}
|
|
143
|
+
height={size}
|
|
144
|
+
className={className}
|
|
145
|
+
style={{ color: strokeColor }}
|
|
146
|
+
>
|
|
147
|
+
<g ref={groupRef}>
|
|
148
|
+
{/* 轨迹路径 */}
|
|
149
|
+
<path
|
|
150
|
+
ref={pathRef}
|
|
151
|
+
fill="none"
|
|
152
|
+
stroke="currentColor"
|
|
153
|
+
strokeLinecap="round"
|
|
154
|
+
strokeLinejoin="round"
|
|
155
|
+
opacity="0.15"
|
|
156
|
+
style={{ strokeWidth: CURVE_CONFIG.strokeWidth * strokeWidthScale }}
|
|
157
|
+
/>
|
|
158
|
+
{/* 粒子 */}
|
|
159
|
+
{Array.from({ length: CURVE_CONFIG.particleCount }, (_, i) => (
|
|
160
|
+
<circle
|
|
161
|
+
key={i}
|
|
162
|
+
ref={(el) => {
|
|
163
|
+
if (el) particlesRef.current[i] = el;
|
|
164
|
+
}}
|
|
165
|
+
fill="currentColor"
|
|
166
|
+
r="0"
|
|
167
|
+
opacity="0"
|
|
168
|
+
/>
|
|
169
|
+
))}
|
|
170
|
+
</g>
|
|
171
|
+
</svg>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { ChatMessage } from '@/lib/types';
|
|
5
|
+
import { usePluginContext } from '@/components/ui/PluginProvider';
|
|
6
|
+
|
|
7
|
+
interface MessageBubbleProps {
|
|
8
|
+
message: ChatMessage;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 渲染消息气泡(纯展示,不含插件管道逻辑) */
|
|
12
|
+
function renderBubble(
|
|
13
|
+
message: ChatMessage,
|
|
14
|
+
content: React.ReactNode,
|
|
15
|
+
isUser: boolean,
|
|
16
|
+
isSystem: boolean,
|
|
17
|
+
isNotice: boolean,
|
|
18
|
+
): React.ReactNode {
|
|
19
|
+
// 系统消息样式
|
|
20
|
+
if (isSystem) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex justify-center px-4 py-1.5">
|
|
23
|
+
<div
|
|
24
|
+
className="px-4 py-2 rounded-xl text-xs text-center max-w-[80%]"
|
|
25
|
+
style={{
|
|
26
|
+
backgroundColor: 'var(--color-bg-tertiary)',
|
|
27
|
+
color: 'var(--color-text-secondary)',
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
{message.content}
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 通知消息:插件自定义 HTML,不发给 AI,持久化到聊天记录
|
|
37
|
+
if (isNotice) {
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex justify-center px-4 py-1.5">
|
|
40
|
+
<div
|
|
41
|
+
className="max-w-[80%] px-4 py-2 rounded-xl text-xs text-center"
|
|
42
|
+
style={{ color: 'var(--color-text-secondary)' }}
|
|
43
|
+
dangerouslySetInnerHTML={{ __html: message.content }}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} px-4 py-1.5`}>
|
|
51
|
+
<div
|
|
52
|
+
className="max-w-[80%] sm:max-w-[70%] px-4 py-3 rounded-2xl text-sm leading-relaxed whitespace-pre-wrap"
|
|
53
|
+
style={{
|
|
54
|
+
backgroundColor: isUser
|
|
55
|
+
? 'var(--color-user-bubble)'
|
|
56
|
+
: 'var(--color-ai-bubble)',
|
|
57
|
+
color: isUser ? '#fff' : 'var(--color-text-primary)',
|
|
58
|
+
borderBottomRightRadius: isUser ? '4px' : undefined,
|
|
59
|
+
borderBottomLeftRadius: !isUser ? '4px' : undefined,
|
|
60
|
+
boxShadow: '0 2px 8px var(--color-shadow)',
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{/* AI 消息显示角色标识 */}
|
|
64
|
+
{!isUser && (
|
|
65
|
+
<div
|
|
66
|
+
className="text-xs font-medium mb-1.5"
|
|
67
|
+
style={{ color: 'var(--color-accent)' }}
|
|
68
|
+
>
|
|
69
|
+
🌍 世界主持人
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
{content}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function MessageBubble({ message }: MessageBubbleProps) {
|
|
79
|
+
const isUser = message.role === 'user';
|
|
80
|
+
const isSystem = message.role === 'system';
|
|
81
|
+
const isNotice = message.role === 'notice';
|
|
82
|
+
const pluginCtx = usePluginContext();
|
|
83
|
+
|
|
84
|
+
// 异步执行 onRenderMessage 管道(支持 async handler)
|
|
85
|
+
const [pipelineResult, setPipelineResult] = useState<string | null | undefined>(undefined);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
pluginCtx.applyRenderMessagePipeline(message).then((result) => {
|
|
90
|
+
if (!cancelled) setPipelineResult(result);
|
|
91
|
+
});
|
|
92
|
+
return () => { cancelled = true; };
|
|
93
|
+
}, [message, pluginCtx]);
|
|
94
|
+
|
|
95
|
+
// 管道尚未完成时,先渲染原始内容
|
|
96
|
+
if (pipelineResult === undefined) {
|
|
97
|
+
const safeContent = typeof message.content === 'string' ? message.content : String(message.content);
|
|
98
|
+
return renderBubble(message, safeContent, isUser, isSystem, isNotice);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 尝试使用插件消息渲染器
|
|
102
|
+
let renderedContent: React.ReactNode = message.content;
|
|
103
|
+
let skipRender = false;
|
|
104
|
+
|
|
105
|
+
// 管道返回 null 表示跳过渲染
|
|
106
|
+
if (pipelineResult === null) {
|
|
107
|
+
skipRender = true;
|
|
108
|
+
} else {
|
|
109
|
+
const processedMessage = { ...message, content: pipelineResult };
|
|
110
|
+
|
|
111
|
+
for (const renderer of pluginCtx.messageRenderers) {
|
|
112
|
+
try {
|
|
113
|
+
if (renderer.matcher(processedMessage)) {
|
|
114
|
+
const rendered = renderer.renderer(processedMessage);
|
|
115
|
+
if (rendered === null) {
|
|
116
|
+
// renderer 返回 null 表示跳过渲染
|
|
117
|
+
skipRender = true;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
if (rendered && rendered !== processedMessage.content) {
|
|
121
|
+
renderedContent = <span dangerouslySetInnerHTML={{ __html: rendered }} />;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.error('[MessageBubble] 插件渲染器执行出错:', e);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 如果 onRenderMessage 管道修改了内容但未触发自定义渲染器,使用修改后的内容
|
|
131
|
+
if (!skipRender && renderedContent === message.content && processedMessage.content !== message.content) {
|
|
132
|
+
renderedContent = processedMessage.content;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 插件标记跳过渲染
|
|
137
|
+
if (skipRender) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 确保 renderedContent 是可渲染的 ReactNode
|
|
142
|
+
const finalContent = typeof renderedContent === 'string'
|
|
143
|
+
? renderedContent
|
|
144
|
+
: (renderedContent ?? String(message.content));
|
|
145
|
+
|
|
146
|
+
return renderBubble(message, finalContent, isUser, isSystem, isNotice);
|
|
147
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
interface WorldCardPreviewProps {
|
|
6
|
+
setting: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function WorldCardPreview({ setting }: WorldCardPreviewProps) {
|
|
10
|
+
const internalKeys = new Set(['_plugins', '_labels', '_fieldMeta', '_fieldOrder']);
|
|
11
|
+
const entries = Object.entries(setting).filter(
|
|
12
|
+
([key, value]) => !internalKeys.has(key) && value !== null && value !== undefined && value !== ''
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
if (entries.length === 0) {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className="rounded-xl border p-5 w-full max-w-lg mx-auto text-center"
|
|
19
|
+
style={{
|
|
20
|
+
backgroundColor: 'var(--color-bg-secondary)',
|
|
21
|
+
borderColor: 'var(--color-border)',
|
|
22
|
+
boxShadow: '0 4px 16px var(--color-shadow)',
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
<span style={{ color: 'var(--color-text-muted)', fontSize: '13px' }}>暂无世界设定数据</span>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 第一个字段作为标题展示
|
|
31
|
+
const [firstKey, firstValue] = entries[0];
|
|
32
|
+
const restEntries = entries.slice(1);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className="rounded-xl border p-5 w-full max-w-lg mx-auto"
|
|
37
|
+
style={{
|
|
38
|
+
backgroundColor: 'var(--color-bg-secondary)',
|
|
39
|
+
borderColor: 'var(--color-border)',
|
|
40
|
+
boxShadow: '0 4px 16px var(--color-shadow)',
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{/* 标题区:第一个字段 */}
|
|
44
|
+
<div className="mb-4">
|
|
45
|
+
<h2
|
|
46
|
+
className="text-xl font-bold"
|
|
47
|
+
style={{ color: 'var(--color-accent)' }}
|
|
48
|
+
>
|
|
49
|
+
{Array.isArray(firstValue) ? firstValue.join('、') : String(firstValue)}
|
|
50
|
+
</h2>
|
|
51
|
+
<span
|
|
52
|
+
className="text-xs px-2 py-0.5 rounded-full"
|
|
53
|
+
style={{
|
|
54
|
+
backgroundColor: 'var(--color-bg-tertiary)',
|
|
55
|
+
color: 'var(--color-text-secondary)',
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{firstKey}
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* 其余字段 */}
|
|
63
|
+
<div className="space-y-3">
|
|
64
|
+
{restEntries.map(([key, value]) => (
|
|
65
|
+
<div key={key}>
|
|
66
|
+
<label
|
|
67
|
+
className="text-xs font-medium uppercase tracking-wide"
|
|
68
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
69
|
+
>
|
|
70
|
+
{key}
|
|
71
|
+
</label>
|
|
72
|
+
{Array.isArray(value) && value.length > 0 ? (
|
|
73
|
+
<div className="flex flex-wrap gap-1.5 mt-1">
|
|
74
|
+
{value.map((item, i) => (
|
|
75
|
+
<span
|
|
76
|
+
key={i}
|
|
77
|
+
className="text-xs px-2 py-0.5 rounded-full border"
|
|
78
|
+
style={{
|
|
79
|
+
borderColor: 'var(--color-border)',
|
|
80
|
+
color: 'var(--color-text-secondary)',
|
|
81
|
+
backgroundColor: 'var(--color-bg-tertiary)',
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{String(item)}
|
|
85
|
+
</span>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
) : (
|
|
89
|
+
<p className="text-sm mt-0.5" style={{ color: 'var(--color-text-primary)' }}>
|
|
90
|
+
{String(value)}
|
|
91
|
+
</p>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface WorldCardUploaderProps {
|
|
6
|
+
onFileLoaded: (file: File) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function WorldCardUploader({ onFileLoaded }: WorldCardUploaderProps) {
|
|
10
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
11
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
12
|
+
|
|
13
|
+
const handleFile = async (file: File) => {
|
|
14
|
+
if (!file.name.endsWith('.svg')) {
|
|
15
|
+
onFileLoaded(Object.assign(file, { _error: '请上传 SVG 格式的世界卡片文件' }) as File);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
onFileLoaded(file);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
setIsDragging(false);
|
|
24
|
+
const file = e.dataTransfer.files[0];
|
|
25
|
+
if (file) handleFile(file);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<input
|
|
31
|
+
ref={fileInputRef}
|
|
32
|
+
type="file"
|
|
33
|
+
accept=".svg"
|
|
34
|
+
className="hidden"
|
|
35
|
+
onChange={(e) => {
|
|
36
|
+
const file = e.target.files?.[0];
|
|
37
|
+
if (file) handleFile(file);
|
|
38
|
+
e.target.value = '';
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
<button
|
|
42
|
+
onClick={() => fileInputRef.current?.click()}
|
|
43
|
+
onDrop={handleDrop}
|
|
44
|
+
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
|
45
|
+
onDragLeave={() => setIsDragging(false)}
|
|
46
|
+
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors"
|
|
47
|
+
style={{
|
|
48
|
+
borderColor: isDragging ? 'var(--color-accent)' : 'var(--color-border)',
|
|
49
|
+
color: 'var(--color-text-primary)',
|
|
50
|
+
backgroundColor: isDragging ? 'var(--color-bg-tertiary)' : 'var(--color-bg-secondary)',
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<i className="fa-solid fa-upload" style={{ fontSize: '12px' }} />
|
|
54
|
+
上传世界卡片
|
|
55
|
+
</button>
|
|
56
|
+
</>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useCallback } from 'react';
|
|
4
|
+
import { useTheme } from './ThemeProvider';
|
|
5
|
+
|
|
6
|
+
interface ConfirmDialogProps {
|
|
7
|
+
open: boolean;
|
|
8
|
+
title: string;
|
|
9
|
+
message: string;
|
|
10
|
+
confirmText?: string;
|
|
11
|
+
cancelText?: string;
|
|
12
|
+
ignoreText?: string;
|
|
13
|
+
danger?: boolean;
|
|
14
|
+
onConfirm: () => void;
|
|
15
|
+
onCancel: () => void;
|
|
16
|
+
onIgnore?: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function ConfirmDialog({
|
|
20
|
+
open,
|
|
21
|
+
title,
|
|
22
|
+
message,
|
|
23
|
+
confirmText = '确认',
|
|
24
|
+
cancelText = '取消',
|
|
25
|
+
ignoreText,
|
|
26
|
+
danger = false,
|
|
27
|
+
onConfirm,
|
|
28
|
+
onCancel,
|
|
29
|
+
onIgnore,
|
|
30
|
+
}: ConfirmDialogProps) {
|
|
31
|
+
const { activeTheme } = useTheme();
|
|
32
|
+
|
|
33
|
+
const handleKeyDown = useCallback(
|
|
34
|
+
(e: KeyboardEvent) => {
|
|
35
|
+
if (!open) return;
|
|
36
|
+
if (e.key === 'Escape') onCancel();
|
|
37
|
+
if (e.key === 'Enter') onConfirm();
|
|
38
|
+
},
|
|
39
|
+
[open, onCancel, onConfirm]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
44
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
45
|
+
}, [handleKeyDown]);
|
|
46
|
+
|
|
47
|
+
if (!open) return null;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
52
|
+
style={{ animation: 'fadeIn 0.15s ease' }}
|
|
53
|
+
>
|
|
54
|
+
{/* 遮罩 */}
|
|
55
|
+
<div
|
|
56
|
+
className="absolute inset-0"
|
|
57
|
+
style={{ backgroundColor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}
|
|
58
|
+
onClick={onCancel}
|
|
59
|
+
/>
|
|
60
|
+
{/* 弹窗 */}
|
|
61
|
+
<div
|
|
62
|
+
className="relative w-full max-w-sm rounded-xl border p-6"
|
|
63
|
+
style={{
|
|
64
|
+
backgroundColor: 'var(--color-bg-secondary)',
|
|
65
|
+
borderColor: 'var(--color-border)',
|
|
66
|
+
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
|
67
|
+
animation: 'scaleIn 0.2s ease',
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{/* 标题 */}
|
|
71
|
+
<h3
|
|
72
|
+
className="text-base font-semibold mb-2"
|
|
73
|
+
style={{ color: 'var(--color-text-primary)' }}
|
|
74
|
+
>
|
|
75
|
+
{title}
|
|
76
|
+
</h3>
|
|
77
|
+
{/* 内容 */}
|
|
78
|
+
<p
|
|
79
|
+
className="text-sm mb-6 leading-relaxed whitespace-pre-line"
|
|
80
|
+
style={{ color: 'var(--color-text-secondary)' }}
|
|
81
|
+
>
|
|
82
|
+
{message}
|
|
83
|
+
</p>
|
|
84
|
+
{/* 按钮 */}
|
|
85
|
+
<div className="flex gap-3 justify-end flex-wrap">
|
|
86
|
+
<button
|
|
87
|
+
onClick={onCancel}
|
|
88
|
+
className="px-4 py-2 rounded-lg text-sm font-medium border transition-colors"
|
|
89
|
+
style={{
|
|
90
|
+
borderColor: 'var(--color-border)',
|
|
91
|
+
color: 'var(--color-text-secondary)',
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{cancelText}
|
|
95
|
+
</button>
|
|
96
|
+
{onIgnore && ignoreText && (
|
|
97
|
+
<button
|
|
98
|
+
onClick={onIgnore}
|
|
99
|
+
className="px-4 py-2 rounded-lg text-sm font-medium border transition-colors"
|
|
100
|
+
style={{
|
|
101
|
+
borderColor: 'var(--color-border)',
|
|
102
|
+
color: 'var(--color-text-muted)',
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
{ignoreText}
|
|
106
|
+
</button>
|
|
107
|
+
)}
|
|
108
|
+
<button
|
|
109
|
+
onClick={onConfirm}
|
|
110
|
+
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
111
|
+
style={{
|
|
112
|
+
backgroundColor: danger
|
|
113
|
+
? '#ef4444'
|
|
114
|
+
: 'var(--color-accent)',
|
|
115
|
+
color: danger ? '#fff' : activeTheme.isDark ? '#000' : '#fff',
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{confirmText}
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<style jsx>{`
|
|
124
|
+
@keyframes fadeIn {
|
|
125
|
+
from { opacity: 0; }
|
|
126
|
+
to { opacity: 1; }
|
|
127
|
+
}
|
|
128
|
+
@keyframes scaleIn {
|
|
129
|
+
from { opacity: 0; transform: scale(0.95) translateY(8px); }
|
|
130
|
+
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
131
|
+
}
|
|
132
|
+
`}</style>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|