xinyu-pro 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.env.example +21 -0
  2. package/README.md +36 -0
  3. package/app/api/chat/route.ts +84 -0
  4. package/app/api/generate-svg/route.ts +171 -0
  5. package/app/api/generate-theme/route.ts +137 -0
  6. package/app/api/plugins/bindings/route.ts +173 -0
  7. package/app/api/plugins/export/route.ts +122 -0
  8. package/app/api/plugins/export-xye/route.ts +156 -0
  9. package/app/api/plugins/files/route.ts +146 -0
  10. package/app/api/plugins/files-list/route.ts +168 -0
  11. package/app/api/plugins/files-upload/route.ts +101 -0
  12. package/app/api/plugins/files-write/route.ts +272 -0
  13. package/app/api/plugins/import/route.ts +140 -0
  14. package/app/api/plugins/import-package/route.ts +231 -0
  15. package/app/api/plugins/resources/route.ts +109 -0
  16. package/app/api/plugins/route.ts +308 -0
  17. package/app/api/plugins/scan/route.ts +280 -0
  18. package/app/api/plugins/storage/route.ts +146 -0
  19. package/app/api/sessions/route.ts +165 -0
  20. package/app/api/settings/route.ts +40 -0
  21. package/app/api/suggest-fields/route.ts +129 -0
  22. package/app/api/templates/route.ts +159 -0
  23. package/app/api/test-api/route.ts +63 -0
  24. package/app/editor/page.tsx +1466 -0
  25. package/app/extensions/create/page.tsx +1422 -0
  26. package/app/extensions/edit/[id]/page.tsx +2342 -0
  27. package/app/extensions/page.tsx +1572 -0
  28. package/app/extensions/tutorial/page.tsx +4258 -0
  29. package/app/favicon.ico +0 -0
  30. package/app/fonts/GeistMonoVF.woff +0 -0
  31. package/app/fonts/GeistVF.woff +0 -0
  32. package/app/game/[id]/page.tsx +996 -0
  33. package/app/globals.css +3 -0
  34. package/app/layout.tsx +26 -0
  35. package/app/loading.tsx +26 -0
  36. package/app/page.tsx +345 -0
  37. package/app/settings/page.tsx +1490 -0
  38. package/bin/cli.js +262 -0
  39. package/components/ChatInput.tsx +106 -0
  40. package/components/ChatWindow.tsx +52 -0
  41. package/components/FullPageLoader.tsx +107 -0
  42. package/components/LoadingDots.tsx +20 -0
  43. package/components/MathCurveLoader.tsx +173 -0
  44. package/components/MessageBubble.tsx +147 -0
  45. package/components/WorldCardPreview.tsx +98 -0
  46. package/components/WorldCardUploader.tsx +58 -0
  47. package/components/ui/ConfirmDialog.tsx +135 -0
  48. package/components/ui/PageHeader.tsx +99 -0
  49. package/components/ui/PermissionConflictDialog.tsx +206 -0
  50. package/components/ui/PluginConfigForm.tsx +192 -0
  51. package/components/ui/PluginFloatingLayer.tsx +52 -0
  52. package/components/ui/PluginIcon.tsx +53 -0
  53. package/components/ui/PluginModalRenderer.tsx +185 -0
  54. package/components/ui/PluginProvider.tsx +1038 -0
  55. package/components/ui/PluginSlotRenderer.tsx +76 -0
  56. package/components/ui/ThemeCustomizer.tsx +174 -0
  57. package/components/ui/ThemeProvider.tsx +125 -0
  58. package/components/ui/ThemeSwitcher.tsx +140 -0
  59. package/components/ui/ToastProvider.tsx +141 -0
  60. package/lib/builtin-plugins.ts +11 -0
  61. package/lib/db-init.ts +35 -0
  62. package/lib/db.ts +244 -0
  63. package/lib/manifest-parser.ts +185 -0
  64. package/lib/parseWorldCard.ts +110 -0
  65. package/lib/plugin-dom-sandbox.ts +327 -0
  66. package/lib/plugin-events.ts +88 -0
  67. package/lib/plugin-files.ts +186 -0
  68. package/lib/plugin-html-sanitizer.ts +79 -0
  69. package/lib/plugin-resource-tracker.ts +175 -0
  70. package/lib/plugin-runtime.ts +2287 -0
  71. package/lib/plugin-security.ts +151 -0
  72. package/lib/plugin-types.ts +416 -0
  73. package/lib/prompt-builder.ts +55 -0
  74. package/lib/router-history.ts +119 -0
  75. package/lib/storage.ts +381 -0
  76. package/lib/themes.ts +129 -0
  77. package/lib/types.ts +117 -0
  78. package/lib/version.ts +55 -0
  79. package/next.config.mjs +43 -0
  80. package/package.json +56 -0
  81. package/plugins/xinyu.bag-system.xye +0 -0
  82. package/plugins/xinyu.cache-optimizer.xye +0 -0
  83. package/plugins/xinyu.dice-arbiter.xye +0 -0
  84. package/plugins/xinyu.game-auto-start-choices.xye +0 -0
  85. package/plugins/xinyu.markdown-render.xye +0 -0
  86. package/plugins/xinyu.slot-ui-beautify.xye +0 -0
  87. package/plugins/xinyu.world-info.xye +0 -0
  88. package/postcss.config.mjs +8 -0
  89. package/public/templates/atlantis.svg +63 -0
  90. package/public/templates/cyber-city.svg +68 -0
  91. package/public/templates/jianghu.svg +69 -0
  92. package/public/templates//351/255/224/346/263/225/344/270/226/347/225/214.svg +137 -0
  93. package/styles/themes.css +111 -0
  94. package/tailwind.config.ts +18 -0
  95. package/tsconfig.json +26 -0
  96. package/version.json +6 -0
@@ -0,0 +1,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
+ }