#add env management
This commit is contained in:
265
app/Console/Commands/EnvCommand.php
Normal file
265
app/Console/Commands/EnvCommand.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\EnvService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class EnvCommand extends Command
|
||||
{
|
||||
protected $signature = 'env:manage
|
||||
{action : 操作类型 (list|environments|apply|save|import|delete)}
|
||||
{--project= : 项目名称}
|
||||
{--environment= : 环境名称}
|
||||
{--content= : 环境文件内容}';
|
||||
|
||||
protected $description = '环境文件管理工具';
|
||||
|
||||
private EnvService $envManager;
|
||||
|
||||
public function __construct(EnvService $envManager)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->envManager = $envManager;
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$action = $this->argument('action');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
return $this->listProjects();
|
||||
case 'environments':
|
||||
return $this->listEnvironments();
|
||||
case 'apply':
|
||||
return $this->applyEnvironment();
|
||||
case 'save':
|
||||
return $this->saveEnvironment();
|
||||
case 'import':
|
||||
return $this->importEnvironment();
|
||||
case 'delete':
|
||||
return $this->deleteEnvironment();
|
||||
default:
|
||||
$this->error("未知操作: {$action}");
|
||||
$this->showUsage();
|
||||
return 1;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error("错误: " . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有项目
|
||||
*/
|
||||
private function listProjects(): int
|
||||
{
|
||||
$projects = $this->envManager->getProjects();
|
||||
|
||||
if (empty($projects)) {
|
||||
$this->info('没有找到任何项目');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('项目列表:');
|
||||
$this->table(
|
||||
['项目名称', '路径', '有.env文件', '环境数量'],
|
||||
array_map(function ($project) {
|
||||
return [
|
||||
$project['name'],
|
||||
$project['path'],
|
||||
$project['has_env'] ? '是' : '否',
|
||||
count($project['envs'])
|
||||
];
|
||||
}, $projects)
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目的环境配置
|
||||
*/
|
||||
private function listEnvironments(): int
|
||||
{
|
||||
$project = $this->option('project');
|
||||
|
||||
if (!$project) {
|
||||
$this->error('请指定项目名称: --project=项目名');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$environments = $this->envManager->getProjectEnvs($project);
|
||||
|
||||
if (empty($environments)) {
|
||||
$this->info("项目 {$project} 没有环境配置文件");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("项目 {$project} 的环境配置:");
|
||||
$this->table(
|
||||
['环境名称', '文件大小', '修改时间'],
|
||||
array_map(function ($env) {
|
||||
return [
|
||||
$env['name'],
|
||||
$this->formatBytes($env['size']),
|
||||
$env['modified_at']
|
||||
];
|
||||
}, $environments)
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用环境配置
|
||||
*/
|
||||
private function applyEnvironment(): int
|
||||
{
|
||||
$project = $this->option('project');
|
||||
$environment = $this->option('environment');
|
||||
|
||||
if (!$project || !$environment) {
|
||||
$this->error('请指定项目名称和环境名称: --project=项目名 --environment=环境名');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->confirm("确定要将 {$environment} 环境应用到项目 {$project} 吗?")) {
|
||||
$success = $this->envManager->applyEnv($project, $environment);
|
||||
|
||||
if ($success) {
|
||||
$this->info("成功将 {$environment} 环境应用到项目 {$project}");
|
||||
return 0;
|
||||
} else {
|
||||
$this->error("应用环境配置失败");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('操作已取消');
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存环境配置
|
||||
*/
|
||||
private function saveEnvironment(): int
|
||||
{
|
||||
$project = $this->option('project');
|
||||
$environment = $this->option('environment');
|
||||
$content = $this->option('content');
|
||||
|
||||
if (!$project || !$environment) {
|
||||
$this->error('请指定项目名称和环境名称: --project=项目名 --environment=环境名');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$content) {
|
||||
$this->info('请输入环境配置内容 (输入完成后按 Ctrl+D):');
|
||||
$content = '';
|
||||
while (($line = fgets(STDIN)) !== false) {
|
||||
$content .= $line;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty(trim($content))) {
|
||||
$this->error('环境配置内容不能为空');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$success = $this->envManager->saveEnv($project, $environment, $content);
|
||||
|
||||
if ($success) {
|
||||
$this->info("成功保存环境配置 {$project}/{$environment}");
|
||||
return 0;
|
||||
} else {
|
||||
$this->error("保存环境配置失败");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从项目导入环境配置
|
||||
*/
|
||||
private function importEnvironment(): int
|
||||
{
|
||||
$project = $this->option('project');
|
||||
$environment = $this->option('environment');
|
||||
|
||||
if (!$project || !$environment) {
|
||||
$this->error('请指定项目名称和环境名称: --project=项目名 --environment=环境名');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$success = $this->envManager->importFromProject($project, $environment);
|
||||
|
||||
if ($success) {
|
||||
$this->info("成功从项目 {$project} 导入环境配置为 {$environment}");
|
||||
return 0;
|
||||
} else {
|
||||
$this->error("导入环境配置失败");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除环境配置
|
||||
*/
|
||||
private function deleteEnvironment(): int
|
||||
{
|
||||
$project = $this->option('project');
|
||||
$environment = $this->option('environment');
|
||||
|
||||
if (!$project || !$environment) {
|
||||
$this->error('请指定项目名称和环境名称: --project=项目名 --environment=环境名');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->confirm("确定要删除环境配置 {$project}/{$environment} 吗?")) {
|
||||
$success = $this->envManager->deleteEnv($project, $environment);
|
||||
|
||||
if ($success) {
|
||||
$this->info("成功删除环境配置 {$project}/{$environment}");
|
||||
return 0;
|
||||
} else {
|
||||
$this->error("删除环境配置失败");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('操作已取消');
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示使用说明
|
||||
*/
|
||||
private function showUsage(): void
|
||||
{
|
||||
$this->info('使用说明:');
|
||||
$this->line(' php artisan env:manage list # 列出所有项目');
|
||||
$this->line(' php artisan env:manage environments --project=项目名 # 列出项目环境');
|
||||
$this->line(' php artisan env:manage apply --project=项目名 --environment=环境名 # 应用环境');
|
||||
$this->line(' php artisan env:manage save --project=项目名 --environment=环境名 # 保存环境');
|
||||
$this->line(' php artisan env:manage import --project=项目名 --environment=环境名 # 导入环境');
|
||||
$this->line(' php artisan env:manage delete --project=项目名 --environment=环境名 # 删除环境');
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字节大小
|
||||
*/
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
|
||||
$bytes /= pow(1024, $pow);
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
264
app/Http/Controllers/EnvController.php
Normal file
264
app/Http/Controllers/EnvController.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\EnvService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class EnvController extends Controller
|
||||
{
|
||||
private EnvService $envManager;
|
||||
|
||||
public function __construct(EnvService $envManager)
|
||||
{
|
||||
$this->envManager = $envManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示环境管理页面
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('env.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有项目列表
|
||||
*/
|
||||
public function getProjects(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$projects = $this->envManager->getProjects();
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $projects
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目的环境配置列表
|
||||
*/
|
||||
public function getEnvs(string $project): JsonResponse
|
||||
{
|
||||
try {
|
||||
$envs = $this->envManager->getProjectEnvs($project);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $envs
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取环境配置文件内容
|
||||
*/
|
||||
public function getEnvContent(string $project, string $env): JsonResponse
|
||||
{
|
||||
try {
|
||||
$content = $this->envManager->getEnvContent($project, $env);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'content' => $content
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存环境配置文件
|
||||
*/
|
||||
public function saveEnv(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
try {
|
||||
$success = $this->envManager->saveEnv(
|
||||
$request->input('project'),
|
||||
$request->input('env'),
|
||||
$request->input('content')
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '环境配置保存成功'
|
||||
]);
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '环境配置保存失败'
|
||||
], 500);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用环境配置到项目
|
||||
*/
|
||||
public function applyEnv(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
try {
|
||||
$success = $this->envManager->applyEnv(
|
||||
$request->input('project'),
|
||||
$request->input('env')
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '环境配置应用成功'
|
||||
]);
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '环境配置应用失败'
|
||||
], 500);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从项目导入环境配置
|
||||
*/
|
||||
public function importEnv(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
try {
|
||||
$success = $this->envManager->importFromProject(
|
||||
$request->input('project'),
|
||||
$request->input('env')
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '环境配置导入成功'
|
||||
]);
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '环境配置导入失败'
|
||||
], 500);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除环境配置
|
||||
*/
|
||||
public function deleteEnv(string $project, string $env): JsonResponse
|
||||
{
|
||||
try {
|
||||
$success = $this->envManager->deleteEnv($project, $env);
|
||||
|
||||
if ($success) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '环境配置删除成功'
|
||||
]);
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '环境配置删除失败'
|
||||
], 500);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目当前.env文件内容
|
||||
*/
|
||||
public function getCurrentEnv(string $project): JsonResponse
|
||||
{
|
||||
try {
|
||||
$content = $this->envManager->getCurrentProjectEnv($project);
|
||||
|
||||
if ($content === null) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '项目没有.env文件'
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'content' => $content
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的环境配置文件
|
||||
*/
|
||||
public function createEnv(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$success = $this->envManager->createNewEnv(
|
||||
$request->input('project'),
|
||||
$request->input('env_name')
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '环境配置文件创建成功'
|
||||
]);
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '环境配置文件创建失败'
|
||||
], 500);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
}
|
33
app/Providers/EnvServiceProvider.php
Normal file
33
app/Providers/EnvServiceProvider.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Console\Commands\EnvCommand;
|
||||
|
||||
class EnvServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* 注册服务
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// 注册环境管理服务
|
||||
$this->app->singleton(\App\Services\EnvService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// 注册Artisan命令
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
EnvCommand::class,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
224
app/Services/EnvService.php
Normal file
224
app/Services/EnvService.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class EnvService
|
||||
{
|
||||
private string $projectsPath;
|
||||
private string $envStoragePath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->projectsPath = '/var/www/Project';
|
||||
$this->envStoragePath = storage_path('app/env');
|
||||
|
||||
// 确保env存储目录存在
|
||||
if (!File::exists($this->envStoragePath)) {
|
||||
File::makeDirectory($this->envStoragePath, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有项目列表
|
||||
*/
|
||||
public function getProjects(): array
|
||||
{
|
||||
if (!File::exists($this->projectsPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$projects = [];
|
||||
$directories = File::directories($this->projectsPath);
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
$projectName = basename($directory);
|
||||
$projects[] = [
|
||||
'name' => $projectName,
|
||||
'path' => $directory,
|
||||
'has_env' => File::exists($directory . '/.env'),
|
||||
'envs' => $this->getProjectEnvs($projectName)
|
||||
];
|
||||
}
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定项目的所有环境配置
|
||||
*/
|
||||
public function getProjectEnvs(string $projectName): array
|
||||
{
|
||||
$projectEnvPath = $this->envStoragePath . '/' . $projectName;
|
||||
|
||||
if (!File::exists($projectEnvPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$envs = [];
|
||||
$files = File::files($projectEnvPath);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file->getExtension() === 'env') {
|
||||
$envName = $file->getFilenameWithoutExtension();
|
||||
$envs[] = [
|
||||
'name' => $envName,
|
||||
'file_path' => $file->getPathname(),
|
||||
'size' => $file->getSize(),
|
||||
'modified_at' => Carbon::createFromTimestamp($file->getMTime())->format('Y-m-d H:i:s')
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $envs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存环境配置文件
|
||||
*/
|
||||
public function saveEnv(string $projectName, string $env, string $content): bool
|
||||
{
|
||||
|
||||
$projectEnvPath = $this->envStoragePath . '/' . $projectName;
|
||||
|
||||
if (!File::exists($projectEnvPath)) {
|
||||
File::makeDirectory($projectEnvPath, 0755, true);
|
||||
}
|
||||
|
||||
$filePath = $projectEnvPath . '/' . $env . '.env';
|
||||
|
||||
return File::put($filePath, $content) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取环境配置文件内容
|
||||
*/
|
||||
public function getEnvContent(string $projectName, string $env): string
|
||||
{
|
||||
|
||||
$filePath = $this->envStoragePath . '/' . $projectName . '/' . $env . '.env';
|
||||
|
||||
if (!File::exists($filePath)) {
|
||||
throw new InvalidArgumentException("环境配置文件不存在: {$env}");
|
||||
}
|
||||
|
||||
return File::get($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用环境配置到项目
|
||||
*/
|
||||
public function applyEnv(string $projectName, string $env): bool
|
||||
{
|
||||
|
||||
$projectPath = $this->projectsPath . '/' . $projectName;
|
||||
|
||||
if (!File::exists($projectPath)) {
|
||||
throw new InvalidArgumentException("项目不存在: {$projectName}");
|
||||
}
|
||||
|
||||
$envContent = $this->getEnvContent($projectName, $env);
|
||||
$targetEnvPath = $projectPath . '/.env';
|
||||
|
||||
// 备份现有的.env文件
|
||||
if (File::exists($targetEnvPath)) {
|
||||
$backupPath = $targetEnvPath . '.backup.' . Carbon::now()->format('Y-m-d-H-i-s');
|
||||
File::copy($targetEnvPath, $backupPath);
|
||||
}
|
||||
|
||||
return File::put($targetEnvPath, $envContent) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除环境配置文件
|
||||
*/
|
||||
public function deleteEnv(string $projectName, string $env): bool
|
||||
{
|
||||
|
||||
$filePath = $this->envStoragePath . '/' . $projectName . '/' . $env . '.env';
|
||||
|
||||
if (!File::exists($filePath)) {
|
||||
return true; // 文件不存在,视为删除成功
|
||||
}
|
||||
|
||||
return File::delete($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从项目导入当前.env文件
|
||||
*/
|
||||
public function importFromProject(string $projectName, string $env): bool
|
||||
{
|
||||
|
||||
$projectEnvPath = $this->projectsPath . '/' . $projectName . '/.env';
|
||||
|
||||
if (!File::exists($projectEnvPath)) {
|
||||
throw new InvalidArgumentException("项目 {$projectName} 没有.env文件");
|
||||
}
|
||||
|
||||
$content = File::get($projectEnvPath);
|
||||
return $this->saveEnv($projectName, $env, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目当前.env文件内容
|
||||
*/
|
||||
public function getCurrentProjectEnv(string $projectName): ?string
|
||||
{
|
||||
|
||||
$projectEnvPath = $this->projectsPath . '/' . $projectName . '/.env';
|
||||
|
||||
if (!File::exists($projectEnvPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return File::get($projectEnvPath);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取存储路径
|
||||
*/
|
||||
public function getStoragePath(): string
|
||||
{
|
||||
return $this->envStoragePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目路径
|
||||
*/
|
||||
public function getProjectsPath(): string
|
||||
{
|
||||
return $this->projectsPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的环境配置文件
|
||||
*/
|
||||
public function createNewEnv(string $projectName, string $envName): bool
|
||||
{
|
||||
$projectEnvPath = $this->envStoragePath . '/' . $projectName;
|
||||
|
||||
if (!File::exists($projectEnvPath)) {
|
||||
File::makeDirectory($projectEnvPath, 0755, true);
|
||||
}
|
||||
|
||||
$filePath = $projectEnvPath . '/' . $envName . '.env';
|
||||
|
||||
// 检查文件是否已存在
|
||||
if (File::exists($filePath)) {
|
||||
throw new InvalidArgumentException("环境配置文件已存在: {$envName}");
|
||||
}
|
||||
|
||||
// 创建空的配置文件
|
||||
$defaultContent = "# {$envName} 环境配置\n# 创建时间: " . Carbon::now()->format('Y-m-d H:i:s') . "\n\n";
|
||||
|
||||
return File::put($filePath, $defaultContent) !== false;
|
||||
}
|
||||
}
|
@@ -2,4 +2,5 @@
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\EnvServiceProvider::class,
|
||||
];
|
||||
|
@@ -65,7 +65,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => 'Asia/Shanghai',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
2570
package-lock.json
generated
Normal file
2570
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
"build": "node node_modules/vite/bin/vite.js build",
|
||||
"dev": "node node_modules/vite/bin/vite.js",
|
||||
"vite": "node node_modules/vite/bin/vite.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -13,5 +14,9 @@
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vue": "^3.5.18"
|
||||
}
|
||||
}
|
||||
|
@@ -1 +1,11 @@
|
||||
import './bootstrap';
|
||||
import { createApp } from 'vue';
|
||||
import EnvManager from './components/EnvManager.vue';
|
||||
|
||||
console.log('App.js loading...');
|
||||
|
||||
const app = createApp({});
|
||||
app.component('env-manager', EnvManager);
|
||||
|
||||
console.log('Mounting app...');
|
||||
app.mount('#app');
|
||||
|
162
resources/js/components/AdminLayout.vue
Normal file
162
resources/js/components/AdminLayout.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="admin-layout h-screen bg-gray-50">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg border-r border-gray-200 flex flex-col">
|
||||
<!-- Logo区域 -->
|
||||
<div class="flex items-center justify-center h-16 px-6 bg-gradient-to-r from-blue-600 to-blue-700 border-b border-blue-800 flex-shrink-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-white">
|
||||
<div class="text-lg font-bold">管理系统</div>
|
||||
<div class="text-xs text-blue-100">Environment Manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="mt-6 px-3 flex flex-col flex-1">
|
||||
<div class="space-y-1 flex-1">
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('env')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'env'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'env' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
环境配置管理
|
||||
</a>
|
||||
|
||||
<!-- 预留的其他菜单项 -->
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('settings')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'settings'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'settings' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
系统设置
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('logs')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'logs'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'logs' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
操作日志
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="mt-auto pt-6 border-t border-gray-200">
|
||||
<div class="px-3 py-2">
|
||||
<div class="text-xs text-gray-500 mb-1">系统信息</div>
|
||||
<div class="text-xs text-gray-400">版本 v1.0.0</div>
|
||||
<div class="text-xs text-gray-400">Laravel 12 + Vue 3</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="pl-64 h-screen">
|
||||
<!-- 页面内容 -->
|
||||
<main class="h-full">
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AdminLayout',
|
||||
props: {
|
||||
pageTitle: {
|
||||
type: String,
|
||||
default: '环境配置管理'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeMenu: 'env'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setActiveMenu(menu) {
|
||||
this.activeMenu = menu;
|
||||
this.$emit('menu-change', menu);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.admin-layout .pl-64 {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.admin-layout .fixed.w-64 {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.admin-layout .fixed.w-64.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
969
resources/js/components/CodeEditor.vue
Normal file
969
resources/js/components/CodeEditor.vue
Normal file
@@ -0,0 +1,969 @@
|
||||
<template>
|
||||
<div class="simple-code-editor">
|
||||
<!-- 编辑器头部工具栏 -->
|
||||
<div class="editor-header">
|
||||
<div class="editor-tabs">
|
||||
<div class="tab active">
|
||||
<div class="tab-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="tab-name">{{ getFileName() }}</span>
|
||||
<div class="tab-close">×</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-controls">
|
||||
<button class="control-btn" @click="formatCode" title="格式化代码">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="control-btn" @click="toggleWrap" title="切换自动换行">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4,6H20V8H4V6M4,18V16H20V18H4M4,13H16L13,16H15L19,12L15,8H13L16,11H4V13Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器主体 - 单层渲染方案 -->
|
||||
<div class="editor-body" :class="{ 'dark-theme': theme === 'dark', 'word-wrap': wordWrap }">
|
||||
<!-- 行号区域 -->
|
||||
<div class="line-numbers-area" ref="lineNumbers">
|
||||
<div
|
||||
v-for="(line, index) in lines"
|
||||
:key="index"
|
||||
class="line-number"
|
||||
:class="{ 'active': index + 1 === currentLine }"
|
||||
>
|
||||
{{ String(index + 1).padStart(3, ' ') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码区域 - 只使用一个带语法高亮的可编辑div -->
|
||||
<div class="code-area">
|
||||
<div
|
||||
ref="codeEditor"
|
||||
class="code-editor-div"
|
||||
:contenteditable="!readonly"
|
||||
@input="handleDivInput"
|
||||
@scroll="handleScroll"
|
||||
@keydown="handleKeydown"
|
||||
@click="updateCursor"
|
||||
@keyup="updateCursor"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@paste="handlePaste"
|
||||
spellcheck="false"
|
||||
v-html="highlightedContent"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器底部状态栏 -->
|
||||
<div class="editor-footer">
|
||||
<div class="status-left">
|
||||
<span class="status-item">行 {{ currentLine }}</span>
|
||||
<span class="status-item">列 {{ currentColumn }}</span>
|
||||
<span class="status-item">{{ lines.length }} 行</span>
|
||||
<span class="status-item">{{ content.length }} 字符</span>
|
||||
</div>
|
||||
<div class="status-right">
|
||||
<span class="status-item">{{ language.toUpperCase() }}</span>
|
||||
<span class="status-item">UTF-8</span>
|
||||
<span class="status-item" :class="{ 'status-success': !readonly }">
|
||||
{{ readonly ? '只读' : '可编辑' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 纯原生实现,无外部依赖
|
||||
|
||||
export default {
|
||||
name: 'CodeEditor',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'env' // 'env', 'javascript', 'php'
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'light' // 'light', 'dark'
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
data() {
|
||||
return {
|
||||
content: this.modelValue,
|
||||
currentLine: 1,
|
||||
currentColumn: 1,
|
||||
focused: false,
|
||||
wordWrap: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
lines() {
|
||||
return this.content.split('\n');
|
||||
},
|
||||
highlightedContent() {
|
||||
if (!this.content) return '';
|
||||
|
||||
if (this.language === 'env') {
|
||||
return this.highlightEnvContent(this.content);
|
||||
} else if (this.language === 'javascript') {
|
||||
return this.highlightJavaScriptContent(this.content);
|
||||
} else if (this.language === 'php') {
|
||||
return this.highlightPhpContent(this.content);
|
||||
}
|
||||
|
||||
return this.escapeHtml(this.content);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 初始化时自动检测语言
|
||||
this.autoDetectLanguage();
|
||||
// 初始化编辑器
|
||||
this.initEditor();
|
||||
},
|
||||
watch: {
|
||||
modelValue(newValue) {
|
||||
if (newValue !== this.content) {
|
||||
this.content = newValue;
|
||||
this.$nextTick(() => {
|
||||
this.initEditor();
|
||||
});
|
||||
}
|
||||
},
|
||||
content() {
|
||||
this.$nextTick(() => {
|
||||
this.initEditor();
|
||||
});
|
||||
},
|
||||
language() {
|
||||
// 语言变化时重新渲染语法高亮
|
||||
this.$nextTick(() => {
|
||||
this.initEditor();
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('update:modelValue', this.content);
|
||||
this.$emit('change', this.content);
|
||||
this.updateCursor();
|
||||
},
|
||||
|
||||
handleDivInput(event) {
|
||||
// 获取纯文本内容
|
||||
const text = this.getPlainTextFromDiv(event.target);
|
||||
this.content = text;
|
||||
this.$emit('update:modelValue', text);
|
||||
this.$emit('change', text);
|
||||
|
||||
// 延迟更新语法高亮,避免光标跳动
|
||||
this.$nextTick(() => {
|
||||
this.updateSyntaxHighlight();
|
||||
this.updateCursor();
|
||||
});
|
||||
},
|
||||
|
||||
handleScroll() {
|
||||
// 同步行号滚动
|
||||
const codeEditor = this.$refs.codeEditor;
|
||||
const lineNumbers = this.$refs.lineNumbers;
|
||||
|
||||
if (codeEditor && lineNumbers) {
|
||||
lineNumbers.scrollTop = codeEditor.scrollTop;
|
||||
}
|
||||
},
|
||||
|
||||
handleFocus() {
|
||||
this.focused = true;
|
||||
},
|
||||
|
||||
handleBlur() {
|
||||
this.focused = false;
|
||||
},
|
||||
|
||||
handleKeydown(event) {
|
||||
// Tab键处理
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
this.insertText(' ');
|
||||
}
|
||||
},
|
||||
|
||||
handlePaste(event) {
|
||||
event.preventDefault();
|
||||
const text = (event.clipboardData || window.clipboardData).getData('text');
|
||||
this.insertText(text);
|
||||
},
|
||||
|
||||
insertText(text) {
|
||||
const selection = window.getSelection();
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(text));
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// 触发input事件
|
||||
this.handleDivInput({ target: this.$refs.codeEditor });
|
||||
}
|
||||
},
|
||||
|
||||
updateCursor() {
|
||||
const selection = window.getSelection();
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const beforeCursor = this.getTextBeforeCursor(range);
|
||||
const lines = beforeCursor.split('\n');
|
||||
this.currentLine = lines.length;
|
||||
this.currentColumn = lines[lines.length - 1].length + 1;
|
||||
}
|
||||
},
|
||||
|
||||
initEditor() {
|
||||
// 初始化编辑器内容
|
||||
const codeEditor = this.$refs.codeEditor;
|
||||
if (codeEditor && this.content) {
|
||||
codeEditor.innerHTML = this.highlightedContent;
|
||||
}
|
||||
},
|
||||
|
||||
getPlainTextFromDiv(div) {
|
||||
// 从contenteditable div中提取纯文本
|
||||
return div.innerText || div.textContent || '';
|
||||
},
|
||||
|
||||
getTextBeforeCursor(range) {
|
||||
// 获取光标前的文本
|
||||
const container = this.$refs.codeEditor;
|
||||
if (!container) return '';
|
||||
|
||||
const tempRange = document.createRange();
|
||||
tempRange.setStart(container, 0);
|
||||
tempRange.setEnd(range.startContainer, range.startOffset);
|
||||
|
||||
return tempRange.toString();
|
||||
},
|
||||
|
||||
updateSyntaxHighlight() {
|
||||
const codeEditor = this.$refs.codeEditor;
|
||||
if (!codeEditor) return;
|
||||
|
||||
// 保存当前光标位置
|
||||
const selection = window.getSelection();
|
||||
let savedRange = null;
|
||||
let cursorOffset = 0;
|
||||
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
cursorOffset = this.getTextBeforeCursor(range).length;
|
||||
}
|
||||
|
||||
// 更新语法高亮内容
|
||||
codeEditor.innerHTML = this.highlightedContent;
|
||||
|
||||
// 恢复光标位置
|
||||
this.$nextTick(() => {
|
||||
this.restoreCursorPosition(cursorOffset);
|
||||
});
|
||||
},
|
||||
|
||||
restoreCursorPosition(offset) {
|
||||
const codeEditor = this.$refs.codeEditor;
|
||||
if (!codeEditor) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
|
||||
try {
|
||||
let currentOffset = 0;
|
||||
const walker = document.createTreeWalker(
|
||||
codeEditor,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
let node;
|
||||
while (node = walker.nextNode()) {
|
||||
const nodeLength = node.textContent.length;
|
||||
if (currentOffset + nodeLength >= offset) {
|
||||
range.setStart(node, offset - currentOffset);
|
||||
range.setEnd(node, offset - currentOffset);
|
||||
break;
|
||||
}
|
||||
currentOffset += nodeLength;
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (e) {
|
||||
// 如果恢复失败,将光标放到末尾
|
||||
range.selectNodeContents(codeEditor);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
},
|
||||
|
||||
formatCode() {
|
||||
// 简单的代码格式化
|
||||
if (this.language === 'env') {
|
||||
const lines = this.content.split('\n');
|
||||
const formatted = lines.map(line => {
|
||||
line = line.trim();
|
||||
if (line.includes('=') && !line.startsWith('#')) {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
const value = valueParts.join('=');
|
||||
return `${key.trim()}=${value}`;
|
||||
}
|
||||
return line;
|
||||
}).join('\n');
|
||||
|
||||
this.content = formatted;
|
||||
this.handleInput();
|
||||
}
|
||||
},
|
||||
|
||||
toggleWrap() {
|
||||
this.wordWrap = !this.wordWrap;
|
||||
},
|
||||
|
||||
getFileName() {
|
||||
const fileNames = {
|
||||
'env': '.env',
|
||||
'javascript': 'script.js',
|
||||
'php': 'config.php'
|
||||
};
|
||||
return fileNames[this.language] || '.env';
|
||||
},
|
||||
|
||||
// 根据文件名自动检测语言
|
||||
detectLanguageFromFilename(filename) {
|
||||
if (!filename) return 'env';
|
||||
|
||||
const ext = filename.toLowerCase();
|
||||
|
||||
// .env 文件检测
|
||||
if (ext === '.env' || ext.startsWith('.env.') || ext.endsWith('.env')) {
|
||||
return 'env';
|
||||
}
|
||||
|
||||
// JavaScript 文件检测
|
||||
if (ext.endsWith('.js') || ext.endsWith('.jsx') || ext.endsWith('.ts') || ext.endsWith('.tsx') || ext.endsWith('.mjs')) {
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
// PHP 文件检测
|
||||
if (ext.endsWith('.php') || ext.endsWith('.phtml') || ext.endsWith('.php3') || ext.endsWith('.php4') || ext.endsWith('.php5')) {
|
||||
return 'php';
|
||||
}
|
||||
|
||||
// 默认返回 env
|
||||
return 'env';
|
||||
},
|
||||
|
||||
// 自动设置语言
|
||||
autoDetectLanguage() {
|
||||
const filename = this.getFileName();
|
||||
const detectedLanguage = this.detectLanguageFromFilename(filename);
|
||||
|
||||
if (detectedLanguage !== this.language) {
|
||||
this.$emit('language-change', detectedLanguage);
|
||||
}
|
||||
},
|
||||
|
||||
highlightEnvContent(content) {
|
||||
return content
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
if (line.trim().startsWith('#')) {
|
||||
// 注释
|
||||
return `<span class="env-comment">${this.escapeHtml(line)}</span>`;
|
||||
} else if (line.includes('=')) {
|
||||
// 键值对
|
||||
const equalIndex = line.indexOf('=');
|
||||
const key = line.substring(0, equalIndex);
|
||||
const value = line.substring(equalIndex + 1);
|
||||
return `<span class="env-key">${this.escapeHtml(key)}</span><span class="env-operator">=</span><span class="env-value">${this.escapeHtml(value)}</span>`;
|
||||
} else {
|
||||
return this.escapeHtml(line);
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
},
|
||||
|
||||
highlightJavaScriptContent(content) {
|
||||
// 简单的JavaScript语法高亮
|
||||
const keywords = ['function', 'const', 'let', 'var', 'if', 'else', 'for', 'while', 'return', 'class', 'import', 'export', 'default'];
|
||||
const keywordRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g');
|
||||
|
||||
return content
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
let highlightedLine = this.escapeHtml(line);
|
||||
|
||||
// 高亮关键字
|
||||
highlightedLine = highlightedLine.replace(keywordRegex, '<span class="js-keyword">$1</span>');
|
||||
|
||||
// 高亮字符串
|
||||
highlightedLine = highlightedLine.replace(/(["'`])((?:\\.|(?!\1)[^\\])*?)\1/g, '<span class="js-string">$1$2$1</span>');
|
||||
|
||||
// 高亮注释
|
||||
highlightedLine = highlightedLine.replace(/(\/\/.*$)/g, '<span class="js-comment">$1</span>');
|
||||
|
||||
return highlightedLine;
|
||||
})
|
||||
.join('\n');
|
||||
},
|
||||
|
||||
highlightPhpContent(content) {
|
||||
// 简单的PHP语法高亮
|
||||
const keywords = ['function', 'class', 'if', 'else', 'elseif', 'while', 'for', 'foreach', 'return', 'public', 'private', 'protected', 'static'];
|
||||
const keywordRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g');
|
||||
|
||||
return content
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
let highlightedLine = this.escapeHtml(line);
|
||||
|
||||
// 高亮PHP标签
|
||||
highlightedLine = highlightedLine.replace(/(<\?php|<\?=|\?>)/g, '<span class="php-tag">$1</span>');
|
||||
|
||||
// 高亮关键字
|
||||
highlightedLine = highlightedLine.replace(keywordRegex, '<span class="php-keyword">$1</span>');
|
||||
|
||||
// 高亮变量
|
||||
highlightedLine = highlightedLine.replace(/(\$\w+)/g, '<span class="php-variable">$1</span>');
|
||||
|
||||
// 高亮字符串
|
||||
highlightedLine = highlightedLine.replace(/(["'])((?:\\.|(?!\1)[^\\])*?)\1/g, '<span class="php-string">$1$2$1</span>');
|
||||
|
||||
// 高亮注释
|
||||
highlightedLine = highlightedLine.replace(/(\/\/.*$|\/\*.*?\*\/)/g, '<span class="php-comment">$1</span>');
|
||||
|
||||
return highlightedLine;
|
||||
})
|
||||
.join('\n');
|
||||
},
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
focus() {
|
||||
if (this.$refs.textarea) {
|
||||
this.$refs.textarea.focus();
|
||||
}
|
||||
},
|
||||
|
||||
getContent() {
|
||||
return this.content;
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simple-code-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Monaco', 'Inconsolata', 'Consolas', monospace;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: visible; /* 移除容器级别的滚动条限制 */
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* 编辑器头部 */
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #ffffff;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
margin-right: 8px;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
color: #9ca3af;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 编辑器主体 */
|
||||
.editor-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #ffffff;
|
||||
overflow: visible; /* 允许内容正常显示,不被截断 */
|
||||
}
|
||||
|
||||
.editor-body.dark-theme {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
/* 行号区域 */
|
||||
.line-numbers-area {
|
||||
background: linear-gradient(180deg, #fafbfc 0%, #f4f6f8 100%);
|
||||
border-right: 1px solid #e2e8f0;
|
||||
padding: 16px 0;
|
||||
min-width: 60px;
|
||||
overflow: hidden; /* 行号区域保持隐藏溢出,避免水平滚动 */
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #8b949e;
|
||||
position: relative;
|
||||
flex-shrink: 0; /* 防止行号区域被压缩 */
|
||||
}
|
||||
|
||||
.dark-theme .line-numbers-area {
|
||||
background: linear-gradient(180deg, #2d3748 0%, #1a202c 100%);
|
||||
border-right-color: #4a5568;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
text-align: right;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.line-number.active {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.dark-theme .line-number.active {
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
/* 代码区域 */
|
||||
.code-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: visible; /* 确保代码区域不被截断 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 可编辑的代码编辑器div - 单层方案,无重影 */
|
||||
.code-editor-div {
|
||||
width: 100%;
|
||||
height: 100%; /* 改为100%高度以充分利用父容器空间 */
|
||||
min-height: 200px; /* 设置最小高度确保可见 */
|
||||
padding: 16px 20px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-family: inherit;
|
||||
color: #24292f;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
overflow: auto; /* 确保编辑器内部滚动条正常工作 */
|
||||
white-space: pre;
|
||||
caret-color: #3b82f6;
|
||||
font-variant-ligatures: common-ligatures;
|
||||
|
||||
/* 确保可编辑性 */
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
|
||||
/* 优化渲染性能 */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
|
||||
/* 防止内容溢出 */
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
/* 确保编辑器能够自适应父容器 */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.word-wrap .code-editor-div {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 确保编辑器在不同容器中都能正确显示 */
|
||||
.code-editor-div:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 自适应高度的额外样式 */
|
||||
.simple-code-editor {
|
||||
max-height: 100vh; /* 防止超出视口高度 */
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
min-height: 300px; /* 确保编辑器有足够的最小高度 */
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div {
|
||||
color: #f0f6fc;
|
||||
caret-color: #60a5fa;
|
||||
}
|
||||
|
||||
/* 编辑器底部状态栏 */
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.dark-theme .editor-footer {
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
||||
border-top-color: #4a5568;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.status-left,
|
||||
.status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.dark-theme .status-success {
|
||||
color: #68d391;
|
||||
}
|
||||
|
||||
/* 语法高亮样式 */
|
||||
.code-editor-div :deep(.env-comment) {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.env-key) {
|
||||
color: #005cc5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.env-operator) {
|
||||
color: #d73a49;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.env-value) {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
/* 深色主题语法高亮 */
|
||||
.dark-theme .code-editor-div :deep(.env-comment) {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.env-key) {
|
||||
color: #79c0ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.env-operator) {
|
||||
color: #ff7b72;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.env-value) {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
/* JavaScript 语法高亮样式 */
|
||||
.code-editor-div :deep(.js-keyword) {
|
||||
color: #d73a49;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.js-string) {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.js-comment) {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* PHP 语法高亮样式 */
|
||||
.code-editor-div :deep(.php-tag) {
|
||||
color: #d73a49;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.php-keyword) {
|
||||
color: #6f42c1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.php-variable) {
|
||||
color: #e36209;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.php-string) {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.php-comment) {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 深色主题 JavaScript */
|
||||
.dark-theme .code-editor-div :deep(.js-keyword) {
|
||||
color: #ff7b72;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.js-string) {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.js-comment) {
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 深色主题 PHP */
|
||||
.dark-theme .code-editor-div :deep(.php-tag) {
|
||||
color: #ff7b72;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.php-keyword) {
|
||||
color: #d2a8ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.php-variable) {
|
||||
color: #ffa657;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.php-string) {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.php-comment) {
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 滚动条样式 - 只为代码编辑器设置滚动条 */
|
||||
.code-editor-div::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.code-editor-div::-webkit-scrollbar-track {
|
||||
background: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.code-editor-div::-webkit-scrollbar-thumb {
|
||||
background: #d0d7de;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #f6f8fa;
|
||||
}
|
||||
|
||||
.code-editor-div::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8b3c1;
|
||||
}
|
||||
|
||||
/* 深色主题滚动条样式 */
|
||||
.dark-theme .code-editor-div::-webkit-scrollbar-track {
|
||||
background: #21262d;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div::-webkit-scrollbar-thumb {
|
||||
background: #484f58;
|
||||
border-color: #21262d;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div::-webkit-scrollbar-thumb:hover {
|
||||
background: #6e7681;
|
||||
}
|
||||
|
||||
/* 选择文本样式 */
|
||||
.code-editor-div::selection {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div::selection {
|
||||
background: rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
/* placeholder样式 */
|
||||
.code-editor-div:empty:before {
|
||||
content: attr(data-placeholder);
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div:empty:before {
|
||||
color: #6e7681;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.editor-header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.line-numbers-area {
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.code-editor-div {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-left,
|
||||
.status-right {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
883
resources/js/components/EnvManagement.vue
Normal file
883
resources/js/components/EnvManagement.vue
Normal file
@@ -0,0 +1,883 @@
|
||||
<template>
|
||||
<div class="env-management h-full flex flex-col p-4 box-border">
|
||||
<!-- 项目选择卡片 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4 flex-shrink-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-sm lg:text-base font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-4 h-4 text-blue-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
</svg>
|
||||
项目选择
|
||||
</h2>
|
||||
<div class="text-xs text-gray-500">
|
||||
共 {{ projects.length }} 个项目
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2 lg:gap-3">
|
||||
<div>
|
||||
<div class="relative">
|
||||
<!-- 搜索输入框 -->
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="projectSearchQuery"
|
||||
@focus="handleProjectFocus"
|
||||
@blur="handleProjectBlur"
|
||||
@keydown="handleProjectKeydown"
|
||||
placeholder="搜索项目..."
|
||||
class="w-full px-4 py-2.5 pr-20 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors duration-200"
|
||||
>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 space-x-1">
|
||||
<!-- 清除按钮 -->
|
||||
<button
|
||||
v-if="projectSearchQuery"
|
||||
@mousedown="clearSearch"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors duration-200"
|
||||
title="清除搜索"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 搜索图标 -->
|
||||
<div class="p-1">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下拉选项列表 -->
|
||||
<div
|
||||
v-if="showProjectDropdown && filteredProjects.length > 0"
|
||||
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="(project, index) in filteredProjects"
|
||||
:key="project.name"
|
||||
@mousedown="selectProject(project)"
|
||||
class="px-3 py-1.5 cursor-pointer hover:bg-blue-50 transition-colors duration-200 flex items-center justify-between"
|
||||
:class="{ 'bg-blue-100': index === highlightedIndex }"
|
||||
>
|
||||
<div class="font-medium text-gray-900 text-sm leading-tight truncate flex-1 mr-2">{{ project.name }}</div>
|
||||
<div class="text-xs text-gray-500 flex-shrink-0">{{ project.envs.length }} 个环境</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无搜索结果 -->
|
||||
<div
|
||||
v-if="showProjectDropdown && filteredProjects.length === 0 && projectSearchQuery"
|
||||
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="px-3 py-2 text-gray-500 text-center text-sm">
|
||||
未找到匹配的项目
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedProject" class="flex items-end space-x-2">
|
||||
<button
|
||||
@click="loadProjects"
|
||||
class="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200 flex items-center space-x-2 text-sm font-medium"
|
||||
title="刷新项目列表"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors duration-200 flex items-center space-x-2 text-sm font-medium"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
<span>新增配置文件</span>
|
||||
</button>
|
||||
<button
|
||||
@click="showImportModal = true"
|
||||
class="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors duration-200 flex items-center space-x-2 text-sm font-medium"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
|
||||
</svg>
|
||||
<span>导入当前.env</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div v-if="selectedProject" class="grid grid-cols-1 lg:grid-cols-10 gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<!-- 左侧:环境列表 (2/10 宽度) -->
|
||||
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col min-h-0">
|
||||
<div class="p-2.5 lg:p-3 border-b border-gray-200">
|
||||
<h3 class="text-sm lg:text-base font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
环境配置列表
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 mt-0.5">项目: {{ selectedProject }}</p>
|
||||
</div>
|
||||
|
||||
<div class="p-2.5 lg:p-3 flex-1 overflow-y-auto">
|
||||
<div v-if="envs.length === 0" class="text-center py-12">
|
||||
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500 text-sm">暂无环境配置文件</p>
|
||||
<p class="text-gray-400 text-xs mt-1">点击"导入当前.env"开始创建</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="env in envs"
|
||||
:key="env.name"
|
||||
class="group border border-gray-200 rounded-lg p-3 hover:border-blue-300 hover:shadow-md transition-all duration-200"
|
||||
:class="{ 'ring-2 ring-blue-500 border-blue-500': selectedEnv === env.name }"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 cursor-pointer" @click="loadEnvContent(env.name)">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2.5 h-2.5 bg-green-400 rounded-full"></div>
|
||||
<h4 class="font-medium text-gray-900 hover:text-blue-600 transition-colors duration-200 text-sm">{{ env.name }}</h4>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5 space-x-3 ml-4.5">
|
||||
<span>{{ formatFileSize(env.size) }}</span>
|
||||
<span>{{ env.modified_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-1">
|
||||
<button
|
||||
@click.stop="applyEnv(env.name)"
|
||||
class="p-1.5 text-green-600 hover:bg-green-50 rounded-md transition-colors duration-200"
|
||||
title="应用"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click.stop="deleteEnv(env.name)"
|
||||
class="p-1.5 text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200"
|
||||
title="删除"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:环境编辑器 (8/10 宽度) -->
|
||||
<div class="lg:col-span-8 bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col min-h-0">
|
||||
<div class="p-3 lg:p-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base lg:text-lg font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-4 h-4 lg:w-5 lg:h-5 text-purple-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
||||
</svg>
|
||||
{{ selectedEnv ? `编辑: ${selectedEnv}` : '环境配置编辑器' }}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
v-model="newEnvName"
|
||||
placeholder="新环境名称"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<button
|
||||
@click="saveEnv"
|
||||
:disabled="!envContent || !envContent.trim()"
|
||||
class="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-3 py-1.5 rounded-lg hover:from-blue-600 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center space-x-1"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
<span>保存</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<!-- 代码编辑器工具栏 -->
|
||||
<div class="mb-2 flex items-center justify-between flex-shrink-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-xs lg:text-sm font-medium text-gray-700">语法高亮:</label>
|
||||
<select
|
||||
v-model="editorLanguage"
|
||||
class="text-xs lg:text-sm border border-gray-300 rounded px-2 py-1 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="env">.env 文件</option>
|
||||
<option value="javascript">JavaScript</option>
|
||||
<option value="php">PHP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-xs lg:text-sm font-medium text-gray-700">主题:</label>
|
||||
<select
|
||||
v-model="editorTheme"
|
||||
class="text-xs lg:text-sm border border-gray-300 rounded px-2 py-1 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="light">浅色</option>
|
||||
<option value="dark">深色</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs lg:text-sm text-gray-500">
|
||||
行数: {{ envContent ? envContent.split('\n').length : 1 }} | 字符数: {{ envContent ? envContent.length : 0 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码编辑器 -->
|
||||
<div class="border border-gray-300 rounded-lg overflow-hidden flex-1 min-h-0">
|
||||
<code-editor
|
||||
v-model="envContent"
|
||||
:language="editorLanguage"
|
||||
:theme="editorTheme"
|
||||
placeholder="在此输入环境配置内容..."
|
||||
@change="onEditorChange"
|
||||
@language-change="handleLanguageChange"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span>支持的格式: .env, JavaScript, PHP</span>
|
||||
<span>编码: UTF-8</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="w-3 h-3 lg:w-4 lg:h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-green-600">语法高亮已启用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增配置文件模态框 -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-5 h-5 text-purple-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
新增配置文件
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">为项目 {{ selectedProject }} 创建新的环境配置文件</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">配置文件名称</label>
|
||||
<input
|
||||
v-model="createEnvName"
|
||||
type="text"
|
||||
placeholder="例如: production, staging, development"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors duration-200"
|
||||
@keyup.enter="createNewEnv"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">文件将保存为 {{ createEnvName }}.env</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-gray-200 flex justify-end space-x-3">
|
||||
<button
|
||||
@click="showCreateModal = false"
|
||||
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="createNewEnv"
|
||||
:disabled="!createEnvName.trim()"
|
||||
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入模态框 -->
|
||||
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
|
||||
</svg>
|
||||
导入当前.env文件
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">环境名称:</label>
|
||||
<input
|
||||
v-model="importEnvName"
|
||||
placeholder="例如: production, development, staging"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">建议使用有意义的环境名称,如 dev、test、prod 等</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-gray-200 flex justify-end space-x-3">
|
||||
<button
|
||||
@click="showImportModal = false"
|
||||
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="importEnv"
|
||||
:disabled="!importEnvName.trim()"
|
||||
class="px-4 py-2 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-lg hover:from-green-600 hover:to-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<div
|
||||
v-if="message.text"
|
||||
class="fixed top-4 right-4 p-4 rounded-lg shadow-lg z-50 max-w-sm"
|
||||
:class="{
|
||||
'bg-green-50 text-green-800 border border-green-200': message.type === 'success',
|
||||
'bg-red-50 text-red-800 border border-red-200': message.type === 'error',
|
||||
'bg-blue-50 text-blue-800 border border-blue-200': message.type === 'info'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg v-if="message.type === 'success'" class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<svg v-else-if="message.type === 'error'" class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span>{{ message.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CodeEditor from './CodeEditor.vue';
|
||||
|
||||
export default {
|
||||
name: 'EnvManagement',
|
||||
components: {
|
||||
CodeEditor
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
projects: [],
|
||||
selectedProject: '',
|
||||
projectSearchQuery: '',
|
||||
showProjectDropdown: false,
|
||||
showAllProjectsOnFocus: false,
|
||||
highlightedIndex: -1,
|
||||
envs: [],
|
||||
selectedEnv: '',
|
||||
envContent: '',
|
||||
newEnvName: '',
|
||||
showCreateModal: false,
|
||||
createEnvName: '',
|
||||
showImportModal: false,
|
||||
importEnvName: '',
|
||||
editorLanguage: 'env',
|
||||
editorTheme: 'light',
|
||||
message: {
|
||||
text: '',
|
||||
type: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredProjects() {
|
||||
// 如果是点击输入框时显示下拉框,总是显示所有项目
|
||||
if (this.showAllProjectsOnFocus) {
|
||||
return this.projects;
|
||||
}
|
||||
|
||||
// 如果没有搜索内容,显示所有项目
|
||||
if (!this.projectSearchQuery) {
|
||||
return this.projects;
|
||||
}
|
||||
|
||||
// 有搜索内容时进行过滤
|
||||
const query = this.projectSearchQuery.toLowerCase();
|
||||
return this.projects.filter(project =>
|
||||
project.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
projectSearchQuery(newValue) {
|
||||
// 当搜索内容变化时,重置高亮索引
|
||||
this.highlightedIndex = -1;
|
||||
|
||||
// 如果用户开始输入,关闭"显示所有项目"的标志,启用过滤
|
||||
if (newValue && this.showAllProjectsOnFocus) {
|
||||
this.showAllProjectsOnFocus = false;
|
||||
}
|
||||
|
||||
// 如果搜索框被清空且没有选中项目,清空选中状态
|
||||
if (!newValue && !this.selectedProject) {
|
||||
this.selectedProject = '';
|
||||
this.envs = [];
|
||||
}
|
||||
},
|
||||
|
||||
projects() {
|
||||
// 当项目列表更新时,重置搜索状态
|
||||
this.highlightedIndex = -1;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('EnvManagement mounted');
|
||||
this.loadProjects();
|
||||
},
|
||||
methods: {
|
||||
async loadProjects() {
|
||||
try {
|
||||
const response = await fetch('/env/api/projects');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.projects = data.data;
|
||||
} else {
|
||||
this.showMessage('加载项目列表失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage('网络错误', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async loadEnvs() {
|
||||
if (!this.selectedProject) {
|
||||
this.envs = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/env/api/projects/${this.selectedProject}/envs`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.envs = data.data;
|
||||
} else {
|
||||
this.showMessage('加载环境列表失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage('网络错误', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async loadEnvContent(envName) {
|
||||
this.selectedEnv = envName;
|
||||
this.newEnvName = envName;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/env/api/projects/${this.selectedProject}/envs/${envName}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.envContent = data.data.content;
|
||||
// 根据环境名称自动检测语言
|
||||
this.autoDetectLanguage(envName);
|
||||
// 自动进入编辑状态
|
||||
this.isEditing = true;
|
||||
} else {
|
||||
this.showMessage('加载环境配置失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage('网络错误', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async saveEnv() {
|
||||
const envName = this.newEnvName || this.selectedEnv;
|
||||
|
||||
if (!envName.trim()) {
|
||||
this.showMessage('请输入环境名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/env/api/envs/save', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
project: this.selectedProject,
|
||||
env: envName,
|
||||
content: this.envContent
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showMessage('环境配置保存成功', 'success');
|
||||
this.selectedEnv = envName;
|
||||
this.newEnvName = '';
|
||||
await this.loadEnvs();
|
||||
} else {
|
||||
this.showMessage(data.message || '保存失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage('网络错误', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async applyEnv(envName) {
|
||||
if (!confirm(`确定要将 ${envName} 环境应用到项目 ${this.selectedProject} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/env/api/envs/apply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
project: this.selectedProject,
|
||||
env: envName
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showMessage('环境配置应用成功', 'success');
|
||||
} else {
|
||||
this.showMessage(data.message || '应用失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage('网络错误', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteEnv(envName) {
|
||||
if (!confirm(`确定要删除环境配置 ${envName} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/env/api/projects/${this.selectedProject}/envs/${envName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showMessage('环境配置删除成功', 'success');
|
||||
if (this.selectedEnv === envName) {
|
||||
this.selectedEnv = '';
|
||||
this.envContent = '';
|
||||
}
|
||||
await this.loadEnvs();
|
||||
} else {
|
||||
this.showMessage(data.message || '删除失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage('网络错误', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async importEnv() {
|
||||
if (!this.importEnvName.trim()) {
|
||||
this.showMessage('请输入环境名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/env/api/envs/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
project: this.selectedProject,
|
||||
env: this.importEnvName
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showMessage('环境配置导入成功', 'success');
|
||||
this.showImportModal = false;
|
||||
this.importEnvName = '';
|
||||
await this.loadEnvs();
|
||||
} else {
|
||||
this.showMessage(data.message || '导入失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage('网络错误', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async createNewEnv() {
|
||||
if (!this.createEnvName.trim()) {
|
||||
this.showMessage('请输入配置文件名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/env/api/envs/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
project: this.selectedProject,
|
||||
env_name: this.createEnvName
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showMessage('配置文件创建成功', 'success');
|
||||
this.showCreateModal = false;
|
||||
// 保存环境名称用于后续加载
|
||||
const envName = this.createEnvName;
|
||||
this.createEnvName = '';
|
||||
await this.loadEnvs();
|
||||
// 自动选择新创建的配置文件
|
||||
setTimeout(() => {
|
||||
this.loadEnvContent(envName);
|
||||
}, 500);
|
||||
} else {
|
||||
this.showMessage(data.message || '创建失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage('网络错误', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 项目搜索相关方法
|
||||
clearSearch() {
|
||||
this.projectSearchQuery = '';
|
||||
this.showProjectDropdown = false;
|
||||
this.highlightedIndex = -1;
|
||||
// 不重置 selectedProject、envs 和编辑器内容,保持当前状态
|
||||
|
||||
// 如果有选中的项目,在清除搜索后恢复显示项目名称
|
||||
setTimeout(() => {
|
||||
if (this.selectedProject && !this.showProjectDropdown) {
|
||||
this.projectSearchQuery = this.selectedProject;
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
|
||||
selectProject(project) {
|
||||
this.selectedProject = project.name;
|
||||
// 显示选中的项目名称在搜索框中
|
||||
this.projectSearchQuery = project.name;
|
||||
this.showProjectDropdown = false;
|
||||
this.highlightedIndex = -1;
|
||||
this.loadEnvs();
|
||||
},
|
||||
|
||||
handleProjectFocus() {
|
||||
// 点击输入框时显示下拉框,并设置显示所有项目的标志
|
||||
this.showProjectDropdown = true;
|
||||
this.showAllProjectsOnFocus = true;
|
||||
this.highlightedIndex = -1;
|
||||
|
||||
// 如果当前显示的是选中的项目名称,清空搜索框以便用户重新搜索
|
||||
if (this.selectedProject && this.projectSearchQuery === this.selectedProject) {
|
||||
this.projectSearchQuery = '';
|
||||
}
|
||||
},
|
||||
|
||||
handleProjectBlur() {
|
||||
// 延迟隐藏下拉框,以便点击选项能够正常工作
|
||||
setTimeout(() => {
|
||||
this.showProjectDropdown = false;
|
||||
this.showAllProjectsOnFocus = false;
|
||||
this.highlightedIndex = -1;
|
||||
|
||||
// 如果有选中的项目,始终在搜索框中显示项目名称
|
||||
if (this.selectedProject) {
|
||||
this.projectSearchQuery = this.selectedProject;
|
||||
} else {
|
||||
// 如果没有选择项目,清空搜索框
|
||||
this.projectSearchQuery = '';
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
|
||||
handleProjectKeydown(event) {
|
||||
if (!this.showProjectDropdown) {
|
||||
this.showProjectDropdown = true;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
this.highlightedIndex = Math.min(
|
||||
this.highlightedIndex + 1,
|
||||
this.filteredProjects.length - 1
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
this.highlightedIndex = Math.max(this.highlightedIndex - 1, -1);
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (this.highlightedIndex >= 0 && this.filteredProjects[this.highlightedIndex]) {
|
||||
this.selectProject(this.filteredProjects[this.highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
this.showProjectDropdown = false;
|
||||
this.highlightedIndex = -1;
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${Math.round(size * 100) / 100} ${units[unitIndex]}`;
|
||||
},
|
||||
|
||||
showMessage(text, type) {
|
||||
this.message = { text, type };
|
||||
setTimeout(() => {
|
||||
this.message = { text: '', type: '' };
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
onEditorChange(content) {
|
||||
// 编辑器内容变化时的处理
|
||||
// 可以在这里添加实时保存、语法检查等功能
|
||||
console.log('Editor content changed, length:', content.length);
|
||||
},
|
||||
|
||||
handleLanguageChange(newLanguage) {
|
||||
// 处理编辑器语言变化
|
||||
this.editorLanguage = newLanguage;
|
||||
},
|
||||
|
||||
// 根据文件名自动检测语言
|
||||
autoDetectLanguage(filename) {
|
||||
if (!filename) return;
|
||||
|
||||
const name = filename.toLowerCase();
|
||||
|
||||
// .env 文件检测
|
||||
if (name === 'env' || name === '.env' || name.startsWith('env.') || name.endsWith('.env')) {
|
||||
this.editorLanguage = 'env';
|
||||
return;
|
||||
}
|
||||
|
||||
// JavaScript 文件检测
|
||||
if (name.endsWith('.js') || name.endsWith('.jsx') || name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.mjs')) {
|
||||
this.editorLanguage = 'javascript';
|
||||
return;
|
||||
}
|
||||
|
||||
// PHP 文件检测
|
||||
if (name.endsWith('.php') || name.endsWith('.phtml') || name.endsWith('.php3') || name.endsWith('.php4') || name.endsWith('.php5')) {
|
||||
this.editorLanguage = 'php';
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据内容特征检测
|
||||
if (this.envContent) {
|
||||
const content = this.envContent.toLowerCase();
|
||||
|
||||
// 检测是否包含典型的环境变量模式
|
||||
if (content.includes('app_name=') || content.includes('db_host=') || content.includes('app_env=')) {
|
||||
this.editorLanguage = 'env';
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测是否包含PHP标签
|
||||
if (content.includes('<?php') || content.includes('<?=')) {
|
||||
this.editorLanguage = 'php';
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测是否包含JavaScript关键字
|
||||
if (content.includes('function') || content.includes('const ') || content.includes('let ') || content.includes('var ')) {
|
||||
this.editorLanguage = 'javascript';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认使用 env
|
||||
this.editorLanguage = 'env';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.env-management {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
</style>
|
80
resources/js/components/EnvManager.vue
Normal file
80
resources/js/components/EnvManager.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<admin-layout
|
||||
:page-title="pageTitle"
|
||||
@menu-change="handleMenuChange"
|
||||
>
|
||||
<!-- 环境管理页面 -->
|
||||
<env-management
|
||||
v-if="currentPage === 'env'"
|
||||
ref="envManagement"
|
||||
/>
|
||||
|
||||
<!-- 系统设置页面 -->
|
||||
<div v-else-if="currentPage === 'settings'" class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<div class="text-center py-12">
|
||||
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">系统设置</h3>
|
||||
<p class="text-gray-500">此功能正在开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作日志页面 -->
|
||||
<div v-else-if="currentPage === 'logs'" class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<div class="text-center py-12">
|
||||
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">操作日志</h3>
|
||||
<p class="text-gray-500">此功能正在开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</admin-layout>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AdminLayout from './AdminLayout.vue';
|
||||
import EnvManagement from './EnvManagement.vue';
|
||||
|
||||
export default {
|
||||
name: 'EnvManager',
|
||||
components: {
|
||||
AdminLayout,
|
||||
EnvManagement
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentPage: 'env',
|
||||
pageTitle: '环境配置管理'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('EnvManager mounted');
|
||||
},
|
||||
methods: {
|
||||
handleMenuChange(menu) {
|
||||
this.currentPage = menu;
|
||||
|
||||
// 更新页面标题
|
||||
const titles = {
|
||||
'env': '环境配置管理',
|
||||
'settings': '系统设置',
|
||||
'logs': '操作日志'
|
||||
};
|
||||
|
||||
this.pageTitle = titles[menu] || '环境配置管理';
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 主应用样式 */
|
||||
</style>
|
15
resources/views/env/index.blade.php
vendored
Normal file
15
resources/views/env/index.blade.php
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>环境文件管理系统</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<env-manager></env-manager>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@@ -1,7 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\EnvController;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
Route::get('/', [EnvController::class, 'index']);
|
||||
|
||||
// 环境管理路由
|
||||
Route::prefix('env')->group(function () {
|
||||
// 页面路由
|
||||
Route::get('/', [EnvController::class, 'index'])->name('env.index');
|
||||
|
||||
// API路由
|
||||
Route::prefix('api')->group(function () {
|
||||
Route::get('/projects', [EnvController::class, 'getProjects']);
|
||||
Route::get('/projects/{project}/envs', [EnvController::class, 'getEnvs']);
|
||||
Route::get('/projects/{project}/envs/{env}', [EnvController::class, 'getEnvContent']);
|
||||
Route::get('/projects/{project}/current-env', [EnvController::class, 'getCurrentEnv']);
|
||||
|
||||
Route::post('/envs/save', [EnvController::class, 'saveEnv']);
|
||||
Route::post('/envs/apply', [EnvController::class, 'applyEnv']);
|
||||
Route::post('/envs/import', [EnvController::class, 'importEnv']);
|
||||
Route::post('/envs/create', [EnvController::class, 'createEnv']);
|
||||
|
||||
Route::delete('/projects/{project}/envs/{env}', [EnvController::class, 'deleteEnv']);
|
||||
});
|
||||
});
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -9,5 +10,22 @@ export default defineConfig({
|
||||
refresh: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
vue({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
// 包含运行时模板编译器
|
||||
isCustomElement: (tag) => false
|
||||
}
|
||||
}
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: true,
|
||||
__VUE_PROD_DEVTOOLS__: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'vue': 'vue/dist/vue.esm-bundler.js'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
Reference in New Issue
Block a user