add standalone tool build and production dynamic loading

This commit is contained in:
jingrow 2025-11-22 00:52:10 +08:00
parent 85058f51e7
commit 22dec06133
4 changed files with 475 additions and 27 deletions

View File

@ -0,0 +1,208 @@
#!/usr/bin/env node
/**
* 单独构建工具脚本
* 用法: node build-tool.js <tool_name>
* 示例: node build-tool.js remove_background
*
* 构建输出:
* - dist/tools/{tool_name}/assets/ 包含构建后的 JS CSS 文件
* - dist/tools/{tool_name}/manifest.json 包含构建文件清单
*/
import { build } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
import Components from 'unplugin-vue-components/vite'
import path from 'node:path'
import fs from 'node:fs'
const currentDir = fileURLToPath(new URL('.', import.meta.url))
const srcDir = path.resolve(currentDir, 'src')
const toolsDir = path.resolve(srcDir, 'views', 'tools')
// 获取工具名称
const toolName = process.argv[2]
if (!toolName) {
console.error('错误: 请提供工具名称')
console.log('用法: node build-tool.js <tool_name>')
console.log('示例: node build-tool.js remove_background')
process.exit(1)
}
const toolDir = path.resolve(toolsDir, toolName)
const toolVueFile = path.resolve(toolDir, `${toolName}.vue`)
// 检查工具是否存在
if (!fs.existsSync(toolVueFile)) {
console.error(`错误: 工具 "${toolName}" 不存在`)
console.error(`路径: ${toolVueFile}`)
process.exit(1)
}
console.log(`开始构建工具: ${toolName}`)
console.log(`工具目录: ${toolDir}`)
// 创建临时入口文件
const tempEntryDir = path.resolve(currentDir, 'tmp', 'tool-build')
const tempEntryFile = path.resolve(tempEntryDir, `${toolName}.js`)
const tempIndexHtml = path.resolve(tempEntryDir, 'index.html')
// 确保临时目录存在
fs.mkdirSync(tempEntryDir, { recursive: true })
// 生成入口文件内容
// 注意:构建为 ES 模块,导出组件供动态 import 使用
const entryContent = `import Component from '../../src/views/tools/${toolName}/${toolName}.vue'
// 导出组件供外部动态加载
export default Component
`
// 生成临时 HTML 文件
const htmlContent = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${toolName}</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./${toolName}.js"></script>
</body>
</html>
`
fs.writeFileSync(tempEntryFile, entryContent, 'utf-8')
fs.writeFileSync(tempIndexHtml, htmlContent, 'utf-8')
// 输出目录
const outputDir = path.resolve(currentDir, 'dist', 'tools', toolName)
// 构建配置(使用与主应用相同的配置)
const buildConfig = {
plugins: [
vue(),
Icons({
autoInstall: true,
compiler: 'vue3'
}),
Components({
resolvers: [
IconsResolver({
prefix: 'i',
enabledCollections: ['tabler']
}),
],
}),
],
resolve: {
alias: {
'@': path.resolve(currentDir, 'src'),
}
},
build: {
outDir: outputDir,
assetsDir: 'assets',
rollupOptions: {
input: tempEntryFile,
output: {
format: 'es', // 使用 ES 模块格式,便于动态 import
// 使用工具名称作为文件名前缀,便于识别
entryFileNames: `assets/${toolName}-[hash].js`,
chunkFileNames: `assets/[name]-[hash].js`,
assetFileNames: (assetInfo) => {
const ext = path.extname(assetInfo.name || '')
if (ext === '.css') {
return `assets/${toolName}-[hash].css`
}
return `assets/[name]-[hash]${ext}`
}
}
},
sourcemap: false,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true
}
}
}
}
// 执行构建
try {
console.log('正在构建...')
await build(buildConfig)
// 生成 manifest 文件
const manifest = {
toolName: toolName,
buildTime: new Date().toISOString(),
files: []
}
// 扫描输出目录,记录所有文件
const assetsDir = path.resolve(outputDir, 'assets')
if (fs.existsSync(assetsDir)) {
const scanDir = (dir, basePath = '') => {
const files = fs.readdirSync(dir)
files.forEach(file => {
const filePath = path.resolve(dir, file)
const relativePath = path.join(basePath, file)
if (fs.statSync(filePath).isFile()) {
const stats = fs.statSync(filePath)
manifest.files.push({
path: relativePath,
size: stats.size,
name: path.basename(filePath)
})
} else if (fs.statSync(filePath).isDirectory()) {
scanDir(filePath, relativePath)
}
})
}
scanDir(assetsDir, 'assets')
}
// 保存 manifest
const manifestPath = path.resolve(outputDir, 'manifest.json')
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
console.log(`\n✓ 构建成功!`)
console.log(`输出目录: ${outputDir}`)
console.log(`构建文件:`)
manifest.files.forEach(file => {
console.log(` - ${file.path} (${(file.size / 1024).toFixed(2)} KB)`)
})
console.log(`\nManifest 文件: ${manifestPath}`)
// 清理临时文件
if (fs.existsSync(tempEntryFile)) {
fs.unlinkSync(tempEntryFile)
}
if (fs.existsSync(tempIndexHtml)) {
fs.unlinkSync(tempIndexHtml)
}
if (fs.existsSync(tempEntryDir) && fs.readdirSync(tempEntryDir).length === 0) {
fs.rmdirSync(tempEntryDir)
}
console.log(`\n提示: 构建文件位于 dist/tools/${toolName}/assets/`)
console.log(`可以将此目录复制到工具包的 frontend/dist/ 目录中`)
} catch (error) {
console.error('构建失败:', error)
// 清理临时文件
if (fs.existsSync(tempEntryFile)) {
fs.unlinkSync(tempEntryFile)
}
if (fs.existsSync(tempIndexHtml)) {
fs.unlinkSync(tempIndexHtml)
}
process.exit(1)
}

View File

@ -5,6 +5,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:tool": "node build-tool.js",
"preview": "vite preview"
},
"dependencies": {

View File

@ -29,6 +29,56 @@ export function ensureToolRoutes(tool: Tool): Tool {
return tool
}
/**
*
*/
function isProduction(): boolean {
return import.meta.env.MODE === 'production' || import.meta.env.PROD
}
/**
*
*/
async function loadToolBuiltComponent(toolName: string): Promise<any> {
try {
// 尝试加载 manifest 文件
const manifestUrl = `/tools/${toolName}/manifest.json`
const manifestResponse = await fetch(manifestUrl)
if (!manifestResponse.ok) {
throw new Error(`Manifest not found: ${manifestUrl}`)
}
const manifest = await manifestResponse.json()
// 查找 JS 文件
const jsFile = manifest.files?.find((f: any) => f.path.endsWith('.js'))
if (!jsFile) {
throw new Error('No JS file found in manifest')
}
// 动态加载 JS 文件
const jsUrl = `/tools/${toolName}/${jsFile.path}`
const module = await import(/* @vite-ignore */ jsUrl)
// 查找 CSS 文件并加载
const cssFile = manifest.files?.find((f: any) => f.path.endsWith('.css'))
if (cssFile) {
const cssUrl = `/tools/${toolName}/${cssFile.path}`
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = cssUrl
document.head.appendChild(link)
}
// 返回组件(假设构建文件导出默认组件)
return module.default || module
} catch (error) {
console.error(`Failed to load built tool component: ${toolName}`, error)
throw error
}
}
export function registerToolRoute(
router: Router,
tool: Tool,
@ -58,11 +108,28 @@ export function registerToolRoute(
return `tools/${toolDirName.replace(/_/g, '-')}`
})()
const toolName = toolWithRoutes.toolName || toolWithRoutes.id
const route: RouteRecordRaw = {
path: routePath,
name: toolWithRoutes.routeName,
component: () => {
return import(finalComponentPath).catch((error) => {
component: async () => {
// 生产环境:优先尝试加载独立构建文件
if (isProduction() && toolName) {
try {
const builtComponent = await loadToolBuiltComponent(toolName)
return builtComponent
} catch (builtError) {
console.warn(`Failed to load built tool ${toolName}, falling back to source:`, builtError)
// 如果加载构建文件失败,回退到源码加载
}
}
// 开发环境或构建文件加载失败:加载源码
try {
return await import(finalComponentPath)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`Failed to load tool component: ${finalComponentPath}`, error)
return {
name: 'ToolComponentError',
@ -70,11 +137,11 @@ export function registerToolRoute(
<div style="padding: 20px; text-align: center;">
<h3>Component Not Found</h3>
<p>Failed to load component: ${finalComponentPath}</p>
<p style="color: #666; font-size: 12px; margin-top: 10px;">Error: ${error.message || error}</p>
<p style="color: #666; font-size: 12px; margin-top: 10px;">Error: ${errorMessage}</p>
</div>
`
}
})
}
},
meta: {
requiresAuth: true,

View File

@ -261,8 +261,20 @@ def _install_tool_from_file(file_path: str, tool_data: Dict[str, Any]) -> Dict[s
return {'success': False, 'error': str(e)}
def _is_production_environment() -> bool:
"""检测是否为生产环境检查dist目录是否存在"""
jingrow_root = get_jingrow_root()
frontend_dist = jingrow_root.parent / "frontend" / "dist"
return frontend_dist.exists() and frontend_dist.is_dir()
def _install_single_tool_directory(tool_dir: str, tool_data: Dict[str, Any]) -> Dict[str, Any]:
"""安装单个工具目录(每个工具独立)"""
"""安装单个工具目录(每个工具独立)
支持两种模式
1. 开发环境复制源码到 src/views/tools/{tool_name}/
2. 生产环境如果工具包包含构建文件复制到 dist/assets/
"""
try:
tool_dir_path = Path(tool_dir)
@ -271,21 +283,9 @@ def _install_single_tool_directory(tool_dir: str, tool_data: Dict[str, Any]) ->
if not tool_name:
return {'success': False, 'error': '工具元数据中缺少 tool_name'}
# 确定目标目录apps/jingrow/frontend/src/views/tools/{tool_name}
jingrow_root = get_jingrow_root()
frontend_root = jingrow_root.parent / "frontend" / "src"
tool_frontend_dir = frontend_root / "views" / "tools" / tool_name
tool_frontend_dir.mkdir(parents=True, exist_ok=True)
# 复制前端组件文件(如果存在)
# 结构tool_name/frontend/tool_name/tool_name.vue
frontend_source = tool_dir_path / "frontend" / tool_name
if frontend_source.exists() and frontend_source.is_dir():
# 复制 frontend/tool_name/ 目录下的所有内容到目标目录
if tool_frontend_dir.exists():
shutil.rmtree(tool_frontend_dir)
shutil.copytree(frontend_source, tool_frontend_dir)
logger.info(f"复制前端组件目录: {frontend_source} -> {tool_frontend_dir}")
frontend_root = jingrow_root.parent / "frontend"
is_production = _is_production_environment()
# 复制后端文件(如果存在)
# 结构tool_name/backend/tool_name/tool_name.py
@ -298,11 +298,74 @@ def _install_single_tool_directory(tool_dir: str, tool_data: Dict[str, Any]) ->
shutil.copytree(backend_source, backend_target)
logger.info(f"复制后端文件目录: {backend_source} -> {backend_target}")
# 处理前端文件
if is_production:
# 生产环境:优先使用构建文件
frontend_dist_source = tool_dir_path / "frontend" / "dist"
if frontend_dist_source.exists() and frontend_dist_source.is_dir():
# 每个工具使用独立的目录dist/tools/{tool_name}/assets/
tool_dist_dir = frontend_root / "dist" / "tools" / tool_name
tool_assets_dir = tool_dist_dir / "assets"
tool_assets_dir.mkdir(parents=True, exist_ok=True)
# 复制构建文件到独立目录
# 检查源目录结构:可能是 frontend/dist/assets/ 或 frontend/dist/ 直接包含文件
assets_source = frontend_dist_source / "assets"
if assets_source.exists() and assets_source.is_dir():
# 如果源目录有 assets 子目录,复制 assets 目录内容
source_dir = assets_source
else:
# 否则直接复制 dist 目录下的文件
source_dir = frontend_dist_source
# 复制所有构建文件
for item in source_dir.iterdir():
if item.is_file():
# 复制文件到工具独立目录
shutil.copy2(item, tool_assets_dir / item.name)
logger.info(f"复制构建文件: {item} -> {tool_assets_dir / item.name}")
elif item.is_dir():
# 如果是目录,递归复制
target_dir = tool_assets_dir / item.name
if target_dir.exists():
shutil.rmtree(target_dir)
shutil.copytree(item, target_dir)
logger.info(f"复制构建目录: {item} -> {target_dir}")
# 复制 manifest.json如果存在
manifest_source = frontend_dist_source / "manifest.json"
if manifest_source.exists():
shutil.copy2(manifest_source, tool_dist_dir / "manifest.json")
logger.info(f"复制 manifest 文件: {manifest_source} -> {tool_dist_dir / 'manifest.json'}")
logger.info(f"生产环境:已复制工具构建文件到独立目录 dist/tools/{tool_name}/assets/")
else:
# 生产环境但没有构建文件,尝试复制源码(可能需要重新构建)
frontend_source = tool_dir_path / "frontend" / tool_name
if frontend_source.exists() and frontend_source.is_dir():
tool_frontend_dir = frontend_root / "src" / "views" / "tools" / tool_name
if tool_frontend_dir.exists():
shutil.rmtree(tool_frontend_dir)
shutil.copytree(frontend_source, tool_frontend_dir)
logger.warning(f"生产环境:工具包未包含构建文件,已复制源码到 {tool_frontend_dir},需要重新构建前端")
else:
logger.warning(f"生产环境:工具包未找到前端文件(既无构建文件也无源码)")
else:
# 开发环境:复制源码
frontend_source = tool_dir_path / "frontend" / tool_name
if frontend_source.exists() and frontend_source.is_dir():
tool_frontend_dir = frontend_root / "src" / "views" / "tools" / tool_name
if tool_frontend_dir.exists():
shutil.rmtree(tool_frontend_dir)
shutil.copytree(frontend_source, tool_frontend_dir)
logger.info(f"开发环境:复制前端组件目录: {frontend_source} -> {tool_frontend_dir}")
return {
'success': True,
'tool_name': tool_name,
'tool_title': tool_data.get('title', tool_name),
'message': f"工具 {tool_name} 安装成功"
'message': f"工具 {tool_name} 安装成功",
'environment': 'production' if is_production else 'development'
}
except Exception as e:
@ -316,10 +379,11 @@ async def uninstall_tool(tool_name: str):
try:
logger.info(f"开始卸载工具: {tool_name}")
jingrow_root = get_jingrow_root()
frontend_root = jingrow_root.parent / "frontend"
# 前端目录apps/jingrow/frontend/src/views/tools/{tool_name}
frontend_root = jingrow_root.parent / "frontend" / "src"
tool_frontend_dir = frontend_root / "views" / "tools" / tool_name
# 前端源码目录apps/jingrow/frontend/src/views/tools/{tool_name}
frontend_src_root = frontend_root / "src"
tool_frontend_dir = frontend_src_root / "views" / "tools" / tool_name
# 后端目录apps/jingrow/jingrow/tools/{tool_name}
tool_backend_dir = jingrow_root / "tools" / tool_name
@ -330,15 +394,26 @@ async def uninstall_tool(tool_name: str):
logger.info(f"后端目录存在: {tool_backend_dir.exists()}")
deleted_paths = []
warnings = []
# 删除前端目录
# 删除前端源码目录
if tool_frontend_dir.exists():
shutil.rmtree(tool_frontend_dir)
deleted_paths.append(f"前端: {tool_frontend_dir}")
deleted_paths.append(f"前端源码: {tool_frontend_dir}")
logger.info(f"删除工具前端目录成功: {tool_frontend_dir}")
else:
logger.warning(f"前端目录不存在: {tool_frontend_dir}")
# 生产环境:删除工具的独立构建目录
if _is_production_environment():
tool_dist_dir = frontend_root / "dist" / "tools" / tool_name
if tool_dist_dir.exists():
shutil.rmtree(tool_dist_dir)
deleted_paths.append(f"前端构建文件: {tool_dist_dir}")
logger.info(f"删除工具构建目录成功: {tool_dist_dir}")
else:
logger.info(f"工具构建目录不存在: {tool_dist_dir}")
# 删除后端目录
if tool_backend_dir.exists():
shutil.rmtree(tool_backend_dir)
@ -356,11 +431,16 @@ async def uninstall_tool(tool_name: str):
'backend_path': str(tool_backend_dir)
}
message = f'工具 {tool_name} 卸载成功'
if warnings:
message += f"。注意:{warnings[0]}"
logger.info(f"工具 {tool_name} 卸载成功,已删除路径: {deleted_paths}")
return {
'success': True,
'message': f'工具 {tool_name} 卸载成功',
'deleted_paths': deleted_paths
'message': message,
'deleted_paths': deleted_paths,
'warnings': warnings
}
except Exception as e:
@ -532,3 +612,95 @@ async def delete_published_tool(request: Request, payload: Dict[str, Any]):
logger.error(f"删除已发布工具失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"删除已发布工具失败: {str(e)}")
@router.post("/jingrow/build-tool/{tool_name}")
async def build_tool(tool_name: str):
"""单独构建工具
构建工具的前端组件生成生产环境可用的构建文件
构建输出位于: apps/jingrow/frontend/dist/tools/{tool_name}/
"""
try:
jingrow_root = get_jingrow_root()
frontend_root = jingrow_root.parent / "frontend"
tool_dir = frontend_root / "src" / "views" / "tools" / tool_name
tool_vue_file = tool_dir / f"{tool_name}.vue"
# 检查工具是否存在
if not tool_vue_file.exists():
raise HTTPException(
status_code=404,
detail=f"工具 {tool_name} 不存在,路径: {tool_vue_file}"
)
# 检查 Node.js 和 npm 是否可用
try:
subprocess.run(['node', '--version'], capture_output=True, check=True, timeout=5)
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
raise HTTPException(
status_code=500,
detail="Node.js 未安装或不可用,无法构建工具"
)
# 切换到前端目录
original_cwd = os.getcwd()
os.chdir(str(frontend_root))
try:
# 执行构建脚本
build_script = frontend_root / "build-tool.js"
if not build_script.exists():
raise HTTPException(
status_code=500,
detail=f"构建脚本不存在: {build_script}"
)
logger.info(f"开始构建工具: {tool_name}")
result = subprocess.run(
['node', 'build-tool.js', tool_name],
capture_output=True,
text=True,
timeout=300, # 5分钟超时
cwd=str(frontend_root)
)
if result.returncode != 0:
error_msg = result.stderr or result.stdout
logger.error(f"构建工具失败: {error_msg}")
raise HTTPException(
status_code=500,
detail=f"构建工具失败: {error_msg}"
)
# 读取 manifest 文件
output_dir = frontend_root / "dist" / "tools" / tool_name
manifest_file = output_dir / "manifest.json"
manifest_data = {}
if manifest_file.exists():
with open(manifest_file, 'r', encoding='utf-8') as f:
manifest_data = json.load(f)
logger.info(f"工具 {tool_name} 构建成功")
return {
'success': True,
'tool_name': tool_name,
'output_dir': str(output_dir),
'manifest': manifest_data,
'message': f"工具 {tool_name} 构建成功",
'build_output': result.stdout
}
finally:
os.chdir(original_cwd)
except HTTPException:
raise
except subprocess.TimeoutExpired:
raise HTTPException(status_code=500, detail="构建超时超过5分钟")
except Exception as e:
logger.error(f"构建工具失败: {str(e)}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"构建工具失败: {str(e)}")