初始化本地仓库
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
/**
|
||||
* 插件安装基类
|
||||
* 使用 Trait 实现职责分离:
|
||||
* - ConfigTrait: 配置管理
|
||||
* - MigrationTrait: 迁移操作
|
||||
* - SeedTrait: 种子操作
|
||||
* - MenuTrait: 菜单操作
|
||||
* - TemplateTrait: 模板资源操作
|
||||
* - DependencyTrait: 依赖安装
|
||||
* 使用方式:
|
||||
* 1. 在插件目录创建 Install.php
|
||||
* 2. 继承此类即可,迁移和种子会自动执行
|
||||
* 3. 可重写 install/uninstall/update 方法添加自定义逻辑
|
||||
* 配置覆盖:
|
||||
* - 默认配置: core/plugin/config/default.php
|
||||
* - 应用配置: core/plugin/config/app.php
|
||||
* - 插件配置: plugin/{name}/config/info.php (可覆盖上述配置)
|
||||
*/
|
||||
|
||||
namespace core\plugin;
|
||||
|
||||
use core\plugin\traits\ConfigTrait;
|
||||
use core\plugin\traits\MigrationTrait;
|
||||
use core\plugin\traits\SeedTrait;
|
||||
use core\plugin\traits\MenuTrait;
|
||||
use core\plugin\traits\TemplateTrait;
|
||||
use core\plugin\traits\DependencyTrait;
|
||||
|
||||
class PluginInstall
|
||||
{
|
||||
use ConfigTrait;
|
||||
use MigrationTrait;
|
||||
use SeedTrait;
|
||||
use MenuTrait;
|
||||
use TemplateTrait;
|
||||
use DependencyTrait;
|
||||
|
||||
/** @var string 插件名称 */
|
||||
protected string $pluginName = '';
|
||||
|
||||
/** @var string 插件路径 */
|
||||
protected string $pluginPath = '';
|
||||
|
||||
/** @var string|null 连接名(默认使用框架配置) */
|
||||
protected ?string $connection = null;
|
||||
|
||||
/** @var array 插件配置 */
|
||||
protected array $appConfig = [];
|
||||
|
||||
/** @var callable|null 进度回调函数(用于在线模式通过 SSE 返回进度) */
|
||||
protected $progressCallback = null;
|
||||
|
||||
/** @var bool 是否为在线模式(非 CLI) */
|
||||
protected bool $isOnlineMode = false;
|
||||
|
||||
/** @var bool 卸载阶段是否保留已复制的模板资源 */
|
||||
protected bool $preserveTemplatesDuringUninstall = false;
|
||||
|
||||
/**
|
||||
* @param callable|null $progressCallback 进度回调
|
||||
* @param bool $isOnlineMode 是否为在线模式
|
||||
*/
|
||||
public function __construct(?callable $progressCallback = null, bool $isOnlineMode = false)
|
||||
{
|
||||
$this->progressCallback = $progressCallback;
|
||||
$this->isOnlineMode = $isOnlineMode;
|
||||
$this->init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置进度回调
|
||||
*/
|
||||
public function setProgressCallback(?callable $callback): self
|
||||
{
|
||||
$this->progressCallback = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否为在线模式
|
||||
*/
|
||||
public function setOnlineMode(bool $isOnlineMode): self
|
||||
{
|
||||
$this->isOnlineMode = $isOnlineMode;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出日志(CLI 模式 echo,在线模式通过回调)
|
||||
*/
|
||||
protected function log(string $message, ?int $progress = null): void
|
||||
{
|
||||
if ($this->isOnlineMode && $this->progressCallback) {
|
||||
// 在线模式:通过回调返回进度
|
||||
($this->progressCallback)($message, $progress);
|
||||
} else {
|
||||
// CLI 模式:直接输出
|
||||
echo $message . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出进度
|
||||
*/
|
||||
protected function progress(string $message, int $progress): void
|
||||
{
|
||||
$this->log($message, $progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出消息(用于 Trait 中的 echo 替换)
|
||||
* CLI 模式直接输出,在线模式输出到缓冲区
|
||||
*/
|
||||
protected function output(string $message): void
|
||||
{
|
||||
echo $message . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
protected function init(): void
|
||||
{
|
||||
$this->pluginPath = $this->getPluginPath();
|
||||
$this->pluginName = basename($this->pluginPath);
|
||||
$this->appConfig = $this->getAppConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件路径 - 子类可重写
|
||||
*/
|
||||
protected function getPluginPath(): string
|
||||
{
|
||||
$class = static::class;
|
||||
$parts = explode('\\', $class);
|
||||
if (count($parts) >= 2 && $parts[0] === 'plugin') {
|
||||
return base_path('plugin/' . $parts[1]);
|
||||
}
|
||||
return base_path('plugin');
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件名称
|
||||
*/
|
||||
protected function getPluginName(): string
|
||||
{
|
||||
return $this->pluginName ?: basename($this->getPluginPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置数据库连接
|
||||
*/
|
||||
public function setConnection(?string $connection): self
|
||||
{
|
||||
$this->connection = $connection;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制重新运行迁移
|
||||
*/
|
||||
public function forceMigrate(): void
|
||||
{
|
||||
$this->output("⚠️ Force migration - clearing migration logs...");
|
||||
$this->clearLogs();
|
||||
$this->runMigrations();
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装
|
||||
*/
|
||||
public function install($version): void
|
||||
{
|
||||
$this->log("====== Plugin Install: {$this->getPluginName()} ======", 0);
|
||||
$this->log("Version: {$version}");
|
||||
$this->log("Path: {$this->pluginPath}");
|
||||
|
||||
// 安装前回调
|
||||
$this->beforeInstall($version);
|
||||
|
||||
// 安装 Composer 依赖(后端)
|
||||
$this->installComposerDeps();
|
||||
|
||||
// 运行迁移(后端)
|
||||
$this->runMigrations();
|
||||
|
||||
// 运行种子(后端)
|
||||
$this->runSeeds();
|
||||
|
||||
// 安装前端依赖
|
||||
$this->installNpmDeps();
|
||||
|
||||
// 复制前端模板到各端
|
||||
$this->copyTemplates();
|
||||
|
||||
// 执行各端安装命令
|
||||
$this->runInstallCommands();
|
||||
|
||||
// 安装后回调 - 可重写用于导入菜单等操作
|
||||
$this->afterInstall($version);
|
||||
|
||||
// 保存插件安装状态
|
||||
$this->savePluginConfig($version);
|
||||
|
||||
$this->log("====== Install Completed ======", 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装前回调 - 可重写
|
||||
*/
|
||||
protected function beforeInstall(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running beforeInstall...");
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装后回调 - 可重写
|
||||
*/
|
||||
protected function afterInstall(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running afterInstall...");
|
||||
|
||||
// 加载插件菜单
|
||||
$this->loadMenus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载前回调 - 可重写
|
||||
*/
|
||||
protected function beforeUninstall(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running beforeUninstall...");
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载后回调 - 清理菜单
|
||||
*/
|
||||
protected function afterUninstall(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running afterUninstall...");
|
||||
|
||||
// 卸载菜单
|
||||
$this->uninstallPluginMenus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载
|
||||
*/
|
||||
public function uninstall($version): void
|
||||
{
|
||||
$this->log("====== Plugin Uninstall: {$this->getPluginName()} ======", 0);
|
||||
|
||||
$this->preserveTemplatesDuringUninstall = true;
|
||||
try {
|
||||
// 卸载前回调和卸载后回调均不得物理删除模板资源。
|
||||
$this->beforeUninstall($version);
|
||||
|
||||
// 获取卸载配置
|
||||
$pluginInfo = $this->getPluginInfo();
|
||||
$uninstallConfig = $pluginInfo['uninstall'] ?? [];
|
||||
$dropTables = $uninstallConfig['drop_tables'] ?? false;
|
||||
|
||||
$this->log("📋 Uninstall config: drop_tables=" . ($dropTables ? 'true' : 'false'));
|
||||
|
||||
// 回滚迁移(可选删除表)
|
||||
$this->rollbackMigrations($dropTables);
|
||||
|
||||
// 清理日志
|
||||
$this->clearLogs();
|
||||
|
||||
$this->afterUninstall($version);
|
||||
} finally {
|
||||
$this->preserveTemplatesDuringUninstall = false;
|
||||
}
|
||||
|
||||
$this->log('📦 Plugin package, templates and dependencies are preserved until deletion; only the installation marker is removed.');
|
||||
|
||||
$this->log("====== Uninstall Completed ======", 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件
|
||||
*/
|
||||
public function delete($version): void
|
||||
{
|
||||
$this->log("====== Plugin Delete: {$this->getPluginName()} ======", 10);
|
||||
|
||||
// 获取插件配置
|
||||
$pluginInfo = $this->getPluginInfo();
|
||||
$uninstallConfig = $pluginInfo['uninstall'] ?? [];
|
||||
$undeletable = $uninstallConfig['undeletable'] ?? false;
|
||||
|
||||
$this->log("📋 Plugin config: undeletable=" . ($undeletable ? 'true' : 'false'), 20);
|
||||
|
||||
// 检查是否允许删除
|
||||
if ($undeletable) {
|
||||
throw new \Exception('系统内置插件不允许删除');
|
||||
}
|
||||
|
||||
// 删除前回调
|
||||
$this->beforeDelete($version);
|
||||
|
||||
// 删除安装时复制到前端项目的模板资源
|
||||
$this->deleteTemplates();
|
||||
|
||||
// 按插件配置移除安装时合并的依赖
|
||||
$globalUninstallConfig = $this->getConfig('uninstall', []);
|
||||
$removeDependencies = $uninstallConfig['remove_dependencies'] ?? $globalUninstallConfig['remove_dependencies'] ?? false;
|
||||
if ($removeDependencies) {
|
||||
$this->removeMergedDependencies();
|
||||
}
|
||||
|
||||
// 获取插件目录
|
||||
$pluginDir = $this->pluginPath;
|
||||
|
||||
if (!is_dir($pluginDir)) {
|
||||
throw new \Exception('插件目录不存在:' . $pluginDir);
|
||||
}
|
||||
|
||||
$this->log("📁 Deleting plugin directory: {$pluginDir}", 40);
|
||||
|
||||
// 使用递归删除整个插件目录
|
||||
$this->removeDirectory($pluginDir);
|
||||
|
||||
$this->log("✅ Plugin deleted successfully", 100);
|
||||
|
||||
// 删除后回调
|
||||
$this->afterDelete($version);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除前回调 - 可重写
|
||||
*/
|
||||
protected function beforeDelete(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running beforeDelete...");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除后回调 - 可重写
|
||||
*/
|
||||
protected function afterDelete(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running afterDelete...");
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归删除目录
|
||||
*/
|
||||
protected function removeDirectory(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = scandir($directory);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
|
||||
if (is_dir($path)) {
|
||||
$this->removeDirectory($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
@rmdir($directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模板资源 - 仅应在删除插件或升级清理阶段调用
|
||||
*/
|
||||
protected function deletePluginTemplates(): void
|
||||
{
|
||||
$this->deleteTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新
|
||||
*/
|
||||
public function update($version): void
|
||||
{
|
||||
$this->output("====== Plugin Update: {$this->getPluginName()} ======");
|
||||
|
||||
// 先卸载
|
||||
$this->uninstall($version);
|
||||
|
||||
// 升级前清理旧模板,避免已删除的旧文件残留
|
||||
$this->deleteTemplates();
|
||||
|
||||
// 再安装
|
||||
$this->install($version);
|
||||
|
||||
$this->output("====== Update Completed ======");
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 端安装命令执行
|
||||
// =========================================================
|
||||
|
||||
/**
|
||||
* 执行各端安装命令
|
||||
*/
|
||||
protected function runInstallCommands(): void
|
||||
{
|
||||
$this->output("📦 Running install commands...");
|
||||
|
||||
$projectRoot = $this->getProjectRoot();
|
||||
|
||||
// 后端安装命令
|
||||
$this->runBackendCommands($projectRoot);
|
||||
|
||||
// 前端安装命令
|
||||
$this->runFrontendCommands($projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行后端安装命令
|
||||
*/
|
||||
protected function runBackendCommands(string $projectRoot): void
|
||||
{
|
||||
// 可在子类中重写实现自定义命令
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行前端安装命令
|
||||
*/
|
||||
protected function runFrontendCommands(string $projectRoot): void
|
||||
{
|
||||
// 可在子类中重写实现自定义命令
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 插件迁移基类
|
||||
*
|
||||
* 迁移文件写法(匿名类方式):
|
||||
*
|
||||
* <?php
|
||||
* use Illuminate\Database\Schema\Builder;
|
||||
* use Illuminate\Database\Schema\Blueprint;
|
||||
*
|
||||
* return new class {
|
||||
* public function up(Builder $schema): void
|
||||
* {
|
||||
* $schema->create('official_app', function (Blueprint $table) {
|
||||
* $table->bigInteger('id')->primary();
|
||||
* $table->string('name', 50);
|
||||
* });
|
||||
* }
|
||||
*
|
||||
* public function down(Builder $schema): void
|
||||
* {
|
||||
* $schema->dropIfExists('official_app');
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
|
||||
namespace core\plugin;
|
||||
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
abstract class PluginMigrate
|
||||
{
|
||||
/**
|
||||
* 迁移向上执行(安装)
|
||||
*/
|
||||
abstract public function up(Builder $schema): void;
|
||||
|
||||
/**
|
||||
* 迁移向下执行(回滚)
|
||||
*/
|
||||
public function down(Builder $schema): void
|
||||
{
|
||||
// 可选实现
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
/**
|
||||
* 插件默认配置
|
||||
* 此文件的配置可被插件的 config/info.php 覆盖
|
||||
* 覆盖规则:info.php 中的配置会合并到此文件的配置中
|
||||
*/
|
||||
|
||||
return [
|
||||
'enable' => true,
|
||||
/**
|
||||
* 安装配置
|
||||
*/
|
||||
'install' => [
|
||||
// 是否自动创建日志目录
|
||||
'auto_mkdir_logs' => true,
|
||||
],
|
||||
|
||||
/**
|
||||
* 卸载配置
|
||||
*/
|
||||
'uninstall' => [
|
||||
// 卸载时是否删除数据表(默认 false)
|
||||
'drop_tables' => false,
|
||||
// 卸载时是否移除合并的依赖(默认 false)
|
||||
'remove_dependencies' => false,
|
||||
],
|
||||
|
||||
/**
|
||||
* 模板资源配置
|
||||
* 插件模板复制目标位置(相对于项目根目录,即 server 的兄弟节点)
|
||||
* 结构: ['端' => '相对路径']
|
||||
*/
|
||||
'template' => [
|
||||
// 后台模板目标路径(相对于项目根目录)
|
||||
'admin' => 'frontend/admin/src/apps',
|
||||
// 前台模板目标路径
|
||||
'web' => 'frontend/web/app/apps',
|
||||
],
|
||||
|
||||
/**
|
||||
* 资源目录配置
|
||||
* 插件资源目录名称
|
||||
*/
|
||||
'resource' => [
|
||||
// 数据库迁移目录
|
||||
'migration' => 'database/migrations',
|
||||
// 数据库种子目录
|
||||
'seed' => 'database/seeds',
|
||||
// 菜单资源目录
|
||||
'menu' => 'menu',
|
||||
// 模板资源目录
|
||||
'template' => 'template',
|
||||
],
|
||||
|
||||
/**
|
||||
* Logo 配置
|
||||
*/
|
||||
'logo' => [
|
||||
// Logo 存储目录(相对于项目根目录)
|
||||
'path' => 'resource/images/plugin/logo',
|
||||
// 默认 logo 文件名
|
||||
'default' => 'default.png',
|
||||
// 支持的扩展名
|
||||
'ext' => ['png', 'jpg', 'jpeg', 'svg', 'webp'],
|
||||
],
|
||||
|
||||
/**
|
||||
* 依赖合并配置
|
||||
*/
|
||||
'dependencies' => [
|
||||
// 后台依赖配置 (admin/package.json)
|
||||
'admin' => [
|
||||
'enabled' => true,
|
||||
'merge_dev' => true, // 合并 devDependencies
|
||||
'merge_prod' => true, // 合并 dependencies
|
||||
],
|
||||
// 前台依赖配置 (web/package.json)
|
||||
'web' => [
|
||||
'enabled' => true,
|
||||
'merge_dev' => true,
|
||||
'merge_prod' => true,
|
||||
],
|
||||
// 后端依赖配置 (server/composer.json)
|
||||
'backend' => [
|
||||
'enabled' => true,
|
||||
'merge_require' => true, // 合并 require
|
||||
'merge_require_dev' => true, // 合并 require-dev
|
||||
],
|
||||
],
|
||||
|
||||
/**
|
||||
* 数据库配置
|
||||
*/
|
||||
'database' => [
|
||||
// 连接名(默认使用框架配置)
|
||||
'connection' => null,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace core\plugin\traits;
|
||||
|
||||
/**
|
||||
* 配置管理 Trait
|
||||
* 实现配置的获取、合并和覆盖机制
|
||||
*/
|
||||
trait ConfigTrait
|
||||
{
|
||||
/**
|
||||
* 获取应用配置
|
||||
*/
|
||||
protected function getAppConfig(): array
|
||||
{
|
||||
static $config = null;
|
||||
|
||||
if ($config === null) {
|
||||
// Trait 中 __DIR__ 是 core/plugin/traits,需要两级目录才能到达 core/plugin
|
||||
$configFile = dirname(dirname(__DIR__)) . '/plugin/config/app.php';
|
||||
$config = file_exists($configFile) ? include $configFile : [];
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置项(合并 appConfig + info.php 配置)
|
||||
* info.php 中的配置优先级更高
|
||||
*/
|
||||
protected function getConfig(string $key, mixed $default = null): mixed
|
||||
{
|
||||
// 先从 appConfig 获取
|
||||
$appConfigValue = $this->getAppConfigValue($key);
|
||||
|
||||
// 再从插件 info.php 获取(覆盖 appConfig)
|
||||
$pluginConfigValue = $this->getPluginConfigValue($key);
|
||||
|
||||
// 插件配置优先
|
||||
if ($pluginConfigValue !== null) {
|
||||
return $pluginConfigValue;
|
||||
}
|
||||
|
||||
return $appConfigValue !== null ? $appConfigValue : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 appConfig 获取配置值
|
||||
*/
|
||||
private function getAppConfigValue(string $key): mixed
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$value = $this->appConfig;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($value[$k])) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从插件 info.php 获取配置值
|
||||
*/
|
||||
private function getPluginConfigValue(string $key): mixed
|
||||
{
|
||||
static $pluginInfo = null;
|
||||
|
||||
if ($pluginInfo === null) {
|
||||
$infoFile = $this->getPluginInfoFile();
|
||||
$pluginInfo = file_exists($infoFile) ? include $infoFile : [];
|
||||
}
|
||||
|
||||
if (empty($pluginInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$keys = explode('.', $key);
|
||||
$value = $pluginInfo;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($value[$k])) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件信息配置文件路径
|
||||
*/
|
||||
protected function getPluginInfoFile(): string
|
||||
{
|
||||
return $this->pluginPath . '/config/info.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件信息(包含隐式默认配置)
|
||||
* info.php 中的配置会覆盖默认配置
|
||||
*/
|
||||
protected function getPluginInfo(): array
|
||||
{
|
||||
// 先获取显式配置(info.php)
|
||||
$infoFile = $this->getPluginInfoFile();
|
||||
$explicitConfig = [];
|
||||
|
||||
if (file_exists($infoFile)) {
|
||||
$explicitConfig = include $infoFile;
|
||||
$explicitConfig = is_array($explicitConfig) ? $explicitConfig : [];
|
||||
}
|
||||
|
||||
// 合并隐式默认配置
|
||||
return array_merge($this->getImplicitConfig(), $explicitConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取隐式默认配置
|
||||
*/
|
||||
protected function getImplicitConfig(): array
|
||||
{
|
||||
return [
|
||||
'dependencies' => [],
|
||||
'require' => [
|
||||
'composer' => [],
|
||||
],
|
||||
'npm_require' => [
|
||||
'admin' => [],
|
||||
'web' => [],
|
||||
],
|
||||
'supports' => [
|
||||
'backend' => true,
|
||||
'frontend' => true,
|
||||
'admin' => true,
|
||||
'web' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置文件路径
|
||||
*/
|
||||
protected function getPluginConfigFile(): string
|
||||
{
|
||||
return $this->pluginPath . '/config/installed.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存插件安装状态
|
||||
*/
|
||||
protected function savePluginConfig(string $version): void
|
||||
{
|
||||
$configFile = $this->getPluginConfigFile();
|
||||
$configDir = dirname($configFile);
|
||||
|
||||
if (!is_dir($configDir)) {
|
||||
mkdir($configDir, 0755, true);
|
||||
}
|
||||
|
||||
$existingConfig = [];
|
||||
if (file_exists($configFile)) {
|
||||
$existingConfig = include $configFile;
|
||||
$existingConfig = is_array($existingConfig) ? $existingConfig : [];
|
||||
}
|
||||
|
||||
$config = array_merge($existingConfig, [
|
||||
'name' => $this->getPluginName(),
|
||||
'version' => $version,
|
||||
'installed_at' => date('Y-m-d H:i:s'),
|
||||
'status' => 'installed',
|
||||
]);
|
||||
|
||||
$content = "<?php\n\n/**\n * 插件安装状态配置\n * 此文件由系统自动生成,请勿手动修改\n */\n\nreturn " . var_export($config, true) . ";\n";
|
||||
|
||||
file_put_contents($configFile, $content);
|
||||
|
||||
$this->output("📝 Plugin config saved: {$configFile}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件安装状态
|
||||
*/
|
||||
protected function getPluginConfig(): ?array
|
||||
{
|
||||
$configFile = $this->getPluginConfigFile();
|
||||
|
||||
if (!file_exists($configFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$config = include $configFile;
|
||||
return is_array($config) ? $config : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查插件是否已安装
|
||||
*/
|
||||
public function isInstalled(): bool
|
||||
{
|
||||
$config = $this->getPluginConfig();
|
||||
return $config !== null && ($config['status'] ?? '') === 'installed';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件版本
|
||||
*/
|
||||
public function getVersion(): ?string
|
||||
{
|
||||
$config = $this->getPluginConfig();
|
||||
return $config['version'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件配置文件
|
||||
*/
|
||||
protected function removePluginConfig(): void
|
||||
{
|
||||
$configFile = $this->getPluginConfigFile();
|
||||
|
||||
if (file_exists($configFile)) {
|
||||
unlink($configFile);
|
||||
$this->output("📝 Plugin config removed: {$configFile}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace core\plugin\traits;
|
||||
|
||||
/**
|
||||
* 依赖安装 Trait
|
||||
*/
|
||||
trait DependencyTrait
|
||||
{
|
||||
/**
|
||||
* 合并 Composer 依赖(后端)
|
||||
*/
|
||||
protected function installComposerDeps(): void
|
||||
{
|
||||
$pluginInfo = $this->getPluginInfo();
|
||||
$require = $pluginInfo['require']['composer'] ?? [];
|
||||
|
||||
if (empty($require)) {
|
||||
$this->output("📦 No Composer dependencies to merge");
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $this->getConfig('dependencies.backend', []);
|
||||
if (empty($config['enabled'])) {
|
||||
$this->output("📦 Composer dependency merge disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output("📦 Merging Composer dependencies...");
|
||||
|
||||
$composerFile = base_path('composer.json');
|
||||
if (!file_exists($composerFile)) {
|
||||
$this->output(" ⚠️ composer.json not found");
|
||||
return;
|
||||
}
|
||||
|
||||
$composerContent = file_get_contents($composerFile);
|
||||
$composer = json_decode($composerContent, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->output(" ⚠️ Invalid composer.json format");
|
||||
return;
|
||||
}
|
||||
|
||||
$merged = false;
|
||||
|
||||
// 合并 require
|
||||
if (!empty($config['merge_require'])) {
|
||||
foreach ($require as $package => $version) {
|
||||
if (!isset($composer['require'][$package])) {
|
||||
$composer['require'][$package] = $version;
|
||||
$merged = true;
|
||||
$this->output(" ➕ Added to require: {$package}:{$version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存
|
||||
if ($merged) {
|
||||
file_put_contents($composerFile, json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
$this->output(" ✅ Composer dependencies merged. Run 'composer install' manually.");
|
||||
} else {
|
||||
$this->output(" ℹ️ No new dependencies to merge");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 NPM 依赖(前端)
|
||||
*/
|
||||
protected function installNpmDeps(): void
|
||||
{
|
||||
$pluginInfo = $this->getPluginInfo();
|
||||
$npmRequire = $pluginInfo['npm_require'] ?? [];
|
||||
|
||||
if (empty($npmRequire)) {
|
||||
$this->output("📦 No NPM dependencies to merge");
|
||||
return;
|
||||
}
|
||||
|
||||
$projectRoot = $this->getProjectRoot();
|
||||
|
||||
// 合并 Admin 端依赖(前端代码统一在 frontend 目录下)
|
||||
$adminConfig = $this->getConfig('dependencies.admin', []);
|
||||
if (!empty($adminConfig['enabled'])) {
|
||||
$adminPath = $this->getFrontendProjectPath('admin');
|
||||
$this->mergeNpmDeps($npmRequire['admin'] ?? [], $adminPath, 'Admin', $adminConfig);
|
||||
$this->mergeNpmDeps($npmRequire['common'] ?? [], $adminPath, 'Admin (common)', $adminConfig);
|
||||
}
|
||||
|
||||
// 合并 Web 端依赖
|
||||
$webConfig = $this->getConfig('dependencies.web', []);
|
||||
if (!empty($webConfig['enabled'])) {
|
||||
$webPath = $this->getFrontendProjectPath('web');
|
||||
$this->mergeNpmDeps($npmRequire['web'] ?? [], $webPath, 'Web', $webConfig);
|
||||
$this->mergeNpmDeps($npmRequire['common'] ?? [], $webPath, 'Web (common)', $webConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 NPM 依赖到 package.json
|
||||
*/
|
||||
protected function mergeNpmDeps(array $packages, string $workDir, string $endpoint, array $config): void
|
||||
{
|
||||
if (empty($packages) || !is_dir($workDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$packageFile = $workDir . '/package.json';
|
||||
if (!file_exists($packageFile)) {
|
||||
$this->output(" ⚠️ {$endpoint}: package.json not found");
|
||||
return;
|
||||
}
|
||||
|
||||
$packageContent = file_get_contents($packageFile);
|
||||
$package = json_decode($packageContent, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->output(" ⚠️ {$endpoint}: Invalid package.json format");
|
||||
return;
|
||||
}
|
||||
|
||||
$merged = false;
|
||||
|
||||
// 合并 dependencies
|
||||
if (!empty($config['merge_prod'])) {
|
||||
foreach ($packages as $pkg => $version) {
|
||||
if (!isset($package['dependencies'][$pkg])) {
|
||||
$package['dependencies'][$pkg] = $version;
|
||||
$merged = true;
|
||||
$this->output(" ➕ {$endpoint}: Added {$pkg}@{$version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 合并 devDependencies
|
||||
if (!empty($config['merge_dev'])) {
|
||||
foreach ($packages as $pkg => $version) {
|
||||
if (!isset($package['devDependencies'][$pkg])) {
|
||||
$package['devDependencies'][$pkg] = $version;
|
||||
$merged = true;
|
||||
$this->output(" ➕ {$endpoint}: Added dev {$pkg}@{$version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存
|
||||
if ($merged) {
|
||||
file_put_contents($packageFile, json_encode($package, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
$this->output(" ✅ {$endpoint}: Dependencies merged. Run 'npm install' manually.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除合并的依赖
|
||||
*/
|
||||
protected function removeMergedDependencies(): void
|
||||
{
|
||||
$this->output("🗑️ Removing merged dependencies...");
|
||||
|
||||
$pluginInfo = $this->getPluginInfo();
|
||||
|
||||
// 移除 Composer 依赖
|
||||
$composerRequire = $pluginInfo['require']['composer'] ?? [];
|
||||
if (!empty($composerRequire)) {
|
||||
$this->removeComposerDeps($composerRequire);
|
||||
}
|
||||
|
||||
// 移除 NPM 依赖
|
||||
$npmRequire = $pluginInfo['npm_require'] ?? [];
|
||||
if (!empty($npmRequire)) {
|
||||
// 前端代码统一在 frontend 目录下
|
||||
$this->removeNpmDeps($npmRequire['admin'] ?? [], $this->getFrontendProjectPath('admin'), 'Admin');
|
||||
$this->removeNpmDeps($npmRequire['common'] ?? [], $this->getFrontendProjectPath('admin'), 'Admin (common)');
|
||||
|
||||
$this->removeNpmDeps($npmRequire['web'] ?? [], $this->getFrontendProjectPath('web'), 'Web');
|
||||
$this->removeNpmDeps($npmRequire['common'] ?? [], $this->getFrontendProjectPath('web'), 'Web (common)');
|
||||
}
|
||||
|
||||
$this->output(" ✅ Dependencies removed. Run 'composer install' or 'npm install' to update.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Composer 依赖
|
||||
*/
|
||||
protected function removeComposerDeps(array $packages): void
|
||||
{
|
||||
$composerFile = base_path('composer.json');
|
||||
if (!file_exists($composerFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$composer = json_decode(file_get_contents($composerFile), true);
|
||||
|
||||
$removed = false;
|
||||
|
||||
foreach ($packages as $package => $version) {
|
||||
if (isset($composer['require'][$package])) {
|
||||
unset($composer['require'][$package]);
|
||||
$removed = true;
|
||||
$this->output(" ➖ Removed composer: {$package}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($removed) {
|
||||
file_put_contents($composerFile, json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 NPM 依赖
|
||||
*/
|
||||
protected function removeNpmDeps(array $packages, string $workDir, string $endpoint): void
|
||||
{
|
||||
if (empty($packages) || !is_dir($workDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$packageFile = $workDir . '/package.json';
|
||||
if (!file_exists($packageFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$package = json_decode(file_get_contents($packageFile), true);
|
||||
|
||||
$removed = false;
|
||||
|
||||
foreach ($packages as $pkg => $version) {
|
||||
// 移除 dependencies
|
||||
if (isset($package['dependencies'][$pkg])) {
|
||||
unset($package['dependencies'][$pkg]);
|
||||
$removed = true;
|
||||
$this->output(" ➖ {$endpoint}: Removed {$pkg}");
|
||||
}
|
||||
// 移除 devDependencies
|
||||
if (isset($package['devDependencies'][$pkg])) {
|
||||
unset($package['devDependencies'][$pkg]);
|
||||
$removed = true;
|
||||
$this->output(" ➖ {$endpoint}: Removed dev {$pkg}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($removed) {
|
||||
file_put_contents($packageFile, json_encode($package, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Shell 命令
|
||||
*/
|
||||
protected function runShellCommand(string $command, string $workDir, string $label): void
|
||||
{
|
||||
if (!is_dir($workDir)) {
|
||||
$this->output(" ⚠️ {$label}: Directory not found: {$workDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output(" 🔧 {$label}: Running '{$command}'");
|
||||
|
||||
try {
|
||||
$descriptorspec = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = proc_open($command, $descriptorspec, $pipes, $workDir);
|
||||
|
||||
if (is_resource($process)) {
|
||||
stream_get_contents($pipes[1]);
|
||||
$error = stream_get_contents($pipes[2]);
|
||||
|
||||
foreach ($pipes as $pipe) {
|
||||
fclose($pipe);
|
||||
}
|
||||
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
if ($exitCode === 0) {
|
||||
$this->output(" ✅ {$label}: Completed successfully");
|
||||
} else {
|
||||
$this->output(" ❌ {$label}: Failed with exit code {$exitCode}");
|
||||
if (!empty($error)) {
|
||||
$this->output(" Error: {$error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->output(" ❌ {$label}: Error - {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace core\plugin\traits;
|
||||
|
||||
use core\uuid\Snowflake;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 菜单操作 Trait
|
||||
*/
|
||||
trait MenuTrait
|
||||
{
|
||||
/**
|
||||
* 预加载系统菜单的 code 映射(用于智能关联父级)
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $_admin_menu_codes = [];
|
||||
protected array $_web_menu_codes = [];
|
||||
|
||||
/**
|
||||
* 加载菜单文件
|
||||
*/
|
||||
protected function loadMenus(): void
|
||||
{
|
||||
$menuResourceDir = $this->getConfig('resource.menu', 'menu');
|
||||
$menuDir = $this->pluginPath . '/resource/' . $menuResourceDir;
|
||||
|
||||
$this->output("📂 Loading menus from: {$menuDir}");
|
||||
|
||||
if (!is_dir($menuDir)) {
|
||||
$this->output("📂 No menu directory found, skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
$files = scandir($menuDir);
|
||||
$this->output("📂 Menu files found: " . implode(', ', array_diff($files, ['.', '..'])));
|
||||
|
||||
// 预加载系统菜单的 code 映射(用于智能关联父级)
|
||||
$this->preloadSystemMenuCodes('admin');
|
||||
$this->preloadSystemMenuCodes('web');
|
||||
|
||||
// 只加载 admin 和 web 两种类型的菜单
|
||||
$this->loadMenuFile($menuDir . '/admin.php', 'admin');
|
||||
$this->loadMenuFile($menuDir . '/web.php', 'web');
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载系统菜单的 code 映射
|
||||
* 用于插件菜单关联到系统已有菜单
|
||||
*
|
||||
* @param string $type 菜单类型 (admin/web)
|
||||
*/
|
||||
protected function preloadSystemMenuCodes(string $type): void
|
||||
{
|
||||
$tableName = $type === 'admin' ? 'sys_menu' : 'web_menu';
|
||||
$connection = $this->connection ?? null;
|
||||
|
||||
try {
|
||||
// 获取系统中非插件来源的菜单 code -> id 映射
|
||||
$menus = Db::connection($connection)
|
||||
->table($tableName)
|
||||
->where('source', 'system')
|
||||
->whereNotNull('code')
|
||||
->where('code', '!=', '')
|
||||
->select(['id', 'code', 'pid', 'type'])
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$key = $type === 'admin' ? '_admin_menu_codes' : '_web_menu_codes';
|
||||
$this->$key = [];
|
||||
|
||||
foreach ($menus as $menu) {
|
||||
$code = $menu->code;
|
||||
$this->$key[$code] = [
|
||||
'id' => $menu->id,
|
||||
'pid' => $menu->pid,
|
||||
'type' => $menu->type,
|
||||
];
|
||||
}
|
||||
|
||||
$this->output(" 📋 Preloaded {$type} system menus: " . count($this->$key) . " codes");
|
||||
} catch (\Throwable $e) {
|
||||
$this->output(" ⚠️ Failed to preload {$type} system menus: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 pid_code 查找父级 ID
|
||||
*
|
||||
* @param string|null $pidCode 父级菜单的 code
|
||||
* @param string $type 菜单类型
|
||||
* @return int 父级 ID,0 表示顶级菜单
|
||||
*/
|
||||
protected function findParentIdByCode(?string $pidCode, string $type): int
|
||||
{
|
||||
if (empty($pidCode)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$key = $type === 'admin' ? '_admin_menu_codes' : '_web_menu_codes';
|
||||
$codes = $this->$key ?? [];
|
||||
|
||||
if (!isset($codes[$pidCode])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$parentInfo = $codes[$pidCode];
|
||||
|
||||
// 如果父级是目录类型(type=1),则返回其 ID
|
||||
// 否则作为顶级菜单
|
||||
if ($type === 'admin') {
|
||||
// admin: 1=目录 2=菜单 3=按钮
|
||||
return in_array($parentInfo['type'], [1]) ? $parentInfo['id'] : 0;
|
||||
} else {
|
||||
// web 菜单类型判断
|
||||
return $parentInfo['id'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单个菜单文件
|
||||
*/
|
||||
protected function loadMenuFile(string $filePath, string $type): void
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
$this->output(" ⚠️ {$type}: Menu file not found");
|
||||
return;
|
||||
}
|
||||
|
||||
$menu = include $filePath;
|
||||
|
||||
if (empty($menu)) {
|
||||
$this->output(" ⚠️ {$type}: Empty menu file");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_array($menu)) {
|
||||
$this->output(" ❌ {$type}: Invalid menu format");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->saveMenuToSystem($type, $menu);
|
||||
|
||||
$this->output(" ✅ {$type}: Menu loaded (" . count($menu) . " items)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存菜单到系统
|
||||
*/
|
||||
protected function saveMenuToSystem(string $type, array $menu): void
|
||||
{
|
||||
if ($type === 'admin') {
|
||||
$this->saveAdminMenu($menu);
|
||||
} else {
|
||||
$this->saveWebMenu($menu);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存后台菜单
|
||||
*/
|
||||
protected function saveAdminMenu(array $menu): void
|
||||
{
|
||||
$tableName = 'sys_menu';
|
||||
|
||||
$this->output(" 📝 Saving admin menu to {$tableName}...");
|
||||
|
||||
try {
|
||||
$connection = $this->connection ?? null;
|
||||
$hasTable = Db::connection($connection)->getSchemaBuilder()->hasTable($tableName);
|
||||
|
||||
if (!$hasTable) {
|
||||
$this->output(" ⚠️ Table {$tableName} does not exist, skipping menu import");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->insertAdminMenuItems($tableName, $menu, $this->pluginName);
|
||||
|
||||
$this->output(" ✅ Admin menu saved to {$tableName}");
|
||||
} catch (\Throwable $e) {
|
||||
$this->output(" ❌ Failed to save admin menu: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入后台菜单项
|
||||
*
|
||||
* @param string $tableName 表名
|
||||
* @param array $items 菜单项
|
||||
* @param string $plugin 插件标识
|
||||
* @param int $parentId 父级ID
|
||||
* @param int $level 当前层级
|
||||
*/
|
||||
protected function insertAdminMenuItems(string $tableName, array $items, string $plugin, int $parentId = 0, int $level = 1): void
|
||||
{
|
||||
$connection = $this->connection ?? null;
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$menuId = (int)Snowflake::generate();
|
||||
|
||||
// 智能获取父级 ID(仅顶级菜单使用 pid_code)
|
||||
// 子菜单通过 parentId(children 嵌套)确定父级
|
||||
if ($parentId === 0 && isset($item['pid_code'])) {
|
||||
$parentId = $this->findParentIdByCode($item['pid_code'], 'admin');
|
||||
}
|
||||
|
||||
// 规范化菜单数据:自动生成缺失的 code
|
||||
$normalizedItem = $this->normalizeMenuItem($item, $plugin, $level, $index);
|
||||
|
||||
$data = [
|
||||
'id' => $menuId,
|
||||
'pid' => $parentId,
|
||||
'app' => $item['app'] ?? $plugin,
|
||||
'source' => 'plugin',
|
||||
'title' => $normalizedItem['title'],
|
||||
'code' => $normalizedItem['code'],
|
||||
'level' => $level,
|
||||
'type' => $normalizedItem['type'],
|
||||
'sort' => $normalizedItem['sort'],
|
||||
'path' => $normalizedItem['path'],
|
||||
'component' => $normalizedItem['component'],
|
||||
'icon' => $normalizedItem['icon'],
|
||||
'is_show' => $normalizedItem['is_show'],
|
||||
'is_link' => $normalizedItem['is_link'],
|
||||
'is_cache' => $normalizedItem['is_cache'],
|
||||
'is_sync' => $normalizedItem['is_sync'],
|
||||
'created_at' => time(),
|
||||
'updated_at' => time(),
|
||||
];
|
||||
|
||||
Db::connection($connection)->table($tableName)->insert($data);
|
||||
|
||||
// 记录本次插入菜单的 code -> id 映射(供子菜单使用)
|
||||
if ($normalizedItem['code']) {
|
||||
$this->_admin_menu_codes[$normalizedItem['code']] = [
|
||||
'id' => $menuId,
|
||||
'pid' => $parentId,
|
||||
'type' => $data['type'],
|
||||
];
|
||||
}
|
||||
|
||||
// 递归处理子菜单,层级 +1
|
||||
if (!empty($item['children'])) {
|
||||
$this->insertAdminMenuItems($tableName, $item['children'], $plugin, $menuId, $level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化菜单项数据
|
||||
* 确保必要字段存在,自动生成缺失的 code
|
||||
*
|
||||
* @param array $item 菜单项
|
||||
* @param string $plugin 插件标识
|
||||
* @param int $level 当前层级
|
||||
* @param int $index 当前索引
|
||||
* @return array 规范化后的菜单项
|
||||
*/
|
||||
protected function normalizeMenuItem(array $item, string $plugin, int $level, int $index): array
|
||||
{
|
||||
// 获取菜单名称
|
||||
$title = $item['name'] ?? $item['title'] ?? '';
|
||||
// 获取路径
|
||||
$path = $item['path'] ?? '';
|
||||
|
||||
// 自动生成 code:如果没有 code 字段,根据插件名和路径生成
|
||||
$code = $item['code'] ?? '';
|
||||
if (empty($code)) {
|
||||
// 从 path 生成 code: /test-plugin/index -> test-plugin:index
|
||||
if (!empty($path)) {
|
||||
$code = trim($path, '/');
|
||||
$code = str_replace('/', ':', $code);
|
||||
}
|
||||
// 如果路径也为空,使用插件名+层级+索引生成唯一 code
|
||||
if (empty($code)) {
|
||||
$code = $plugin . ':level' . $level . '_' . $index;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'code' => $code,
|
||||
'type' => $item['type'] ?? ($level === 1 ? 1 : 2), // 1=目录 2=菜单
|
||||
'sort' => $item['sort'] ?? 0,
|
||||
'path' => $path,
|
||||
'component' => $item['component'] ?? ($level === 1 ? '/layout' : ''),
|
||||
'icon' => $item['icon'] ?? '',
|
||||
'is_show' => $item['is_show'] ?? 1,
|
||||
'is_link' => $item['is_link'] ?? 0,
|
||||
'is_cache' => $item['is_cache'] ?? 0,
|
||||
'is_sync' => $item['is_sync'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存前台菜单
|
||||
*/
|
||||
protected function saveWebMenu(array $menu): void
|
||||
{
|
||||
$tableName = 'web_menu';
|
||||
|
||||
$this->output(" 📝 Saving web menu to {$tableName}...");
|
||||
|
||||
try {
|
||||
$connection = $this->connection ?? null;
|
||||
$hasTable = Db::connection($connection)->getSchemaBuilder()->hasTable($tableName);
|
||||
|
||||
if (!$hasTable) {
|
||||
$this->output(" ⚠️ Table {$tableName} does not exist, skipping menu import");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->insertWebMenuItems($tableName, $menu, $this->pluginName);
|
||||
|
||||
$this->output(" ✅ Web menu saved to {$tableName}");
|
||||
} catch (\Throwable $e) {
|
||||
$this->output(" ❌ Failed to save web menu: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入前台菜单项
|
||||
*
|
||||
* @param string $tableName 表名
|
||||
* @param array $items 菜单项
|
||||
* @param string $plugin 插件标识
|
||||
* @param int $parentId 父级ID
|
||||
* @param int $level 当前层级
|
||||
*/
|
||||
protected function insertWebMenuItems(string $tableName, array $items, string $plugin, int $parentId = 0, int $level = 1): void
|
||||
{
|
||||
$connection = $this->connection ?? null;
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$menuId = (int)Snowflake::generate();
|
||||
|
||||
// 智能获取父级 ID(仅顶级菜单使用 pid_code)
|
||||
// 子菜单通过 parentId(children 嵌套)确定父级
|
||||
if ($parentId === 0 && isset($item['pid_code'])) {
|
||||
$parentId = $this->findParentIdByCode($item['pid_code'], 'web');
|
||||
}
|
||||
|
||||
// 规范化菜单数据:自动生成缺失的 code
|
||||
$normalizedItem = $this->normalizeWebMenuItem($item, $plugin, $level, $index);
|
||||
|
||||
// target: 1=当前窗口 2=新窗口
|
||||
$targetValue = 1; // 默认当前窗口
|
||||
if (isset($item['target'])) {
|
||||
$targetValue = $item['target'] === '_self' || $item['target'] === 1 || $item['target'] === '1' ? 1 : 2;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $menuId,
|
||||
'app' => $item['app'] ?? $plugin,
|
||||
'category' => $item['category'] ?? '1',
|
||||
'source' => 'plugin',
|
||||
'code' => $normalizedItem['code'],
|
||||
'name' => $normalizedItem['name'],
|
||||
'url' => $normalizedItem['url'],
|
||||
'pid' => $parentId,
|
||||
'level' => $level,
|
||||
'type' => $normalizedItem['type'],
|
||||
'sort' => $normalizedItem['sort'],
|
||||
'target' => $targetValue,
|
||||
'icon' => $normalizedItem['icon'],
|
||||
'is_show' => $normalizedItem['is_show'],
|
||||
'enabled' => $normalizedItem['enabled'],
|
||||
'created_at' => time(),
|
||||
'updated_at' => time(),
|
||||
];
|
||||
|
||||
Db::connection($connection)->table($tableName)->insert($data);
|
||||
|
||||
// 记录本次插入菜单的 code -> id 映射(供子菜单使用)
|
||||
if ($normalizedItem['code']) {
|
||||
$this->_web_menu_codes[$normalizedItem['code']] = [
|
||||
'id' => $menuId,
|
||||
'pid' => $parentId,
|
||||
'type' => $data['type'],
|
||||
];
|
||||
}
|
||||
|
||||
// 递归处理子菜单,层级 +1
|
||||
if (!empty($item['children'])) {
|
||||
$this->insertWebMenuItems($tableName, $item['children'], $plugin, $menuId, $level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化前台菜单项数据
|
||||
* 确保必要字段存在,自动生成缺失的 code
|
||||
*
|
||||
* @param array $item 菜单项
|
||||
* @param string $plugin 插件标识
|
||||
* @param int $level 当前层级
|
||||
* @param int $index 当前索引
|
||||
* @return array 规范化后的菜单项
|
||||
*/
|
||||
protected function normalizeWebMenuItem(array $item, string $plugin, int $level, int $index): array
|
||||
{
|
||||
// 获取菜单名称
|
||||
$name = $item['name'] ?? $item['title'] ?? '';
|
||||
// 获取 URL
|
||||
$url = $item['path'] ?? $item['url'] ?? '';
|
||||
|
||||
// 自动生成 code:如果没有 code 字段,根据插件名和 URL 生成
|
||||
$code = $item['code'] ?? '';
|
||||
if (empty($code)) {
|
||||
// 从 url 生成 code
|
||||
if (!empty($url)) {
|
||||
$code = trim($url, '/');
|
||||
$code = str_replace('/', ':', $code);
|
||||
}
|
||||
// 如果 URL 也为空,使用插件名+层级+索引生成唯一 code
|
||||
if (empty($code)) {
|
||||
$code = $plugin . ':level' . $level . '_' . $index;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'code' => $code,
|
||||
'url' => $url,
|
||||
'type' => $item['type'] ?? 1,
|
||||
'sort' => $item['sort'] ?? 0,
|
||||
'icon' => $item['icon'] ?? '',
|
||||
'is_show' => $item['is_show'] ?? 1,
|
||||
'enabled' => $item['enabled'] ?? 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除插件菜单
|
||||
*/
|
||||
protected function clearPluginMenus(string $plugin): void
|
||||
{
|
||||
$this->clearMenusByTable('sys_menu', $plugin);
|
||||
$this->clearMenusByTable('web_menu', $plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据表清除菜单
|
||||
*/
|
||||
protected function clearMenusByTable(string $tableName, string $plugin): void
|
||||
{
|
||||
try {
|
||||
$connection = $this->connection ?? null;
|
||||
if (!Db::connection($connection)->getSchemaBuilder()->hasTable($tableName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query = Db::connection($connection)
|
||||
->table($tableName)
|
||||
->where('source', 'plugin')
|
||||
->where(function ($query) use ($tableName, $plugin) {
|
||||
$query->where('app', $plugin)
|
||||
->orWhere('code', 'like', $plugin . ':%');
|
||||
|
||||
if ($tableName === 'sys_menu') {
|
||||
$query->orWhere('path', 'like', '/' . $plugin . '%')
|
||||
->orWhere('component', 'like', '/' . $plugin . '/%');
|
||||
} else {
|
||||
$query->orWhere('url', 'like', '/' . $plugin . '%');
|
||||
}
|
||||
});
|
||||
|
||||
$deleted = $query->delete();
|
||||
|
||||
if ($deleted > 0) {
|
||||
$this->output(" 🗑️ Cleared {$deleted} menus from {$tableName}");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->output(" ⚠️ Failed to clear {$tableName}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入插件菜单(快捷方法)
|
||||
*/
|
||||
public function importPluginMenus(): void
|
||||
{
|
||||
$this->importMenus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件菜单(快捷方法)
|
||||
*/
|
||||
public function uninstallPluginMenus(): void
|
||||
{
|
||||
$this->clearMenus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入菜单(子类可重写)
|
||||
*/
|
||||
protected function importMenus(): void
|
||||
{
|
||||
$this->loadMenus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除菜单(子类可重写)
|
||||
*/
|
||||
protected function clearMenus(): void
|
||||
{
|
||||
$this->clearPluginMenus($this->pluginName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
<?php
|
||||
|
||||
namespace core\plugin\traits;
|
||||
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 迁移操作 Trait
|
||||
*/
|
||||
trait MigrationTrait
|
||||
{
|
||||
/**
|
||||
* 插件临时目录路径
|
||||
*/
|
||||
const RUNTIME_PLUGIN_PATH = 'install/plugin';
|
||||
|
||||
/**
|
||||
* 运行迁移
|
||||
*/
|
||||
public function runMigrations(): void
|
||||
{
|
||||
$migrationsDir = $this->findMigrationsDir();
|
||||
|
||||
if (!$migrationsDir) {
|
||||
$this->output("⚠️ No migrations directory found");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output("📁 Migrations dir: {$migrationsDir}");
|
||||
|
||||
// 获取已执行的迁移
|
||||
$executed = $this->getExecutedMigrations();
|
||||
|
||||
// 获取所有迁移文件
|
||||
$files = glob($migrationsDir . '/*.php');
|
||||
|
||||
$pending = [];
|
||||
foreach ($files as $file) {
|
||||
$basename = basename($file, '.php');
|
||||
if (!in_array($basename, $executed)) {
|
||||
$pending[] = [$file, $basename];
|
||||
}
|
||||
}
|
||||
|
||||
sort($pending);
|
||||
|
||||
if (empty($pending)) {
|
||||
$this->output("✅ No pending migrations");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output("📋 Pending migrations: " . count($pending));
|
||||
|
||||
$batch = $this->getNextBatch();
|
||||
$schema = $this->getSchemaBuilder();
|
||||
|
||||
// 直接执行迁移
|
||||
foreach ($pending as $migration) {
|
||||
$this->runMigration($migration[0], $migration[1], $schema);
|
||||
}
|
||||
|
||||
// 记录批次
|
||||
$this->recordBatch($batch, $pending);
|
||||
|
||||
$this->output("✅ Migrations completed (batch: {$batch})");
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行单个迁移
|
||||
*/
|
||||
protected function runMigration(string $file, string $name, Builder $schema): void
|
||||
{
|
||||
$this->output(" 📝 Migrating: {$name}");
|
||||
|
||||
try {
|
||||
$migration = require $file;
|
||||
$migration->up($schema);
|
||||
$this->output(" ✅ Migrated: {$name}");
|
||||
} catch (\Throwable $e) {
|
||||
$this->output(" ❌ Error: {$e->getMessage()}");
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚迁移
|
||||
* @param bool $dropTables 是否删除表
|
||||
*/
|
||||
public function rollbackMigrations(bool $dropTables = false): void
|
||||
{
|
||||
if ($dropTables) {
|
||||
$this->dropAllTables();
|
||||
$this->saveMigrationRows([]);
|
||||
$this->output("✅ All plugin tables dropped");
|
||||
return;
|
||||
}
|
||||
|
||||
// 不删除表,只清理迁移日志
|
||||
$this->saveMigrationRows([]);
|
||||
$this->output("📝 Migration records cleared (tables preserved)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件所有表
|
||||
*/
|
||||
protected function dropAllTables(): void
|
||||
{
|
||||
$tables = $this->getPluginTables();
|
||||
if (empty($tables)) {
|
||||
$this->output(" ⚠️ No tables to drop");
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = $this->getSchemaBuilder();
|
||||
foreach ($tables as $table) {
|
||||
$this->output(" 🗑️ Dropping table: {$table}");
|
||||
$schema->dropIfExists($table);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件相关的所有表
|
||||
*/
|
||||
protected function getPluginTables(): array
|
||||
{
|
||||
$tables = [];
|
||||
$migrationsDir = $this->findMigrationsDir();
|
||||
|
||||
// 从迁移文件解析表名
|
||||
if (is_dir($migrationsDir)) {
|
||||
$files = glob($migrationsDir . '/*.php');
|
||||
foreach ($files as $file) {
|
||||
$content = file_get_contents($file);
|
||||
if (preg_match_all('/->create\([\'"](\w+)[\'"]/', $content, $matches)) {
|
||||
$tables = array_merge($tables, $matches[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库查询以插件名前缀的表
|
||||
$prefix = $this->pluginName . '_';
|
||||
try {
|
||||
$connection = $this->connection ?? null;
|
||||
$db = Db::connection($connection)->getDoctrineConnection();
|
||||
$tablesList = $db->getSchemaManager()->listTableNames();
|
||||
|
||||
foreach ($tablesList as $tableName) {
|
||||
if (strpos($tableName, $prefix) === 0) {
|
||||
$tables[] = $tableName;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 忽略查询错误
|
||||
}
|
||||
|
||||
return array_unique($tables);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚单个迁移
|
||||
*/
|
||||
protected function rollbackMigration(string $name, Builder $schema): void
|
||||
{
|
||||
$this->output(" 📝 Rolling back: {$name}");
|
||||
|
||||
$migrationsDir = $this->findMigrationsDir();
|
||||
$file = $migrationsDir . '/' . $name . '.php';
|
||||
|
||||
if (!file_exists($file)) {
|
||||
$this->output(" ⚠️ Migration file not found: {$file}");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$migration = require $file;
|
||||
$migration->down($schema);
|
||||
$this->output(" ✅ Rolled back: {$name}");
|
||||
} catch (\Throwable $e) {
|
||||
$this->output(" ❌ Error: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找迁移目录
|
||||
*/
|
||||
protected function findMigrationsDir(): ?string
|
||||
{
|
||||
$resourceDir = $this->getConfig('resource.migration', 'database/migrations');
|
||||
|
||||
$dirs = [
|
||||
$this->pluginPath . '/resource/database/migrations',
|
||||
$this->pluginPath . '/' . $resourceDir,
|
||||
$this->pluginPath . '/install/migrations',
|
||||
base_path("resource/database/migrations/plugin/{$this->pluginName}"),
|
||||
];
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
if (is_dir($dir)) {
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Schema Builder
|
||||
*/
|
||||
protected function getSchemaBuilder(): Builder
|
||||
{
|
||||
return Db::connection($this->connection ?? null)->getSchemaBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取迁移日志文件
|
||||
*/
|
||||
protected function getMigrationLogFile(): string
|
||||
{
|
||||
return runtime_path(self::RUNTIME_PLUGIN_PATH . '/' . $this->pluginName . '-migrations.log');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已执行的迁移
|
||||
*/
|
||||
protected function getExecutedMigrations(): array
|
||||
{
|
||||
$logFile = $this->getMigrationLogFile();
|
||||
|
||||
if (!file_exists($logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = trim(file_get_contents($logFile));
|
||||
|
||||
if (empty($content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter(explode(PHP_EOL, $content));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取迁移记录行
|
||||
*/
|
||||
protected function getMigrationRows(): array
|
||||
{
|
||||
$logFile = $this->getMigrationLogFile();
|
||||
|
||||
if (!file_exists($logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = trim(file_get_contents($logFile));
|
||||
|
||||
if (empty($content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach (explode(PHP_EOL, $content) as $line) {
|
||||
$parts = explode(',', $line, 2);
|
||||
if (count($parts) === 2) {
|
||||
$rows[] = $parts;
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存迁移记录行
|
||||
*/
|
||||
protected function saveMigrationRows(array $rows): void
|
||||
{
|
||||
$logFile = $this->getMigrationLogFile();
|
||||
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
if (empty($rows)) {
|
||||
@unlink($logFile);
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = array_map(fn($r) => "{$r[0]},{$r[1]}", $rows);
|
||||
file_put_contents($logFile, implode(PHP_EOL, $lines) . PHP_EOL, LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一批次号
|
||||
*/
|
||||
protected function getNextBatch(): int
|
||||
{
|
||||
$rows = $this->getMigrationRows();
|
||||
|
||||
if (empty($rows)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$batches = array_map(fn($row) => (int)$row[0], $rows);
|
||||
|
||||
return max($batches) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录批次
|
||||
*/
|
||||
protected function recordBatch(int $batch, array $migrations): void
|
||||
{
|
||||
$logFile = $this->getMigrationLogFile();
|
||||
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$lines = array_map(fn($m) => "{$batch},{$m[1]}", $migrations);
|
||||
file_put_contents($logFile, PHP_EOL . implode(PHP_EOL, $lines), FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理日志
|
||||
*/
|
||||
protected function clearLogs(): void
|
||||
{
|
||||
@unlink($this->getMigrationLogFile());
|
||||
@unlink($this->getSeedLogFile());
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 SQL(从迁移文件生成)
|
||||
*
|
||||
* @param bool $withData 是否包含数据
|
||||
* @param string|null $outputFile 输出文件路径(null 则输出到插件目录)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public function exportSql(bool $withData = false, ?string $outputFile = null): bool
|
||||
{
|
||||
$migrationsDir = $this->findMigrationsDir();
|
||||
|
||||
if (!$migrationsDir || !is_dir($migrationsDir)) {
|
||||
$this->output("⚠️ No migrations directory found");
|
||||
return false;
|
||||
}
|
||||
|
||||
$files = glob($migrationsDir . '/*.php');
|
||||
|
||||
if (empty($files)) {
|
||||
$this->output("⚠️ No migration files found");
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->output("📤 Exporting SQL from " . count($files) . " migration file(s)...");
|
||||
|
||||
// 从迁移文件生成 SQL
|
||||
$sql = $this->generateSqlFromMigrations($files, $withData);
|
||||
|
||||
// 默认输出到插件的 resource/database 目录
|
||||
if (!$outputFile) {
|
||||
$outputDir = $this->pluginPath . '/resource/database';
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0755, true);
|
||||
}
|
||||
$filename = $withData ? 'install.sql' : 'structure.sql';
|
||||
$outputFile = $outputDir . '/' . $filename;
|
||||
}
|
||||
|
||||
$dir = dirname($outputFile);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
file_put_contents($outputFile, $sql);
|
||||
|
||||
$this->output("✅ SQL exported to: {$outputFile}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从迁移文件生成 SQL
|
||||
*/
|
||||
protected function generateSqlFromMigrations(array $files, bool $withData): string
|
||||
{
|
||||
$sql = "-- Plugin: {$this->pluginName}\n";
|
||||
$sql .= "-- Exported at: " . date('Y-m-d H:i:s') . "\n";
|
||||
$sql .= "-- Generated from migration files\n\n";
|
||||
|
||||
$sql .= "SET FOREIGN_KEY_CHECKS = 0;\n\n";
|
||||
|
||||
// 按文件名排序
|
||||
sort($files);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$basename = basename($file, '.php');
|
||||
$this->output(" 📤 Processing: {$basename}");
|
||||
|
||||
$sql .= $this->generateSqlFromMigrationFile($file, $withData);
|
||||
$sql .= "\n";
|
||||
}
|
||||
|
||||
$sql .= "SET FOREIGN_KEY_CHECKS = 1;\n";
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从单个迁移文件生成 SQL
|
||||
*/
|
||||
protected function generateSqlFromMigrationFile(string $file, bool $withData): string
|
||||
{
|
||||
$sql = "";
|
||||
$content = file_get_contents($file);
|
||||
|
||||
// 提取所有表名
|
||||
preg_match_all('/->create\([\'"](\w+)[\'"]\s*,/', $content, $tableMatches);
|
||||
|
||||
if (empty($tableMatches[1])) {
|
||||
return $sql;
|
||||
}
|
||||
|
||||
foreach ($tableMatches[1] as $tableName) {
|
||||
$sql .= "-- ----------------------------\n";
|
||||
$sql .= "-- Table structure for {$tableName}\n";
|
||||
$sql .= "-- ----------------------------\n";
|
||||
$sql .= "DROP TABLE IF EXISTS `{$tableName}`;\n";
|
||||
|
||||
// 从迁移代码提取 CREATE TABLE 部分
|
||||
// 匹配 ->create('table_name', function (Blueprint $table) { ... });
|
||||
$pattern = '/->create\([\'"]' . preg_quote($tableName, '/') . '[\'"]\s*,\s*function\s*\([^)]*\)\s*\{([\s\S]*?)\}\s*\)/';
|
||||
|
||||
if (preg_match($pattern, $content, $matches)) {
|
||||
$body = $matches[1];
|
||||
$sql .= $this->generateCreateTableFromBlueprint($tableName, $body);
|
||||
}
|
||||
|
||||
$sql .= ";\n\n";
|
||||
|
||||
// 如果需要数据,尝试从数据库获取(如果表存在)
|
||||
if ($withData) {
|
||||
$sql .= $this->generateInsertDataSql($tableName);
|
||||
$sql .= "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Blueprint 代码生成 CREATE TABLE
|
||||
*/
|
||||
protected function generateCreateTableFromBlueprint(string $tableName, string $body): string
|
||||
{
|
||||
$columns = [];
|
||||
$indexes = [];
|
||||
|
||||
// 匹配 $table->列方法
|
||||
// $table->id();
|
||||
// $table->string('name', 100);
|
||||
// $table->text('content')->nullable();
|
||||
// $table->foreignId('user_id')->references('id')->on('users');
|
||||
|
||||
// 匹配各种列定义
|
||||
$lines = explode("\n", $body);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line) || $line === '$table->') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 移除 $table-> 前缀和结尾分号
|
||||
$line = ltrim($line, '$table->');
|
||||
$line = rtrim($line, ';');
|
||||
|
||||
// 匹配方法调用
|
||||
if (preg_match('/^(\w+)\s*\(([^)]*)\)(.*)/', $line, $matches)) {
|
||||
$method = $matches[1];
|
||||
$args = $matches[2];
|
||||
$modifiers = $matches[3] ?? '';
|
||||
|
||||
$colDef = $this->parseBlueprintMethod($method, $args, $modifiers);
|
||||
|
||||
if ($colDef) {
|
||||
$columns[] = $colDef;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($columns)) {
|
||||
return "CREATE TABLE `{$tableName}` ()";
|
||||
}
|
||||
|
||||
return "CREATE TABLE `{$tableName}` (\n " . implode(",\n ", $columns) . "\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Blueprint 方法
|
||||
*/
|
||||
protected function parseBlueprintMethod(string $method, string $args, string $modifiers): ?string
|
||||
{
|
||||
// 解析参数
|
||||
$argList = [];
|
||||
if ($args) {
|
||||
// 简单解析逗号分隔的参数
|
||||
$depth = 0;
|
||||
$current = '';
|
||||
for ($i = 0; $i < strlen($args); $i++) {
|
||||
$char = $args[$i];
|
||||
if ($char === '(') {
|
||||
$depth++;
|
||||
$current .= $char;
|
||||
} elseif ($char === ')') {
|
||||
$depth--;
|
||||
$current .= $char;
|
||||
} elseif ($char === ',' && $depth === 0) {
|
||||
$argList[] = trim($current);
|
||||
$current = '';
|
||||
} else {
|
||||
$current .= $char;
|
||||
}
|
||||
}
|
||||
if ($current) {
|
||||
$argList[] = trim($current);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理修饰符
|
||||
$nullable = strpos($modifiers, '->nullable()') !== false;
|
||||
$default = null;
|
||||
if (preg_match('/->default\(([^)]+)\)/', $modifiers, $dm)) {
|
||||
$default = $dm[1];
|
||||
}
|
||||
$autoIncrement = strpos($modifiers, '->autoIncrement()') !== false
|
||||
|| strpos($modifiers, '->autoIncrements()') !== false;
|
||||
$primary = strpos($modifiers, '->primary()') !== false;
|
||||
|
||||
// 根据方法名生成列定义
|
||||
return match ($method) {
|
||||
'id' => $this->formatColumn('id', 'bigint', $nullable, $default, $autoIncrement, $primary),
|
||||
'bigIncrements' => $this->formatColumn(null, 'bigint', false, null, true, true),
|
||||
'bigInteger' => $this->formatColumn($argList[0] ?? null, 'bigint', $nullable, $default, $autoIncrement, $primary),
|
||||
'integer', 'increments', 'mediumIncrements', 'smallIncrements', 'tinyIncrements' =>
|
||||
$this->formatColumn($argList[0] ?? null, 'int', $nullable, $default, $autoIncrement, $primary),
|
||||
'bigInteger' => $this->formatColumn($argList[0] ?? null, 'bigint', $nullable, $default, $autoIncrement, $primary),
|
||||
'string' => $this->formatColumn($argList[0] ?? null, 'varchar(' . ($argList[1] ?? 255) . ')', $nullable, $default, false, false),
|
||||
'text' => $this->formatColumn($argList[0] ?? null, 'text', $nullable, $default, false, false),
|
||||
'longText' => $this->formatColumn($argList[0] ?? null, 'longtext', $nullable, $default, false, false),
|
||||
'mediumText' => $this->formatColumn($argList[0] ?? null, 'mediumtext', $nullable, $default, false, false),
|
||||
'json' => $this->formatColumn($argList[0] ?? null, 'json', $nullable, $default, false, false),
|
||||
'boolean' => $this->formatColumn($argList[0] ?? null, 'tinyint(1)', $nullable, $default, false, false),
|
||||
'date' => $this->formatColumn($argList[0] ?? null, 'date', $nullable, $default, false, false),
|
||||
'datetime' => $this->formatColumn($argList[0] ?? null, 'datetime', $nullable, $default, false, false),
|
||||
'timestamp' => $this->formatColumn($argList[0] ?? null, 'timestamp', $nullable, $default, false, false),
|
||||
'time' => $this->formatColumn($argList[0] ?? null, 'time', $nullable, $default, false, false),
|
||||
'float' => $this->formatColumn($argList[0] ?? null, 'float', $nullable, $default, false, false),
|
||||
'double' => $this->formatColumn($argList[0] ?? null, 'double', $nullable, $default, false, false),
|
||||
'decimal' => $this->formatColumn($argList[0] ?? null, 'decimal(' . ($argList[1] ?? '10,2') . ')', $nullable, $default, false, false),
|
||||
'enum' => $this->formatColumn($argList[0] ?? null, 'enum(' . ($argList[1] ?? '') . ')', $nullable, $default, false, false),
|
||||
'uuid' => $this->formatColumn($argList[0] ?? null, 'char(36)', $nullable, $default, false, false),
|
||||
'foreignId', 'foreignIdFor' => $this->formatColumn($argList[0] ?? null, 'bigint', $nullable, $default, false, false),
|
||||
'foreignUuid' => $this->formatColumn($argList[0] ?? null, 'char(36)', $nullable, $default, false, false),
|
||||
'rememberToken' => $this->formatColumn('remember_token', 'varchar(100)', true, null, false, false),
|
||||
'timestamps' => "`created_at` datetime NULL,\n `updated_at` datetime NULL",
|
||||
'timestampable' => "`created_at` datetime NULL,\n `updated_at` datetime NULL",
|
||||
'softDeletes' => "`deleted_at` datetime NULL",
|
||||
'softDeletesTz' => "`deleted_at` datetime NULL",
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化列定义
|
||||
*/
|
||||
protected function formatColumn(?string $name, string $type, bool $nullable, ?string $default, bool $autoIncrement, bool $primary): ?string
|
||||
{
|
||||
if (!$name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$col = "`{$name}` {$type}";
|
||||
|
||||
if ($nullable) {
|
||||
$col .= " NULL";
|
||||
} else {
|
||||
$col .= " NOT NULL";
|
||||
}
|
||||
|
||||
if ($autoIncrement) {
|
||||
$col .= " AUTO_INCREMENT";
|
||||
}
|
||||
|
||||
if ($default !== null) {
|
||||
$col .= " DEFAULT " . $default;
|
||||
}
|
||||
|
||||
if ($primary) {
|
||||
$col .= ", PRIMARY KEY (`{$name}`)";
|
||||
}
|
||||
|
||||
return $col;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 INSERT DATA SQL(从数据库)
|
||||
*/
|
||||
protected function generateInsertDataSql(string $table): string
|
||||
{
|
||||
$sql = "-- ----------------------------\n";
|
||||
$sql .= "-- Data for {$table}\n";
|
||||
$sql .= "-- ----------------------------\n";
|
||||
|
||||
try {
|
||||
$connection = $this->connection ?? null;
|
||||
$pdo = Db::connection($connection)->getPdo();
|
||||
|
||||
// 检查表是否存在
|
||||
$stmt = $pdo->query("SHOW TABLES LIKE '{$table}'");
|
||||
if ($stmt->rowCount() === 0) {
|
||||
$sql .= "-- Table does not exist, skipping data\n";
|
||||
return $sql;
|
||||
}
|
||||
|
||||
$stmt = $pdo->query("SELECT * FROM `{$table}`");
|
||||
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($rows)) {
|
||||
$sql .= "-- No data\n";
|
||||
return $sql;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$values = array_map([$this, 'quoteValue'], array_values($row));
|
||||
$sql .= "INSERT INTO `{$table}` (`" . implode('`, `', array_keys($row)) . "`) VALUES (" . implode(', ', $values) . ");\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$sql .= "-- Error: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义值
|
||||
*/
|
||||
protected function quoteValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
// 使用 PDO 的 quote
|
||||
$connection = $this->connection ?? null;
|
||||
$pdo = Db::connection($connection)->getPdo();
|
||||
return $pdo->quote($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace core\plugin\traits;
|
||||
|
||||
/**
|
||||
* 种子操作 Trait
|
||||
*/
|
||||
trait SeedTrait
|
||||
{
|
||||
/**
|
||||
* 插件临时目录路径
|
||||
*/
|
||||
const RUNTIME_PLUGIN_PATH = 'install/plugin';
|
||||
|
||||
/**
|
||||
* 运行种子
|
||||
*/
|
||||
public function runSeeds(): void
|
||||
{
|
||||
$seedsDir = $this->findSeedsDir();
|
||||
|
||||
if (!$seedsDir) {
|
||||
$this->output("⚠️ No seeds directory found");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output("📁 Seeds dir: {$seedsDir}");
|
||||
|
||||
$executed = $this->getExecutedSeeds();
|
||||
|
||||
$files = glob($seedsDir . '/*.php');
|
||||
|
||||
$pending = [];
|
||||
foreach ($files as $file) {
|
||||
$basename = basename($file, '.php');
|
||||
$seedName = str_replace('Seeder', '', $basename);
|
||||
if (!in_array($seedName, $executed)) {
|
||||
$pending[] = [$file, $basename];
|
||||
}
|
||||
}
|
||||
|
||||
sort($pending);
|
||||
|
||||
if (empty($pending)) {
|
||||
$this->output("✅ No pending seeds");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output("📋 Pending seeds: " . count($pending));
|
||||
|
||||
foreach ($pending as $seed) {
|
||||
$this->runSeed($seed[0], $seed[1]);
|
||||
}
|
||||
|
||||
$this->output("✅ Seeds completed");
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行单个种子
|
||||
*/
|
||||
protected function runSeed(string $file, string $className): void
|
||||
{
|
||||
$this->output(" 📝 Seeding: {$className}");
|
||||
|
||||
require_once $file;
|
||||
|
||||
$seeder = null;
|
||||
|
||||
$classes = get_declared_classes();
|
||||
foreach ($classes as $cls) {
|
||||
if (str_ends_with($cls, '\\' . $className) || $cls === $className) {
|
||||
$seeder = new $cls();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$seeder) {
|
||||
$this->output(" ❌ Seeder class not found: {$className}");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (method_exists($seeder, 'run')) {
|
||||
$seeder->run();
|
||||
}
|
||||
$this->output(" ✅ Seeded: {$className}");
|
||||
|
||||
$this->recordSeed(str_replace('Seeder', '', $className));
|
||||
} catch (\Throwable $e) {
|
||||
$this->output(" ❌ Error: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找种子目录
|
||||
*/
|
||||
protected function findSeedsDir(): ?string
|
||||
{
|
||||
$resourceDir = $this->getConfig('resource.seed', 'database/seeds');
|
||||
|
||||
$dirs = [
|
||||
$this->pluginPath . '/resource/database/seeds',
|
||||
$this->pluginPath . '/' . $resourceDir,
|
||||
$this->pluginPath . '/install/seeds',
|
||||
base_path("resource/database/seeds/plugin/{$this->pluginName}"),
|
||||
];
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
if (is_dir($dir)) {
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取种子日志文件
|
||||
*/
|
||||
protected function getSeedLogFile(): string
|
||||
{
|
||||
return runtime_path(self::RUNTIME_PLUGIN_PATH . '/' . $this->pluginName . '-seeds.log');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已执行的种子
|
||||
*/
|
||||
protected function getExecutedSeeds(): array
|
||||
{
|
||||
$logFile = $this->getSeedLogFile();
|
||||
|
||||
if (!file_exists($logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = trim(file_get_contents($logFile));
|
||||
|
||||
if (empty($content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter(explode(PHP_EOL, $content));
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录种子
|
||||
*/
|
||||
protected function recordSeed(string $name): void
|
||||
{
|
||||
$logFile = $this->getSeedLogFile();
|
||||
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($logFile, PHP_EOL . $name, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace core\plugin\traits;
|
||||
|
||||
/**
|
||||
* 模板资源操作 Trait
|
||||
*/
|
||||
trait TemplateTrait
|
||||
{
|
||||
/**
|
||||
* 复制模板资源到目标目录
|
||||
*/
|
||||
protected function copyTemplates(): void
|
||||
{
|
||||
$templateDir = $this->pluginPath . '/resource/template';
|
||||
|
||||
if (!is_dir($templateDir)) {
|
||||
$this->output("📂 No template directory found, skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output("📂 Copying templates from {$templateDir}...");
|
||||
|
||||
$this->copyTemplateDir('admin');
|
||||
$this->copyTemplateDir('web');
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制单个端的模板
|
||||
*/
|
||||
protected function copyTemplateDir(string $endpoint): void
|
||||
{
|
||||
$resourceDir = $this->getConfig('resource.template', 'template');
|
||||
$targetBase = $this->getConfig("template.{$endpoint}");
|
||||
|
||||
$sourceDir = $this->pluginPath . '/resource/' . $resourceDir . '/' . $endpoint;
|
||||
$targetDir = $this->getProjectRoot() . '/' . $targetBase . '/' . $this->pluginName;
|
||||
|
||||
if (!is_dir($sourceDir)) {
|
||||
$this->output(" ⚠️ {$endpoint}: Template source not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir(dirname($targetDir))) {
|
||||
mkdir(dirname($targetDir), 0755, true);
|
||||
}
|
||||
|
||||
$this->recurseCopy($sourceDir, $targetDir);
|
||||
|
||||
$this->output(" ✅ {$endpoint}: Templates copied to {$targetDir}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归复制目录
|
||||
*/
|
||||
protected function recurseCopy(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;
|
||||
}
|
||||
|
||||
$sourcePath = $source . '/' . $file;
|
||||
$targetPath = $target . '/' . $file;
|
||||
|
||||
if (is_dir($sourcePath)) {
|
||||
$this->recurseCopy($sourcePath, $targetPath);
|
||||
} else {
|
||||
copy($sourcePath, $targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件模板资源
|
||||
*/
|
||||
protected function deleteTemplates(): void
|
||||
{
|
||||
if ($this->preserveTemplatesDuringUninstall) {
|
||||
$this->output('📦 Template resources are preserved during uninstall.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output("🗑️ Deleting plugin templates...");
|
||||
|
||||
$this->deleteTemplateDir('admin');
|
||||
$this->deleteTemplateDir('web');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个端模板
|
||||
*/
|
||||
protected function deleteTemplateDir(string $endpoint): void
|
||||
{
|
||||
$targetBase = $this->getConfig("template.{$endpoint}");
|
||||
|
||||
$targetDir = $this->getProjectRoot() . '/' . $targetBase . '/' . $this->pluginName;
|
||||
|
||||
if (!is_dir($targetDir)) {
|
||||
$this->output(" ⚠️ {$endpoint}: Template directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recurseDelete($targetDir);
|
||||
|
||||
$this->output(" ✅ {$endpoint}: Templates deleted");
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归删除目录
|
||||
*/
|
||||
protected function recurseDelete(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->recurseDelete($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入模板(子类可重写)
|
||||
*/
|
||||
protected function importTemplates(): void
|
||||
{
|
||||
$this->copyTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目根目录
|
||||
*/
|
||||
protected function getProjectRoot(): string
|
||||
{
|
||||
return dirname(base_path());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取前端基础目录
|
||||
* 前端代码统一在 frontend 目录下
|
||||
*/
|
||||
protected function getFrontendPath(): string
|
||||
{
|
||||
return $this->getProjectRoot() . DIRECTORY_SEPARATOR . 'frontend';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取前端指定项目目录
|
||||
*
|
||||
* @param string $frontendType 前端类型(admin/web/uni-app)
|
||||
* @return string 前端项目目录
|
||||
*/
|
||||
protected function getFrontendProjectPath(string $frontendType): string
|
||||
{
|
||||
return $this->getFrontendPath() . DIRECTORY_SEPARATOR . $frontendType;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user