新增热更新后台管理系统

This commit is contained in:
YHH
2025-06-19 18:32:32 +08:00
parent 37d75c3281
commit d29c9a96f4
21 changed files with 6749 additions and 4 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"name": "cocos-ecs-hotupdate-admin",
"version": "1.0.0",
"description": "Cocos ECS Extension Hot Update Management Backend",
"main": "dist/server.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"migrate": "ts-node src/database/migrate.ts",
"simple": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"sqlite3": "^5.1.6",
"cors": "^2.8.5",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"adm-zip": "^0.5.10",
"crypto": "^1.0.1",
"fs-extra": "^11.1.1",
"moment": "^2.29.4",
"node-cron": "^3.0.3"
},
"devDependencies": {
"@types/express": "^4.17.17",
"@types/multer": "^1.4.7",
"@types/node": "^20.5.0",
"@types/cors": "^2.8.13",
"@types/bcryptjs": "^2.4.2",
"@types/jsonwebtoken": "^9.0.2",
"@types/adm-zip": "^0.5.0",
"@types/fs-extra": "^11.0.1",
"@types/node-cron": "^3.0.8",
"typescript": "^5.1.6",
"ts-node-dev": "^2.0.0"
}
}

View File

@@ -0,0 +1,686 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cocos ECS Extension - 热更新管理后台</title>
<link href="https://cdn.jsdelivr.net/npm/element-plus@2.4.4/dist/index.css" rel="stylesheet">
<style>
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.header { background: #409eff; color: white; padding: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.container { max-width: 1200px; margin: 20px auto; padding: 0 20px; }
.card { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin-bottom: 20px; }
.upload-area {
border: 2px dashed #dcdfe6;
border-radius: 8px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover { border-color: #409eff; background: #f0f8ff; }
.version-list { max-height: 400px; overflow-y: auto; }
.version-item {
padding: 16px;
border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-between;
align-items: center;
}
.version-info h4 { margin: 0 0 8px 0; color: #303133; }
.version-info p { margin: 0; color: #909399; font-size: 14px; }
.status-tag {
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: bold;
}
.status-active { background: #f0f9ff; color: #409eff; }
.status-draft { background: #fdf6ec; color: #e6a23c; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
.stat-card {
padding: 24px;
text-align: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
}
.stat-number { font-size: 32px; font-weight: bold; margin-bottom: 8px; }
.stat-label { font-size: 14px; opacity: 0.9; }
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary { background: #409eff; color: white; }
.btn-primary:hover { background: #337ecc; }
.btn-danger { background: #f56c6c; color: white; }
.btn-danger:hover { background: #dd6161; }
.btn-success { background: #67c23a; color: white; }
.btn-success:hover { background: #5daf34; }
.plugin-card:hover { border-color: #409eff; background: #f0f8ff; }
.plugin-active { border-color: #409eff !important; background: #f0f8ff !important; }
.filtered-out { opacity: 0.3; }
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 600px;
max-width: 90%;
max-height: 90%;
overflow-y: auto;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
color: #303133;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #909399;
padding: 0;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: #f5f7fa;
color: #606266;
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 20px 24px;
border-top: 1px solid #e4e7ed;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #606266;
font-weight: 500;
}
.form-group small {
display: block;
margin-top: 4px;
color: #909399;
font-size: 12px;
}
.btn.btn-default {
background: #fff;
color: #606266;
border: 1px solid #dcdfe6;
}
.btn.btn-default:hover {
color: #409eff;
border-color: #c6e2ff;
background: #ecf5ff;
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<h1 style="margin: 0; font-size: 24px;">🚀 Cocos ECS Extension - 热更新管理后台</h1>
</div>
<div class="container">
<!-- 插件选择器 -->
<div class="card">
<div style="padding: 20px;">
<h2>🔌 插件管理</h2>
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<div
v-for="plugin in plugins"
:key="plugin.id"
@click="selectPlugin(plugin.id)"
:class="'plugin-card ' + (currentPlugin === plugin.id ? 'plugin-active' : '')"
style="flex: 1; min-width: 250px; padding: 20px; border: 2px solid #e4e7ed; border-radius: 8px; cursor: pointer; transition: all 0.3s;"
>
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 32px;">{{ plugin.icon }}</span>
<div style="flex: 1;">
<h3 style="margin: 0; color: #303133;">{{ plugin.displayName }}</h3>
<p style="margin: 4px 0 0 0; color: #909399; font-size: 14px;">{{ plugin.description || '暂无描述' }}</p>
<div style="margin: 8px 0 0 0; display: flex; gap: 16px; font-size: 12px;">
<span style="color: #67c23a;">{{ plugin.status === 'active' ? '✅ 活跃' : '⏸️ 暂停' }}</span>
<span style="color: #409eff;">📦 版本: {{ plugin.versionCount || 0 }}</span>
<span style="color: #e6a23c;">📥 下载: {{ plugin.totalDownloads || 0 }}</span>
</div>
<p style="margin: 4px 0 0 0; color: #c0c4cc; font-size: 12px;">{{ plugin.author || '未知作者' }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 统计数据 -->
<div class="card">
<div style="padding: 20px;">
<h2>📊 系统统计</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number">{{ stats.totalVersions }}</div>
<div class="stat-label">总版本数</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ stats.activeUsers }}</div>
<div class="stat-label">活跃用户</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ stats.todayUpdates }}</div>
<div class="stat-label">今日更新</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ stats.successRate }}%</div>
<div class="stat-label">成功率</div>
</div>
</div>
</div>
</div>
<!-- 上传新版本 -->
<div class="card">
<div style="padding: 20px;">
<h2>📦 上传新版本</h2>
<div class="upload-area" @click="selectFile">
<div v-if="!uploading">
<i style="font-size: 48px; color: #c0c4cc;">📁</i>
<p style="margin: 16px 0 0 0; color: #606266;">点击或拖拽文件到此处上传插件包</p>
<p style="margin: 8px 0 0 0; color: #909399; font-size: 14px;">支持 .zip 格式,最大 100MB</p>
</div>
<div v-else>
<i style="font-size: 48px; color: #409eff;"></i>
<p style="margin: 16px 0 0 0; color: #409eff;">上传中... {{ uploadProgress }}%</p>
</div>
</div>
<input type="file" ref="fileInput" @change="handleFileSelect" accept=".zip" style="display: none;">
<div v-if="selectedFile" style="margin-top: 20px; padding: 16px; background: #f5f7fa; border-radius: 4px;">
<h4 style="margin: 0 0 12px 0;">版本信息</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div>
<label>目标插件:</label>
<select v-model="versionInfo.pluginId" style="width: 100%; padding: 8px; border: 1px solid #dcdfe6; border-radius: 4px;">
<option v-for="plugin in plugins" :key="plugin.id" :value="plugin.id">
{{ plugin.icon }} {{ plugin.displayName }}
</option>
</select>
</div>
<div>
<label>版本号:</label>
<input v-model="versionInfo.version" style="width: 100%; padding: 8px; border: 1px solid #dcdfe6; border-radius: 4px;">
</div>
<div>
<label>发布渠道:</label>
<select v-model="versionInfo.channel" style="width: 100%; padding: 8px; border: 1px solid #dcdfe6; border-radius: 4px;">
<option value="stable">稳定版</option>
<option value="beta">测试版</option>
<option value="dev">开发版</option>
</select>
</div>
</div>
<div style="margin-top: 16px;">
<label>更新说明:</label>
<textarea v-model="versionInfo.description" rows="3" style="width: 100%; padding: 8px; border: 1px solid #dcdfe6; border-radius: 4px;"></textarea>
</div>
<div style="margin-top: 16px;">
<label>
<input type="checkbox" v-model="versionInfo.mandatory"> 强制更新
</label>
</div>
<div style="margin-top: 16px;">
<button class="btn btn-primary" @click="uploadVersion" :disabled="uploading">
{{ uploading ? '上传中...' : '发布版本' }}
</button>
</div>
</div>
</div>
</div>
<!-- 版本管理 -->
<div class="card">
<div style="padding: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>📋 版本管理 - {{ getCurrentPluginName() }}</h2>
<div style="display: flex; gap: 12px; align-items: center;">
<button class="btn btn-primary" @click="showCreatePlugin = true">+ 创建新项目</button>
<select v-model="currentPlugin" @change="filterVersions" style="padding: 8px; border: 1px solid #dcdfe6; border-radius: 4px;">
<option value="">全部插件</option>
<option v-for="plugin in plugins" :key="plugin.id" :value="plugin.id">
{{ plugin.icon }} {{ plugin.displayName }}
</option>
</select>
<button class="btn btn-success" @click="refreshVersions">刷新列表</button>
</div>
</div>
<div class="version-list">
<div v-for="version in filteredVersions" :key="version.id" class="version-item">
<div class="version-info">
<h4>
<span style="margin-right: 8px;">{{ getPluginIcon(version.pluginId) }}</span>
{{ version.version }} ({{ getChannelName(version.channel) }})
<span style="font-size: 12px; color: #909399; margin-left: 8px;">- {{ getPluginDisplayName(version.pluginId) }}</span>
</h4>
<p>{{ version.description }}</p>
<p>发布时间: {{ formatDate(version.release_date) }} | 文件大小: {{ formatSize(version.file_size) }}</p>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span :class="'status-tag status-' + version.status">
{{ getStatusName(version.status) }}
</span>
<button class="btn btn-primary" @click="editVersion(version)">编辑</button>
<button class="btn btn-danger" @click="deleteVersion(version)">删除</button>
</div>
</div>
<div v-if="filteredVersions.length === 0" style="text-align: center; padding: 40px; color: #909399;">
{{ currentPlugin ? '该插件暂无版本数据' : '暂无版本数据' }}
</div>
</div>
</div>
</div>
</div>
<!-- 创建项目模态框 -->
<div v-if="showCreatePlugin" class="modal-overlay" @click="showCreatePlugin = false">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>🎯 创建新项目</h3>
<button @click="showCreatePlugin = false" class="modal-close">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>项目ID *</label>
<input v-model="newPlugin.id" placeholder="例如: my-awesome-plugin" style="width: 100%; padding: 12px; border: 1px solid #dcdfe6; border-radius: 4px;">
<small>项目ID必须是唯一的建议使用小写字母和短横线</small>
</div>
<div class="form-group">
<label>项目名称 *</label>
<input v-model="newPlugin.name" placeholder="例如: my-awesome-plugin" style="width: 100%; padding: 12px; border: 1px solid #dcdfe6; border-radius: 4px;">
</div>
<div class="form-group">
<label>显示名称 *</label>
<input v-model="newPlugin.displayName" placeholder="例如: My Awesome Plugin" style="width: 100%; padding: 12px; border: 1px solid #dcdfe6; border-radius: 4px;">
</div>
<div class="form-group">
<label>项目描述</label>
<textarea v-model="newPlugin.description" rows="3" placeholder="描述你的项目功能和特点..." style="width: 100%; padding: 12px; border: 1px solid #dcdfe6; border-radius: 4px;"></textarea>
</div>
<div class="form-group">
<label>作者</label>
<input v-model="newPlugin.author" placeholder="例如: Your Name" style="width: 100%; padding: 12px; border: 1px solid #dcdfe6; border-radius: 4px;">
</div>
<div class="form-group">
<label>仓库地址</label>
<input v-model="newPlugin.repository" placeholder="例如: https://github.com/user/repo" style="width: 100%; padding: 12px; border: 1px solid #dcdfe6; border-radius: 4px;">
</div>
<div class="form-group">
<label>图标 (Emoji)</label>
<input v-model="newPlugin.icon" placeholder="📦" maxlength="2" style="width: 100%; padding: 12px; border: 1px solid #dcdfe6; border-radius: 4px;">
</div>
</div>
<div class="modal-footer">
<button @click="showCreatePlugin = false" class="btn btn-default">取消</button>
<button @click="createPlugin" class="btn btn-primary" :disabled="creatingPlugin">
{{ creatingPlugin ? '创建中...' : '创建项目' }}
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.8/dist/vue.global.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
uploading: false,
uploadProgress: 0,
selectedFile: null,
currentPlugin: 'cocos-ecs-extension', // 当前选中的插件
showCreatePlugin: false,
creatingPlugin: false,
newPlugin: {
id: '',
name: '',
displayName: '',
description: '',
author: '',
repository: '',
icon: '📦'
},
plugins: [],
versionInfo: {
pluginId: 'cocos-ecs-extension',
version: '',
channel: 'stable',
description: '',
mandatory: false
},
stats: {
totalVersions: 0,
activeUsers: 0,
todayUpdates: 0,
successRate: 100.0
},
versions: []
}
},
computed: {
filteredVersions() {
if (!this.currentPlugin) {
return this.versions;
}
return this.versions.filter(v => v.pluginId === this.currentPlugin);
}
},
methods: {
selectPlugin(pluginId) {
this.currentPlugin = pluginId;
this.versionInfo.pluginId = pluginId;
},
filterVersions() {
// 触发计算属性重新计算
},
getCurrentPluginName() {
if (!this.currentPlugin) return '全部插件';
const plugin = this.plugins.find(p => p.id === this.currentPlugin);
return plugin ? plugin.displayName : '未知插件';
},
getPluginIcon(pluginId) {
const plugin = this.plugins.find(p => p.id === pluginId);
return plugin ? plugin.icon : '📦';
},
getPluginDisplayName(pluginId) {
const plugin = this.plugins.find(p => p.id === pluginId);
return plugin ? plugin.displayName : pluginId;
},
selectFile() {
this.$refs.fileInput.click();
},
handleFileSelect(event) {
const file = event.target.files[0];
if (file) {
this.selectedFile = file;
// 自动提取版本号(如果文件名包含版本信息)
const match = file.name.match(/(\d+\.\d+\.\d+)/);
if (match) {
this.versionInfo.version = match[1];
}
}
},
async uploadVersion() {
if (!this.selectedFile) return;
this.uploading = true;
this.uploadProgress = 0;
try {
const formData = new FormData();
formData.append('package', this.selectedFile);
formData.append('pluginId', this.versionInfo.pluginId);
formData.append('version', this.versionInfo.version);
formData.append('channel', this.versionInfo.channel);
formData.append('description', this.versionInfo.description);
formData.append('mandatory', this.versionInfo.mandatory.toString());
// 模拟上传进度
const progressInterval = setInterval(() => {
this.uploadProgress = Math.min(this.uploadProgress + Math.random() * 15, 90);
}, 200);
// 调用真实的上传API
const response = await fetch('/api/upload/package', {
method: 'POST',
body: formData
});
clearInterval(progressInterval);
this.uploadProgress = 100;
const result = await response.json();
if (result.success) {
alert(`版本上传成功!\n插件: ${result.data.pluginId}\n版本: ${result.data.version}\n文件数: ${result.data.filesCount}`);
// 重置表单
this.selectedFile = null;
this.versionInfo = {
pluginId: this.currentPlugin,
version: '',
channel: 'stable',
description: '',
mandatory: false
};
this.$refs.fileInput.value = '';
// 刷新数据
await this.refreshVersions();
await this.refreshStats();
} else {
alert('上传失败: ' + result.error);
}
} catch (error) {
alert('上传失败: ' + error.message);
} finally {
this.uploading = false;
}
},
async refreshVersions() {
try {
const params = new URLSearchParams();
if (this.currentPlugin) {
params.set('pluginId', this.currentPlugin);
}
const response = await fetch(`/api/versions?${params}`);
const result = await response.json();
if (result.success) {
this.versions = result.data.map(v => ({
id: v.id,
pluginId: v.pluginId,
version: v.version,
channel: v.channel,
description: v.description,
release_date: v.releaseDate,
file_size: v.fileSize,
status: v.status
}));
} else {
console.error('获取版本列表失败:', result.error);
}
} catch (error) {
console.error('刷新版本列表失败:', error);
}
},
async refreshStats() {
try {
const response = await fetch('/api/stats');
const result = await response.json();
if (result.success) {
this.stats = result.data;
} else {
console.error('获取统计数据失败:', result.error);
}
} catch (error) {
console.error('刷新统计数据失败:', error);
}
},
editVersion(version) {
// 编辑版本
console.log('编辑版本:', version);
},
deleteVersion(version) {
if (confirm('确定要删除版本 ' + version.version + ' 吗?')) {
// 删除版本
console.log('删除版本:', version);
}
},
getChannelName(channel) {
const names = { stable: '稳定版', beta: '测试版', dev: '开发版' };
return names[channel] || channel;
},
getStatusName(status) {
const names = { active: '已发布', draft: '草稿' };
return names[status] || status;
},
formatDate(dateString) {
return new Date(dateString).toLocaleString('zh-CN');
},
formatSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return size.toFixed(1) + ' ' + units[unitIndex];
}
},
async mounted() {
// 加载插件列表
await this.loadPlugins();
// 加载初始数据
await this.refreshStats();
await this.refreshVersions();
},
async loadPlugins() {
try {
const response = await fetch('/api/plugins');
const result = await response.json();
if (result.success) {
this.plugins = result.data.map(p => ({
id: p.id,
name: p.name,
displayName: p.displayName,
description: p.description,
author: p.author,
repository: p.repository,
icon: p.icon,
status: p.status,
versionCount: p.versionCount || 0,
totalDownloads: p.totalDownloads || 0
}));
// 如果没有选中插件且有插件数据,自动选中第一个
if (!this.currentPlugin && this.plugins.length > 0) {
this.currentPlugin = this.plugins[0].id;
this.versionInfo.pluginId = this.plugins[0].id;
}
} else {
console.error('获取插件列表失败:', result.error);
}
} catch (error) {
console.error('加载插件列表失败:', error);
}
},
async createPlugin() {
if (!this.newPlugin.id || !this.newPlugin.name || !this.newPlugin.displayName) {
alert('请填写必要的字段项目ID、项目名称、显示名称');
return;
}
this.creatingPlugin = true;
try {
const response = await fetch('/api/plugins', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.newPlugin)
});
const result = await response.json();
if (result.success) {
alert(`项目创建成功!\n项目名称: ${result.data.displayName}\n项目ID: ${result.data.id}`);
// 重置表单
this.newPlugin = {
id: '',
name: '',
displayName: '',
description: '',
author: '',
repository: '',
icon: '📦'
};
// 关闭模态框
this.showCreatePlugin = false;
// 刷新插件列表
await this.loadPlugins();
await this.refreshStats();
} else {
alert('创建失败: ' + result.error);
}
} catch (error) {
alert('创建失败: ' + error.message);
} finally {
this.creatingPlugin = false;
}
}
}).mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,283 @@
import * as sqlite3 from 'sqlite3';
import { promisify } from 'util';
import * as path from 'path';
import * as fs from 'fs-extra';
// 数据库实例
export let db: sqlite3.Database;
/**
* 插件信息接口
*/
export interface PluginInfo {
id: string;
name: string;
displayName: string;
description: string;
author: string;
repository: string;
icon: string;
status: 'active' | 'inactive';
}
/**
* 初始化数据库
*/
export async function initDatabase(): Promise<void> {
const dbPath = path.join(__dirname, '../../data/hotupdate.db');
// 确保数据目录存在
await fs.ensureDir(path.dirname(dbPath));
return new Promise((resolve, reject) => {
db = new sqlite3.Database(dbPath, (err: any) => {
if (err) {
console.error('数据库连接失败:', err);
reject(err);
return;
}
console.log('✅ 数据库连接成功');
createTables().then(resolve).catch(reject);
});
});
}
/**
* 创建数据表
*/
async function createTables(): Promise<void> {
const run = promisify(db.run.bind(db));
try {
// 插件信息表
await run(`
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
author TEXT,
repository TEXT,
icon TEXT,
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 版本信息表 - 添加plugin_id字段
await run(`
CREATE TABLE IF NOT EXISTS versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
version TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'stable',
release_date DATETIME DEFAULT CURRENT_TIMESTAMP,
description TEXT,
download_url TEXT NOT NULL,
file_size INTEGER NOT NULL,
checksum TEXT NOT NULL,
mandatory BOOLEAN DEFAULT 0,
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE,
UNIQUE(plugin_id, version, channel)
)
`);
// 版本文件表
await run(`
CREATE TABLE IF NOT EXISTS version_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
file_hash TEXT NOT NULL,
file_size INTEGER NOT NULL,
action TEXT NOT NULL DEFAULT 'update',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (version_id) REFERENCES versions (id) ON DELETE CASCADE
)
`);
// 用户表
await run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'admin',
last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 更新统计表 - 添加plugin_id字段
await run(`
CREATE TABLE IF NOT EXISTS update_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
version TEXT NOT NULL,
user_count INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
failure_count INTEGER DEFAULT 0,
last_updated DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
)
`);
// 配置表
await run(`
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('✅ 数据表创建成功');
// 插入默认数据
await insertDefaultData();
} catch (error) {
console.error('❌ 数据表创建失败:', error);
throw error;
}
}
/**
* 插入默认数据
*/
async function insertDefaultData(): Promise<void> {
const run = promisify(db.run.bind(db)) as (sql: string, params?: any[]) => Promise<any>;
// 插入默认插件
const defaultPlugins: PluginInfo[] = [
{
id: 'cocos-ecs-extension',
name: 'cocos-ecs-extension',
displayName: 'Cocos ECS Framework',
description: '为Cocos Creator提供高性能ECS框架支持',
author: 'esengine',
repository: 'https://github.com/esengine/ecs-framework',
icon: '🎮',
status: 'active'
},
{
id: 'behaviour-tree-ai',
name: 'behaviour-tree-ai',
displayName: 'Behaviour Tree AI',
description: '智能行为树AI系统支持可视化编辑',
author: 'esengine',
repository: 'https://github.com/esengine/ecs-framework/tree/master/thirdparty/BehaviourTree-ai',
icon: '🧠',
status: 'active'
}
];
for (const plugin of defaultPlugins) {
try {
await run(
`INSERT OR IGNORE INTO plugins
(id, name, display_name, description, author, repository, icon, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[plugin.id, plugin.name, plugin.displayName, plugin.description,
plugin.author, plugin.repository, plugin.icon, plugin.status]
);
} catch (error) {
console.warn('插件数据插入失败:', plugin.id);
}
}
// 插入默认配置
const defaultConfigs = [
{ key: 'server_url', value: 'http://localhost:3001', description: '服务器地址' },
{ key: 'cdn_url', value: 'https://cdn.earthonline-game.cn', description: 'CDN地址' },
{ key: 'auto_backup', value: 'true', description: '自动备份' },
{ key: 'max_backup_count', value: '5', description: '最大备份数量' },
{ key: 'upload_max_size', value: '100', description: '上传文件最大大小(MB)' }
];
for (const config of defaultConfigs) {
try {
await run(
'INSERT OR IGNORE INTO config (key, value, description) VALUES (?, ?, ?)',
[config.key, config.value, config.description]
);
} catch (error) {
console.warn('配置插入失败:', config.key);
}
}
// 插入默认管理员用户
const bcrypt = require('bcryptjs');
const defaultPassword = 'admin123';
const hashedPassword = await bcrypt.hash(defaultPassword, 10);
try {
await run(
'INSERT OR IGNORE INTO users (username, password_hash, role) VALUES (?, ?, ?)',
['admin', hashedPassword, 'admin']
);
console.log('✅ 默认管理员账户创建成功 (用户名: admin, 密码: admin123)');
} catch (error) {
console.warn('默认用户创建失败:', error);
}
}
/**
* 执行查询
*/
export function query(sql: string, params: any[] = []): Promise<any[]> {
return new Promise((resolve, reject) => {
db.all(sql, params, (err: any, rows: any) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
}
/**
* 执行单个查询
*/
export function queryOne(sql: string, params: any[] = []): Promise<any> {
return new Promise((resolve, reject) => {
db.get(sql, params, (err: any, row: any) => {
if (err) {
reject(err);
} else {
resolve(row);
}
});
});
}
/**
* 执行更新/插入
*/
export function execute(sql: string, params: any[] = []): Promise<{ lastID: number; changes: number }> {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err: any) {
if (err) {
reject(err);
} else {
resolve({ lastID: (this as any).lastID, changes: (this as any).changes });
}
});
});
}
/**
* 关闭数据库连接
*/
export function closeDatabase(): void {
db.close();
}

View File

@@ -0,0 +1,221 @@
import { Router, Request, Response } from 'express';
import * as bcrypt from 'bcryptjs';
import * as jwt from 'jsonwebtoken';
import { query, execute } from '../database';
export const authRoutes = Router();
// JWT密钥 - 生产环境应该从环境变量读取
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const JWT_EXPIRES_IN = '24h';
interface LoginRequest {
username: string;
password: string;
}
interface AuthRequest extends Request {
user?: {
id: number;
username: string;
role: string;
};
}
/**
* 登录
*/
authRoutes.post('/login', async (req: Request, res: Response) => {
try {
const { username, password }: LoginRequest = req.body;
if (!username || !password) {
return res.status(400).json({
success: false,
error: '用户名和密码不能为空'
});
}
// 查找用户
const users = await query(`
SELECT id, username, password_hash, role
FROM users
WHERE username = ?
`, [username]);
if (users.length === 0) {
return res.status(401).json({
success: false,
error: '用户名或密码错误'
});
}
const user = users[0];
// 验证密码
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
error: '用户名或密码错误'
});
}
// 生成JWT token
const token = jwt.sign(
{
id: user.id,
username: user.username,
role: user.role
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
// 更新最后登录时间
await execute(`
UPDATE users
SET last_login = CURRENT_TIMESTAMP
WHERE id = ?
`, [user.id]);
res.json({
success: true,
data: {
token,
user: {
id: user.id,
username: user.username,
role: user.role
}
}
});
} catch (error) {
console.error('登录失败:', error);
res.status(500).json({
success: false,
error: '登录失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 验证token
*/
authRoutes.get('/verify', authenticateToken, (req: AuthRequest, res: Response) => {
res.json({
success: true,
data: {
user: req.user
}
});
});
/**
* 登出 (清除客户端token即可)
*/
authRoutes.post('/logout', (req: Request, res: Response) => {
res.json({
success: true,
message: '登出成功'
});
});
/**
* 修改密码
*/
authRoutes.post('/change-password', authenticateToken, async (req: AuthRequest, res: Response) => {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({
success: false,
error: '当前密码和新密码不能为空'
});
}
if (newPassword.length < 6) {
return res.status(400).json({
success: false,
error: '新密码长度不能少于6位'
});
}
// 获取当前用户信息
const users = await query(`
SELECT password_hash FROM users WHERE id = ?
`, [req.user!.id]);
if (users.length === 0) {
return res.status(404).json({
success: false,
error: '用户不存在'
});
}
// 验证当前密码
const isCurrentPasswordValid = await bcrypt.compare(currentPassword, users[0].password_hash);
if (!isCurrentPasswordValid) {
return res.status(401).json({
success: false,
error: '当前密码错误'
});
}
// 加密新密码
const saltRounds = 10;
const hashedNewPassword = await bcrypt.hash(newPassword, saltRounds);
// 更新密码
await execute(`
UPDATE users
SET password_hash = ?
WHERE id = ?
`, [hashedNewPassword, req.user!.id]);
res.json({
success: true,
message: '密码修改成功'
});
} catch (error) {
console.error('修改密码失败:', error);
res.status(500).json({
success: false,
error: '修改密码失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* JWT Token验证中间件
*/
export function authenticateToken(req: AuthRequest, res: Response, next: any) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({
success: false,
error: '访问令牌缺失'
});
}
jwt.verify(token, JWT_SECRET, (err: any, user: any) => {
if (err) {
return res.status(403).json({
success: false,
error: '访问令牌无效或已过期'
});
}
req.user = user;
next();
});
}

View File

@@ -0,0 +1,190 @@
import { Router, Request, Response } from 'express';
import { query, execute } from '../database';
export const configRoutes = Router();
/**
* 获取所有配置
*/
configRoutes.get('/', async (req: Request, res: Response) => {
try {
const configs = await query(`
SELECT key, value, description, updated_at
FROM config
ORDER BY key
`);
res.json({
success: true,
data: configs
});
} catch (error) {
console.error('获取配置失败:', error);
res.status(500).json({
success: false,
error: '获取配置失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 获取特定配置
*/
configRoutes.get('/:key', async (req: Request, res: Response) => {
try {
const { key } = req.params;
const config = await query(`
SELECT key, value, description, updated_at
FROM config
WHERE key = ?
`, [key]);
if (config.length === 0) {
return res.status(404).json({
success: false,
error: '配置不存在'
});
}
res.json({
success: true,
data: config[0]
});
} catch (error) {
console.error('获取配置失败:', error);
res.status(500).json({
success: false,
error: '获取配置失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 更新配置
*/
configRoutes.put('/:key', async (req: Request, res: Response) => {
try {
const { key } = req.params;
const { value, description } = req.body;
if (value === undefined) {
return res.status(400).json({
success: false,
error: '缺少配置值'
});
}
// 检查配置是否存在
const existing = await query('SELECT key FROM config WHERE key = ?', [key]);
if (existing.length === 0) {
// 创建新配置
await execute(`
INSERT INTO config (key, value, description, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
`, [key, value, description || '']);
} else {
// 更新现有配置
await execute(`
UPDATE config
SET value = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = ?
`, [value, description || '', key]);
}
res.json({
success: true,
message: '配置更新成功'
});
} catch (error) {
console.error('更新配置失败:', error);
res.status(500).json({
success: false,
error: '更新配置失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 删除配置
*/
configRoutes.delete('/:key', async (req: Request, res: Response) => {
try {
const { key } = req.params;
await execute('DELETE FROM config WHERE key = ?', [key]);
res.json({
success: true,
message: '配置删除成功'
});
} catch (error) {
console.error('删除配置失败:', error);
res.status(500).json({
success: false,
error: '删除配置失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 批量更新配置
*/
configRoutes.post('/batch', async (req: Request, res: Response) => {
try {
const { configs } = req.body;
if (!Array.isArray(configs)) {
return res.status(400).json({
success: false,
error: '配置数据格式错误'
});
}
for (const config of configs) {
const { key, value, description } = config;
if (!key || value === undefined) {
continue;
}
// 检查配置是否存在
const existing = await query('SELECT key FROM config WHERE key = ?', [key]);
if (existing.length === 0) {
await execute(`
INSERT INTO config (key, value, description, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
`, [key, value, description || '']);
} else {
await execute(`
UPDATE config
SET value = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = ?
`, [value, description || '', key]);
}
}
res.json({
success: true,
message: '批量配置更新成功'
});
} catch (error) {
console.error('批量更新配置失败:', error);
res.status(500).json({
success: false,
error: '批量更新配置失败',
message: error instanceof Error ? error.message : String(error)
});
}
});

View File

@@ -0,0 +1,302 @@
import { Router, Request, Response } from 'express';
import { query, queryOne, execute } from '../database';
export const pluginRoutes = Router();
/**
* 获取插件列表
*/
pluginRoutes.get('/', async (req: Request, res: Response) => {
try {
const plugins = await query(`
SELECT
p.*,
COUNT(DISTINCT v.id) as version_count,
COALESCE(SUM(us.user_count), 0) as total_downloads,
MAX(v.created_at) as latest_version_date
FROM plugins p
LEFT JOIN versions v ON p.id = v.plugin_id
LEFT JOIN update_stats us ON p.id = us.plugin_id
GROUP BY p.id
ORDER BY p.display_name
`);
res.json({
success: true,
data: plugins.map(p => ({
id: p.id,
name: p.name,
displayName: p.display_name,
description: p.description,
author: p.author,
repository: p.repository,
icon: p.icon,
status: p.status,
versionCount: p.version_count,
totalDownloads: p.total_downloads,
latestVersionDate: p.latest_version_date,
createdAt: p.created_at
}))
});
} catch (error) {
console.error('获取插件列表失败:', error);
res.status(500).json({
success: false,
error: '获取插件列表失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 获取特定插件信息
*/
pluginRoutes.get('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const plugin = await queryOne(`
SELECT
p.*,
COUNT(DISTINCT v.id) as version_count,
COALESCE(SUM(us.user_count), 0) as total_downloads,
MAX(v.created_at) as latest_version_date
FROM plugins p
LEFT JOIN versions v ON p.id = v.plugin_id
LEFT JOIN update_stats us ON p.id = us.plugin_id
WHERE p.id = ?
GROUP BY p.id
`, [id]);
if (!plugin) {
return res.status(404).json({
success: false,
error: '插件不存在'
});
}
res.json({
success: true,
data: {
id: plugin.id,
name: plugin.name,
displayName: plugin.display_name,
description: plugin.description,
author: plugin.author,
repository: plugin.repository,
icon: plugin.icon,
status: plugin.status,
versionCount: plugin.version_count,
totalDownloads: plugin.total_downloads,
latestVersionDate: plugin.latest_version_date,
createdAt: plugin.created_at
}
});
} catch (error) {
console.error('获取插件信息失败:', error);
res.status(500).json({
success: false,
error: '获取插件信息失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 创建新插件
*/
pluginRoutes.post('/', async (req: Request, res: Response) => {
try {
const { id, name, displayName, description, author, repository, icon } = req.body;
if (!id || !name || !displayName) {
return res.status(400).json({
success: false,
error: '缺少必要参数: id, name, displayName'
});
}
// 检查插件ID是否已存在
const existingPlugin = await queryOne('SELECT id FROM plugins WHERE id = ?', [id]);
if (existingPlugin) {
return res.status(400).json({
success: false,
error: '插件ID已存在'
});
}
// 创建插件记录
await execute(`
INSERT INTO plugins
(id, name, display_name, description, author, repository, icon)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [id, name, displayName, description || '', author || '', repository || '', icon || '📦']);
console.log(`✅ 新插件创建成功: ${displayName} (${id})`);
res.json({
success: true,
data: {
id,
name,
displayName,
description: description || '',
author: author || '',
repository: repository || '',
icon: icon || '📦',
status: 'active'
},
message: '插件创建成功'
});
} catch (error) {
console.error('创建插件失败:', error);
res.status(500).json({
success: false,
error: '创建插件失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 更新插件信息
*/
pluginRoutes.put('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { displayName, description, author, repository, icon, status } = req.body;
// 检查插件是否存在
const existingPlugin = await queryOne('SELECT id FROM plugins WHERE id = ?', [id]);
if (!existingPlugin) {
return res.status(404).json({
success: false,
error: '插件不存在'
});
}
// 更新插件信息
await execute(`
UPDATE plugins
SET display_name = ?, description = ?, author = ?, repository = ?, icon = ?, status = ?, updated_at = ?
WHERE id = ?
`, [displayName, description, author, repository, icon, status, new Date().toISOString(), id]);
res.json({
success: true,
message: '插件信息更新成功'
});
} catch (error) {
console.error('更新插件失败:', error);
res.status(500).json({
success: false,
error: '更新插件失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 删除插件(软删除)
*/
pluginRoutes.delete('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
// 检查插件是否存在
const existingPlugin = await queryOne('SELECT id FROM plugins WHERE id = ?', [id]);
if (!existingPlugin) {
return res.status(404).json({
success: false,
error: '插件不存在'
});
}
// 检查是否有版本记录
const versionCount = await queryOne('SELECT COUNT(*) as count FROM versions WHERE plugin_id = ?', [id]);
if (versionCount && versionCount.count > 0) {
// 如果有版本记录,只进行软删除
await execute(`
UPDATE plugins
SET status = 'inactive', updated_at = ?
WHERE id = ?
`, [new Date().toISOString(), id]);
res.json({
success: true,
message: '插件已停用(因为存在版本记录)'
});
} else {
// 如果没有版本记录,可以直接删除
await execute('DELETE FROM plugins WHERE id = ?', [id]);
res.json({
success: true,
message: '插件删除成功'
});
}
} catch (error) {
console.error('删除插件失败:', error);
res.status(500).json({
success: false,
error: '删除插件失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 激活/停用插件
*/
pluginRoutes.patch('/:id/status', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { status } = req.body;
if (!['active', 'inactive'].includes(status)) {
return res.status(400).json({
success: false,
error: '无效的状态值,必须是 active 或 inactive'
});
}
// 检查插件是否存在
const existingPlugin = await queryOne('SELECT id FROM plugins WHERE id = ?', [id]);
if (!existingPlugin) {
return res.status(404).json({
success: false,
error: '插件不存在'
});
}
// 更新状态
await execute(`
UPDATE plugins
SET status = ?, updated_at = ?
WHERE id = ?
`, [status, new Date().toISOString(), id]);
res.json({
success: true,
message: `插件已${status === 'active' ? '激活' : '停用'}`
});
} catch (error) {
console.error('更新插件状态失败:', error);
res.status(500).json({
success: false,
error: '更新插件状态失败',
message: error instanceof Error ? error.message : String(error)
});
}
});

View File

@@ -0,0 +1,299 @@
import { Router, Request, Response } from 'express';
import { query, queryOne, execute } from '../database';
export const statsRoutes = Router();
/**
* 获取整体统计信息
*/
statsRoutes.get('/', async (req: Request, res: Response) => {
try {
// 插件数量统计
const pluginStats = await query(`
SELECT
COUNT(*) as total_plugins,
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_plugins,
COUNT(CASE WHEN status = 'inactive' THEN 1 END) as inactive_plugins
FROM plugins
`);
// 版本数量统计
const versionStats = await query(`
SELECT
COUNT(*) as total_versions,
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_versions,
COUNT(CASE WHEN channel = 'stable' THEN 1 END) as stable_versions,
COUNT(CASE WHEN channel = 'beta' THEN 1 END) as beta_versions,
COUNT(CASE WHEN mandatory = 1 THEN 1 END) as mandatory_versions
FROM versions
`);
// 文件数量统计
const fileStats = await query(`
SELECT
COUNT(*) as total_files,
SUM(file_size) as total_size,
AVG(file_size) as avg_file_size
FROM version_files
`);
// 更新统计
const updateStats = await query(`
SELECT
SUM(user_count) as total_users,
SUM(success_count) as total_success,
SUM(failure_count) as total_failure
FROM update_stats
`);
res.json({
success: true,
data: {
plugins: pluginStats[0] || {},
versions: versionStats[0] || {},
files: fileStats[0] || {},
updates: updateStats[0] || {}
}
});
} catch (error) {
console.error('获取统计信息失败:', error);
res.status(500).json({
success: false,
error: '获取统计信息失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 获取插件使用统计
*/
statsRoutes.get('/plugins', async (req: Request, res: Response) => {
try {
const { limit = 10 } = req.query;
const pluginStats = await query(`
SELECT
p.id,
p.display_name,
p.icon,
COUNT(v.id) as version_count,
COALESCE(SUM(us.user_count), 0) as total_users,
COALESCE(SUM(us.success_count), 0) as total_success,
COALESCE(SUM(us.failure_count), 0) as total_failure,
MAX(v.created_at) as latest_version_date
FROM plugins p
LEFT JOIN versions v ON p.id = v.plugin_id AND v.status = 'active'
LEFT JOIN update_stats us ON p.id = us.plugin_id
WHERE p.status = 'active'
GROUP BY p.id, p.display_name, p.icon
ORDER BY total_users DESC
LIMIT ?
`, [Number(limit)]);
res.json({
success: true,
data: pluginStats
});
} catch (error) {
console.error('获取插件统计失败:', error);
res.status(500).json({
success: false,
error: '获取插件统计失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 获取版本使用统计
*/
statsRoutes.get('/versions', async (req: Request, res: Response) => {
try {
const { pluginId, days = 30 } = req.query;
let sql = `
SELECT
v.id,
v.version,
v.channel,
v.release_date,
p.display_name as plugin_name,
COALESCE(us.user_count, 0) as user_count,
COALESCE(us.success_count, 0) as success_count,
COALESCE(us.failure_count, 0) as failure_count,
CASE
WHEN us.user_count > 0
THEN ROUND((us.success_count * 1.0 / us.user_count) * 100, 2)
ELSE 0
END as success_rate
FROM versions v
LEFT JOIN plugins p ON v.plugin_id = p.id
LEFT JOIN update_stats us ON v.plugin_id = us.plugin_id AND v.version = us.version
WHERE v.status = 'active'
AND v.release_date >= date('now', '-' || ? || ' days')
`;
const params: any[] = [Number(days)];
if (pluginId) {
sql += ' AND v.plugin_id = ?';
params.push(pluginId);
}
sql += ' ORDER BY v.release_date DESC';
const versionStats = await query(sql, params);
res.json({
success: true,
data: versionStats
});
} catch (error) {
console.error('获取版本统计失败:', error);
res.status(500).json({
success: false,
error: '获取版本统计失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 获取趋势统计
*/
statsRoutes.get('/trends', async (req: Request, res: Response) => {
try {
const { period = 'week' } = req.query;
let dateFormat = '%Y-%m-%d';
let dateInterval = '7 days';
if (period === 'month') {
dateFormat = '%Y-%m';
dateInterval = '30 days';
} else if (period === 'year') {
dateFormat = '%Y';
dateInterval = '365 days';
}
// 版本发布趋势
const versionTrends = await query(`
SELECT
strftime(?, release_date) as period,
COUNT(*) as version_count,
COUNT(CASE WHEN channel = 'stable' THEN 1 END) as stable_count,
COUNT(CASE WHEN channel = 'beta' THEN 1 END) as beta_count
FROM versions
WHERE release_date >= date('now', '-' || ? || '')
AND status = 'active'
GROUP BY strftime(?, release_date)
ORDER BY period DESC
LIMIT 20
`, [dateFormat, dateInterval, dateFormat]);
// 更新使用趋势
const updateTrends = await query(`
SELECT
strftime(?, last_updated) as period,
SUM(user_count) as total_users,
SUM(success_count) as total_success,
SUM(failure_count) as total_failure
FROM update_stats
WHERE last_updated >= date('now', '-' || ? || '')
GROUP BY strftime(?, last_updated)
ORDER BY period DESC
LIMIT 20
`, [dateFormat, dateInterval, dateFormat]);
res.json({
success: true,
data: {
versions: versionTrends,
updates: updateTrends
}
});
} catch (error) {
console.error('获取趋势统计失败:', error);
res.status(500).json({
success: false,
error: '获取趋势统计失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 记录更新成功
*/
statsRoutes.post('/success', async (req: Request, res: Response) => {
try {
const { pluginId, version } = req.body;
if (!pluginId || !version) {
return res.status(400).json({
success: false,
error: '缺少必要参数: pluginId, version'
});
}
await execute(`
UPDATE update_stats
SET success_count = success_count + 1, last_updated = ?
WHERE plugin_id = ? AND version = ?
`, [new Date().toISOString(), pluginId, version]);
res.json({
success: true,
message: '更新成功记录已保存'
});
} catch (error) {
console.error('记录更新成功失败:', error);
res.status(500).json({
success: false,
error: '记录更新成功失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 记录更新失败
*/
statsRoutes.post('/failure', async (req: Request, res: Response) => {
try {
const { pluginId, version, error: errorMessage } = req.body;
if (!pluginId || !version) {
return res.status(400).json({
success: false,
error: '缺少必要参数: pluginId, version'
});
}
await execute(`
UPDATE update_stats
SET failure_count = failure_count + 1, last_updated = ?
WHERE plugin_id = ? AND version = ?
`, [new Date().toISOString(), pluginId, version]);
console.log(`❌ 更新失败记录: ${pluginId} v${version} - ${errorMessage}`);
res.json({
success: true,
message: '更新失败记录已保存'
});
} catch (error) {
console.error('记录更新失败失败:', error);
res.status(500).json({
success: false,
error: '记录更新失败失败',
message: error instanceof Error ? error.message : String(error)
});
}
});

View File

@@ -0,0 +1,251 @@
import { Router, Request, Response } from 'express';
import * as multer from 'multer';
import * as path from 'path';
import * as fs from 'fs-extra';
import * as crypto from 'crypto';
import * as AdmZip from 'adm-zip';
import { execute, query } from '../database';
interface MulterRequest extends Request {
file?: any;
}
export const uploadRoutes = Router();
// 配置multer用于文件上传
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../uploads/packages');
await fs.ensureDir(uploadDir);
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// 生成唯一文件名
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 100 * 1024 * 1024 // 100MB限制
},
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/zip' || path.extname(file.originalname).toLowerCase() === '.zip') {
cb(null, true);
} else {
cb(new Error('只允许上传ZIP文件'));
}
}
});
/**
* 上传插件包
*/
uploadRoutes.post('/package', upload.single('package'), async (req: MulterRequest, res: Response) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: '未选择文件'
});
}
const { pluginId, version, channel, description, mandatory } = req.body;
if (!pluginId || !version || !channel) {
// 删除已上传的文件
await fs.remove(req.file.path);
return res.status(400).json({
success: false,
error: '缺少必要参数: pluginId, version, channel'
});
}
console.log(`📦 上传插件包: ${pluginId} v${version} (${channel})`);
// 计算文件哈希
const fileHash = await calculateFileHash(req.file.path);
// 分析ZIP文件内容
const zipAnalysis = await analyzeZipFile(req.file.path);
// 生成下载URL
const downloadUrl = `/uploads/packages/${req.file.filename}`;
// 创建版本记录
const result = await execute(`
INSERT INTO versions
(plugin_id, version, channel, description, download_url, file_size, checksum, mandatory, release_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
pluginId,
version,
channel,
description || '',
downloadUrl,
req.file.size,
fileHash,
mandatory === 'true' ? 1 : 0,
new Date().toISOString()
]);
const versionId = result.lastID;
// 添加文件记录
for (const file of zipAnalysis.files) {
await execute(`
INSERT INTO version_files (version_id, file_path, file_hash, file_size, action)
VALUES (?, ?, ?, ?, ?)
`, [versionId, file.path, file.hash, file.size, 'update']);
}
res.json({
success: true,
data: {
id: versionId,
pluginId,
version,
channel,
description: description || '',
downloadUrl,
fileSize: req.file.size,
checksum: fileHash,
mandatory: mandatory === 'true',
filesCount: zipAnalysis.files.length,
uploadedAt: new Date().toISOString()
}
});
} catch (error) {
// 清理文件
if (req.file) {
await fs.remove(req.file.path).catch(() => {});
}
console.error('上传失败:', error);
res.status(500).json({
success: false,
error: '上传失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 上传进度查询
*/
uploadRoutes.get('/progress/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
// 这里可以实现真正的进度追踪
// 目前返回完成状态
res.json({
success: true,
data: {
id,
progress: 100,
status: 'completed'
}
});
} catch (error) {
console.error('查询上传进度失败:', error);
res.status(500).json({
success: false,
error: '查询上传进度失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 获取上传历史
*/
uploadRoutes.get('/history', async (req: Request, res: Response) => {
try {
const { pluginId, limit = 10 } = req.query;
let sql = `
SELECT v.*, p.display_name as plugin_display_name, p.icon as plugin_icon
FROM versions v
LEFT JOIN plugins p ON v.plugin_id = p.id
WHERE 1=1
`;
const params: any[] = [];
if (pluginId) {
sql += ' AND v.plugin_id = ?';
params.push(pluginId);
}
sql += ` ORDER BY v.created_at DESC LIMIT ?`;
params.push(Number(limit));
const uploads = await query(sql, params);
res.json({
success: true,
data: uploads.map(u => ({
id: u.id,
pluginId: u.plugin_id,
version: u.version,
channel: u.channel,
fileSize: u.file_size,
uploadedAt: u.created_at,
pluginDisplayName: u.plugin_display_name,
pluginIcon: u.plugin_icon
}))
});
} catch (error) {
console.error('获取上传历史失败:', error);
res.status(500).json({
success: false,
error: '获取上传历史失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 计算文件哈希值
*/
async function calculateFileHash(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* 分析ZIP文件内容
*/
async function analyzeZipFile(filePath: string): Promise<{ files: Array<{ path: string; hash: string; size: number }> }> {
const zip = new AdmZip(filePath);
const entries = zip.getEntries();
const files: Array<{ path: string; hash: string; size: number }> = [];
for (const entry of entries) {
if (!entry.isDirectory) {
const content = entry.getData();
const hash = crypto.createHash('sha256').update(content).digest('hex');
files.push({
path: entry.entryName,
hash: hash,
size: entry.header.size
});
}
}
return { files };
}

View File

@@ -0,0 +1,348 @@
import { Router, Request, Response } from 'express';
import { query, queryOne, execute } from '../database';
export const versionRoutes = Router();
/**
* 获取所有版本列表
*/
versionRoutes.get('/', async (req: Request, res: Response) => {
try {
const { pluginId, channel, limit = 50 } = req.query;
let sql = `
SELECT v.*, p.display_name as plugin_display_name, p.icon as plugin_icon
FROM versions v
LEFT JOIN plugins p ON v.plugin_id = p.id
WHERE v.status = 'active'
`;
const params: any[] = [];
if (pluginId) {
sql += ' AND v.plugin_id = ?';
params.push(pluginId);
}
if (channel) {
sql += ' AND v.channel = ?';
params.push(channel);
}
sql += ` ORDER BY v.created_at DESC LIMIT ?`;
params.push(Number(limit));
const versions = await query(sql, params);
res.json({
success: true,
data: versions
});
} catch (error) {
console.error('获取版本列表失败:', error);
res.status(500).json({
success: false,
error: '获取版本列表失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 获取特定版本详情
*/
versionRoutes.get('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const version = await query(`
SELECT v.*, p.display_name as plugin_display_name, p.icon as plugin_icon
FROM versions v
LEFT JOIN plugins p ON v.plugin_id = p.id
WHERE v.id = ? AND v.status = 'active'
`, [id]);
if (version.length === 0) {
return res.status(404).json({
success: false,
error: '版本不存在'
});
}
// 获取版本文件列表
const files = await query(`
SELECT * FROM version_files WHERE version_id = ?
`, [id]);
res.json({
success: true,
data: {
...version[0],
files
}
});
} catch (error) {
console.error('获取版本详情失败:', error);
res.status(500).json({
success: false,
error: '获取版本详情失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 删除版本
*/
versionRoutes.delete('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
// 软删除
await execute(`
UPDATE versions SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [id]);
res.json({
success: true,
message: '版本删除成功'
});
} catch (error) {
console.error('删除版本失败:', error);
res.status(500).json({
success: false,
error: '删除版本失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 更新版本状态
*/
versionRoutes.patch('/:id/status', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { status } = req.body;
if (!['active', 'inactive', 'deleted'].includes(status)) {
return res.status(400).json({
success: false,
error: '无效的状态值'
});
}
await execute(`
UPDATE versions SET status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [status, id]);
res.json({
success: true,
message: '状态更新成功'
});
} catch (error) {
console.error('更新版本状态失败:', error);
res.status(500).json({
success: false,
error: '更新版本状态失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 检查更新 (供客户端使用)
*/
versionRoutes.post('/check', async (req: Request, res: Response) => {
try {
const { currentVersion, pluginId = 'cocos-ecs-extension', channel = 'stable', platform, editorVersion } = req.body;
console.log(`📱 插件更新检查: ${pluginId} v${currentVersion} (${channel}) - ${platform}`);
// 查找最新版本
const latestVersion = await queryOne(`
SELECT * FROM versions
WHERE plugin_id = ? AND channel = ? AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
`, [pluginId, channel]);
if (!latestVersion) {
return res.json({
hasUpdate: false,
message: `暂无 ${pluginId}${channel} 版本`,
currentVersion
});
}
// 比较版本号
const hasUpdate = isNewerVersion(latestVersion.version, currentVersion);
if (hasUpdate) {
// 记录更新检查
await execute(`
INSERT OR REPLACE INTO update_stats (plugin_id, version, user_count, last_updated)
VALUES (?, ?, COALESCE((SELECT user_count FROM update_stats WHERE plugin_id = ? AND version = ?), 0) + 1, ?)
`, [pluginId, latestVersion.version, pluginId, latestVersion.version, new Date().toISOString()]);
// 获取文件列表
const files = await query(`
SELECT file_path, file_hash, file_size, action
FROM version_files
WHERE version_id = ?
`, [latestVersion.id]);
res.json({
hasUpdate: true,
version: latestVersion.version,
releaseDate: latestVersion.release_date,
description: latestVersion.description,
downloadUrl: latestVersion.download_url,
fileSize: latestVersion.file_size,
checksum: latestVersion.checksum,
mandatory: latestVersion.mandatory === 1,
files: files.map(f => ({
path: f.file_path,
hash: f.file_hash,
size: f.file_size,
action: f.action
}))
});
} else {
res.json({
hasUpdate: false,
message: `${pluginId} 当前已是最新版本`,
currentVersion
});
}
} catch (error) {
console.error('检查更新失败:', error);
res.status(500).json({
hasUpdate: false,
error: '检查更新失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 创建新版本
*/
versionRoutes.post('/', async (req: Request, res: Response) => {
try {
const { pluginId, version, channel, description, downloadUrl, fileSize, checksum, mandatory, files } = req.body;
// 检查版本是否已存在
const existingVersion = await queryOne(`
SELECT id FROM versions
WHERE plugin_id = ? AND version = ? AND channel = ?
`, [pluginId, version, channel]);
if (existingVersion) {
return res.status(400).json({
success: false,
error: '该版本已存在'
});
}
// 创建版本记录
const result = await execute(`
INSERT INTO versions
(plugin_id, version, channel, description, download_url, file_size, checksum, mandatory, release_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [pluginId, version, channel, description, downloadUrl, fileSize, checksum, mandatory ? 1 : 0, new Date().toISOString()]);
const versionId = result.lastID;
// 添加文件记录
if (files && files.length > 0) {
for (const file of files) {
await execute(`
INSERT INTO version_files (version_id, file_path, file_hash, file_size, action)
VALUES (?, ?, ?, ?, ?)
`, [versionId, file.path, file.hash, file.size, file.action || 'update']);
}
}
res.json({
success: true,
data: {
id: versionId,
pluginId,
version,
channel,
description,
downloadUrl,
fileSize,
checksum,
mandatory
}
});
} catch (error) {
console.error('创建版本失败:', error);
res.status(500).json({
success: false,
error: '创建版本失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 更新版本信息
*/
versionRoutes.put('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { description, status, mandatory } = req.body;
await execute(`
UPDATE versions
SET description = ?, status = ?, mandatory = ?, updated_at = ?
WHERE id = ?
`, [description, status, mandatory ? 1 : 0, new Date().toISOString(), id]);
res.json({
success: true,
message: '版本信息更新成功'
});
} catch (error) {
console.error('更新版本失败:', error);
res.status(500).json({
success: false,
error: '更新版本失败',
message: error instanceof Error ? error.message : String(error)
});
}
});
/**
* 比较版本号
*/
function isNewerVersion(newVersion: string, currentVersion: string): boolean {
const parseVersion = (version: string) => {
return version.split('.').map(Number);
};
const newParts = parseVersion(newVersion);
const currentParts = parseVersion(currentVersion);
const maxLength = Math.max(newParts.length, currentParts.length);
for (let i = 0; i < maxLength; i++) {
const newPart = newParts[i] || 0;
const currentPart = currentParts[i] || 0;
if (newPart > currentPart) return true;
if (newPart < currentPart) return false;
}
return false;
}

View File

@@ -0,0 +1,91 @@
import * as express from 'express';
import { Request, Response, NextFunction } from 'express';
import * as cors from 'cors';
import * as path from 'path';
import { authRoutes } from './routes/auth';
import { uploadRoutes } from './routes/upload';
import { versionRoutes } from './routes/versions';
import { configRoutes } from './routes/config';
import { statsRoutes } from './routes/stats';
import { initDatabase } from './database';
const app = express();
const PORT = process.env.PORT || 3001;
// 中间件
app.use(cors());
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
// 静态文件服务
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
app.use('/assets', express.static(path.join(__dirname, '../public')));
// API路由
app.use('/api/auth', authRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/config', configRoutes);
app.use('/api/stats', statsRoutes);
// 热更新客户端API (供Cocos Creator插件使用)
app.post('/api/plugin-updates/check', (req: Request, res: Response) => {
// 检查插件更新
res.json({
message: '当前已是最新版本',
hasUpdate: false,
currentVersion: req.body?.currentVersion || '1.0.0'
});
});
// 管理界面路由
app.get('/', (req: Request, res: Response) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
app.get('/admin', (req: Request, res: Response) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// 健康检查
app.get('/health', (req: Request, res: Response) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: '1.0.0'
});
});
// 错误处理中间件
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
console.error('Error:', err);
res.status(500).json({
error: '服务器内部错误',
message: process.env.NODE_ENV === 'development' ? err.message : '请联系管理员'
});
});
// 404处理
app.use('*', (req: Request, res: Response) => {
res.status(404).json({ error: '接口不存在' });
});
// 启动服务器
async function startServer() {
try {
// 初始化数据库
await initDatabase();
app.listen(PORT, () => {
console.log(`🚀 热更新管理后台启动成功!`);
console.log(`📍 服务地址: http://localhost:${PORT}`);
console.log(`📱 管理界面: http://localhost:${PORT}`);
console.log(`📱 管理界面(admin): http://localhost:${PORT}/admin`);
console.log(`🔗 健康检查: http://localhost:${PORT}/health`);
});
} catch (error) {
console.error('❌ 服务器启动失败:', error);
process.exit(1);
}
}
startServer();

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"uploads"
]
}

View File

@@ -14,13 +14,15 @@
"dependencies": { "dependencies": {
"vue": "^3.1.4", "vue": "^3.1.4",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"ws": "^8.14.2" "ws": "^8.14.2",
"adm-zip": "^0.5.10"
}, },
"devDependencies": { "devDependencies": {
"@cocos/creator-types": "^3.8.6", "@cocos/creator-types": "^3.8.6",
"@types/fs-extra": "^9.0.5", "@types/fs-extra": "^9.0.5",
"@types/node": "^18.17.1", "@types/node": "^18.17.1",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@types/adm-zip": "^0.5.0",
"typescript": "^5.8.2" "typescript": "^5.8.2"
}, },
"panels": { "panels": {
@@ -98,6 +100,11 @@
"path": "i18n:menu.develop/ECS Framework", "path": "i18n:menu.develop/ECS Framework",
"label": "ECS 开发工具", "label": "ECS 开发工具",
"message": "open-panel" "message": "open-panel"
},
{
"path": "i18n:menu.panel/ECS Framework",
"label": "检查更新",
"message": "check-plugin-updates"
} }
], ],
"assets": { "assets": {
@@ -177,6 +184,21 @@
"check-behavior-tree-installed" "check-behavior-tree-installed"
] ]
}, },
"check-plugin-updates": {
"methods": [
"check-plugin-updates"
]
},
"set-hot-update-config": {
"methods": [
"set-hot-update-config"
]
},
"get-hot-update-config": {
"methods": [
"get-hot-update-config"
]
},
"open-behavior-tree-docs": { "open-behavior-tree-docs": {
"methods": [ "methods": [
"open-behavior-tree-docs" "open-behavior-tree-docs"

View File

@@ -0,0 +1,571 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import * as crypto from 'crypto';
import { exec } from 'child_process';
/**
* 热更新配置接口
*/
interface HotUpdateConfig {
serverUrl: string;
currentVersion: string;
updateChannel: 'stable' | 'beta' | 'dev';
autoCheck: boolean;
checkInterval: number; // 分钟
}
/**
* 版本信息接口
*/
interface VersionInfo {
version: string;
releaseDate: string;
description: string;
downloadUrl: string;
fileSize: number;
checksum: string;
mandatory: boolean; // 是否强制更新
files: UpdateFile[];
}
/**
* 更新文件接口
*/
interface UpdateFile {
path: string;
hash: string;
size: number;
action: 'add' | 'update' | 'delete';
}
/**
* 热更新处理器
*/
export class HotUpdateHandler {
private static readonly CONFIG_FILE = 'hot-update-config.json';
private static readonly VERSION_FILE = 'version-info.json';
private static readonly EXTENSION_PATH = Editor.Package.getPath('cocos-ecs-extension') || '';
private static config: HotUpdateConfig;
private static updateTimer: NodeJS.Timeout | null = null;
/**
* 初始化热更新系统
*/
static async initialize(): Promise<void> {
console.log('[HotUpdate] 初始化热更新系统...');
try {
await this.loadConfig();
await this.startAutoCheck();
console.log('[HotUpdate] 热更新系统初始化完成');
} catch (error) {
console.error('[HotUpdate] 初始化失败:', error);
}
}
/**
* 加载配置
*/
private static async loadConfig(): Promise<void> {
const configPath = path.join(this.EXTENSION_PATH, this.CONFIG_FILE);
try {
if (await fs.pathExists(configPath)) {
this.config = await fs.readJSON(configPath);
} else {
// 创建默认配置
this.config = {
serverUrl: 'https://earthonline-game.cn/api/plugin-updates',
currentVersion: this.getCurrentVersion(),
updateChannel: 'stable',
autoCheck: true,
checkInterval: 60 // 60分钟检查一次
};
await this.saveConfig();
}
} catch (error) {
console.error('[HotUpdate] 配置加载失败:', error);
throw error;
}
}
/**
* 保存配置
*/
private static async saveConfig(): Promise<void> {
const configPath = path.join(this.EXTENSION_PATH, this.CONFIG_FILE);
await fs.writeJSON(configPath, this.config, { spaces: 2 });
}
/**
* 获取当前版本
*/
private static getCurrentVersion(): string {
try {
const packagePath = path.join(this.EXTENSION_PATH, 'package.json');
const packageInfo = fs.readJSONSync(packagePath);
return packageInfo.version;
} catch (error) {
console.error('[HotUpdate] 无法获取当前版本:', error);
return '1.0.0';
}
}
/**
* 开始自动检查
*/
private static async startAutoCheck(): Promise<void> {
if (!this.config.autoCheck) {
return;
}
// 立即检查一次
await this.checkForUpdates(true);
// 设置定时检查
if (this.updateTimer) {
clearInterval(this.updateTimer);
}
this.updateTimer = setInterval(async () => {
await this.checkForUpdates(true);
}, this.config.checkInterval * 60 * 1000);
}
/**
* 检查更新
*/
static async checkForUpdates(silent: boolean = false): Promise<VersionInfo | null> {
console.log('[HotUpdate] 检查更新中...');
try {
const response = await fetch(`${this.config.serverUrl}/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
currentVersion: this.config.currentVersion,
pluginId: 'cocos-ecs-extension', // 当前插件ID
channel: this.config.updateChannel,
platform: process.platform,
editorVersion: Editor.App.version
})
});
if (!response.ok) {
throw new Error(`服务器响应错误: ${response.status}`);
}
const versionInfo: VersionInfo = await response.json();
if (this.isNewerVersion(versionInfo.version, this.config.currentVersion)) {
console.log(`[HotUpdate] 发现新版本: ${versionInfo.version}`);
if (!silent) {
await this.showUpdateDialog(versionInfo);
}
return versionInfo;
} else {
if (!silent) {
Editor.Dialog.info('检查更新', {
detail: '当前已是最新版本!'
});
}
return null;
}
} catch (error) {
console.error('[HotUpdate] 检查更新失败:', error);
if (!silent) {
Editor.Dialog.error('检查更新失败', {
detail: `无法连接到更新服务器:\n\n${error instanceof Error ? error.message : String(error)}`
});
}
return null;
}
}
/**
* 比较版本号
*/
private static isNewerVersion(newVersion: string, currentVersion: string): boolean {
const parseVersion = (version: string) => {
return version.split('.').map(Number);
};
const newParts = parseVersion(newVersion);
const currentParts = parseVersion(currentVersion);
const maxLength = Math.max(newParts.length, currentParts.length);
for (let i = 0; i < maxLength; i++) {
const newPart = newParts[i] || 0;
const currentPart = currentParts[i] || 0;
if (newPart > currentPart) return true;
if (newPart < currentPart) return false;
}
return false;
}
/**
* 显示更新对话框
*/
private static async showUpdateDialog(versionInfo: VersionInfo): Promise<void> {
const message = `发现新版本 ${versionInfo.version}!\n\n` +
`发布时间: ${versionInfo.releaseDate}\n\n` +
`更新内容:\n${versionInfo.description}\n\n` +
`文件大小: ${this.formatFileSize(versionInfo.fileSize)}`;
const buttons = versionInfo.mandatory ? ['立即更新'] : ['立即更新', '稍后提醒', '跳过此版本'];
const result = await Editor.Dialog.info('插件更新', {
detail: message,
buttons: buttons
});
switch (result.response) {
case 0: // 立即更新
await this.downloadAndInstallUpdate(versionInfo);
break;
case 1: // 稍后提醒
if (!versionInfo.mandatory) {
console.log('[HotUpdate] 用户选择稍后更新');
}
break;
case 2: // 跳过此版本
if (!versionInfo.mandatory) {
await this.skipVersion(versionInfo.version);
}
break;
}
}
/**
* 下载并安装更新
*/
private static async downloadAndInstallUpdate(versionInfo: VersionInfo): Promise<void> {
console.log(`[HotUpdate] 开始下载更新: ${versionInfo.version}`);
try {
// 显示进度对话框
const progressDialog = this.showProgressDialog('正在下载更新...');
// 下载更新包
const updatePath = await this.downloadUpdate(versionInfo, (progress) => {
// 更新进度
console.log(`[HotUpdate] 下载进度: ${progress}%`);
});
progressDialog.detail = '正在验证文件...';
// 验证文件完整性
const isValid = await this.verifyUpdate(updatePath, versionInfo.checksum);
if (!isValid) {
throw new Error('文件校验失败,更新包可能已损坏');
}
progressDialog.detail = '正在安装更新...';
// 安装更新
await this.installUpdate(updatePath, versionInfo);
// 更新版本信息
this.config.currentVersion = versionInfo.version;
await this.saveConfig();
// 显示安装完成对话框
const result = await Editor.Dialog.info('更新完成', {
detail: `插件已成功更新到版本 ${versionInfo.version}!\n\n为了使更新生效需要重启Cocos Creator编辑器。`,
buttons: ['立即重启', '稍后重启']
});
if (result.response === 0) {
this.restartEditor();
}
} catch (error) {
console.error('[HotUpdate] 更新失败:', error);
Editor.Dialog.error('更新失败', {
detail: `更新过程中发生错误:\n\n${error instanceof Error ? error.message : String(error)}`
});
}
}
/**
* 下载更新
*/
private static async downloadUpdate(versionInfo: VersionInfo, onProgress?: (progress: number) => void): Promise<string> {
const response = await fetch(versionInfo.downloadUrl);
if (!response.ok) {
throw new Error(`下载失败: ${response.status}`);
}
const totalSize = parseInt(response.headers.get('content-length') || '0');
let downloadedSize = 0;
const tempPath = path.join(this.EXTENSION_PATH, 'temp', `update-${versionInfo.version}.zip`);
await fs.ensureDir(path.dirname(tempPath));
const writer = fs.createWriteStream(tempPath);
const reader = response.body?.getReader();
if (!reader) {
throw new Error('无法创建下载流');
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
writer.write(value);
downloadedSize += value.length;
if (onProgress && totalSize > 0) {
const progress = Math.round((downloadedSize / totalSize) * 100);
onProgress(progress);
}
}
writer.end();
return tempPath;
} catch (error) {
writer.destroy();
await fs.remove(tempPath).catch(() => {}); // 忽略删除错误
throw error;
}
}
/**
* 验证更新包
*/
private static async verifyUpdate(filePath: string, expectedChecksum: string): Promise<boolean> {
try {
const fileBuffer = await fs.readFile(filePath);
const hash = crypto.createHash('sha256');
hash.update(fileBuffer);
const actualChecksum = hash.digest('hex');
return actualChecksum === expectedChecksum;
} catch (error) {
console.error('[HotUpdate] 文件校验失败:', error);
return false;
}
}
/**
* 安装更新
*/
private static async installUpdate(updatePath: string, versionInfo: VersionInfo): Promise<void> {
const extractPath = path.join(this.EXTENSION_PATH, 'temp', 'extract');
try {
// 清理临时目录
await fs.remove(extractPath);
await fs.ensureDir(extractPath);
// 解压更新包
await this.extractZip(updatePath, extractPath);
// 备份当前版本
const backupPath = path.join(this.EXTENSION_PATH, 'backup', this.config.currentVersion);
await this.createBackup(backupPath);
// 应用更新文件
await this.applyUpdateFiles(extractPath, versionInfo.files);
// 清理临时文件
await fs.remove(path.dirname(updatePath));
} catch (error) {
console.error('[HotUpdate] 安装更新失败:', error);
// 尝试恢复备份
await this.restoreBackup();
throw error;
}
}
/**
* 解压ZIP文件
*/
private static async extractZip(zipPath: string, extractPath: string): Promise<void> {
return new Promise((resolve, reject) => {
// 这里使用node的解压库您可能需要安装 yauzl 或 adm-zip
const AdmZip = require('adm-zip');
try {
const zip = new AdmZip(zipPath);
zip.extractAllTo(extractPath, true);
resolve();
} catch (error) {
reject(error);
}
});
}
/**
* 应用更新文件
*/
private static async applyUpdateFiles(extractPath: string, files: UpdateFile[]): Promise<void> {
for (const file of files) {
const sourcePath = path.join(extractPath, file.path);
const targetPath = path.join(this.EXTENSION_PATH, file.path);
try {
switch (file.action) {
case 'add':
case 'update':
await fs.ensureDir(path.dirname(targetPath));
await fs.copy(sourcePath, targetPath, { overwrite: true });
break;
case 'delete':
await fs.remove(targetPath);
break;
}
} catch (error) {
console.error(`[HotUpdate] 处理文件失败 ${file.path}:`, error);
throw error;
}
}
}
/**
* 创建备份
*/
private static async createBackup(backupPath: string): Promise<void> {
await fs.ensureDir(backupPath);
const sourceFiles = [
'source',
'static',
'package.json',
'README.md'
];
for (const file of sourceFiles) {
const sourcePath = path.join(this.EXTENSION_PATH, file);
const targetPath = path.join(backupPath, file);
if (await fs.pathExists(sourcePath)) {
await fs.copy(sourcePath, targetPath);
}
}
}
/**
* 恢复备份
*/
private static async restoreBackup(): Promise<void> {
const backupPath = path.join(this.EXTENSION_PATH, 'backup', this.config.currentVersion);
if (await fs.pathExists(backupPath)) {
console.log('[HotUpdate] 正在恢复备份...');
const backupFiles = await fs.readdir(backupPath);
for (const file of backupFiles) {
const sourcePath = path.join(backupPath, file);
const targetPath = path.join(this.EXTENSION_PATH, file);
await fs.copy(sourcePath, targetPath, { overwrite: true });
}
}
}
/**
* 跳过版本
*/
private static async skipVersion(version: string): Promise<void> {
const skipPath = path.join(this.EXTENSION_PATH, 'skipped-versions.json');
let skippedVersions: string[] = [];
if (await fs.pathExists(skipPath)) {
skippedVersions = await fs.readJSON(skipPath);
}
if (!skippedVersions.includes(version)) {
skippedVersions.push(version);
await fs.writeJSON(skipPath, skippedVersions);
}
}
/**
* 显示进度对话框
*/
private static showProgressDialog(message: string) {
// 这是一个简化版本,实际可能需要创建自定义进度条
return {
detail: message
};
}
/**
* 格式化文件大小
*/
private static formatFileSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* 重启编辑器
*/
private static restartEditor(): void {
// 注意:这个功能需要特殊权限,可能需要用户手动重启
console.log('[HotUpdate] 请求重启编辑器');
try {
// 尝试重启编辑器(可能不会成功)
Editor.App.quit();
} catch (error) {
console.warn('[HotUpdate] 无法自动重启编辑器:', error);
}
}
/**
* 设置更新配置
*/
static async setConfig(newConfig: Partial<HotUpdateConfig>): Promise<void> {
this.config = { ...this.config, ...newConfig };
await this.saveConfig();
// 重新启动自动检查
if (this.config.autoCheck) {
await this.startAutoCheck();
} else if (this.updateTimer) {
clearInterval(this.updateTimer);
this.updateTimer = null;
}
}
/**
* 获取配置
*/
static getConfig(): HotUpdateConfig {
return { ...this.config };
}
/**
* 清理资源
*/
static cleanup(): void {
if (this.updateTimer) {
clearInterval(this.updateTimer);
this.updateTimer = null;
}
}
}

View File

@@ -1,3 +1,4 @@
export { EcsFrameworkHandler } from './EcsFrameworkHandler'; export { EcsFrameworkHandler } from './EcsFrameworkHandler';
export { BehaviorTreeHandler } from './BehaviorTreeHandler'; export { BehaviorTreeHandler } from './BehaviorTreeHandler';
export { PanelHandler } from './PanelHandler'; export { PanelHandler } from './PanelHandler';
export { HotUpdateHandler } from './HotUpdateHandler';

View File

@@ -1,6 +1,6 @@
// @ts-ignore // @ts-ignore
import packageJSON from '../package.json'; import packageJSON from '../package.json';
import { EcsFrameworkHandler, BehaviorTreeHandler, PanelHandler } from './handlers'; import { EcsFrameworkHandler, BehaviorTreeHandler, PanelHandler, HotUpdateHandler } from './handlers';
import { readJSON } from 'fs-extra'; import { readJSON } from 'fs-extra';
import * as path from 'path'; import * as path from 'path';
import { AssetInfo } from '@cocos/creator-types/editor/packages/asset-db/@types/public'; import { AssetInfo } from '@cocos/creator-types/editor/packages/asset-db/@types/public';
@@ -177,6 +177,29 @@ export const methods: { [key: string]: (...any: any) => any } = {
throw new Error('文件路径不存在或数据无效'); throw new Error('文件路径不存在或数据无效');
} }
}, },
// ================ 热更新管理 ================
/**
* 检查插件更新
*/
'check-plugin-updates'() {
return HotUpdateHandler.checkForUpdates(false);
},
/**
* 设置热更新配置
*/
'set-hot-update-config'(...args: any[]) {
const config = args.length >= 2 ? args[1] : args[0];
return HotUpdateHandler.setConfig(config);
},
/**
* 获取热更新配置
*/
'get-hot-update-config'() {
return HotUpdateHandler.getConfig();
},
}; };
@@ -187,6 +210,11 @@ export const methods: { [key: string]: (...any: any) => any } = {
*/ */
export function load() { export function load() {
console.log('[Cocos ECS Extension] 扩展已加载'); console.log('[Cocos ECS Extension] 扩展已加载');
// 初始化热更新系统
HotUpdateHandler.initialize().catch(error => {
console.error('[Cocos ECS Extension] 热更新初始化失败:', error);
});
} }
/** /**
@@ -195,4 +223,7 @@ export function load() {
*/ */
export function unload() { export function unload() {
console.log('[Cocos ECS Extension] 扩展已卸载'); console.log('[Cocos ECS Extension] 扩展已卸载');
// 清理热更新资源
HotUpdateHandler.cleanup();
} }

View File

@@ -0,0 +1,50 @@
@echo off
chcp 65001 >nul
title Cocos ECS Extension - 热更新管理后台
echo.
echo ======================================
echo 🚀 Cocos ECS Extension 热更新管理后台
echo ======================================
echo.
:: 检查Node.js是否安装
node --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ 错误: 未检测到Node.js请先安装Node.js
echo 下载地址: https://nodejs.org/
pause
exit /b 1
)
:: 获取Node.js版本
for /f "tokens=1" %%i in ('node --version') do set NODE_VERSION=%%i
echo ✅ Node.js版本: %NODE_VERSION%
:: 检查是否首次运行
if not exist "admin-backend\node_modules" (
echo.
echo 📦 首次运行,正在安装依赖...
cd admin-backend
call npm install
if %errorlevel% neq 0 (
echo ❌ 依赖安装失败
pause
exit /b 1
)
cd ..
echo ✅ 依赖安装完成
)
:: 启动服务
echo.
echo 🚀 启动热更新管理后台...
echo 📍 管理界面地址: http://localhost:3001
echo.
echo 💡 提示: 按 Ctrl+C 可停止服务
echo.
cd admin-backend
call npm run dev
pause

View File

@@ -7,5 +7,9 @@
"node", "node",
"@cocos/creator-types/editor", "@cocos/creator-types/editor",
] ]
} },
"exclude": [
"admin-backend/**/*",
"admin-desktop/**/*"
]
} }

86
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "2.1.22", "version": "2.1.22",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/multer": "^1.4.13",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"ws": "^8.18.2" "ws": "^8.18.2"
}, },
@@ -574,12 +575,68 @@
"win32" "win32"
] ]
}, },
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true "dev": true
}, },
"node_modules/@types/express": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz",
"integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
},
"node_modules/@types/multer": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.0", "version": "20.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.0.tgz",
@@ -589,12 +646,41 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true "dev": true
}, },
"node_modules/@types/send": {
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "*"
}
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",

View File

@@ -52,6 +52,7 @@
"url": "https://github.com/esengine/ecs-framework.git" "url": "https://github.com/esengine/ecs-framework.git"
}, },
"dependencies": { "dependencies": {
"@types/multer": "^1.4.13",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"ws": "^8.18.2" "ws": "^8.18.2"
} }