@chuchen3d/wallpaper-plugin-sdk 0.0.0-stage → 1.1.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/LICENSE +20 -0
- package/README.md +449 -2
- package/package.json +57 -4
- package/src/cli.js +99 -0
- package/src/commands/build.js +135 -0
- package/src/commands/example.js +375 -0
- package/src/commands/init.js +200 -0
- package/src/commands/validate.js +76 -0
- package/src/index.js +40 -0
- package/src/templates/dev-project.js +357 -0
- package/src/templates/manifest.js +143 -0
- package/src/templates/plugin-entry.js +82 -0
- package/src/templates/vue-component.js +213 -0
- package/src/utils/colors.js +31 -0
- package/src/utils/packager.js +210 -0
- package/src/utils/paramsConverter.js +44 -0
- package/src/utils/validator.js +119 -0
- package/src/utils/viteBuilder.js +79 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue 组件模板生成器
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** 生成 Vue 组件模板 */
|
|
6
|
+
export function generateVueComponentTemplate(componentName, manifest) {
|
|
7
|
+
const entry = manifest.entry || `${componentName}.vue`
|
|
8
|
+
const params = manifest.params || {}
|
|
9
|
+
|
|
10
|
+
// 生成参数读取辅助函数
|
|
11
|
+
const paramHelpers = Object.entries(params).map(([key, def]) => {
|
|
12
|
+
return `/** ${def.label || key} */
|
|
13
|
+
function get${toPascalCase(key)}() {
|
|
14
|
+
return props.paramValues?.['${key}'] ?? ${JSON.stringify(def.default)}
|
|
15
|
+
}`
|
|
16
|
+
}).join('\n\n')
|
|
17
|
+
|
|
18
|
+
// 生成参数面板 HTML(用于 dev-preview.js)
|
|
19
|
+
const paramPanelHTML = Object.entries(params).map(([key, def]) => {
|
|
20
|
+
if (def.type === 'number') {
|
|
21
|
+
return ` <label>${def.label || key}: <input type="range" id="param-${key}" min="${def.min ?? 0}" max="${def.max ?? 100}" step="${def.step ?? 1}" value="${def.default}"> <span id="val-${key}">${def.default}</span></label>`
|
|
22
|
+
}
|
|
23
|
+
if (def.type === 'toggle') {
|
|
24
|
+
return ` <label>${def.label || key}: <input type="checkbox" id="param-${key}" ${def.default ? 'checked' : ''}></label>`
|
|
25
|
+
}
|
|
26
|
+
if (def.type === 'color') {
|
|
27
|
+
return ` <label>${def.label || key}: <input type="color" id="param-${key}" value="${def.default}"></label>`
|
|
28
|
+
}
|
|
29
|
+
if (def.type === 'select') {
|
|
30
|
+
const options = (def.options || []).map(opt => `<option value="${opt.value}">${opt.label}</option>`).join('')
|
|
31
|
+
return ` <label>${def.label || key}: <select id="param-${key}">${options}</select></label>`
|
|
32
|
+
}
|
|
33
|
+
return ''
|
|
34
|
+
}).filter(Boolean).join('\n')
|
|
35
|
+
|
|
36
|
+
return `<template>
|
|
37
|
+
<canvas ref="canvasRef" class="wallpaper-canvas"></canvas>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<script setup>
|
|
41
|
+
/**
|
|
42
|
+
* ════════════════════════════════════════════════════════════
|
|
43
|
+
* 🎨 ${manifest.name || componentName} — 逸然3D壁纸插件
|
|
44
|
+
* ════════════════════════════════════════════════════════════
|
|
45
|
+
*
|
|
46
|
+
* 作者: ${manifest.author?.name || '开发者'}
|
|
47
|
+
* 描述: ${manifest.description || '一款精美的壁纸插件'}
|
|
48
|
+
*
|
|
49
|
+
* 开发指南:
|
|
50
|
+
* 1. 在 <template> 中只需保留 <canvas> 元素(必须含 class="wallpaper-canvas",否则截图面板找不到画布)
|
|
51
|
+
* 2. 在 init() 中创建你的动画/渲染逻辑(Three.js 用户记得开 preserveDrawingBuffer: true)
|
|
52
|
+
* 3. 在 animate() 中实现帧动画
|
|
53
|
+
* 4. 在 onUnmounted() 中清理资源
|
|
54
|
+
* 5. 使用 getParam() 读取用户调整的参数值
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { ref, onMounted, onUnmounted } from 'vue'
|
|
58
|
+
|
|
59
|
+
const props = defineProps({
|
|
60
|
+
/** 父容器引用(用于尺寸获取和事件绑定) */
|
|
61
|
+
containerRef: { type: Object, default: null },
|
|
62
|
+
/** 参数值对象(从宿主参数面板或 manifest.json 读取) */
|
|
63
|
+
paramValues: { type: Object, default: () => ({}) },
|
|
64
|
+
/** 性能策略(fpsLimit、pixelRatioCap、mouseInteraction) */
|
|
65
|
+
performancePolicy: { type: Object, default: () => ({}) },
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const canvasRef = ref(null)
|
|
69
|
+
|
|
70
|
+
// ===== 参数读取辅助函数 =====
|
|
71
|
+
${paramHelpers}
|
|
72
|
+
|
|
73
|
+
/** 安全获取参数值(带默认值回退) */
|
|
74
|
+
function getParam(key, defaultValue) {
|
|
75
|
+
const val = props.paramValues?.[key]
|
|
76
|
+
return val !== undefined ? val : defaultValue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ===== 核心变量 =====
|
|
80
|
+
let animationId = null
|
|
81
|
+
let lastFrameTime = 0
|
|
82
|
+
|
|
83
|
+
// ===== 生命周期 =====
|
|
84
|
+
onMounted(() => {
|
|
85
|
+
init()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
onUnmounted(() => {
|
|
89
|
+
// 取消动画帧
|
|
90
|
+
if (animationId) {
|
|
91
|
+
cancelAnimationFrame(animationId)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 移除事件监听
|
|
95
|
+
if (props.containerRef) {
|
|
96
|
+
props.containerRef.removeEventListener('mousemove', onMouseMove)
|
|
97
|
+
}
|
|
98
|
+
window.removeEventListener('resize', onResize)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ===== 初始化 =====
|
|
102
|
+
function init() {
|
|
103
|
+
const canvas = canvasRef.value
|
|
104
|
+
const container = props.containerRef || canvas.parentElement
|
|
105
|
+
|
|
106
|
+
if (!canvas || !container) {
|
|
107
|
+
console.warn('[${componentName}] Canvas 或容器未找到')
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 获取 Canvas 2D 上下文(如需 Three.js 可改为 WebGL)
|
|
112
|
+
const ctx = canvas.getContext('2d')
|
|
113
|
+
|
|
114
|
+
// ★ 如改用 Three.js,请按下面写法(preserveDrawingBuffer: true 是截图面板能正常工作的关键):
|
|
115
|
+
// import * as THREE from 'three'
|
|
116
|
+
// const renderer = new THREE.WebGLRenderer({ canvas, preserveDrawingBuffer: true, antialias: true, alpha: true })
|
|
117
|
+
// const scene = new THREE.Scene()
|
|
118
|
+
// const camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 100)
|
|
119
|
+
// camera.position.z = 5
|
|
120
|
+
// // ... 建场景、几何体、材质 ...
|
|
121
|
+
|
|
122
|
+
// 设置 Canvas 尺寸(统一走外部 onResize,保证 onUnmounted 的 removeEventListener 能匹配)
|
|
123
|
+
onResize()
|
|
124
|
+
|
|
125
|
+
// 绑定事件
|
|
126
|
+
if (container) {
|
|
127
|
+
container.addEventListener('mousemove', onMouseMove)
|
|
128
|
+
}
|
|
129
|
+
window.addEventListener('resize', onResize)
|
|
130
|
+
|
|
131
|
+
// 开始动画循环
|
|
132
|
+
animate()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ===== 动画循环 =====
|
|
136
|
+
function animate(timestamp) {
|
|
137
|
+
animationId = requestAnimationFrame(animate)
|
|
138
|
+
|
|
139
|
+
// FPS 限制(性能模式下可能为 30 或 15,暂停时为 0)
|
|
140
|
+
const fpsLimit = props.performancePolicy?.fpsLimit || 60
|
|
141
|
+
if (fpsLimit > 0 && fpsLimit < 60) {
|
|
142
|
+
if (!lastFrameTime) lastFrameTime = timestamp
|
|
143
|
+
const intervalMs = 1000 / fpsLimit
|
|
144
|
+
if (timestamp - lastFrameTime < intervalMs) return
|
|
145
|
+
lastFrameTime = timestamp
|
|
146
|
+
} else if (fpsLimit === 0) {
|
|
147
|
+
return // 暂停渲染
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 读取参数
|
|
151
|
+
const opacity = getOpacity()
|
|
152
|
+
const speed = getSpeed()
|
|
153
|
+
|
|
154
|
+
// ★ 在此编写你的渲染逻辑 ★
|
|
155
|
+
// 示例:绘制一个渐变背景
|
|
156
|
+
const canvas = canvasRef.value
|
|
157
|
+
if (!canvas) return
|
|
158
|
+
const ctx = canvas.getContext('2d')
|
|
159
|
+
const width = canvas.width
|
|
160
|
+
const height = canvas.height
|
|
161
|
+
|
|
162
|
+
// 清除画布
|
|
163
|
+
ctx.clearRect(0, 0, width, height)
|
|
164
|
+
|
|
165
|
+
// 示例:绘制动态粒子(可根据参数调整)
|
|
166
|
+
const particleCount = getParam('particleCount', 50)
|
|
167
|
+
for (let i = 0; i < particleCount; i++) {
|
|
168
|
+
const x = (Math.sin(timestamp * 0.001 * speed + i) * 0.5 + 0.5) * width
|
|
169
|
+
const y = (Math.cos(timestamp * 0.001 * speed + i * 0.7) * 0.5 + 0.5) * height
|
|
170
|
+
const size = 2 + Math.sin(timestamp * 0.002 + i) * 1
|
|
171
|
+
|
|
172
|
+
ctx.beginPath()
|
|
173
|
+
ctx.arc(x, y, size, 0, Math.PI * 2)
|
|
174
|
+
ctx.fillStyle = \`rgba(255, 255, 255, \${opacity * 0.6})\`
|
|
175
|
+
ctx.fill()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ===== 事件处理 =====
|
|
180
|
+
function onMouseMove(event) {
|
|
181
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
182
|
+
// 可在这里处理鼠标交互
|
|
183
|
+
// targetMouseX = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
|
184
|
+
// targetMouseY = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onResize() {
|
|
188
|
+
// ★ 注意:直接用 props.containerRef(不要取 .parentElement —— 在主窗口预览模式下
|
|
189
|
+
// parentElement 是包含参数面板的整个 preview-section,尺寸是错的)
|
|
190
|
+
const container = props.containerRef || canvasRef.value?.parentElement || window
|
|
191
|
+
const width = container.clientWidth || window.innerWidth
|
|
192
|
+
const height = container.clientHeight || window.innerHeight
|
|
193
|
+
if (canvasRef.value) {
|
|
194
|
+
canvasRef.value.width = width
|
|
195
|
+
canvasRef.value.height = height
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
</script>
|
|
199
|
+
|
|
200
|
+
<style scoped>
|
|
201
|
+
.wallpaper-canvas {
|
|
202
|
+
width: 100%;
|
|
203
|
+
height: 100%;
|
|
204
|
+
display: block;
|
|
205
|
+
}
|
|
206
|
+
</style>
|
|
207
|
+
`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 工具函数:kebab-case → PascalCase */
|
|
211
|
+
function toPascalCase(str) {
|
|
212
|
+
return str.replace(/(?:^|-)(\w)/g, (_, c) => c.toUpperCase())
|
|
213
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 终端颜色工具(chalk 封装)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import chalk from 'chalk'
|
|
6
|
+
|
|
7
|
+
export const colors = {
|
|
8
|
+
success: (msg) => chalk.green(`✅ ${msg}`),
|
|
9
|
+
error: (msg) => chalk.red(`❌ ${msg}`),
|
|
10
|
+
warn: (msg) => chalk.yellow(`⚠️ ${msg}`),
|
|
11
|
+
info: (msg) => chalk.cyan(`ℹ️ ${msg}`),
|
|
12
|
+
title: (msg) => chalk.bold.white(msg),
|
|
13
|
+
subtitle: (msg) => chalk.gray(msg),
|
|
14
|
+
highlight: (msg) => chalk.magenta(msg),
|
|
15
|
+
file: (msg) => chalk.cyan(msg),
|
|
16
|
+
code: (msg) => chalk.yellow(`\`${msg}\``),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 打印分隔线 */
|
|
20
|
+
export function printSeparator() {
|
|
21
|
+
console.log(chalk.gray('─'.repeat(50)))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 打印标题头 */
|
|
25
|
+
export function printHeader(title, version = '1.0.0') {
|
|
26
|
+
console.log('')
|
|
27
|
+
console.log('╔══════════════════════════════════════╗')
|
|
28
|
+
console.log(`║ 🧩 初尘3D壁纸 插件 SDK v${version.padEnd(10)} ║`)
|
|
29
|
+
console.log('╚══════════════════════════════════════╝')
|
|
30
|
+
console.log('')
|
|
31
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 打包核心逻辑(ZIP 生成 + 文件过滤)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs'
|
|
6
|
+
import path from 'path'
|
|
7
|
+
import JSZip from 'jszip'
|
|
8
|
+
|
|
9
|
+
/** 打包文件过滤规则 */
|
|
10
|
+
const PACK_EXCLUDE = [
|
|
11
|
+
'node_modules',
|
|
12
|
+
'.git',
|
|
13
|
+
'.gitignore',
|
|
14
|
+
'package.json',
|
|
15
|
+
'package-lock.json',
|
|
16
|
+
'yarn.lock',
|
|
17
|
+
'pnpm-lock.yaml',
|
|
18
|
+
'vite.config.js',
|
|
19
|
+
'index.html',
|
|
20
|
+
'dev-preview.js',
|
|
21
|
+
'README.md',
|
|
22
|
+
'ICON_GUIDE.md',
|
|
23
|
+
'.DS_Store',
|
|
24
|
+
'Thumbs.db',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** 判断是否应排除 */
|
|
28
|
+
function shouldExclude(filePath) {
|
|
29
|
+
const basename = path.basename(filePath)
|
|
30
|
+
return PACK_EXCLUDE.some(pattern => {
|
|
31
|
+
if (pattern.startsWith('.')) {
|
|
32
|
+
// 隐藏文件/目录
|
|
33
|
+
return basename === pattern || filePath.includes(`/${pattern}/`) || filePath.endsWith(`/${pattern}`)
|
|
34
|
+
}
|
|
35
|
+
return basename === pattern
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 打包项目为 .yizirwp ZIP */
|
|
40
|
+
export async function packProject(projectDir, outputName, manifest, options = {}) {
|
|
41
|
+
const zip = new JSZip()
|
|
42
|
+
let fileCount = 0
|
|
43
|
+
|
|
44
|
+
// 1. 添加 manifest.json
|
|
45
|
+
zip.file('manifest.json', JSON.stringify(manifest, null, 2))
|
|
46
|
+
fileCount++
|
|
47
|
+
|
|
48
|
+
// 2. 添加入口 .vue 文件
|
|
49
|
+
if (manifest.entry) {
|
|
50
|
+
const entryPath = path.resolve(projectDir, manifest.entry)
|
|
51
|
+
if (fs.existsSync(entryPath)) {
|
|
52
|
+
const content = fs.readFileSync(entryPath)
|
|
53
|
+
zip.file(manifest.entry, content)
|
|
54
|
+
fileCount++
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 3. 添加 manifest.files 中声明的其他文件(排除入口,避免重复)
|
|
59
|
+
if (Array.isArray(manifest.files)) {
|
|
60
|
+
for (const file of manifest.files) {
|
|
61
|
+
if (file === manifest.entry) continue
|
|
62
|
+
if (file === 'manifest.json') continue
|
|
63
|
+
|
|
64
|
+
const filePath = path.resolve(projectDir, file)
|
|
65
|
+
if (fs.existsSync(filePath) && !shouldExclude(file)) {
|
|
66
|
+
const stat = fs.statSync(filePath)
|
|
67
|
+
if (stat.isDirectory()) {
|
|
68
|
+
// 目录:递归添加所有文件
|
|
69
|
+
await addDirectoryToZip(zip, filePath, file)
|
|
70
|
+
fileCount += countFilesInDir(filePath)
|
|
71
|
+
} else {
|
|
72
|
+
const content = fs.readFileSync(filePath)
|
|
73
|
+
zip.file(file, content)
|
|
74
|
+
fileCount++
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 4. 检查并添加 thumbnail
|
|
81
|
+
const thumbnailPatterns = ['thumbnail.png', 'thumbnail.jpg', 'thumbnail.svg']
|
|
82
|
+
for (const thumb of thumbnailPatterns) {
|
|
83
|
+
const thumbPath = path.resolve(projectDir, thumb)
|
|
84
|
+
if (fs.existsSync(thumbPath)) {
|
|
85
|
+
zip.file(thumb, fs.readFileSync(thumbPath))
|
|
86
|
+
fileCount++
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 5. 添加预览视频/GIF(options.preview 或自动检测 preview.{mp4,webm,mov,gif})
|
|
92
|
+
const previewPath = options.preview || autoDetectPreview(projectDir)
|
|
93
|
+
if (previewPath) {
|
|
94
|
+
const previewName = path.basename(previewPath)
|
|
95
|
+
zip.file(`preview/${previewName}`, fs.readFileSync(previewPath))
|
|
96
|
+
fileCount++
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 6. 生成 ZIP(原本是第 5 步)
|
|
100
|
+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
|
|
101
|
+
|
|
102
|
+
// 7. 写入输出文件(原本是第 6 步)
|
|
103
|
+
const outputPath = outputName || `${manifest.id}-${manifest.version}.yizirwp`
|
|
104
|
+
fs.writeFileSync(outputPath, buffer)
|
|
105
|
+
|
|
106
|
+
const fileSize = (buffer.length / 1024).toFixed(1)
|
|
107
|
+
|
|
108
|
+
return { outputPath, fileSize: `${fileSize} KB`, fileCount }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 从 dist 目录打包已构建的插件
|
|
113
|
+
*
|
|
114
|
+
* @param {string} projectDir — 插件项目根目录
|
|
115
|
+
* @param {string} outputName — 输出文件名(.yizirwp)
|
|
116
|
+
* @param {Object} builtManifest — 已重写的 manifest 对象(entry=index.js)
|
|
117
|
+
* @returns {Promise<{ outputPath: string, fileSize: string, fileCount: number }>}
|
|
118
|
+
*/
|
|
119
|
+
export async function packFromDist(projectDir, outputName, builtManifest, options = {}) {
|
|
120
|
+
const zip = new JSZip()
|
|
121
|
+
let fileCount = 0
|
|
122
|
+
|
|
123
|
+
const distDir = path.resolve(projectDir, 'dist')
|
|
124
|
+
|
|
125
|
+
// 1. 添加已重写的 manifest.json
|
|
126
|
+
zip.file('manifest.json', JSON.stringify(builtManifest, null, 2))
|
|
127
|
+
fileCount++
|
|
128
|
+
|
|
129
|
+
// 2. 添加构建产物 index.js
|
|
130
|
+
const indexJsPath = path.resolve(distDir, 'index.js')
|
|
131
|
+
if (!fs.existsSync(indexJsPath)) {
|
|
132
|
+
throw new Error('构建产物 dist/index.js 不存在')
|
|
133
|
+
}
|
|
134
|
+
zip.file('index.js', fs.readFileSync(indexJsPath))
|
|
135
|
+
fileCount++
|
|
136
|
+
|
|
137
|
+
// 3. 添加缩略图(如果项目根目录存在)
|
|
138
|
+
const thumbnailPatterns = ['thumbnail.png', 'thumbnail.jpg', 'thumbnail.svg']
|
|
139
|
+
for (const thumb of thumbnailPatterns) {
|
|
140
|
+
const thumbPath = path.resolve(projectDir, thumb)
|
|
141
|
+
if (fs.existsSync(thumbPath)) {
|
|
142
|
+
zip.file(thumb, fs.readFileSync(thumbPath))
|
|
143
|
+
fileCount++
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 4. 添加预览(options.preview 或自动检测 preview.{mp4,webm,mov,gif})
|
|
149
|
+
const previewPath = options.preview || autoDetectPreview(projectDir)
|
|
150
|
+
if (previewPath) {
|
|
151
|
+
const previewName = path.basename(previewPath)
|
|
152
|
+
zip.file(`preview/${previewName}`, fs.readFileSync(previewPath))
|
|
153
|
+
fileCount++
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 5. 生成 ZIP(原本是第 4 步)
|
|
157
|
+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
|
|
158
|
+
|
|
159
|
+
// 6. 写入输出文件(原本是第 5 步)
|
|
160
|
+
const outputPath = outputName || `${builtManifest.id}-${builtManifest.version}.yizirwp`
|
|
161
|
+
fs.writeFileSync(outputPath, buffer)
|
|
162
|
+
|
|
163
|
+
const fileSize = (buffer.length / 1024).toFixed(1)
|
|
164
|
+
|
|
165
|
+
return { outputPath, fileSize: `${fileSize} KB`, fileCount }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 递归添加目录到 ZIP */
|
|
169
|
+
async function addDirectoryToZip(zip, dirPath, prefix) {
|
|
170
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
|
173
|
+
|
|
174
|
+
const fullPath = path.join(dirPath, entry.name)
|
|
175
|
+
const relativePath = `${prefix}/${entry.name}`
|
|
176
|
+
|
|
177
|
+
if (entry.isDirectory()) {
|
|
178
|
+
await addDirectoryToZip(zip, fullPath, relativePath)
|
|
179
|
+
} else {
|
|
180
|
+
zip.file(relativePath, fs.readFileSync(fullPath))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 统计目录中的文件数 */
|
|
186
|
+
function countFilesInDir(dirPath) {
|
|
187
|
+
let count = 0
|
|
188
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
|
191
|
+
|
|
192
|
+
const fullPath = path.join(dirPath, entry.name)
|
|
193
|
+
if (entry.isDirectory()) {
|
|
194
|
+
count += countFilesInDir(fullPath)
|
|
195
|
+
} else {
|
|
196
|
+
count++
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return count
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 自动检测项目根目录的预览文件(mp4 / webm / mov / gif) */
|
|
203
|
+
function autoDetectPreview(projectDir) {
|
|
204
|
+
const patterns = ['preview.mp4', 'preview.webm', 'preview.mov', 'preview.gif']
|
|
205
|
+
for (const p of patterns) {
|
|
206
|
+
const full = path.resolve(projectDir, p)
|
|
207
|
+
if (fs.existsSync(full)) return full
|
|
208
|
+
}
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 参数格式转换器
|
|
3
|
+
*
|
|
4
|
+
* 将 SDK 扁平格式(manifest.params = { key: { type, default, ... } })
|
|
5
|
+
* 转换为应用侧期望的格式({ defaults: {...}, definitions: [...] })。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 转换参数格式
|
|
10
|
+
*
|
|
11
|
+
* @param {Object<string, {type: string, default: *, label?: string, min?: number, max?: number, step?: number, options?: *, tier?: string}>} sdkParams
|
|
12
|
+
* @returns {{ defaults: Object<string, *>, definitions: Array<Object> }}
|
|
13
|
+
*/
|
|
14
|
+
export function convertParamsToAppFormat(sdkParams) {
|
|
15
|
+
if (!sdkParams || typeof sdkParams !== 'object' || Array.isArray(sdkParams)) {
|
|
16
|
+
return { defaults: {}, definitions: [] }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const defaults = {}
|
|
20
|
+
const definitions = []
|
|
21
|
+
|
|
22
|
+
for (const [key, def] of Object.entries(sdkParams)) {
|
|
23
|
+
if (!def || typeof def !== 'object') continue
|
|
24
|
+
|
|
25
|
+
defaults[key] = def.default
|
|
26
|
+
|
|
27
|
+
const definition = {
|
|
28
|
+
key,
|
|
29
|
+
type: def.type,
|
|
30
|
+
label: def.label || key,
|
|
31
|
+
default: def.default,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (def.min !== undefined) definition.min = def.min
|
|
35
|
+
if (def.max !== undefined) definition.max = def.max
|
|
36
|
+
if (def.step !== undefined) definition.step = def.step
|
|
37
|
+
if (def.options !== undefined) definition.options = def.options
|
|
38
|
+
if (def.tier !== undefined) definition.tier = def.tier
|
|
39
|
+
|
|
40
|
+
definitions.push(definition)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { defaults, definitions }
|
|
44
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manifest.json 校验工具
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs'
|
|
6
|
+
import path from 'path'
|
|
7
|
+
|
|
8
|
+
/** 校验 manifest.json 格式 */
|
|
9
|
+
export function validateManifest(manifest, projectDir) {
|
|
10
|
+
const errors = []
|
|
11
|
+
const warnings = []
|
|
12
|
+
|
|
13
|
+
// 必需字段
|
|
14
|
+
const required = ['id', 'name', 'version', 'author', 'entry', 'files']
|
|
15
|
+
for (const field of required) {
|
|
16
|
+
if (!(field in manifest)) {
|
|
17
|
+
errors.push(`缺少必需字段: "${field}"`)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ID 格式(kebab-case)
|
|
22
|
+
if (manifest.id && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(manifest.id)) {
|
|
23
|
+
errors.push(`ID 格式错误: "${manifest.id}"(仅允许小写字母、数字、连字符)`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 版本号(语义化)
|
|
27
|
+
if (manifest.version && !/^\d+\.\d+\.\d+/.test(manifest.version)) {
|
|
28
|
+
errors.push(`版本号格式错误: "${manifest.version}"(应为语义化版本如 1.0.0)`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 入口文件是否存在
|
|
32
|
+
if (manifest.entry && projectDir) {
|
|
33
|
+
const entryPath = path.resolve(projectDir, manifest.entry)
|
|
34
|
+
if (!fs.existsSync(entryPath)) {
|
|
35
|
+
errors.push(`入口文件不存在: "${manifest.entry}"`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// files 中声明的文件是否都存在
|
|
40
|
+
if (Array.isArray(manifest.files) && projectDir) {
|
|
41
|
+
for (const file of manifest.files) {
|
|
42
|
+
if (!fs.existsSync(path.resolve(projectDir, file))) {
|
|
43
|
+
errors.push(`声明的文件不存在: "${file}"`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} else if (!Array.isArray(manifest.files)) {
|
|
47
|
+
errors.push('"files" 必须是数组类型')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 推荐字段
|
|
51
|
+
const recommended = ['description', 'tags', 'category', 'params']
|
|
52
|
+
for (const field of recommended) {
|
|
53
|
+
if (!(field in manifest)) {
|
|
54
|
+
warnings.push(`建议提供字段: "${field}"`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 商业字段校验(可选)
|
|
59
|
+
if ('isPremium' in manifest && typeof manifest.isPremium !== 'boolean') {
|
|
60
|
+
errors.push('"isPremium" 必须是布尔值')
|
|
61
|
+
}
|
|
62
|
+
if ('price' in manifest) {
|
|
63
|
+
if (typeof manifest.price !== 'number' || manifest.price < 0) {
|
|
64
|
+
errors.push('"price" 必须是非负数字')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if ('originalPrice' in manifest) {
|
|
68
|
+
if (typeof manifest.originalPrice !== 'number' || manifest.originalPrice < 0) {
|
|
69
|
+
errors.push('"originalPrice" 必须是非负数字')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if ('tier' in manifest) {
|
|
73
|
+
const validTiers = ['free', 'monthly', 'yearly', 'lifetime']
|
|
74
|
+
if (!validTiers.includes(manifest.tier)) {
|
|
75
|
+
errors.push(`"tier" 必须是 ${validTiers.join('/')} 之一,当前值: "${manifest.tier}"`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ★ 真隔离重构(2026-10-08):沙箱运行时字段校验
|
|
80
|
+
// network.domains → 宿主沙箱 CSP connect-src 唯一放行依据,宁拒勿放
|
|
81
|
+
if ('network' in manifest) {
|
|
82
|
+
if (manifest.network === null || typeof manifest.network !== 'object' || Array.isArray(manifest.network)) {
|
|
83
|
+
errors.push('"network" 必须是对象,形如 { "domains": ["https://example.com"] }')
|
|
84
|
+
} else if ('domains' in manifest.network) {
|
|
85
|
+
const domains = manifest.network.domains
|
|
86
|
+
if (!Array.isArray(domains)) {
|
|
87
|
+
errors.push('"network.domains" 必须是字符串数组')
|
|
88
|
+
} else {
|
|
89
|
+
for (const d of domains) {
|
|
90
|
+
const host = typeof d === 'string'
|
|
91
|
+
? d.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
|
|
92
|
+
: ''
|
|
93
|
+
if (!/^([a-z0-9-]+\.)+[a-z]{2,}(:\d+)?$/i.test(host)) {
|
|
94
|
+
errors.push(`"network.domains" 含非法域名: ${JSON.stringify(d)}(仅支持 https 域名,如 "https://api.example.com")`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (domains.length > 8) {
|
|
98
|
+
errors.push('"network.domains" 最多声明 8 个域名')
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if ('apiVersion' in manifest) {
|
|
104
|
+
if (!Number.isInteger(manifest.apiVersion) || manifest.apiVersion < 1) {
|
|
105
|
+
errors.push('"apiVersion" 必须是不小于 1 的整数(当前沙箱 API 大版本为 1)')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 预览文件提示(warning):项目根存在 preview.* 但 manifest 未声明
|
|
110
|
+
if (projectDir) {
|
|
111
|
+
const previewPatterns = ['preview.mp4', 'preview.webm', 'preview.mov', 'preview.gif']
|
|
112
|
+
const foundPreview = previewPatterns.find(p => fs.existsSync(path.resolve(projectDir, p)))
|
|
113
|
+
if (foundPreview && !('preview' in manifest)) {
|
|
114
|
+
warnings.push(`检测到预览文件 ${foundPreview},但 manifest 未声明 "preview" 字段(不影响打包,仅提示)`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { valid: errors.length === 0, errors, warnings }
|
|
119
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite 构建器
|
|
3
|
+
*
|
|
4
|
+
* 使用 Vite 的 JavaScript API 将 Vue SFC 插件编译为自包含的 IIFE JS bundle。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs'
|
|
8
|
+
import path from 'path'
|
|
9
|
+
import { build } from 'vite'
|
|
10
|
+
import vue from '@vitejs/plugin-vue'
|
|
11
|
+
import { generatePluginEntry } from '../templates/plugin-entry.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 构建 Vue SFC 插件为 IIFE bundle
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} options
|
|
17
|
+
* @param {string} options.projectDir — 插件项目根目录(含 manifest.json 和 .vue 入口)
|
|
18
|
+
* @param {Object} options.manifest — 已解析的 manifest 对象
|
|
19
|
+
* @returns {Promise<{ outputDir: string, entryFileName: string }>}
|
|
20
|
+
*/
|
|
21
|
+
export async function buildPlugin({ projectDir, manifest }) {
|
|
22
|
+
const entryFileName = manifest.entry
|
|
23
|
+
const entryFullPath = path.resolve(projectDir, entryFileName)
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(entryFullPath)) {
|
|
26
|
+
throw new Error(`入口文件不存在: ${entryFileName}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 生成临时入口文件(.js 包装器,import 真正的 .vue 组件)
|
|
30
|
+
const tempEntryPath = path.resolve(projectDir, '.yiziran-plugin-entry.js')
|
|
31
|
+
const entryCode = generatePluginEntry(`./${entryFileName}`)
|
|
32
|
+
fs.writeFileSync(tempEntryPath, entryCode)
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await build({
|
|
36
|
+
configFile: false,
|
|
37
|
+
logLevel: 'warn',
|
|
38
|
+
root: projectDir,
|
|
39
|
+
plugins: [vue()],
|
|
40
|
+
define: {
|
|
41
|
+
'process.env.NODE_ENV': JSON.stringify('production'),
|
|
42
|
+
'process.env': '{}',
|
|
43
|
+
},
|
|
44
|
+
build: {
|
|
45
|
+
lib: {
|
|
46
|
+
entry: tempEntryPath,
|
|
47
|
+
name: 'YiziranPlugin',
|
|
48
|
+
formats: ['iife'],
|
|
49
|
+
fileName: () => 'index.js',
|
|
50
|
+
},
|
|
51
|
+
outDir: path.resolve(projectDir, 'dist'),
|
|
52
|
+
emptyOutDir: true,
|
|
53
|
+
minify: false,
|
|
54
|
+
sourcemap: false,
|
|
55
|
+
rollupOptions: {
|
|
56
|
+
// ★ 真隔离重构(2026-10-08):产物自包含 —— vue/three 全部打进 IIFE。
|
|
57
|
+
// 旧方案 external 到宿主 window.__YIZIRAN_VUE__/__YIZIRAN_THREE__ 依赖
|
|
58
|
+
// 同上下文执行,沙箱(opaque origin iframe)内不可达,已废除。
|
|
59
|
+
// 代价:包 +约 700KB 未压缩(zip 后 +150~250KB)。
|
|
60
|
+
external: [],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
resolve: {
|
|
64
|
+
alias: {
|
|
65
|
+
vue: 'vue/dist/vue.esm-bundler.js',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
outputDir: path.resolve(projectDir, 'dist'),
|
|
72
|
+
entryFileName: 'index.js',
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
if (fs.existsSync(tempEntryPath)) {
|
|
76
|
+
fs.unlinkSync(tempEntryPath)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|