手动操作繁琐
npm run build 编译容易出错
开发效率低
完全自动化
安全可靠
用户友好
用户访问插件管理页面
http://localhost:3000/plugins/index
↓
点击插件的"安装"按钮
↓
前端发送 POST 请求
POST https://sixshop.ddev.site/admin/system/extension/{plugin-id}/install
↓
后端处理安装
├─ 1. 检查扩展状态
├─ 2. 运行数据库迁移(创建表)
├─ 3. 调用 Extension::install()
├─ 4. 保存默认配置
└─ 5. 更新扩展状态为"已安装"
↓
触发 after_install_extension 事件
↓
✨ 自动部署前端代码
FrontendDeployService::deploy()
├─ 1. 检查插件是否有前端代码
├─ 2. 复制前端代码到 frontend/admin/src/views/
├─ 3. 记录部署日志
├─ 4. 推送部署状态(可选)
└─ 5. 触发 npm 编译(可选)
↓
返回 200 OK
↓
前端显示"安装成功"
用户可以立即使用插件功能
用户访问插件管理页面
http://localhost:3000/plugins/index
↓
点击插件的"卸载"按钮
↓
前端发送 POST 请求
POST https://sixshop.ddev.site/admin/system/extension/{plugin-id}/uninstall
↓
触发 before_uninstall_extension 事件
↓
✨ 自动清理前端代码
FrontendDeployService::remove()
├─ 1. 检查前端代码是否存在
├─ 2. 删除 frontend/admin/src/views/{plugin-id}/
├─ 3. 记录删除日志
├─ 4. 推送删除状态(可选)
└─ 5. 触发 npm 编译(可选)
↓
后端处理卸载
├─ 1. 运行数据库回滚(删除表)
├─ 2. 调用 Extension::uninstall()
├─ 3. 删除配置数据
└─ 4. 更新扩展状态为"未安装"
↓
返回 200 OK
↓
前端显示"卸载成功"
插件完全清理,不留痕迹
ExtensionController::install()
↓
ExtensionManager::install()
├─ 检查扩展状态
├─ 运行数据库迁移
├─ 调用 Extension::install()
└─ 更新扩展状态
↓
触发 after_install_extension 事件
↓
FrontendDeployService::deploy()
├─ 检查是否有前端代码
├─ 复制前端代码到 views 目录
├─ 记录部署日志
├─ 推送部署状态(可选)
└─ 触发 npm 编译(可选)
↓
返回安装成功
安装:
卸载:
对用户:
对开发者:
对系统:
路径检测:
状态管理:
安全性:
可靠性:
职责:
位置:
backend/runtime/extension/system/src/Service/FrontendDeployService.php
职责:
位置:
backend/runtime/extension/system/src/Job/BuildFrontendJob.php
监听事件:
after_install_extension - 安装后部署前端before_uninstall_extension - 卸载前清理前端位置:
backend/runtime/extension/system/src/Extension.php
插件可能安装在两个位置:
backend/runtime/extension/business-card/
├── frontend/ ✨ 新增:前端代码目录
│ └── business-card/ # 使用插件名称作为子目录
│ ├── api/ # API 接口
│ │ └── index.js
│ ├── components/ # 组件
│ │ ├── CardPanel.vue
│ │ ├── CardForm.vue
│ │ └── ...
│ ├── index.vue # 主页面
│ └── router.js # 路由配置
├── src/ # 后端代码
├── database/ # 数据库迁移
└── ...
backend/vendor/sixdec/business-card/
├── frontend/ ✨ 新增:前端代码目录
│ └── business-card/ # 使用插件名称作为子目录
│ ├── api/
│ ├── components/
│ ├── index.vue
│ └── router.js
├── src/
├── database/
└── ...
说明:
runtime/extension/ - 本地开发的插件vendor/six-shop/ - Six-Shop 官方插件vendor/sixdec/ - SixDec 第三方插件frontend/{plugin-name}/ - 使用插件名称作为子目录,方便直接复制frontend/admin/src/views/
├── {plugin-name}/ ✨ 自动复制到这里
│ ├── api/
│ ├── components/
│ ├── index.vue
│ └── router.js
├── other-plugin/
└── ...
backend/runtime/
├── frontend_deploy/ ✨ 新增:部署记录目录
│ ├── deployed.json # 已部署的插件列表
│ └── logs/ # 部署日志
│ ├── business-card.log
│ └── ...
└── ...
/**
* 获取插件的实际路径
*
* 插件可能在两个位置:
* 1. runtime/extension/{plugin-name} - 本地开发
* 2. vendor/{vendor}/{plugin-name} - Composer 安装
*/
private function getPluginPath(string $extensionId): string
{
// 方法 1: 使用 Helper::extension_path()
// 这个方法已经处理了路径检测
$path = Helper::extension_path($extensionId);
if (is_dir($path)) {
return $path;
}
// 方法 2: 从 CoreService 获取 Composer 映射
if (isset(CoreService::$extensionComposerMap[$extensionId])) {
$composerInfo = CoreService::$extensionComposerMap[$extensionId];
$vendorPath = base_path('vendor/' . $composerInfo['name']);
if (is_dir($vendorPath)) {
return $vendorPath;
}
}
// 方法 3: 手动检测常见路径
$possiblePaths = [
base_path('runtime/extension/' . $extensionId),
base_path('vendor/six-shop/' . $extensionId),
base_path('vendor/sixdec/' . $extensionId),
];
foreach ($possiblePaths as $path) {
if (is_dir($path)) {
return $path;
}
}
throw new RuntimeException("找不到插件路径:{$extensionId}");
}
/**
* 获取插件前端代码路径
*/
private function getPluginFrontendPath(string $extensionId): string
{
$pluginPath = $this->getPluginPath($extensionId);
return $pluginPath . '/frontend';
}
/**
* 检查插件是否有前端代码
*/
private function hasFrontendCode(string $extensionId): bool
{
try {
$frontendPath = $this->getPluginFrontendPath($extensionId);
} catch (\Exception $e) {
Log::warning("获取插件路径失败:{$extensionId},错误:" . $e->getMessage());
return false;
}
// 检查目录是否存在
if (!is_dir($frontendPath)) {
return false;
}
// 检查必需文件是否存在
$requiredFiles = ['index.vue', 'router.js'];
foreach ($requiredFiles as $file) {
if (!file_exists($frontendPath . '/' . $file)) {
Log::warning("插件 {$extensionId} 缺少必需文件:{$file}");
return false;
}
}
return true;
}
/**
* 复制前端代码
*
* 源目录结构:{plugin-path}/frontend/business-card/
* 目标目录:frontend/admin/src/views/business-card/
*
* 直接复制 frontend/ 目录下的内容到 views/ 目录
*/
private function copyFrontendCode(string $extensionId): void
{
// 1. 获取源目录:{plugin-path}/frontend/
$sourcePath = $this->getPluginPath($extensionId) . '/frontend';
if (!is_dir($sourcePath)) {
throw new RuntimeException("前端代码目录不存在:{$sourcePath}");
}
// 2. 获取目标目录:frontend/admin/src/views/
$targetPath = base_path('frontend/admin/src/views');
if (!is_dir($targetPath)) {
throw new RuntimeException("目标目录不存在:{$targetPath}");
}
// 3. 直接复制 frontend/ 目录下的所有内容
// 例如:frontend/business-card/ -> views/business-card/
$this->copyDirectory($sourcePath, $targetPath);
Log::info("前端代码复制成功:{$extensionId}");
}
/**
* 递归复制目录
*/
private function copyDirectory(string $source, string $target): void
{
// 递归复制文件
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
// 计算目标路径
$subPath = $iterator->getSubPathName();
$targetPath = $target . DIRECTORY_SEPARATOR . $subPath;
if ($item->isDir()) {
// 创建目录
if (!is_dir($targetPath)) {
mkdir($targetPath, 0755, true);
}
} else {
// 复制文件
$targetDir = dirname($targetPath);
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
copy($item, $targetPath);
}
}
}
优势:
示例:
源:backend/runtime/extension/business-card/frontend/business-card/
目标:frontend/admin/src/views/business-card/
复制结果:
frontend/admin/src/views/
└── business-card/
├── api/
├── components/
├── index.vue
└── router.js
/**
* 删除前端代码
*/
private function removeDirectory(string $path): void
{
if (!is_dir($path)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
rmdir($item);
} else {
unlink($item);
}
}
rmdir($path);
}
/**
* 触发前端编译
*/
private function triggerBuild(string $extensionId): void
{
// 开发环境不编译
if (app()->isDebug()) {
Log::info("开发环境,跳过编译");
return;
}
// 检查 exec 是否可用
if (!$this->isExecAvailable()) {
Log::warning("exec 函数被禁用,无法自动编译");
return;
}
// 使用队列异步编译
Queue::push(new BuildFrontendJob($extensionId));
}
/**
* 检查 exec 是否可用
*/
private function isExecAvailable(): bool
{
$disabled = explode(',', ini_get('disable_functions'));
return !in_array('exec', $disabled);
}
/**
* 安全执行命令
*/
private function safeExec(string $command): array
{
// 1. 检查函数可用性
if (!$this->isExecAvailable()) {
throw new RuntimeException('exec 函数被禁用');
}
// 2. 转义命令
$command = escapeshellcmd($command);
// 3. 执行并获取输出
exec($command, $output, $returnCode);
return [
'output' => $output,
'code' => $returnCode
];
}
/**
* 验证路径安全性
*/
private function validatePath(string $path): bool
{
// 1. 检查路径是否包含危险字符
if (preg_match('/\.\./', $path)) {
return false;
}
// 2. 检查路径是否在允许的范围内
$allowedPaths = [
base_path('frontend/admin/src/views'),
Helper::extension_path()
];
$realPath = realpath($path);
foreach ($allowedPaths as $allowedPath) {
if (strpos($realPath, realpath($allowedPath)) === 0) {
return true;
}
}
return false;
}
/**
* 检查文件系统权限
*/
private function checkPermissions(): array
{
$errors = [];
// 检查源目录可读
$sourcePath = Helper::extension_path();
if (!is_readable($sourcePath)) {
$errors[] = "源目录不可读:{$sourcePath}";
}
// 检查目标目录可写
$targetPath = base_path('frontend/admin/src/views');
if (!is_writable($targetPath)) {
$errors[] = "目标目录不可写:{$targetPath}";
}
return $errors;
}
| 方案 | 实时性 | 实现难度 | 服务器要求 | 推荐度 |
|---|---|---|---|---|
| WebSocket | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 需要 WebSocket | ⭐⭐⭐⭐ |
| 轮询 | ⭐⭐⭐ | ⭐⭐ | 无特殊要求 | ⭐⭐⭐⭐⭐ |
| 日志文件 | ⭐⭐ | ⭐ | 无特殊要求 | ⭐⭐⭐ |
/**
* 设置部署状态
*/
private function setDeployStatus(string $extensionId, string $status, string $message, int $progress = 0): void
{
cache('frontend_deploy_' . $extensionId, [
'status' => $status, // idle, copying, building, success, error
'message' => $message,
'progress' => $progress, // 0-100
'updated_at' => time()
], 3600); // 缓存1小时
}
/**
* 获取部署状态
*/
public function getDeployStatus(string $extensionId): array
{
return cache('frontend_deploy_' . $extensionId) ?: [
'status' => 'idle',
'message' => '',
'progress' => 0,
'updated_at' => 0
];
}
// 安装插件后开始轮询
async function installExtension(extensionId) {
// 1. 调用安装接口
await api.post(`/extension/${extensionId}/install`)
// 2. 开始轮询部署状态
startPolling(extensionId)
}
// 轮询部署状态
function startPolling(extensionId) {
const timer = setInterval(async () => {
const status = await api.get(`/extension/${extensionId}/deploy-status`)
// 更新进度条
updateProgress(status.progress, status.message)
// 检查是否完成
if (status.status === 'success') {
ElMessage.success('安装完成!')
clearInterval(timer)
} else if (status.status === 'error') {
ElMessage.error('安装失败:' + status.message)
clearInterval(timer)
}
}, 2000) // 每2秒查询一次
}
public function deploy(string $extensionId): bool
{
// 检查是否有前端代码
if (!$this->hasFrontendCode($extensionId)) {
Log::info("插件 {$extensionId} 没有前端代码,跳过部署");
return true; // 返回 true 表示成功(跳过)
}
// 继续部署流程...
}
private function triggerBuild(string $extensionId): void
{
if (!$this->isExecAvailable()) {
// 记录警告
Log::warning("exec 函数被禁用,无法自动编译");
// 写入标记文件,提示用户手动编译
file_put_contents(
runtime_path('need_build.txt'),
"请手动执行:cd frontend/admin && npm run build\n",
FILE_APPEND
);
return;
}
// 正常编译流程...
}
class BuildFrontendJob
{
public function handle(): void
{
try {
// 执行编译
$result = $this->safeExec('cd frontend/admin && npm run build');
if ($result['code'] !== 0) {
throw new RuntimeException('编译失败:' . implode("\n", $result['output']));
}
// 更新状态为成功
$this->setStatus('success', '编译完成', 100);
} catch (\Exception $e) {
// 记录错误
Log::error('前端编译失败:' . $e->getMessage());
// 更新状态为失败
$this->setStatus('error', $e->getMessage(), 0);
// 不抛出异常,避免影响其他任务
}
}
}
{
"deployed": [
{
"extension_id": "business-card",
"deployed_at": "2024-11-04 10:30:00",
"source_path": "/path/to/extension/business-card/frontend",
"target_path": "/path/to/admin/views/business-card",
"files_count": 15,
"status": "success"
}
]
}
/**
* 记录部署信息
*/
private function logDeploy(string $extensionId, array $info): void
{
$logFile = runtime_path('frontend_deploy/deployed.json');
// 读取现有记录
$deployed = [];
if (file_exists($logFile)) {
$deployed = json_decode(file_get_contents($logFile), true);
}
// 添加新记录
$deployed['deployed'][] = array_merge([
'extension_id' => $extensionId,
'deployed_at' => date('Y-m-d H:i:s'),
], $info);
// 写入文件
file_put_contents($logFile, json_encode($deployed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
正常安装
无前端代码
重复安装
卸载清理
异常处理
# 1. 测试安装
POST /admin/system/extension/business_card/install
# 2. 检查文件是否复制
ls frontend/admin/src/views/business-card
# 3. 检查部署状态
GET /admin/system/extension/business_card/deploy-status
# 4. 测试卸载
POST /admin/system/extension/business_card/uninstall
# 5. 检查文件是否删除
ls frontend/admin/src/views/business-card # 应该不存在
// 使用延迟队列,避免并发编译
Queue::later(5, new BuildFrontendJob($extensionId));
// 使用队列优先级
Queue::push(new BuildFrontendJob($extensionId), '', 'high');
cd backend/runtime/extension/your-plugin
mkdir frontend
frontend/
├── api/
│ └── index.js
├── components/
│ └── YourComponent.vue
├── index.vue
└── router.js
# 安装插件
POST /admin/system/extension/your-plugin/install
# 检查前端代码
ls frontend/admin/src/views/your-plugin
# 检查目录权限
ls -la frontend/admin/src/views
ls -la backend/runtime/extension
# 查看部署记录
cat backend/runtime/frontend_deploy/deployed.json
# 查看编译日志
cat backend/runtime/frontend_deploy/logs/your-plugin.log
# 如果自动编译失败,可以手动编译
cd frontend/admin
npm run build
A: 系统会自动检测并跳过编译,生成提示文件 runtime/need_build.txt,管理员可以手动执行编译。
A: 编译是异步执行的,不会阻塞安装流程。可以通过轮询查看编译进度。
A: 系统会自动删除已复制的文件,不会留下垃圾文件。
A: 不需要。开发环境会跳过编译,直接使用 Vite 热更新。
A: 调用 API:GET /admin/system/extension/{id}/deploy-status
如有问题,请联系:
backend/runtime/frontend_deploy/logs/文档结束