923 lines
30 KiB
PHP
923 lines
30 KiB
PHP
<?php
|
||||
|
|
declare(strict_types=1);
|
|||
|
|
/**
|
|||
|
|
*+------------------
|
|||
|
|
* madong
|
|||
|
|
*+------------------
|
|||
|
|
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
|||
|
|
*+------------------
|
|||
|
|
* Author: Mr. April (405784684@qq.com)
|
|||
|
|
*+------------------
|
|||
|
|
* Official Website: https://madong.tech
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
namespace app\service\admin\plugin;
|
|||
|
|
|
|||
|
|
use app\dao\plugin\PluginDao;
|
|||
|
|
use app\model\plugin\Plugin;
|
|||
|
|
use app\process\Monitor;
|
|||
|
|
use core\base\BaseService;
|
|||
|
|
use core\exception\handler\AdminException;
|
|||
|
|
use core\uuid\Snowflake;
|
|||
|
|
use support\Container;
|
|||
|
|
use Workerman\Timer;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Plugin服务层
|
|||
|
|
*
|
|||
|
|
* @author Mr.April
|
|||
|
|
* @since 1.0
|
|||
|
|
*/
|
|||
|
|
class PluginDevelopService extends BaseService
|
|||
|
|
{
|
|||
|
|
private const MONITOR_RESUME_DELAY = 3;
|
|||
|
|
|
|||
|
|
private string $pluginDir;
|
|||
|
|
private string $adminDir;
|
|||
|
|
private string $webDir;
|
|||
|
|
|
|||
|
|
public function __construct(PluginDao $dao)
|
|||
|
|
{
|
|||
|
|
$this->dao = $dao;
|
|||
|
|
$basePath = base_path();
|
|||
|
|
$projectPath = dirname($basePath);
|
|||
|
|
$frontendPath = $projectPath . '/frontend';
|
|||
|
|
|
|||
|
|
$this->pluginDir = $basePath . '/plugin';
|
|||
|
|
$this->adminDir = $frontendPath . '/admin/src/apps';
|
|||
|
|
$this->webDir = $frontendPath . '/web/app/apps';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取插件列表(扫描插件目录并同步到数据库)
|
|||
|
|
*
|
|||
|
|
* @param array $where 查询条件(标准filters格式)
|
|||
|
|
* @param int $page 页码
|
|||
|
|
* @param int $limit 每页数量
|
|||
|
|
*
|
|||
|
|
* @return array
|
|||
|
|
* @throws \Exception
|
|||
|
|
*/
|
|||
|
|
public function getList(array $where = [], int $page = 1, int $limit = 15): array
|
|||
|
|
{
|
|||
|
|
// 先扫描插件目录,将新插件同步到数据库
|
|||
|
|
$this->syncPlugins();
|
|||
|
|
return $this->dao->getList($where, $page, $limit);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 扫描插件目录并同步到数据库
|
|||
|
|
*
|
|||
|
|
* @return void
|
|||
|
|
* @throws \Exception
|
|||
|
|
*/
|
|||
|
|
private function syncPlugins(): void
|
|||
|
|
{
|
|||
|
|
// 获取插件根目录下所有子目录
|
|||
|
|
if (!is_dir($this->pluginDir)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$dirs = scandir($this->pluginDir);
|
|||
|
|
$pluginsToInsert = [];
|
|||
|
|
$existingPluginKeys = [];
|
|||
|
|
|
|||
|
|
foreach ($dirs as $dir) {
|
|||
|
|
// 跳过.和..目录
|
|||
|
|
if ($dir === '.' || $dir === '..') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 优先读取 info.php 获取插件基本信息
|
|||
|
|
$infoPath = $this->pluginDir . '/' . $dir . '/config/info.php';
|
|||
|
|
if (!file_exists($infoPath)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加载插件 info 配置
|
|||
|
|
$infoConfig = include $infoPath;
|
|||
|
|
if (!is_array($infoConfig)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查插件归属类型:madong(官方)、third(第三方)、custom(自定义)
|
|||
|
|
$pluginType = $infoConfig['type'] ?? 'madong';
|
|||
|
|
|
|||
|
|
// 检查是否为madong类型的插件
|
|||
|
|
if (!str_starts_with($pluginType, 'madong:') && $pluginType !== 'madong') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$pluginKey = $infoConfig['name'] ?? $dir;
|
|||
|
|
$existingPluginKeys[] = $pluginKey;
|
|||
|
|
|
|||
|
|
// 检查 installed.php 判断是否已安装
|
|||
|
|
$installedPath = $this->pluginDir . '/' . $dir . '/config/installed.php';
|
|||
|
|
$isInstalled = file_exists($installedPath);
|
|||
|
|
$installedAt = 0;
|
|||
|
|
$status = 0; // 默认未安装
|
|||
|
|
|
|||
|
|
if ($isInstalled) {
|
|||
|
|
$installedConfig = include $installedPath;
|
|||
|
|
if (is_array($installedConfig) && !empty($installedConfig['installed_at'])) {
|
|||
|
|
$installedAt = strtotime($installedConfig['installed_at']);
|
|||
|
|
} else {
|
|||
|
|
$installedAt = time();
|
|||
|
|
}
|
|||
|
|
$status = 1; // 已安装
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查插件是否已存在于数据库
|
|||
|
|
$existingPlugin = $this->dao->findByKey($pluginKey);
|
|||
|
|
if ($existingPlugin) {
|
|||
|
|
// 如果数据库存在但安装状态不同,同步更新
|
|||
|
|
if ($existingPlugin->installed_at != $installedAt || $existingPlugin->status != $status) {
|
|||
|
|
$existingPlugin->installed_at = $installedAt;
|
|||
|
|
$existingPlugin->status = $status;
|
|||
|
|
$existingPlugin->save();
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 读取icon和cover图片(base64或路径)
|
|||
|
|
$icon = $this->getPluginImageBase64($dir, 'icon');
|
|||
|
|
$cover = $this->getPluginImageBase64($dir, 'cover');
|
|||
|
|
|
|||
|
|
// 使用 info.php 的字段
|
|||
|
|
$pluginsToInsert[] = [
|
|||
|
|
'id' => Snowflake::generate(),
|
|||
|
|
'title' => $infoConfig['description'] ?? $pluginKey,
|
|||
|
|
'key' => $pluginKey,
|
|||
|
|
'desc' => $infoConfig['description'] ?? '',
|
|||
|
|
'author' => $infoConfig['author'] ?? '',
|
|||
|
|
'version' => $infoConfig['version'] ?? '1.0.0',
|
|||
|
|
'type' => $pluginType,
|
|||
|
|
'icon' => $icon,
|
|||
|
|
'cover' => $cover,
|
|||
|
|
'status' => $status,
|
|||
|
|
'support_app' => 'admin',
|
|||
|
|
'created_at' => time(),
|
|||
|
|
'updated_at' => time(),
|
|||
|
|
'installed_at' => $installedAt,
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
// 批量插入新插件
|
|||
|
|
if (!empty($pluginsToInsert)) {
|
|||
|
|
$this->dao->batchInsert($pluginsToInsert);
|
|||
|
|
}
|
|||
|
|
// 移除数据库中已不存在的插件
|
|||
|
|
$this->removeNonExistingPlugins($existingPluginKeys);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 移除数据库中已不存在的插件
|
|||
|
|
*
|
|||
|
|
* @param array $existingPluginKeys 现有的插件key列表
|
|||
|
|
*
|
|||
|
|
* @return void
|
|||
|
|
* @throws \Exception
|
|||
|
|
*/
|
|||
|
|
private function removeNonExistingPlugins(array $existingPluginKeys): void
|
|||
|
|
{
|
|||
|
|
$allDbPluginKeys = $this->dao->getAllKeys();
|
|||
|
|
$keysToDelete = array_diff($allDbPluginKeys, $existingPluginKeys);
|
|||
|
|
|
|||
|
|
if (!empty($keysToDelete)) {
|
|||
|
|
// 批量删除不存在的插件
|
|||
|
|
foreach ($keysToDelete as $key) {
|
|||
|
|
$plugin = $this->dao->findByKey($key);
|
|||
|
|
if ($plugin) {
|
|||
|
|
$plugin->delete();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取插件图片的base64编码
|
|||
|
|
*
|
|||
|
|
* @param string $pluginName 插件名称
|
|||
|
|
* @param string $imageName 图片名称(icon或cover)
|
|||
|
|
*
|
|||
|
|
* @return string|null
|
|||
|
|
*/
|
|||
|
|
private function getPluginImageBase64(string $pluginName, string $imageName): ?string
|
|||
|
|
{
|
|||
|
|
$imagePath = $this->pluginDir . '/' . $pluginName . '/public/' . $imageName . '.png';
|
|||
|
|
if (!file_exists($imagePath)) {
|
|||
|
|
// 尝试查找resource目录下的图片
|
|||
|
|
$resourcePath = $this->pluginDir . '/' . $pluginName . '/resource/public/' . $imageName . '.png';
|
|||
|
|
if (!file_exists($resourcePath)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
$imagePath = $resourcePath;
|
|||
|
|
}
|
|||
|
|
$imageData = file_get_contents($imagePath);
|
|||
|
|
if ($imageData === false) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return 'data:image/png;base64,' . base64_encode($imageData);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取插件详情
|
|||
|
|
*
|
|||
|
|
* @param string|int $id 插件ID
|
|||
|
|
*
|
|||
|
|
* @return Plugin|null
|
|||
|
|
* @throws \Exception
|
|||
|
|
*/
|
|||
|
|
public function read(string|int $id): ?Plugin
|
|||
|
|
{
|
|||
|
|
$plugin = $this->dao->get($id);
|
|||
|
|
if (!$plugin) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
$pluginConfigPath = $this->pluginDir . '/' . $plugin->key . '/config/app.php';
|
|||
|
|
if (file_exists($pluginConfigPath)) {
|
|||
|
|
$config = include $pluginConfigPath;
|
|||
|
|
if (is_array($config)) {
|
|||
|
|
// 更新插件信息
|
|||
|
|
$plugin->title = $config['title'] ?? $plugin->title;
|
|||
|
|
$plugin->desc = $config['description'] ?? $plugin->desc;
|
|||
|
|
$plugin->author = $config['author'] ?? $plugin->author;
|
|||
|
|
$plugin->version = $config['version'] ?? $plugin->version;
|
|||
|
|
$plugin->type = $config['type'] ?? $plugin->type;
|
|||
|
|
$plugin->icon = $this->getPluginImageBase64($plugin->key, 'icon');
|
|||
|
|
$plugin->cover = $this->getPluginImageBase64($plugin->key, 'cover');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return $plugin;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 创建插件
|
|||
|
|
*
|
|||
|
|
* @param array $data
|
|||
|
|
*
|
|||
|
|
* @return void
|
|||
|
|
* @throws \Throwable
|
|||
|
|
* @throws \core\exception\handler\AdminException
|
|||
|
|
*/
|
|||
|
|
public function store(array $data): void
|
|||
|
|
{
|
|||
|
|
$this->transaction(function () use ($data) {
|
|||
|
|
try {
|
|||
|
|
if (empty($data['icon'])) {
|
|||
|
|
$data['icon'] = $this->generateDefaultIcon($data['title'] ?? 'Plugin');
|
|||
|
|
}
|
|||
|
|
if (empty($data['cover'])) {
|
|||
|
|
$data['cover'] = $this->generateDefaultCover($data['title'] ?? 'Plugin');
|
|||
|
|
}
|
|||
|
|
$model = $this->dao->save($data);
|
|||
|
|
if (empty($model)) {
|
|||
|
|
throw new AdminException('插件创建失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$monitor_support_pause = method_exists(Monitor::class, 'pause');
|
|||
|
|
if ($monitor_support_pause) {
|
|||
|
|
Monitor::pause();
|
|||
|
|
}
|
|||
|
|
//生成插件模板
|
|||
|
|
/**@var \app\service\core\plugin\PluginDevelopService $service */
|
|||
|
|
$service = Container::make(\app\service\core\plugin\PluginDevelopService::class);
|
|||
|
|
$pluginName = $model->key;
|
|||
|
|
$pluginTitle = $model->title;
|
|||
|
|
$pluginDescription = $model->desc ?? '';
|
|||
|
|
$frontendType = $data['frontend_type'] ?? 'admin';
|
|||
|
|
$result = $service->generatePluginTemplate($pluginName, $pluginTitle, $pluginDescription, $frontendType);
|
|||
|
|
if ($result['code'] !== 200) {
|
|||
|
|
throw new AdminException($result['message']);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成插件 info.php 配置文件
|
|||
|
|
$this->createPluginInfoConfig($pluginName, $data);
|
|||
|
|
|
|||
|
|
//添加图片到对应插件位置
|
|||
|
|
$publicPath = $this->pluginDir . '/' . $pluginName . '/public';
|
|||
|
|
// 确保public目录存在
|
|||
|
|
if (!is_dir($publicPath)) {
|
|||
|
|
mkdir($publicPath, 0755, true);
|
|||
|
|
}
|
|||
|
|
$iconPath = $publicPath . '/icon.png';
|
|||
|
|
$coverPath = $publicPath . '/cover.png';
|
|||
|
|
$this->saveBase64Image($data['icon'], $iconPath);
|
|||
|
|
$this->saveBase64Image($data['cover'], $coverPath);
|
|||
|
|
if ($monitor_support_pause) {
|
|||
|
|
Timer::add(self::MONITOR_RESUME_DELAY, static function () {
|
|||
|
|
Monitor::resume();
|
|||
|
|
}, null, false);
|
|||
|
|
}
|
|||
|
|
} catch (\Exception $e) {
|
|||
|
|
throw new AdminException($e->getMessage());
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 打包插件
|
|||
|
|
*
|
|||
|
|
* @param string $pluginKey 插件标识
|
|||
|
|
* @param bool $updateTemplate 是否更新前端模板到resource/template目录(默认true)
|
|||
|
|
*
|
|||
|
|
* @return array
|
|||
|
|
* @throws \core\exception\handler\AdminException
|
|||
|
|
*/
|
|||
|
|
public function buildPlugin(string $pluginKey, bool $updateTemplate = true): array
|
|||
|
|
{
|
|||
|
|
try {
|
|||
|
|
// 查询插件信息
|
|||
|
|
$plugin = $this->dao->findByKey($pluginKey);
|
|||
|
|
if (!$plugin) {
|
|||
|
|
throw new AdminException('插件不存在');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 定义路径
|
|||
|
|
$backendPath = $this->pluginDir . '/' . $pluginKey;
|
|||
|
|
$adminPath = $this->adminDir . '/' . $pluginKey;
|
|||
|
|
$webPath = $this->webDir . '/' . $pluginKey;
|
|||
|
|
$resourcePath = $backendPath . '/resource/template';
|
|||
|
|
|
|||
|
|
// 验证后端插件目录是否存在
|
|||
|
|
if (!is_dir($backendPath)) {
|
|||
|
|
throw new AdminException("插件后端目录不存在: {$backendPath}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 是否更新前端模板到 resource/template 目录
|
|||
|
|
if ($updateTemplate) {
|
|||
|
|
// 清理并创建template目录
|
|||
|
|
if (is_dir($resourcePath)) {
|
|||
|
|
$this->removeDirectory($resourcePath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$resourceCount = 0;
|
|||
|
|
|
|||
|
|
// 复制后台前端到template目录(如果存在)
|
|||
|
|
if (is_dir($adminPath)) {
|
|||
|
|
$this->copyDirectory($adminPath, $resourcePath . '/admin');
|
|||
|
|
$resourceCount++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 复制前台前端到resource目录(如果存在)
|
|||
|
|
if (is_dir($webPath)) {
|
|||
|
|
$this->copyDirectory($webPath, $resourcePath . '/web');
|
|||
|
|
$resourceCount++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if ($resourceCount === 0) {
|
|||
|
|
// 没有前端资源,清空resource目录
|
|||
|
|
if (is_dir($resourcePath)) {
|
|||
|
|
rmdir($resourcePath);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 不更新时检查是否已有模板
|
|||
|
|
$resourceCount = (is_dir($resourcePath . '/admin') || is_dir($resourcePath . '/web')) ? 1 : 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 调用核心服务的 build 方法打包插件
|
|||
|
|
$pluginsDir = base_path() . '/runtime/plugins';
|
|||
|
|
/** @var \app\service\core\plugin\PluginDevelopService $coreService */
|
|||
|
|
$coreService = Container::make(\app\service\core\plugin\PluginDevelopService::class);
|
|||
|
|
$result = $coreService->build($pluginKey, $pluginsDir);
|
|||
|
|
|
|||
|
|
if ($result['code'] !== 200) {
|
|||
|
|
throw new AdminException($result['message']);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return [
|
|||
|
|
'plugin_key' => $pluginKey,
|
|||
|
|
'zip_path' => $result['data']['zip_file_path'],
|
|||
|
|
'has_frontend' => $resourceCount > 0,
|
|||
|
|
];
|
|||
|
|
} catch (\Exception $e) {
|
|||
|
|
throw new AdminException('插件打包失败: ' . $e->getMessage());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 复制目录
|
|||
|
|
*
|
|||
|
|
* @param string $source 源目录
|
|||
|
|
* @param string $target 目标目录
|
|||
|
|
*
|
|||
|
|
* @return void
|
|||
|
|
*/
|
|||
|
|
private function copyDirectory(string $source, string $target): void
|
|||
|
|
{
|
|||
|
|
if (!is_dir($source)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!is_dir($target)) {
|
|||
|
|
mkdir($target, 0755, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$files = scandir($source);
|
|||
|
|
foreach ($files as $file) {
|
|||
|
|
if ($file === '.' || $file === '..') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$sourceFile = $source . '/' . $file;
|
|||
|
|
$targetFile = $target . '/' . $file;
|
|||
|
|
|
|||
|
|
if (is_dir($sourceFile)) {
|
|||
|
|
$this->copyDirectory($sourceFile, $targetFile);
|
|||
|
|
} else {
|
|||
|
|
copy($sourceFile, $targetFile);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 删除目录及其内容
|
|||
|
|
*
|
|||
|
|
* @param string $dir 目录路径
|
|||
|
|
*
|
|||
|
|
* @return void
|
|||
|
|
*/
|
|||
|
|
private function removeDirectory(string $dir): void
|
|||
|
|
{
|
|||
|
|
if (!is_dir($dir)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$files = scandir($dir);
|
|||
|
|
foreach ($files as $file) {
|
|||
|
|
if ($file === '.' || $file === '..') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$path = $dir . '/' . $file;
|
|||
|
|
if (is_dir($path)) {
|
|||
|
|
$this->removeDirectory($path);
|
|||
|
|
} else {
|
|||
|
|
unlink($path);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
rmdir($dir);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新插件信息
|
|||
|
|
*
|
|||
|
|
* @param int|string $id 插件ID
|
|||
|
|
* @param array $data 更新数据
|
|||
|
|
*
|
|||
|
|
* @return void
|
|||
|
|
* @throws \Throwable
|
|||
|
|
* @throws \core\exception\handler\AdminException
|
|||
|
|
*/
|
|||
|
|
public function update(int|string $id, array $data): void
|
|||
|
|
{
|
|||
|
|
$this->transaction(function () use ($id, $data) {
|
|||
|
|
$model = $this->dao->get($id);
|
|||
|
|
if (!$model) {
|
|||
|
|
throw new AdminException('插件不存在');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$pluginKey = $model->getAttribute('key');
|
|||
|
|
|
|||
|
|
// 更新数据库 - 排除key字段,不允许修改插件标识
|
|||
|
|
$updateData = $data;
|
|||
|
|
unset($updateData['key']);
|
|||
|
|
$model->fill($updateData);
|
|||
|
|
$model->save();
|
|||
|
|
|
|||
|
|
// 更新插件配置文件 app.php
|
|||
|
|
$this->updatePluginConfig($pluginKey, $data);
|
|||
|
|
|
|||
|
|
// 添加图片到对应插件位置(仅当提供了新的base64图片时)
|
|||
|
|
if (isset($data['icon']) || isset($data['cover'])) {
|
|||
|
|
$pluginDirName = $this->findPluginDirectory($pluginKey);
|
|||
|
|
if ($pluginDirName) {
|
|||
|
|
$publicPath = $this->pluginDir . '/' . $pluginDirName . '/public';
|
|||
|
|
// 确保public目录存在
|
|||
|
|
if (!is_dir($publicPath)) {
|
|||
|
|
mkdir($publicPath, 0755, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!empty($data['icon'])) {
|
|||
|
|
$iconPath = $publicPath . '/icon.png';
|
|||
|
|
$this->saveBase64Image($data['icon'], $iconPath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!empty($data['cover'])) {
|
|||
|
|
$coverPath = $publicPath . '/cover.png';
|
|||
|
|
$this->saveBase64Image($data['cover'], $coverPath);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 根据插件key查找实际的目录名
|
|||
|
|
*
|
|||
|
|
* @param string $pluginKey 插件key
|
|||
|
|
*
|
|||
|
|
* @return string|null
|
|||
|
|
*/
|
|||
|
|
private function findPluginDirectory(string $pluginKey): ?string
|
|||
|
|
{
|
|||
|
|
if (!is_dir($this->pluginDir)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$dirs = scandir($this->pluginDir);
|
|||
|
|
foreach ($dirs as $dir) {
|
|||
|
|
if ($dir === '.' || $dir === '..') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 优先使用 info.php 查找插件key
|
|||
|
|
$infoPath = $this->pluginDir . '/' . $dir . '/config/info.php';
|
|||
|
|
if (file_exists($infoPath)) {
|
|||
|
|
$infoConfig = include $infoPath;
|
|||
|
|
if (is_array($infoConfig) && isset($infoConfig['name']) && $infoConfig['name'] === $pluginKey) {
|
|||
|
|
return $dir;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果 info.php 不存在或找不到,尝试从 app.php 查找(旧版本兼容)
|
|||
|
|
$configPath = $this->pluginDir . '/' . $dir . '/config/app.php';
|
|||
|
|
if (!file_exists($configPath)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$config = include $configPath;
|
|||
|
|
if (!is_array($config)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (($config['name'] ?? '') === $pluginKey) {
|
|||
|
|
return $dir;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新插件配置文件
|
|||
|
|
*
|
|||
|
|
* @param string $pluginKey 插件标识
|
|||
|
|
* @param array $data 更新数据
|
|||
|
|
*
|
|||
|
|
* @return void
|
|||
|
|
* @throws \core\exception\handler\AdminException
|
|||
|
|
*/
|
|||
|
|
private function updatePluginConfig(string $pluginKey, array $data): void
|
|||
|
|
{
|
|||
|
|
// 查找实际的插件目录名
|
|||
|
|
$pluginDirName = $this->findPluginDirectory($pluginKey);
|
|||
|
|
if (!$pluginDirName) {
|
|||
|
|
throw new AdminException('插件目录不存在: ' . $pluginKey);
|
|||
|
|
}
|
|||
|
|
$configPath = $this->pluginDir . '/' . $pluginDirName . '/config/app.php';
|
|||
|
|
// 检查配置文件是否存在
|
|||
|
|
if (!file_exists($configPath)) {
|
|||
|
|
throw new AdminException('插件配置文件不存在: ' . $configPath);
|
|||
|
|
}
|
|||
|
|
// 加载当前配置
|
|||
|
|
$config = include $configPath;
|
|||
|
|
if (!is_array($config)) {
|
|||
|
|
throw new AdminException('插件配置文件格式错误');
|
|||
|
|
}
|
|||
|
|
// 更新配置项
|
|||
|
|
$fieldMapping = [
|
|||
|
|
'title' => 'title',
|
|||
|
|
'desc' => 'description',
|
|||
|
|
'author' => 'author',
|
|||
|
|
'version' => 'version',
|
|||
|
|
'type' => 'type',
|
|||
|
|
'status' => 'enable',
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
foreach ($fieldMapping as $dbField => $configField) {
|
|||
|
|
if (isset($data[$dbField])) {
|
|||
|
|
if ($dbField === 'status') {
|
|||
|
|
// status字段转换为enable布尔值
|
|||
|
|
$config[$configField] = (bool)$data[$dbField];
|
|||
|
|
} else {
|
|||
|
|
$config[$configField] = $data[$dbField];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成PHP配置文件内容
|
|||
|
|
$phpContent = "<?php\n\nreturn " . $this->arrayToPhpCode($config) . ";\n";
|
|||
|
|
|
|||
|
|
// 写入配置文件
|
|||
|
|
file_put_contents($configPath, $phpContent);
|
|||
|
|
|
|||
|
|
// 同时更新 info.php 配置文件
|
|||
|
|
$this->updatePluginInfoConfig($pluginDirName, $data);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 创建插件 info.php 配置文件
|
|||
|
|
*
|
|||
|
|
* @param string $pluginKey 插件标识
|
|||
|
|
* @param array $data 插件数据
|
|||
|
|
*
|
|||
|
|
* @return void
|
|||
|
|
* @throws \core\exception\handler\AdminException
|
|||
|
|
*/
|
|||
|
|
private function createPluginInfoConfig(string $pluginKey, array $data): void
|
|||
|
|
{
|
|||
|
|
$pluginDir = $this->pluginDir . '/' . $pluginKey;
|
|||
|
|
$configDir = $pluginDir . '/config';
|
|||
|
|
|
|||
|
|
// 确保 config 目录存在
|
|||
|
|
if (!is_dir($configDir)) {
|
|||
|
|
mkdir($configDir, 0755, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$infoConfig = [
|
|||
|
|
'name' => $pluginKey,
|
|||
|
|
'identifier' => $pluginKey,
|
|||
|
|
'type' => $data['type'] ?? 'madong', // 插件归属类型:madong(官方)、third(第三方)、custom(自定义)
|
|||
|
|
'version' => $data['version'] ?? '1.0.0',
|
|||
|
|
'description' => $data['desc'] ?? '',
|
|||
|
|
'author' => $data['author'] ?? '',
|
|||
|
|
'author_email' => $data['author_email'] ?? '',
|
|||
|
|
'website' => $data['website'] ?? '',
|
|||
|
|
'uninstall' => [
|
|||
|
|
'drop_tables' => false,
|
|||
|
|
'remove_dependencies' => false,
|
|||
|
|
],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
$phpContent = "<?php\n\n/**\n * 插件信息配置\n */\n\nreturn " . $this->arrayToPhpCode($infoConfig) . ";\n";
|
|||
|
|
file_put_contents($configDir . '/info.php', $phpContent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新插件 info.php 配置文件
|
|||
|
|
*
|
|||
|
|
* @param string $pluginDirName 插件目录名
|
|||
|
|
* @param array $data 更新数据
|
|||
|
|
*
|
|||
|
|
* @return void
|
|||
|
|
* @throws \core\exception\handler\AdminException
|
|||
|
|
*/
|
|||
|
|
private function updatePluginInfoConfig(string $pluginDirName, array $data): void
|
|||
|
|
{
|
|||
|
|
$infoPath = $this->pluginDir . '/' . $pluginDirName . '/config/info.php';
|
|||
|
|
|
|||
|
|
// 如果 info.php 不存在,则创建
|
|||
|
|
if (!file_exists($infoPath)) {
|
|||
|
|
$this->createPluginInfoConfig($pluginDirName, $data);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加载当前配置
|
|||
|
|
$infoConfig = include $infoPath;
|
|||
|
|
if (!is_array($infoConfig)) {
|
|||
|
|
$infoConfig = [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新配置项
|
|||
|
|
$fieldMapping = [
|
|||
|
|
'type' => 'type',
|
|||
|
|
'version' => 'version',
|
|||
|
|
'desc' => 'description',
|
|||
|
|
'author' => 'author',
|
|||
|
|
'author_email' => 'author_email',
|
|||
|
|
'website' => 'website',
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
foreach ($fieldMapping as $dbField => $infoField) {
|
|||
|
|
if (isset($data[$dbField])) {
|
|||
|
|
$infoConfig[$infoField] = $data[$dbField];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 确保必要的字段存在
|
|||
|
|
if (!isset($infoConfig['name'])) {
|
|||
|
|
$infoConfig['name'] = $pluginDirName;
|
|||
|
|
}
|
|||
|
|
if (!isset($infoConfig['identifier'])) {
|
|||
|
|
$infoConfig['identifier'] = $pluginDirName;
|
|||
|
|
}
|
|||
|
|
if (!isset($infoConfig['type'])) {
|
|||
|
|
$infoConfig['type'] = 'madong'; // 默认归属 madong
|
|||
|
|
}
|
|||
|
|
if (!isset($infoConfig['uninstall'])) {
|
|||
|
|
$infoConfig['uninstall'] = [
|
|||
|
|
'drop_tables' => false,
|
|||
|
|
'remove_dependencies' => false,
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成PHP配置文件内容
|
|||
|
|
$phpContent = "<?php\n\n/**\n * 插件信息配置\n */\n\nreturn " . $this->arrayToPhpCode($infoConfig) . ";\n";
|
|||
|
|
|
|||
|
|
// 写入配置文件
|
|||
|
|
file_put_contents($infoPath, $phpContent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 将数组转换为PHP代码格式
|
|||
|
|
*
|
|||
|
|
* @param array $array 数组
|
|||
|
|
* @param int $level 缩进级别
|
|||
|
|
*
|
|||
|
|
* @return string
|
|||
|
|
*/
|
|||
|
|
private function arrayToPhpCode(array $array, int $level = 1): string
|
|||
|
|
{
|
|||
|
|
$indent = str_repeat(' ', $level);
|
|||
|
|
$nextIndent = str_repeat(' ', $level + 1);
|
|||
|
|
|
|||
|
|
$result = "[\n";
|
|||
|
|
|
|||
|
|
foreach ($array as $key => $value) {
|
|||
|
|
if (is_numeric($key)) {
|
|||
|
|
$result .= $nextIndent;
|
|||
|
|
} else {
|
|||
|
|
$result .= $nextIndent . "'{$key}' => ";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (is_array($value)) {
|
|||
|
|
$result .= $this->arrayToPhpCode($value, $level + 1);
|
|||
|
|
} elseif (is_bool($value)) {
|
|||
|
|
$result .= $value ? 'true' : 'false';
|
|||
|
|
} elseif (is_null($value)) {
|
|||
|
|
$result .= 'null';
|
|||
|
|
} elseif (is_string($value)) {
|
|||
|
|
// 转义单引号
|
|||
|
|
$escaped = str_replace("'", "\\'", $value);
|
|||
|
|
$result .= "'{$escaped}'";
|
|||
|
|
} else {
|
|||
|
|
$result .= $value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$result .= ",\n";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$result .= $indent . ']';
|
|||
|
|
|
|||
|
|
return $result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 保存base64图片到文件
|
|||
|
|
*
|
|||
|
|
* @param string $base64 base64编码的图片
|
|||
|
|
* @param string $targetPath 目标路径
|
|||
|
|
*
|
|||
|
|
* @return void
|
|||
|
|
*/
|
|||
|
|
private function saveBase64Image(string $base64, string $targetPath): void
|
|||
|
|
{
|
|||
|
|
// 提取base64数据
|
|||
|
|
if (preg_match('/^data:image\/(\w+);base64,/', $base64, $matches)) {
|
|||
|
|
$imageData = substr($base64, strpos($base64, ',') + 1);
|
|||
|
|
$imageData = base64_decode($imageData);
|
|||
|
|
if ($imageData !== false) {
|
|||
|
|
file_put_contents($targetPath, $imageData);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成默认图标(64x64)
|
|||
|
|
*
|
|||
|
|
* @param string $text 文字
|
|||
|
|
*
|
|||
|
|
* @return string base64编码的图片
|
|||
|
|
*/
|
|||
|
|
private function generateDefaultIcon(string $text): string
|
|||
|
|
{
|
|||
|
|
// 提取首字母
|
|||
|
|
$firstChar = mb_substr($text, 0, 1, 'UTF-8');
|
|||
|
|
|
|||
|
|
// 创建64x64的图像
|
|||
|
|
$size = 64;
|
|||
|
|
$image = imagecreatetruecolor($size, $size);
|
|||
|
|
|
|||
|
|
// 分配颜色
|
|||
|
|
$bgColor = imagecolorallocate($image, 64, 158, 255); // 蓝色背景 #409EFF
|
|||
|
|
$textColor = imagecolorallocate($image, 255, 255, 255); // 白色文字
|
|||
|
|
|
|||
|
|
// 填充背景
|
|||
|
|
imagefill($image, 0, 0, $bgColor);
|
|||
|
|
|
|||
|
|
// 绘制圆角矩形
|
|||
|
|
$radius = 12;
|
|||
|
|
$corners = [
|
|||
|
|
[0, 0], [$size - 1, 0],
|
|||
|
|
[0, $size - 1], [$size - 1, $size - 1],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
foreach ($corners as $corner) {
|
|||
|
|
imagefilledarc($image, $corner[0] + $radius, $corner[1] + $radius, $radius * 2, $radius * 2, 0, 360, $bgColor, IMG_ARC_PIE);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 添加文字
|
|||
|
|
$fontSize = 24;
|
|||
|
|
$fontFile = $this->getFontFile();
|
|||
|
|
if ($fontFile && file_exists($fontFile)) {
|
|||
|
|
$bbox = imagettfbbox($fontSize, 0, $fontFile, $firstChar);
|
|||
|
|
$textWidth = $bbox[2] - $bbox[0];
|
|||
|
|
$textHeight = $bbox[7] - $bbox[1];
|
|||
|
|
$x = ($size - $textWidth) / 2 - $bbox[0];
|
|||
|
|
$y = ($size - $textHeight) / 2 - $bbox[1];
|
|||
|
|
imagettftext($image, $fontSize, 0, $x, $y, $textColor, $fontFile, $firstChar);
|
|||
|
|
} else {
|
|||
|
|
// 如果没有字体文件,使用内置字体
|
|||
|
|
imagestring($image, 5, ($size - imagefontwidth(5)) / 2, ($size - imagefontheight(5)) / 2, $firstChar, $textColor);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 转换为base64
|
|||
|
|
ob_start();
|
|||
|
|
imagepng($image);
|
|||
|
|
$imageData = ob_get_clean();
|
|||
|
|
imagedestroy($image);
|
|||
|
|
|
|||
|
|
return 'data:image/png;base64,' . base64_encode($imageData);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成默认封面(200x120)
|
|||
|
|
*
|
|||
|
|
* @param string $text 文字
|
|||
|
|
*
|
|||
|
|
* @return string base64编码的图片
|
|||
|
|
*/
|
|||
|
|
private function generateDefaultCover(string $text): string
|
|||
|
|
{
|
|||
|
|
// 创建200x120的图像
|
|||
|
|
$width = 200;
|
|||
|
|
$height = 120;
|
|||
|
|
$image = imagecreatetruecolor($width, $height);
|
|||
|
|
|
|||
|
|
// 分配颜色
|
|||
|
|
$bgColor = imagecolorallocate($image, 240, 242, 245); // 浅灰色背景 #F0F2F5
|
|||
|
|
$lineColor = imagecolorallocate($image, 220, 223, 230); // 线条颜色 #DCDFE6
|
|||
|
|
$textColor = imagecolorallocate($image, 144, 147, 153); // 文字颜色 #909399
|
|||
|
|
|
|||
|
|
// 填充背景
|
|||
|
|
imagefill($image, 0, 0, $bgColor);
|
|||
|
|
|
|||
|
|
// 绘制圆角
|
|||
|
|
$radius = 8;
|
|||
|
|
$corners = [
|
|||
|
|
[0, 0], [$width - 1, 0],
|
|||
|
|
[0, $height - 1], [$width - 1, $height - 1],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
foreach ($corners as $corner) {
|
|||
|
|
imagefilledarc($image, $corner[0] + $radius, $corner[1] + $radius, $radius * 2, $radius * 2, 0, 360, $bgColor, IMG_ARC_PIE);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 绘制虚线边框
|
|||
|
|
imagesetthickness($image, 1);
|
|||
|
|
imagerectangle($image, 1, 1, $width - 2, $height - 2, $lineColor);
|
|||
|
|
|
|||
|
|
// 绘制中间的+号
|
|||
|
|
$plusSize = 24;
|
|||
|
|
$centerX = $width / 2;
|
|||
|
|
$centerY = $height / 2;
|
|||
|
|
|
|||
|
|
imagesetthickness($image, 2);
|
|||
|
|
imageline($image, $centerX - $plusSize, $centerY, $centerX + $plusSize, $centerY, $textColor);
|
|||
|
|
imageline($image, $centerX, $centerY - $plusSize, $centerX, $centerY + $plusSize, $textColor);
|
|||
|
|
|
|||
|
|
// 转换为base64
|
|||
|
|
ob_start();
|
|||
|
|
imagepng($image);
|
|||
|
|
$imageData = ob_get_clean();
|
|||
|
|
imagedestroy($image);
|
|||
|
|
|
|||
|
|
return 'data:image/png;base64,' . base64_encode($imageData);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取字体文件路径
|
|||
|
|
*
|
|||
|
|
* @return string|null
|
|||
|
|
*/
|
|||
|
|
private function getFontFile(): ?string
|
|||
|
|
{
|
|||
|
|
$fontPaths = [
|
|||
|
|
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', // Linux
|
|||
|
|
'C:/Windows/Fonts/msyh.ttc', // Windows 微软雅黑
|
|||
|
|
'C:/Windows/Fonts/simhei.ttf', // Windows 黑体
|
|||
|
|
'/System/Library/Fonts/PingFang.ttc', // macOS
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
foreach ($fontPaths as $path) {
|
|||
|
|
if (file_exists($path)) {
|
|||
|
|
return $path;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
}
|